Compare commits

..
269 Commits
Author SHA1 Message Date
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
NIKHIL b40bc71ca6 fix(daemon): keep spaced settings JSON whole when reading a Claude command line (#2665)
The Claude command-line reader keeps a --settings or --mcp-config JSON value whole when it's written with spaces, by tracking brace depth on those two options only, so a seat launched through a proxy alias with spaced settings can be verified and its conversation read (#2508). Other options' values aren't grouped. Contributed by Nikhi00718, with billbenetti's diagnosis.
2026-11-08 09:29:26 -08:00
Mike Schwarz 4d7c50d514 fix(openshell): capture optional workspace volumes from mounts (#2755)
OpenShell 0.1.6 custom-image sandboxes mount control and channel volumes without a managed workspace volume, so delete-owned refused them before dispatch, which also blocked recreate and exact restore. Capture now keeps the exact OpenShell-owned volumes observed on the selected workload and supervisor mounts: control and channel stay required, the workspace volume is kept when present, and unrelated or user-supplied mounts are excluded. Complete post-delete inventories, container and socket identity checks and environment_cleanup_unconfirmed are unchanged. A native run on OpenShell 0.1.6 passed cleanup, recreate, exact resume and byte preservation; startup replay failed and strict before-tools recall was not met, so this is a bounded cleanup repair, not an end-to-end worker pass.
2026-11-08 09:24:38 -08:00
Mike Schwarz 1538231b1f fix(terminal): let tiles show whole lines, and attach only while someone types (#2757)
A tile now shows its seat by reading the screen and letting the tile wrap it, so a narrow tile shows whole lines and never sizes the seat. A writable tile attaches on the first key: the terminal stays in one raw mode from that key to the attach, the first keys go through the new client (control bytes by name), a paste is kept framed and whole, and every read and the first input check the server and seat identity, so a restarted server's reused session can't receive the keys. The tile detaches after 30 s idle. Read-only tiles only show. Limits: no real Herdr 0.9.5 pane was run; redraw flicker and Herdr's agent detection on a showing tile are unchecked; keys in the same burst as a paste skip tmux's key table; tmux prefix plus PgUp in one burst reaches the agent as text.
2026-11-08 09:15:59 -08:00
NIKHIL 44affadaa6 fix(queue): hold a prompt-clear wake retry while a compaction owes its restore (#2666)
retryPromptWakesOnClear no longer treats a finished managed compaction's "Compacted" as a cleared prompt while the seat still owes a post-compaction step (turn boundary, restore or audit). It uses the same hasPendingPostCompactStage check that parked-owner reminders already use, and the retry stays armed so the next clear after those steps sends it. This is the Recovered queue notification part of #2575, where the resent wake went in 0.1 s after "Compacted" and the agent worked for 21 minutes on its compacted context without a restore. Contributed by Nikhi00718, with billbenetti's timing from the issue.
2026-11-08 09:09:54 -08:00
billbenetti 011b995835 fix(daemon): start the seats' tmux server in a systemd user scope of its own (#2710) (#2749)
When a systemd user manager is available, the daemon starts the seats' tmux server with systemd-run --user --scope, so seat processes run in their own transient scope instead of the daemon's cgroup (#2710). --expand-environment=no keeps the server's argv literal on systemd 258, where scope expansion is on by default. An older systemd that refuses that option or OOMPolicy=continue gets a retry without only the refused option, recognised from systemd's own message on stderr; a timeout or any other failure falls back to the existing start with its reason. The reason a scope isn't tried at all is logged once per adapter. Contributed by billbenetti.
2026-11-08 08:55:40 -08:00
Mike Schwarz ce0186c408 docs(roadmap): name 0.7.10 as the current release (#2760)
ROADMAP.md names 0.7.10 as the current release and links its release notes. The summary is the published notes' first two sentences. No roadmap item is removed, since the published notes don't say any of them shipped. Text only.
2026-11-08 08:52:14 -08:00
Mike Schwarz 545a423489 chore(release): bind the packaged-plugin baseline to published 0.7.10 and advance openrig-core to 0.1.27 (#2756)
scripts/fixtures/packaged-plugins-last-release.json is bound to published 0.7.10 (archive sha256 c55aff7e, openrig-core 0.1.26 payload 23ed778d), taken with the check's own --snapshot recipe, and both openrig-core manifests advance to 0.1.27. Six openrig-core files changed since v0.7.10, and the installer keeps installed bytes whose version is equal or newer, so without the bump an upgraded install would keep 0.7.10's copies. The check fails at 0.1.26 and passes at 0.1.27. Same chore as earlier releases' baseline updates.
2026-11-08 06:50:27 -08:00
Mike Schwarz d2c5bbdbd2 docs(changelog): carry the published 0.7.10 section onto main (#2754)
Carries the published 0.7.10 section from the v0.7.10 tag (a226b1b6) onto main above 0.7.9, as the 0.7.9 section was carried. Eight lines change from the tag's text so the OpenShell statements match the tested result and the published release notes: the guest CA fix holds with OpenShell 0.1.6 (sign-in, useful work, a verified export and a finished task), the maintained cleanup still fails against 0.1.6 because it doesn't mount a workspace volume, and recreate and exact restore haven't run. Everything else is the tag's section byte for byte. Text only.
2026-11-08 06:31:00 -08:00
Mike Schwarz 220e27a9a7 fix(daemon): one standing notice for a seat's unclaimed rows instead of a finding per row back to it (#2752)
In runStuckSweep, an unclaimed-obligation finding whose route would be the row's own destination, for an agent seat with no declared route, joins one standing notice per seat (kind unclaimed-backlog) instead of a finding per row. The notice records every row it stands for, is rewritten in place without a wake, and closes only when each of its rows is claimed, parked or closed, or has its own finding actually written; a raised threshold or the wake ladder is not resolution. A closed notice holds back only the rows it listed, until each has newer evidence, and an unreadable list counts as unknown. Seats with a delegation parent or escalates_to route, failed routes, people, terminal seats and the other finding kinds are unchanged. No setting, route, table, migration or error code. Reported with data by billbenetti.
2026-11-08 05:45:18 -08:00
Mike Schwarz 859b540cf0 test(daemon): keep the refocus work-root hook cells off the 2 s lookup cap (#2753)
refocus-work-root.test.ts's hook cells set OPENRIG_REFOCUS_BUDGET_MS=52000, the documented maximum, with a 60 s test timeout, so the hook's python3 trace no longer hits its 2 s default lookup cap on a slow runner. A new cell puts a python3 that sleeps 3 s on the hook's PATH; without the budget it fails with the same TRACE GAP ETIMEDOUT text CI showed. No product file changes.
2026-11-08 05:13:45 -08:00
Mike Schwarz 0fce9b52e7 fix(daemon): prove a Claude a launcher execs through the native installer's symlink (#2751)
In native-process-lineage.ts, the script-launch rule also accepts the OS name claude when the process's path is a recognized Claude executable, which covers an exec'ing launcher in front of the native installer's symlink to a versioned file. Both Claude argv readers now treat --chrome and --no-chrome as switches without a value, so a launch carrying them still proves its conversation. The foreground, single-candidate, token and fingerprint checks are unchanged. Tests cover the matcher, delivery, three refusals, an adapter resume reaching ready, and a wrong conversation refused. Reported with the full launcher chain by oneandonlydean.
2026-11-08 05:05:36 -08:00
Mike Schwarz 4d6200e11a fix(mcp): an external caller's task reaches a seat named without its rig (#2750)
In rig_queue_create, for an external grant only, a destination without @ resolves to the configured receiver when the name is that seat's and this connection's grant reaches it, or otherwise to that seat in the connection's one rig; the result passes the same scope check and must be an existing seat, or nothing is written. A grant over several rigs never picks a rig for a name alone except the receiver's. An out_of_scope refusal adds "Omit destination to reach the team's configured receiver" only when this connection's grant reaches the receiver, without naming it. Local callers (stdio, or HTTP without a grant service) are unchanged.
2026-11-08 04:36:31 -08:00
Samuel 3ac7aea4ee feat: add local Claude Code launch wrappers (#2746)
Extends #2609's launch-wrapper contract to local Claude Code seats. The wrapper is inserted where each Claude path already builds its native command: the classic shell shim, the pinned classic exec in the adapter and resume adapter, and the managed launch script's final exec after its environment is rebuilt. A declared wrapper that is missing, unresolvable or can't exec its target stops the launch with the existing reason rather than starting the seat unwrapped. With no wrapper declared, every Claude command and path is unchanged. No new stored field, migration, mode or error code. Docs: agent-startup-guide.md and rig-spec.md.
2026-11-08 03:19:57 -08:00
Mike Schwarz d7d1dd41eb fix(daemon): keep a helper harness's turns off its seat's activity (#2743)
The activity relay now sends each activity report's session id and transcript, plus whether Claude Code's marker CLAUDE_PROJECT_DIR is set (a boolean, never its value). One rule in routes/activity.ts judges them at the identity, staged-successor and turn-activity writes: a claude-code report must name <session id>.jsonl, and a codex report counts as another runtime only when it carries Claude Code's marker and names <session id>.jsonl, so a Codex seat's own legacy rollout name still counts. A report from an older relay without these fields keeps today's behaviour. A helper's permission request can still appear as the seat's observed permission stop, and a helper of the seat's own runtime isn't told apart. Follows #2740; reported by pr0xc3nt4ur1 in #2702.
2026-11-08 01:09:49 -08:00
Mike Schwarz f225192e4e fix(daemon): a Codex helper inside a Claude seat keeps the seat's resume token (#2740)
In routes/activity.ts, a claude-code identity report whose transcriptPath key is present but doesn't name <session id>.jsonl returns ok with tokenPersisted false and transcript_not_this_session, so a Codex helper that inherited a Claude seat's environment no longer replaces the seat's resume token with its thread id or publishes it as the seat's identity. The seat's own reports, in either path spelling, still move the token; a real Codex seat is untouched; a relay that sends no transcript field writes as today. The rule follows the producer behaviour inspected in Claude Code 2.1.282. Reported by pr0xc3nt4ur1, with the evidence table and the set-resume-token workaround.
2026-11-07 23:49:30 -08:00
Mike Schwarz 7d15cefc8e fix(workflow): keep a run's root objective on its instance (#2738)
Migration 154 adds a nullable root_objective column to workflow_instances. WorkflowRuntime.instantiate, which instantiate-lifecycle and run reach, stores the objective, and rowToInstance returns it as rootObjective, so list, show and trace carry it with no reader changes; status rows built from list carry it too. show, trace and status print an objective line only when one is recorded, so an older instance, which reads null, renders as before. No back-fill: older objectives stay in their entry packets. Reported and traced by billbenetti.
2026-11-07 22:37:50 -08:00
Mike Schwarz cab7f569fb feat(mcp): a person answers the team's questions from their assistant (#2735)
mcp-server.ts adds person-scoped question tools for a grant carrying question read and answer and person.entity_id, which mcp-http.ts checks against the daemon's entity pattern; only rig_question_answer's mission-control call uses a client acting as <person>@external. A question is offered and answered only while it sits in that person's Steering view with an answer verb and the view's perspective is exactly that person, and a row read afterwards is re-checked against the attention item's verb and recipient, so a moved park is dropped. Each step posts with its own operationId and expectedRevision; a retry reconciles by operation identity and, once it confirms an earlier step, stops and reports recorded and notRecorded instead of resuming, so it never overwrites a newer answer. An identical retry on a settled row confirms through audit reconciliation; thrown or 5xx sends report the confirmed prefix and the indeterminate step. With nobody registered the tools refuse (person_not_registered); a malformed person withholds only the person tools. hosted-members.md and the openrig-user skill describe it, and the skill's tool counts are corrected to 39 and 20.
2026-11-07 21:48:30 -08:00
w4rc0n 78d73bc7b3 fix(daemon): never start the daemon or a seat with a herdr-core terminal's scope (#2737)
daemon-lifecycle.ts removes HC_SCOPE, HC_TERMINAL_ID and HC_TERMINAL_SOCKET from the environment it gives the daemon process, before that process exists, and parent-session-env.ts adds the same three names to the parent-session variables kept out of seats and the tmux server, while a seat's explicitly declared env stays. Inherited herdr-core terminal scope could make the detached daemon and seat processes eligible for herdr-core's cleanup of the launching terminal. Existing fallback paths still warn and continue. Tests cover the CLI start, the parent-session scrub, the seat integration and the tmux credential environment.
2026-11-07 21:45:44 -08:00
Mike Schwarz ba8c01467a docs: carry the 0.7.10 capability map and agent-spec teaching onto main (#2736)
public-what-you-can-do.md and docs/reference/agent-spec.md take the release-only changes from 14d89431, cd8e32f1 and f90481bc by a three-way merge against their merge-base with main (d539277a), with no conflict and neither file overwritten: the capability-delta-v0.7.10 marker and 92 top-level verbs counted from main's built program, rig exec and its qualified adapters, permission observations with their sensitive and in-memory limits, agent-spec's scalar-to-list rule, tmux-mode delivery, managed compaction's held step, and Codex non-login shells. Main's later #2698, #2699 and #2626 guidance stays.
2026-11-07 21:11:39 -08:00
Mike Schwarz aa0599cd41 fix(daemon): a cross-host handoff says when it overrode a claim (#2734)
closeCrossHostHandoffSource takes the source's claim as the route read it before forwarding. If the row was claimed since, it still closes, records the overridden claim in the transition note, and stages one claim-overridden wake to the claimer in the close's transaction, naming the destination and the successor's plain id. That wake is current only while its row stays closed, owned by the claimer, and its latest close is the notice's own transition. Startup cleanup skips it, the recovery drain selects it on its closed row, and the prompt-clear retry finds such rows in one pass from the notices' id range on the outbox key, with ordinary wakes still pending-only. A full held-message quota records the wake as not retained without undoing the close. Refs #2631 (item 2); the opt-in --if-state pending is not included.
2026-11-07 20:20:34 -08:00
Mike Schwarz 8a46d8fabc docs(plugin): record exactly what the person typed as their answer (#2733)
The working-with-an-openrig-team skill's question step, in all three copies, and the front-door instructions in mcp-server.ts say to record exactly what the person typed in resolution, with no label, quotation marks or words of the agent's own. FRONT_DOOR_WORKFLOW_VERSION moves from front-door/v1 to front-door/v2; it is used only in the instructions text and its test. The skill-edge digests are regenerated, and the front-door test requires the new wording and refuses the old. No behaviour change.
2026-11-07 19:49:06 -08:00
Mike Schwarz 59416773ce fix(tui): neutral names in the demo stream events (#2724)
packages/tui/src/demo-data.ts lines 211-212: the two demo stream events' sourceSession values become dev50-guard@openrig-build and dev50-planner@openrig-build, and the second body reads "provider re-auth completed on remote-host", the snapshot's own demo host. The pinned "provider re-auth completed" text is kept.
2026-11-07 19:44:00 -08:00
Mike Schwarz 39c4f9fd6e feat(plugin): marketplace files at the repo root, a square logo, and Codex listing metadata (#2719)
Root .claude-plugin/marketplace.json and .cursor-plugin/marketplace.json list the openrig plugin at its subpath (packages/daemon/assets/plugins/openrig). The plugin's .codex-plugin/plugin.json gains a logo, composerIcon, category, Read/Write capabilities and three default prompts, its .cursor-plugin/plugin.json gains a logo, and assets/logo.svg is a square mark drawn white on a filled dark square. The plugin version test covers the manifests. This is listing source only; no marketplace submission.
2026-11-07 18:43:23 -08:00
Mike Schwarz 0f88021f4a docs(plugin): give a VS Code or Cursor agent a seat with its own server, turning the plugin's off (#2718)
The openrig plugin README's "No seat" section describes starting the app from a shell as working only for that launch, and its server note becomes the steps to give the app a seat: run your own rig mcp serve --front-door server with OPENRIG_SESSION_NAME set and turn the plugin's server off (in VS Code under another name, disabled in MCP: List Servers, since two servers named openrig stop each other; in Cursor, disable openrig · Plugin), with Copilot CLI marked unverified. rig_whoami's seatless message in mcp-server.ts says the same in one sentence, and the working-with-an-openrig-team skill's "Another app" step says it in all three copies, with the skill-edge digests regenerated. No behaviour changes.
2026-11-07 18:31:35 -08:00
Mike Schwarz 1bcbef7e3f build: pack the ACP launcher beside the CLI, at one version, with hashes (#2715)
scripts/pack-release.mjs adds npm run pack:release -- <dir> for releases that ship @openrig/acp beside @openrig/cli. After a successful build:package it checks that the launcher's version, its exact @openrig/cli pin, and the registry listing's version and npx package all match the CLI's version, naming every mismatched file and field and writing nothing on a mismatch or when packages/cli has no daemon/dist. It packs the CLI with the producer's existing npm pack --workspace packages/cli --ignore-scripts --json form and the launcher by path, then writes RELEASE-ARTIFACTS.json with each archive's path, version, size, sha256 and integrity, the listing's agent.json and icon.svg hashes, and the publish order (CLI, launcher, listing). It publishes nothing; its output says to publish the recorded archive files themselves by path, since publishing from packages/cli reruns prepublishOnly. A comment notes npm 10 still runs a prepare script under --ignore-scripts; neither package has one. build-package.sh's last line names the step.
2026-11-07 17:42:43 -08:00
Tingri Lohar Mohammed Mohsin 73ad2c4e07 fix(refocus): trace work in a catalogued project outside workspace.root (#2717)
rig queue whoami's route (routes/queue.ts) already reads the project catalogue for per-row missions roots; it now also returns the healthy catalogued project roots. The openrig-core refocus hook passes them through its existing cached whoami answer to trace-to-root.py as extra work roots, a refocus-only input, so OPENRIG_WORKSPACE_ROOT and the CLI's config are untouched. The script ascends from the node to the deepest known root that contains it: a catalogued project's root, inside or outside workspace.root, or workspace.root itself. A node outside every known root is still reported as a gap, and an unavailable catalogue gives no extra roots. The refocusing skill documents it, and the skill-edge digests are regenerated. No new command, refusal or error code.
2026-11-07 17:37:18 -08:00
billbenetti 92a1c738fc fix(daemon): the compaction gate survives a restart, and a stale sample of another conversation ends an unready map (#2682 follow-up) (#2716)
In claude-compaction-enforcer.ts the replacement attempt records the ended attempt's own conversation as afterEndedSessionId (null when it read unknown) beside afterEnded, and recoverAfterRestart rebuilds the gate from it in both unsent-replacement paths, so a restart matches the in-memory result; a record from an older daemon reads it as unknown, which keeps the gate, and the field is dropped with afterEnded and not shown by rig compact --state. drainPendingPreparedCompact now checks for another known conversation of the same seat before returning on an incomplete map, so a stale sample of another conversation ends an automatic attempt whose request went out instead of leaving its map to be sent there later. One visible change: with an invalid policy, an automatic attempt still waiting on its map reads invalid_policy rather than no_pending_stage. Follow-up to #2682 and #2698.
2026-11-07 17:22:03 -08:00
Mike Schwarz 937acb681b docs(acp): rig acp serve uses rig's daemon, which OPENRIG_URL can name (#2714)
The README, docs/reference/acp-agents.md, the sign-in method's description in agent-server.ts and the serve help in commands/acp.ts say rig acp serve uses rig's daemon lookup, local by default or the daemon OPENRIG_URL names, where they had said it talks only to the local daemon. The sign-in method keeps its name, "OpenRig on this machine", qualified by its description. Two code comments drop "local". No code path changes.
2026-11-07 17:06:18 -08:00
James B. Shields 1be8615a33 fix(skills): bound catalog Git reads to five seconds (#2713)
skill-catalog.ts gives gitState's four synchronous execFileSync Git reads (rev-parse --show-toplevel, status, rev-parse HEAD and the committed SKILL.md read) a five-second timeout each, as the daemon's other synchronous Git sites already bound their own reads. A timed-out discovery shows the existing unreadable-repository error, and other failures keep their existing handling and the committed-name fallback. The three projection-ignore Git calls are outside this change, and it is not an overall catalog deadline or a throughput change. Tests cover real local Git stalls on each read. Refs #161 (item 4).
2026-11-07 17:02:26 -08:00
Mike Schwarz 172685c892 fix(cli): rig acp serve says in the chat when a question ends unanswered (#2712)
In agent-server.ts one helper, unanswered(), handles every way a shown question ends unanswered here: dismissed, answered with a value that isn't one of its choices, the turn cancelled, the work closed first, or a form request the app couldn't show. It writes the card's tool_call_update as before and one agent_message_chunk naming the seat, the question and its row. outstanding becomes a Map from tool call to the question's name so finishQuestions can name each one. Nothing is recorded as an answer, and no further question opens after a cancel. docs/reference/acp-agents.md says so. A follow-up to #2708.
2026-11-07 16:48:16 -08:00
James B. Shields 2d0bd8b10e fix(handover): confirm Claude rebuild packet submission (#2650)
Rebuild handover delivery of the priming packet now goes through the same submission check as the fresh restore packet (confirmPacketSubmission, which reads the Claude composer after submit and retries once). A packet still observed staged after that retry, or a composer that can't be read, returns unconfirmed instead of failing, so the ordinary continuity and commit path binds the running successor while the result carries restorePacket.status unconfirmed with its reason and the startup context is not marked delivered. A failed paste or submit still fails with context_delivery_failed; its guidance now says the discovery candidate was marked vanished, the successor may still process the packet, and its state should be checked before another attempt. rig seat handover's rebuild output names the priming packet as unconfirmed, heads the artifact list "Rebuild packet includes", and for a still-staged packet says to submit the existing packet once only if it is still staged, never to resend blindly or rerun the handover. Tests add a fresh and rebuild matrix over clear, staged, unreadable, edited and unrelated screens and the CLI's rebuild wording for both unconfirmed reasons.
2026-11-07 16:45:29 -08:00
Mike Schwarz 4a18628c8a fix(daemon): reconcile a restored seat once it's proven resumed (#2711)
SeatAttentionReconciler.onSeatActivity runs the existing reconcileNodeRuntimeTruth once per change into running or idle, for a seat whose full-restore outcome still needs attention (not reconciled, not fenced by a later ready fresh launch, not an assisted startup), and onRestoreCompleted checks each of the rig's seats last read running or idle once when a full restore completes; startup.ts wires both from the existing activity reading and the restore.completed event. The check itself is unchanged in its proof: a live tmux session, exact native resume-token lineage and a usable pane. A failure writes no reconciliation and emits no refusal. Before appending a pass, reconcileNodeRuntimeTruth re-reads the latest restore attempt, the binding's session and the expected resume token, and returns proof_superseded without a receipt if any changed, which also covers rig seat clear-attention; two overlapping checks for one attempt append one receipt. getDerivedAttentionOutcome reads restore outcomes and starts through their own indexes instead of every event in the rig. The startup guide and the state taxonomy describe the automatic reconcile, with clear-attention as its on-demand form. For #2622.
2026-11-07 16:43:11 -08:00
billbenetti f554785b9d fix(daemon): hold a prepared managed compaction until its window instead of re-requesting (#2682) (#2698)
In claude-compaction-enforcer.ts, an automatic attempt whose map is ready but whose seat is still busy at the deadline is held, keeping its map, until an idle window lets /compact go, instead of stopping and re-preparing. The hold ends when a fresh sample dips below the threshold (a dip before the hold is remembered even if usage climbs back), when usage grows PREPARED_MAP_MAX_GROWTH_PERCENT (15) points past the delivered map, after PREPARED_MAP_MAX_AGE_MS (2 h), when reconcilePreparations finds the map or its marker gone (preparation_incomplete), when a known conversation differs from the attempt's on the fresh path or at drainPendingPreparedCompact (an armed map is stopped too), or on a new occupant generation; a /compact already in flight is left alone. stopPreparation records an ended attempt whose request was delivered, and endedAttemptGate then answers preparation_stopped during the cooldown and, after it, lets a replacement through only when the actual send finds the seat idle; the gate survives a stale observation and a restart within the recovery window (default 6 h). ContextUsageStore keeps an unknown sample's conversation and session name, and waitUntilIdle gains a single-observation option for the gate. New stop reasons are below_threshold_while_prepared and prepared_map_stale; prepared_window_closed is gone. The CLI help and the context pack name the ends and the 6 h bound. For #2682.
2026-11-07 16:11:47 -08:00
Mike Schwarz 1898ac9f56 fix(mcp): tell a seatless plugin agent how its app can get a seat (#2709)
In mcp-server.ts, the seatless rig_whoami message no longer tells the agent to set OPENRIG_SESSION_NAME in its environment or where the server is configured, which a plugin's server in VS Code, Copilot CLI or Cursor can't take. It names each app's documented route instead: the plugin's seat setting in Claude Code, --env on codex mcp add for Codex, and for VS Code, Copilot CLI and Cursor either starting the app from a shell with the variable set or running your own rig mcp serve --front-door server. seat_required now points the agent to rig_whoami for those steps. One test cell asserts the wording; nothing else changes.
2026-11-07 15:56:50 -08:00
billbenetti 759d309483 perf(activity): OPENRIG_SEAT_POLL_INTERVAL_MS slows the seat-activity pollers (#2688)
seat-activity-service.ts gains startSeatPollers, which starts the activity sampler and the structural-activity poller at one interval read once at daemon start: OPENRIG_SEAT_POLL_INTERVAL_MS when set and valid, otherwise the typed daemon.seat_poll_interval_ms setting, otherwise the existing 1000 ms. Values run from 1000 to 2000 ms; an invalid environment value is dropped with a store warning rather than clamped. index.ts calls it in place of the two direct starts. The setting is registered in the daemon's settings store and the CLI's config store and config command, and the settings key count moves to 100 alongside retention.events_days. tmux-servers.md documents the trade: fewer spawned tmux clients per second against slower activity readings. Tests cover the precedence, the bounds, the invalid value and that both pollers share the interval. For #2687.
2026-11-07 15:42:19 -08:00
billbenetti b270e55dc8 fix(outbox): at a full held quota, coalesce a sender's own held messages into its newest (#2430) (#2701)
In outbox-handler.ts, a held message from a sender to a seat retires that sender's earlier held messages there into itself (retired_by outbox@system, retirement_reason "coalesced into <id>"), and the newest carries coalesced { count, outboxIds } on rig seat held-messages and --id, repointed on each later coalesce; "newest" is by dispatch time, then insertion order. A late-settling group folds only its own members into the newest row, so a newer multipart send's members stay held together. Lookups and repoints require outbox@system, the same sender and the same seat, so a hand retirement with the same reason text is never treated as a link. draft-aware-delivery.ts coalesces only after an attempt settles as retained, and queue-repository.ts routes a draft-aware wake's capacity check through the same per-sender rule. An urgent marking carries forward within one sender's own messages, for display only. seat-delivery.md describes the rule, says the sender's next held message clears an older backlog, and names rig seat retire-held-message for immediate relief. For #2430's held-message part.
2026-11-07 15:35:22 -08:00
Mike Schwarz f91a1f681b fix(cli): rig acp serve after its first T3 Code run (#2708)
In agent-server.ts, an app whose initialize advertises clientCapabilities.elicitation.form gets each team question as ACP elicitation/create in form mode: one string field whose enum lists each choice by its label (with "(recommended)" where marked, and the option id added when labels repeat), because the released T3 Code reads choices only from enum. The answer maps back to the option by that exact value; decline, cancel or any other value records nothing and the question stays open. Other apps keep session/request_permission with one allow_once option per choice. When the prompt holds exactly one <user_request> opening and closing mark, those words become the row's summary and the session/list title; otherwise the prompt's first line stays, as before, and the row body is always the full prompt. commands/acp.ts, the README and the guide say to register a person first (the only registered person is the default), pick one with rig gateway default when there are several, or pass --as, and name Zed as the tested example.
2026-11-07 15:31:30 -08:00
billbenetti 856650a129 perf(daemon): batch the stuck sweep's per-row reads and yield the event loop (#2649)
The stuck sweep reads recovery findings and custody rows' linked successors through batched lookups (createRecoveryLookup, createLinkedSuccessorLookup) with the same predicates as the per-row queries, lent to synchronous reads and dropped after every yield or awaited write. runStuckSweep yields the event loop with setImmediate every STUCK_SWEEP_YIELD_EVERY_ROWS (50) rows. Each finding kind is a rule for one row: right before a finding is created its rule runs again on the row as it is now, without the lookups, and the finding takes that run's route, reason, targets and escalation note and hop; a row that no longer qualifies gets none. Before the resolution closes a rule-resolved finding for a row this pass did not find, the rule runs again on that row, so a row that came back during the pass keeps its finding. findOverdue and findUndelivered take a qitemId so a listed row can be re-read by id. The scheduler runs one pass at a time: a tick during a pass is skipped, logged with the running pass's start, and reported as runningSince and skippedTicks in the sweep's status. Tests add a batch suite, scheduler cases, sweep cases and a cross-rig escalation case.
2026-11-07 14:57:26 -08:00
billbenetti 5e5b328e7c perf(daemon): prune the history-only event types from events (retention.events_days) (#2699)
retention.events_days (OPENRIG_RETENTION_EVENTS_DAYS; daemon, CLI and TUI settings) defaults to 0, which keeps every event; a positive day count turns on pruning of eight history-only types: view.changed, snapshot.created, watchdog.evaluation_fired, queue.created, queue.updated, queue.claimed, queue.unclaimed and queue.handed_off. The existing retention sweep, at boot and daily, runs pruneEvents: it walks from a stored low-water mark to the row before the newest in bounded windows, checks each row's own age against the cutoff (so out-of-order timestamps can't strand older rows), yields and pauses between windows, and deletes only the listed types older than the cutoff. A capped pass stores resume_seq so the next sweep continues where it stopped, and the mark never passes a young row of a listed type. Migration 153_events_retention_marks adds the one-row marks table. The help and whats-new.md say what pruning gives up (replay from /api/events past the cutoff, the historical telemetry export, older log and feed entries) and that a new value is read at the next sweep. The other retention jobs are unchanged. For #2697.
2026-11-07 14:38:41 -08:00
Mike Schwarz 7ae8f6ef5b fix(tmux): say when tmux control mode is off, and how to move a team onto it (#2707)
In tmux-control.ts, the control client records each server version it reads: a version that rules control mode out is logged once, naming the version, that the running server decides rather than the binary, and the guide's address; an eligible version reads "allowed", and "Control mode is on" is logged at the first successful attach per version, with the status reading "on" only while a client is attached. A new GET /api/adapters/tmux/control answers that state from memory with no tmux call, and rig doctor's new tmux_control_mode check warns when control mode is asked for and off, warns when it is allowed but no client is connected, warns when the shell sets the variable but the daemon wasn't started with it, passes only when on, and otherwise skips, including against an older daemon. docs/reference/tmux-servers.md gains "Moving an existing team onto tmux control mode", which joins the reference pack so the address in the log and in doctor resolves. Which servers get control mode is unchanged. Refs #161.
2026-11-07 14:34:25 -08:00
Mike Schwarz 7f3b85e993 fix(mcp): treat an empty or unfilled seat as no seat (#2657)
In mcp.ts and mcp-server.ts, a seat that is empty, blank or a literal unfilled template such as ${user_config.seat} is treated as no seat, so the server never stamps writes with the placeholder or with an inherited primary or legacy name. With no seat, create and chat declare the sender through the existing declared-actor path as unknown-mcp-<8 hex>, minted once per rig mcp serve process: once per connection over stdio, and once at start for every request's server over HTTP (mcp-http.ts), so a same-id create retry carries the same source. Each such row opens with a one-line note of what is known (the client's own name and version, no seat, a local connection) and says there is no return address; a task's note says to follow it by its task id. Plain sends stay unsigned. rig_whoami names the label. Claim, update, handoff, unclaim and decide still answer actor_required, and owned or mine listing still needs a seat, so a shared label reaches no author-equality check. The working-with-an-openrig-team skill (home, canonical and plugin copies, with digests), the openrig-user skill and the openrig plugin README now say that work goes through as the unknown sender rather than telling an app to stop.
2026-11-07 14:16:12 -08:00
Mike Schwarz a979ac5b95 fix(workflows): a running workflow keeps its spec when another file reuses its id and version (#2696)
Migration 152 adds workflow_instances.spec_source_path and spec_json, both nullable; at instantiate a run keeps the spec it runs, rig placeholders resolved, and the file it was instantiated from. getForInstance reads the cache row while it still comes from the run's own file, matched by real path, so an edit there reaches the run and a resume re-resolves from it; once another file with the same id and version has replaced the row, the run uses its kept copy. Just before another file replaces a row, the unfinished runs (active, waiting or failed) started from the row's file take the row's spec, in one transaction with the replacement. A file read at a new path with the same bytes after its old path is gone counts as moved, and its runs follow it carrying that spec; any other file leaves them the kept copy. An unrelated byte-identical copy read after the old file is gone also counts as moved. The stuck-overdue exception route resolves the instance's own spec and bound rig, and the slice and reconciliation views read a run's own spec. A lifecycle revision clears the kept copy, and instances from before migration 152 read the cache row as before. The daemon logs each carry or move with the files and the run count. Refs #2607 (the spec-cache overwrite; the {pod} placeholder is separate).
2026-11-07 13:41:19 -08:00
Mike Schwarz 45c85ec971 fix(terminal): clean the follow explanation strip (#2706)
In herdr-follow-view.ts, the strip pane runs printf to clear the screen and scrollback once, then execs tail on the event log, so the login shell's prompt and the echoed command no longer sit above the events and the tail's own errors stay visible. A new canvas records stripConfirmed false and only it gets the 10 s startup window; a strip saved before that field existed gets one process read. In terminal-service.ts, when live following is unsupported and no snapshot can open, the result names the reason: the resolver's error, or incomplete task membership. The CLI help for --follow names Linux and macOS Herdr protocol 22, and terminal-sessions.md describes the uncertain recovery after a lost create reply combined with a startup directory change. Follow-up to #2663; the strip tests cover the cleared start, the legacy single read and the snapshot reason.
2026-11-07 13:32:59 -08:00
Mike Schwarz 2eb3c498bc feat(queue): read a code result's exact pair from its artifact (#2659)
An assignee whose result is code can write a JSON manifest of kind openrig.code-result/v1 (repo, base, revision, paths, optional branch and fetch hints) and set it as the item's existing evidenceRef. rig_artifact_read adds codeResult to the file it returns only when the whole text file was read, it names that kind, and base and revision are full commit ids (40 or 64 lowercase hex); a manifest of the kind that doesn't qualify gets codeResultIssue with the reason, so a branch or a short id is never read as a revision. Any other file reads exactly as before, with no new column, store or queue state. rig queue artifact <qitemId> [--root <name>] [--json] does the same read through the same shared roots for CLI clients. The shared reader lives in queue-artifact.ts, and rig_artifact_read's scope rule is unchanged. docs: coordination-primitive.md and cli-reference.md describe the manifest and the verb.
2026-11-07 12:51:07 -08:00
Mike Schwarz b7eac810de fix(cli): rig acp serve after its first real-client run (#2686)
rig acp serve: a cancel that lands while an answer is being recorded stops a multi-question ask before it opens the next question, keeping the answer already chosen; each step's title leads with its row's latest note (lead@team: <note> · Sent to lead@team (qitem-…)), because Zed shows steps collapsed and cuts the end of a title; session/load replays the questions the team asked during each prompt, in the order asked, with "You answered: …", still open, or closed unanswered, read from the session's tagged rows; and an acp.to setting that can't be read comes back as the reason, for the app's sign-in step to show. The openrig-acp launcher starts the @openrig/cli Node resolves for it, never a rig on the PATH, and when that copy's version differs from the pinned one it says so on stderr and runs it. The ACP agents guide and the package README give the full registration command. Tests cover the cancel ordering, the titles, the replay and the launcher's version notice.
2026-11-07 12:49:49 -08:00
Twot 6b9e725950 fix(terminal): avoid closing reattach with 1011 during broker teardown (#2671)
In TerminalSessionBroker.ts, TerminalBrokerRegistry.attach treats a torn-down entry as absent: it registers a fresh broker at once and has it wait for the old broker's teardown (stopPipePane and the window-size restore) before it attaches, so the window size is still given back before a new broker pins it. All four teardown paths (a failed open, dispose, session death and the last detach) share one teardown promise, so dispose and session death now await their stopPipePane rather than firing it off, and a late stop can no longer cut a newer broker's pipe. onEmpty checks identity, so an earlier broker's completed teardown never evicts a newer one, and get() skips torn-down brokers. A regression test reattaches during teardown and gets a fresh broker, not a 1011 close.
2026-11-07 12:45:24 -08:00
billbenetti feede634a9 perf(ui): the event hub's first connection replays the newest 100 events, not the whole log (#2704)
In topology-events.ts, the shared hub's URL becomes /api/events?replay=100, using MAX_REPLAY_EVENTS (moved above it), which is also how many events the hub keeps for a late subscriber. The daemon route is unchanged: Last-Event-ID takes precedence over replay, so a reconnecting EventSource still gets every event after its cursor, while a new hub after a reload gets the newest 100. Older startup history is no longer streamed to listeners on first connect, so its old activity flashes and edge packets don't replay; current topology still comes from the HTTP baselines. Tests pin the new URL, check that a late subscriber gets the newest 100 of 150, and cover the route's newest-100 replay and its cursor precedence. Related to #2694 and #2695.
2026-11-07 12:43:30 -08:00
billbenetti 1b67f88f68 fix(transport): read the Claude composer when a statusLine or the task panel sits outside the box (#2700) (#2703)
In session-transport.ts, inspectComposerInput closes a framed composer on its first aligned rule, as the capture reader already does, and trusts the rows between that rule and the recognized footer only by position: none may be a rule or prompt-shaped, and a framed prompt directly above doesn't count as a composer of its own. findClaudeFooterIndex skips the background task panel only in its own shape (rows of "  ◯ " at column 2, with an optional "  ● " row above) and only right under a recognized footer. findClaudeComposer and classifyPaneActivity find the closing rule above a statusLine only when climbing from a recognized footer, and their idle window ends at the footer when the panel is recognized and stays clear of the statusLine and panel rows, so a tall panel can't push the status head out of view. readSeatBeforePaste and the startup composerRegion are unchanged. Tests cover both shapes from cropped real captures, the gap and panel variants that must stay unknown, idle classification, and draft-aware delivery end to end. For #2700.
2026-11-07 12:35:07 -08:00
billbenetti 48baf465e7 perf(daemon): deliver view.changed in memory instead of storing it (#2695)
EventBus.publishTransient(event, causeSeq?) delivers an event to the in-memory subscribers without an INSERT, without a seq of its own and without moving the notify watermark; the event carries transient: true and, as its seq, the seq of the stored event that caused it (the newest stored seq when there is none). Every transient goes to one pending queue that the end of each fan-out empties, before the rows subscribers stored meanwhile are drained, so a subscriber sees the cause, then its transients, then the next stored event; outside a fan-out and outside a notify envelope it is delivered before the call returns, and a rolled-back envelope drops only what it queued. ViewProjector.notifyViewChanged publishes view.changed this way (causeSeq is optional), and the view event bridge passes the trigger's seq, so on the view SSE streams a trigger's view.changed frames carry that seq as their id. /api/events skips transient events, so its live stream and its replay agree. The as-built event-system and UI shell docs and the daemon HTTP clients reference say that /api/events carries stored events only, that the view streams are live-only and their id is not a replay cursor, and that view.changed rows stored by older versions still replay and export until they are pruned.
2026-11-07 12:09:28 -08:00
ziadeh 7fae244b05 docs(reference): a restored seat counts as needing attention until rig seat clear-attention (#2674)
docs/reference/agent-startup-guide.md gains a section, "After a restored seat's prompt": a restore that stops at a prompt, such as Claude's resume selection, records the restore outcome attention_required; answering the prompt or running rig seat continue doesn't change it, so the seat's lifecycle stays attention_required (LIFECYCLE att in rig ps --nodes, needsAttention, the rig's attentionCount and the TUI's attention totals) even once its startup status is ready. After a full restore of a Claude or Codex seat, rig seat clear-attention <seat> checks the live seat (its tmux session is alive and the pane runs the agent resuming the recorded conversation) and then records operator_recovered; a failed check says which check failed, and --reason doesn't bypass it. A wrong recorded conversation can be set first with rig seat set-resume-token; after a partial restore or a community harness's assisted startup, --reason acknowledges the outcome. docs/reference/agent-state-taxonomy.md and the getting-started troubleshooting table say the same. Refs #2622.
2026-11-07 12:02:29 -08:00
Mike Schwarz bb417260e1 feat(plugins): an Agent Plugins 1.0 root manifest for the openrig plugin (#2683)
packages/daemon/assets/plugins/openrig gains a root plugin.json (the same name, version, description, author and license as the Claude Code manifest) and an mcp.json with one stdio server, openrig, running rig mcp serve --front-door with no environment, in the Agent Plugins 1.0 format that VS Code, Copilot CLI and Cursor read, plus a .cursor-plugin/plugin.json carrying only the name, version and description, without which Cursor ignores the root files and runs the Claude Code entry. check-packaged-plugin-versions.mjs treats the root plugin.json and .cursor-plugin/plugin.json as manifests too, so their versions must agree with the others, with a test cell for each. The README describes the server entries, the install-from-folder steps (VS Code verified on 1.145, Cursor on 3.27, Copilot CLI not yet verified), and what having no seat means: the queue tools that change a task and chatroom send answer actor_required, listing the questions sent to you needs a seat, rig_send sends unsigned, rig_whoami says there is no seat, environment export needs an HTTP work grant, and an app started from a shell with OPENRIG_SESSION_NAME (or RIGGED_SESSION_NAME) set acts as that seat.
2026-11-07 12:00:22 -08:00
Mike Schwarz 6375c58819 fix(host): a pairing request stays open as long as rig host pair waits (#2692)
rig host pair sends its --timeout (already bounded by resolvePairTimeoutMs) as timeoutMs with POST /api/hosts/pair-request, and the target keeps the request open that long, up to the 32-bit millisecond ceiling; a requester that sends none (an older CLI) gets the 10 minutes it always did. The approval item states the life in the largest whole unit and the UTC expiry to the second, instead of always saying 10 minutes. Expiry is checked against the request's own expiresAt, and an issuance prunes an expired request 50 minutes after it expires (for the default life, the same hour after creation as before), so a waiting requester reads expired rather than pair_unknown. The CLI polls at once, then every 2 s, never sleeping past its deadline. The --timeout help and the CLI reference say the request stays open that long, and that a target older than this CLI keeps it 10 minutes. Refs #2608 (item 1 only).
2026-11-07 11:53:30 -08:00
Mike Schwarz d17cab58cf feat: @openrig/acp, the launcher the ACP registry starts, and acp.to (#2655)
packages/acp adds @openrig/acp: one executable, bin/openrig-acp.js, which starts the rig acp serve of the @openrig/cli it depends on at exactly the same version, passing on the app's arguments, stdin, stdout, environment and working directory; its own messages go to stderr, it exits with the server's code or signal, and with no @openrig/cli installed it explains on stderr and exits 1. packages/acp/registry/agent.json lists @openrig/acp at that version with no arguments, beside a 16x16 currentColor icon. Because a registry launch has no arguments, rig acp serve without --to reads the new acp.to setting (rig config set acp.to <member@rig>, or OPENRIG_ACP_TO), empty by default and accepting only member@rig; the daemon's settings twin and both pinned key lists carry it. rig acp serve still reads open questions on the pass a cancel wakes but shows none of them, a cancel that lands during that read included. The ACP agents guide names the setting, says sessions are found among the 2,000 newest rows the caller sent, and says a choice is recorded as a click in Slack would be. A lockstep test pins the launcher's version, its @openrig/cli pin and the listing's version to the CLI's. Neither package is published by this change.
2026-11-07 11:51:59 -08:00
Mike Schwarz 0401c79078 fix(terminal): preserve follow pane identity across shell startup changes (#2663)
Herdr follow views and ensure-viewer accept macOS as well as Linux and check for Herdr protocol 22 instead of exactly version 0.9.5, comparing the selected socket by real path. A viewer keeps the pane and terminal IDs that its successful creation reply returned even if the login shell's rc has already changed directory, so the reported cwd no longer has to match; a lost reply still recovers only through the unique private cwd, and the foreground attach argv and current seat binding are still confirmed before reuse. The attach command is written to an owned 0600 script in the viewer's private directory, hash-checked on reuse and before removal, so the typed input stays short under a canonical-mode login shell, and retiring a viewer removes that script with its directory. A follow view's strip gets a 10-second startup window only until its first start is confirmed, which is then recorded, and the strip pane is re-read afterwards. Where live following is unsupported, the open returns follow_unsupported instead of an error, and a complete task opens as a one-shot snapshot that notes it won't track later owner changes, with its selector retired before the snapshot is dispatched. docs/reference/terminal-sessions.md is updated to match.
2026-11-07 11:47:50 -08:00
Seán McNamara 33ba747047 perf(daemon): skip native enrichment for idle-shell proof (#2690)
verifyPaneIdleShell's default process lister is now readProcessTable, timed under the existing codex.runtime.list_processes site, instead of listNativeProcesses, so the idle-shell proof reads the complete raw table without native executable-path or argv enrichment. An injected lister is still used as given, the proof still needs the same idle shell in two fresh samples, and a failed read still returns no proof. Native identity checks keep the enriched listing. idle-shell-process-sampling.test.ts covers the proof from two fresh tables without enrichment, a failed first or second sample, and the injected lister.
2026-11-07 11:46:45 -08:00
Seán McNamara a5cedb48f6 fix: clean up editor timers and stabilize verification tests (#2689)
FileEditor in FilesWorkspace.tsx clears its saved indicator from an effect whose cleanup cancels the 2-second timer, instead of an untracked setTimeout after a save, so the timer can't fire after the editor unmounts; files-editor-draft.test.tsx checks that closing the editor cancels it. session-transport.test.ts uses the existing stepped clock for a readiness-transition case and measures the readiness deadline after admission, keeping the original deadlines. The opr-0.4.1.13 table-crash stress case keeps its full workload with a 30-second test ceiling for shared-host contention.
2026-11-07 11:45:33 -08:00
Seán McNamara 18314f9b18 test(daemon): separate native Codex final reads from polling budget (#2681)
codex-resume-final-native.test.ts runs each case with pollMs 200 and maxWaitMs 200 and a spied sleep, so two loop observations exercise one retry before the gated final read; it asserts three captures and one 200 ms sleep, and keeps real terminal and process observations through the final read. The new codex-resume-final-budget.test.ts checks, for the default budget (26 loop reads), the native fixture's budget (2) and the minimum budget (1), that each of five outcomes (access-token refusal, incompatible client, missing session, ready prompt, occupied shell) is classified only after the loop is exhausted. Outer test timeouts are unchanged, and no product code changes.
2026-11-07 11:44:39 -08:00
Seán McNamara 1336a02e6d test: make prompt episode fixture chronology deterministic (#2685)
prompt-blocked-alert.test.ts: the fallback-routed alert case and the clear-then-return case give their synthetic refusals explicit timestamps around the queue's clear marker and fake only Date for their queue writes, restored in finally, so their outcome no longer depends on wall-clock time catching up with the helper's increments. The fallback case keeps its old and new alert identity and exact wake counts; the adjacent case still checks that one new refusal is not enough, including one that shares the clear marker's millisecond. No production code, sleeps or timeout changes.
2026-11-07 11:43:42 -08:00
billbenetti 88debfdb64 fix(daemon): don't report a claim as stalled while its owner runs a background shell or awaits its managed restore (#2675)
Claude Code's self-report status shell is kept as backgroundWork beside the seat's idle-at-prompt activity, never as a new activity value, anchored at the first observation of that shell and not inherited by a new occupant. A claimed row whose owner is idle with a live background shell reads working instead of stalled-after-claim for up to 60 minutes counted from the claim's own anchor, the same one the stall uses, so a shell that never ends or is restarted each turn cannot hold a claim. A row whose owner is owed a managed post-compaction restore reads working for up to 20 minutes from when the daemon began owing it (the enforcer's new postCompactStageOwedSince). A pending input keeps the needs-input reading, and past either ceiling the row reads stalled with the ceiling named in its evidence. The pickup names the grace it is working on, and the stuck sweep leaves an open stalled-after-claim recovery open while its row works only on a grace, so the recovery is still active when the grace expires. The parked-owner reminder skips a seat whose background shell was first seen under 60 minutes ago and names a shell past that ceiling in its evidence. Tests cover each ceiling, the row face and the sweep through a restore's grace and its expiry, and the parked-owner skip.
2026-11-07 11:41:30 -08:00
Seán McNamara 8379221f3e test: publish pane isolation status receipts atomically (#2673)
claude-interactive-isolation.test.ts: the fixture's PROMPT_COMMAND writes the exit status to a sibling temporary file and renames it into place, so the test never reads a receipt that redirection has created but echo hasn't written yet. Before the staged send, the test waits until the pane's command is bash again. The polling helper takes a boolean or a promise of one and keeps the original six-second budget as an elapsed-time deadline. Every existing assertion is kept; no product code changes.
2026-11-07 11:40:21 -08:00
billbenetti 53713ad284 feat(workflow): add rig workflow unregister with --prune-missing (#2635) (#2676)
rig workflow unregister [spec] calls the new POST /api/workflow/specs/unregister, which removes the cached spec rows for <name> or <name>@<version>, or with --prune-missing every registered version whose source file is absent (ENOENT or ENOTDIR), optionally scoped to [spec]. It keeps and reports three kinds of row: a version unfinished work is pinned to (retained, readable by name and version and hidden from discovery when its file is gone, as the scanner does), a file still inside the workflows folder or the built-in starters (the next scan would register it again), and a file whose presence couldn't be checked because of an access error. An empty version after @ is a usage error, never every version; naming nothing without --prune-missing, or a version without a name, is a usage error too, and an unmatched spec is spec_not_found. The spec cache gains listRowRefs, cli-reference.md lists the verb, and the daemon and CLI tests cover removal, pruning, each kept reason and the usage errors.
2026-11-07 11:37:30 -08:00
billbenetti c5a2be5614 feat(cli): queue show, list, transitions, update, claim and block take --host <id> (#2678)
rig queue show, transitions, list, update and block gain --host <id>. The CLI resolves the id in its host registry, together with the learned bindings the queue forwarder would use (the forwarder's eligibility rule, shared through the new @openrig/daemon/learned-host-bindings export), and calls that host's daemon directly with its bearer; a write uses the cross-host timeout and is recorded there as <seat>@<rig>@<this host>. --host local, or the selected daemon's own host id, stays on the selected daemon. An unknown host, an SSH-only entry or an unavailable bearer stops before any request (unknown_host, host_not_http, host_bearer_unavailable, or host_registry_unreadable when the registry cannot be read), and a learned binding that isn't eligible is named. A write's receipt keeps the host it used, and printed follow-up commands carry the same --host; a not-found read on the local daemon also suggests naming the host. claim takes no --host, because only the destination seat on its own host may claim. The cross-host skill (both copies), its digest and cli-reference.md are updated, and queue-host-existing-row.test.ts covers each verb, binding eligibility, the receipt and the error paths.
2026-11-07 11:36:00 -08:00
billbenetti 3e41daea2d perf(daemon): route targeted set-option and short one-line buffers through the tmux control client (#2636)
In the tmux control-client exec (used only with OPENRIG_TMUX_CONTROL=1), set-option joins the routed commands when every flag up to the option name is read and it names an absolute target (=session, %pane, @window, $session id, or a session part before ":") and expands no format (-F). A lone "load-buffer -b <name> <path>" on the same server is read by the daemon, non-blocking, with O_NOFOLLOW, regular files only, at most 4096 bytes, and sent as "set-buffer -b <name> -- <text>" when the bytes are non-empty valid UTF-8 with no control character and no trailing ";". The file is read only once a control client is connected, and a load stays a spawned load-buffer when the server has an after-load-buffer or after-set-buffer hook set, globally or on any session, asked once per client; otherwise any other shape, a failed read, or a read that doesn't return exactly the size the stat saw (a short read or a file written meanwhile) also keeps the original spawned load-buffer. A set-buffer a caller sends is not routed. tmux-control-read-short-file.test.ts covers the short and grown reads; tmux-control.test.ts covers the routed and kept-spawned set-option forms, the set-buffer argv and each kept-spawned file case; tmux-control-native.test.ts checks on real tmux that a pasted text lands byte for byte with nothing spawned, and that window, session and pane options set through the client read back from a spawned show-options.
2026-11-07 11:34:30 -08:00
Aummadour 0344edd0d6 fix(restore): keep local hook selection unknown without source evidence (#2617)
The Claude activity-hook reader now also reads the node's validated launch recipe. When it has an absolute rigRoot, a local: agent ref is resolved against that root (re-anchored for shipped specs, as before), and the agent.yaml found there is used when its spec name matches the seat's resolved spec name; otherwise the selection reads unknown. Nodes without a usable recipe keep the recorded-roots search, which now skips a root that resolves the ref back to itself, since it is no independent evidence of the agent's source, and names that case when it leaves no folder. restore-claude-activity.test.ts covers the wrong-root case, ordinary local:. recovery from the recipe's root, an added member, retained development checkouts with dot and non-dot refs, a reverse selection and a missing development agent.
2026-11-07 11:21:09 -08:00
Mike Schwarz a0f410e161 fix(transport): read the input box for drafts the pane classifier misses (#2653)
For an ordinary Claude Code or Codex send, when the transport has the transport.draft_delivery reader and the send carries no input rule of its own (managed compaction, a prompt override), SessionTransport reads the input box once just before the paste, within the input-box read deadline. If the box reads typed and the setting is refuse, the send returns target_needs_input with activity prompt_draft before any input, as a recognized draft does, and the queue's existing retry applies. If the setting is deliver, the result's advisory says the message was pasted into the draft and submitted with it, said once per send. An unreadable box and a transport without the reader keep today's behaviour. rig config help names the refuse-mode retry limit and that the draft's own activity wins under --wait-for-idle.
2026-11-07 05:14:13 -08:00
billbenetti a34cb55ad7 fix(cli): add --full to rig workflow trace so a long trail comes back complete (#2656) (#2667)
rig workflow trace gains --full, which passes ?full=true to the daemon's existing GET /api/workflow/<id>/trace route; there is no new daemon route. Without the flag the request has no query, as before. --json prints the daemon payload unchanged. Human output adds one line naming the --full command when the trail came back with trailCompleteness recent. docs/as-built/cli-reference.md lists the option.
2026-11-07 05:04:32 -08:00
Mike Schwarz 642be39e5b feat(cli): read immutable context reconstructions locally (#2207)
Adds rig context reconstruction inspect|view <revision>, which reads exact immutable v0 base records, replays deltas and follows pinned compositions from local stores, with no daemon call, skill install, context projection or remote fetch. The catalog project and work home are resolved through catalog-selection logic extracted from work-install, so both use the same selection. Inspect shows source manifests, purpose, depth, dates and approximate size; view keeps constraints, authority, gaps and transitive dependencies, shows actual omissions apart from authored coverage, and keeps inherited observation dates. A missing record or reference stays an explicit incomplete result. A store that exists on this machine is read at its original path; a store that moved needs a read-time mapping, and immutable pointers and revision IDs are never rewritten. Canonical hashes are Python-compatible for safe integer JSON values, and unsupported numeric representations are rejected. Every byte read is charged against a 32 MiB budget before it is decoded or parsed, and later reads stop once it is spent. docs: reconstructions.md and project-workspace.md describe the commands.
2026-11-07 05:00:30 -08:00
Mike Schwarz 8edc076d81 fix(daemon): retire a Claude seat's earlier copies of openrig-core's skills at its next launch (#2660)
A shared retirement in seat skill storage removes an automatic core copy only when its ownership and source-root records identify it as a copy from the directly loaded core's real path, its tree still matches the recorded digest, core still supplies the skill, no explicit selection names it, and no skill-projections record or following entry names it. The copy is moved through the existing bounded staging with rollback, and its id is removed from every topology owner's selection so restore and saved-loadout apply can't recreate it. Edited, linked, unreadable, unrecorded and projection-recorded copies are kept and named, and seats without direct core are untouched. It runs before the harness reads its plugin roots: in launchNode before the session for a fresh, restore or launch start, and after takeOverPane succeeds for a kept-pane relaunch; in a handover after the predecessor is proven gone and before respawn; and in set-runtime only when the runtime actually changes. Saved-loadout apply is unchanged from main. Its notes reach the launch, fresh-launch, handover and switch results as warnings, and no new refusal is added.
2026-11-07 04:58:30 -08:00
Mike Schwarz b1bed9f8c2 fix(daemon): don't compare a resumed seat's predecessor model with its pin (#2664)
CurrentModelReader.readEffectiveModel(), which the divergence monitor uses, now reads the newest model record's model and time for Claude and Codex and applies observe()'s rule: a record at or before the current occupant's tenure boot reads pending ("no model record from the current occupant yet"), and a record whose time or boot can't be placed reads pending too. Pending is the monitor's existing no-verdict state, so it checks again at the next poll and never settles a verdict on the predecessor's record. A resumed conversation keeps its predecessor's records in the same file, which is how the false verdict arose. Pi's reader is unchanged. A Codex seat with no registered occupant tenure now reads pending, with the monitor's existing unchecked warning, where it was compared before.
2026-11-07 04:48:33 -08:00
Mike Schwarz 772fa5c5fb feat(plugins): an openrig plugin for a person's own coding agent (#2647)
Adds packages/daemon/assets/plugins/openrig beside openrig-core, shipped in the npm package. Its Claude Code manifest runs rig mcp serve --front-door with OPENRIG_SESSION_NAME from the plugin's seat setting, the app's address on the person's team as a hosted member; there is no root .mcp.json and no hooks. A marketplace manifest named openrig-app installs it from the package already on the machine, and a Codex manifest connects it with one command. One new skill, working-with-an-openrig-team, describes the loop around the existing MCP tools; its home is the spec copy, and mirror-skills generates the canonical and plugin copies and routes it in the skills index. rig mcp serve --front-door gives the plugin's seat front-door instructions (the person's questions and words, with no member work-loop authority), and rig_whoami answers a declared hosted seat with no session when its rig exists; the default instruction modes and tool exposure are unchanged.
2026-11-07 04:44:19 -08:00
NIKHIL c26af88dc7 fix(transport): read Claude's own-row "Restart to update" banner as a status line (#2662)
CLAUDE_STATUS_WARNINGS, the exact rows that may sit under Claude's mode footer, gains one alternative: "✔ Update installed · Restart to update", the wording Claude 2.1.319 prints on its own row in a narrow pane. The pattern stays anchored at both ends and still matches "/restart to apply" and "Restart to apply" exactly, so the footer search reaches the mode footer and the draft-aware reader, activity classification and send readiness read the prompt as before; a near miss still reads unknown. The versioned banner and the /rc row were already handled. Tests add the row to the warning-footer readiness matrix and an empty, typed and near-miss composer case.
2026-11-07 04:40:13 -08:00
Mike Schwarz 6fa0ad5c6e feat(cli): rig acp serve asks the person the team's questions and reopens sessions (#2651)
rig acp serve's agent server asks the person: during an open turn it polls the caller's open asks and puts one to the app as session/request_permission only when its tags carry this chat's exact acp-session:<id>; an untagged ask from a seat the turn's work reached is named, never asked. A choice posts the existing Mission Control answer verb, so it is recorded by the same answer contract Slack and the TUI use, with the actor taken from the caller's session header; a refused answer is shown with the daemon's reason. On cancel the turn waits up to 2 s for questions the app is settling, then finishes them before returning cancelled. Sessions are the caller's acp-serve rows tagged acp-session:<id>: session/list groups them, session/load replays each root through the same chain follower a live turn uses, and a later prompt carries the session tag and names the earlier roots. session/load of an unknown session returns JSON-RPC -32602. The answer POST sends OPENRIG_AUTH_BEARER_TOKEN only as its Authorization header. docs: acp-agents.md describes questions and sessions.
2026-11-07 04:37:10 -08:00
Mike Schwarz f5b62726ff feat(transport): transport.draft_delivery lets ordinary sends reach a seat with a draft (#2645)
Adds transport.draft_delivery (deliver or refuse; default deliver) to the CLI config store, the daemon's settings store and the TUI config model, with OPENRIG_TRANSPORT_DRAFT_DELIVERY taken first when it is exactly deliver or refuse. The daemon reads it at each send that meets a person's draft: only a readable refuse opts out, and a value or read that fails falls back to deliver. Under deliver, SessionTransport pastes the message at the current cursor into the draft and sends Enter, so the combined input is submitted. Under refuse, the send is refused as before and a work notice is retried once the prompt is clear. Menus, permission prompts and hook-reported requests still refuse, a per-seat typing guard still holds, and managed compaction still waits for an empty prompt. rig config help describes both values and that a draft OpenRig can't detect isn't mentioned in the result.
2026-11-07 03:26:18 -08:00
Samuel 30b9556355 feat(gateway): add authenticated loopback text relay (#2624)
Adds a relay connector to the gateway: an HTTP loopback transport to an operator-owned bridge process, configured per connector from an absolute secrets file (OPENRIG_RELAY_URL, OPENRIG_RELAY_OUTBOUND_TOKEN, OPENRIG_RELAY_INBOUND_TOKEN), with no environment fallback. Outbound, each decision freezes its complete relayEnvelope on its DispatchBuffer entry before the first attempt and posts it whole, once per attempt, with deliveryId set to the decision ID; a 2xx records relay-owner-notification-accepted and drains the entry, a failure records relay-owner-notification-transport-failed and keeps it for replay of the same bytes, and a stop mid-attempt records neither. Inbound, POST /api/gateway/relay/inbound requires the connector's bearer token from a direct loopback socket peer (forwarding headers are ignored), attributes the reply by sender.handle against that connector's registered people, and commits the answer or resolve with its audit record through the Mission Control write contract, deduplicating an exact replay of the same eventId. Inbound size limits are a documented protocol contract, outbound references are fixed-width digests that the inbound contract accepts whole, and a ledger whose state can't be read returns a scoped retryable 503. Readiness warns, rather than going unready, on group or other bits on a readable secrets file and on one token used for both directions within a connector. docs/reference/gateway-relay.md describes the topology, custody, configuration and wire protocol.
2026-11-07 03:14:50 -08:00
Mike Schwarz bfbe416861 fix: qualify bare external attach names with their rig (#2641)
An external (non-tmux) self-attach resolves its session name in SelfAttachService.resolveContext. The explicit name (an external_cli context's sessionName, or else --display-name) is kept exactly when it already contains @ or is already saved for the same node in sessions; any other explicit bare name becomes name@rig. With no explicit name, the derived default stays logicalId-with-dashes@rig, as before. A tmux context passes through unchanged. The attach command's --display-name help says new bare names gain @rig. A member already bound under a bare name keeps it on an ordinary re-attach, and so stays unaddressable by the queue until an explicit repair.
2026-11-07 02:49:24 -08:00
Wu Shuwen 00cb932c0f fix(cli): report masked configuration overrides (#2547)
A whole-file rig config reset now names the keys whose effective value still comes from an environment variable, and keeps the existing message when there are none. If reading the settings back after the reset fails, the reset is still reported as successful (exit 0) and the read-back problem is noted on stderr. Single-key reset is unchanged. Contributed by dajiaohuang; reported in #2528.
2026-11-07 02:11:39 -08:00
Mike Schwarz 73638fcddd feat(cli): rig acp serve, OpenRig as an agent in ACP apps (#2648)
rig acp serve runs OpenRig as an ACP agent over stdio. A prompt becomes a queue row to the receiver named by --to, attributed to the caller (--as, the seat environment, or the default person) with the session's follow-up lineage in its body; the turn follows the row and its handoffs, including handoff-and-complete's done/handed_off_to form, and ends with the team's result, naming the closer from the row's closing transition. The row ID is chosen before the create: when a create gets no answer, a readback that finds the row carries on, and one that doesn't keeps the ID as unconfirmed in the session's lineage; an answered 4xx is a plain refusal and a 5xx stays unknown. The JSON-RPC peer splits into the existing client end and a new stream end over stdin and stdout, with no protocol SDK dependency. The queue is the only store, and a 2 s poll follows open turns.
2026-11-07 02:09:44 -08:00
Mike Schwarz 0c57bdb38c fix(terminal): preserve styling in snapshots and scroll-back (#2654)
The terminal broker's attach snapshot and scroll-back window now request tmux captures with escape sequences, so colour, bold and dim survive the first paint. The painter keeps tmux's carried styling across rows instead of resetting each row, resets once before clearing the screen and once after the last painted row, and replays the styling of rows above the first painted row when a height crop or the scroll-back window drops them. The captures feed only the websocket painter; activity, input-box and prompt detection keep their own plain captures. Reported by niklasbaldi in #2598.
2026-11-07 02:07:43 -08:00
Wu Shuwen 9b1d94d6f0 docs(reference): clarify bearer and browser boundary (#2652)
daemon-http-clients.md no longer says a token never gets past the browser boundary's 403 refusals. A valid configured bearer token in the Authorization header can pass the target-name check for a well-formed but unaccepted Host; it cannot pass a malformed Host or a refused Origin. The sentence links Browser access for the full rules. Docs only. Contributed by dajiaohuang; refs #2486, where the correction was reported.
2026-11-07 01:39:01 -08:00
Seán McNamara 378d25301d fix(cli): avoid cold compile-cache writes for piped bare usage (#2643)
bin-wrapper enables Node's compile cache except for a bare invocation with stdin or stdout piped or redirected, which only prints usage. That case loaded the command tree only to print usage, and with a cold cache the deferred cache writes near exit could push it past a caller's deadline after the usage was already printed. Any script argument, including piped --help and --version, and a bare run with both streams on a terminal, keep the existing cache attempt, matching the front door's own predicate. A new bin-wrapper test covers the piped and terminal cases. Contributed by seanmcn; reported in #2642.
2026-11-07 01:07:22 -08:00
Seán McNamara b1065870f6 feat(pi): switch accounts and preserve selection through the seat lifecycle (#2630)
rig provider switch --account-home and --clear-account-home select or clear an existing Pi seat's account home through a confirmed successor with a fresh conversation and handover; an unconfirmed or failed switch leaves the old account and state intact, and the selection survives restart, resume, restore and daemon restart (migration 151 keeps switch history and creates or copies no credentials). The chosen directory must already hold a readable auth.json, which the runner links into the seat's managed agent directory; env-only accounts and wrappers that create credentials later are outside this path, and an existing seat-local login or unmanaged auth link is preserved through the confirmed switch. Selected accounts load a Node preload that points only their auth-lock operations at the chosen account's auth.json.lock pathname, even when that auth.json is a symlink, and removes its own NODE_OPTIONS entry before Pi starts tools; outside the managed target it does nothing. A Node-hosted launch wrapper that execs Pi can consume the preload, so that Pi gets no shared-auth lock and keeps its native per-seat lock; compiled Pi and wrappers that discard NODE_OPTIONS are likewise not covered. Contributed by seanmcn; requested in #2629.
2026-11-06 23:52:50 -08:00
Mike Schwarz 892eb55b5e fix(transport): leave a pane's mode through the seat's address (#2644)
leavePaneMode passes the seat's session name to cancelPaneMode, as the text and Enter writes do. The guard resolves the seat and writes to its bound pane; the mode reads stay on the bound pane. Before this, the cancel addressed the raw pane ID, and when a stale binding in another rig named the same reused ID, resolveGuardTarget matched two bindings and refused the cancel (guard_target_unknown), so the send failed pane_in_mode with no text sent. A new session-transport cell covers the stale same-pane binding, asserting delivery and submission, the cancel's target, the reads on the pane, and resolveGuardTarget on both forms. Not changed: reconciling stale bindings, and a person scrolling again between leaving the mode and the Enter.
2026-11-06 23:48:10 -08:00
Seán McNamara cfbe5372db fix(claude): pass short permission-hook policy names (#2640)
Claude kernel and team launches pass the named policy kernel or team to the PreToolUse hook instead of the base64 policy, shrinking the argument from 988 to 6 characters for kernel seats and from 1,640 to 4 for team seats. The three policy lists move unchanged into a shared CommonJS asset with TypeScript declarations, read by both the launcher and the hook, so every allow and ask decision is unchanged; legacy encoded arguments still decode, and the missing-hook fallback is kept. rig-spec.md documents that named-policy sessions read the installed lists on each later Bash hook call while native inline allowances stay as launched, and that rolling back in place to a pre-name hook gives no hook decision, so the lifecycle asks can disappear under the native Bash(rig:*) allowance until affected Claude seats are relaunched. Contributed by seanmcn; requested in #2639.
2026-11-06 23:37:50 -08:00
Mike Schwarz 09781df0ae fix(codex): keep OpenRig's command folder on managed seats' tool PATH (#2613)
Managed Codex seats now pass Codex -c allow_login_shell=false, so tool commands run in a non-login shell and inherit the environment their pane's shell set up, OpenRig's command folder included; plain rig works however OpenRig was installed, with a Codex that supports allow_login_shell. Inherited setup isn't re-run for each command, so setup that lives only in login or rc files, and shell functions and aliases, may be missing from tool commands, and a model request for login: true gets Codex's own refusal. rig config set launch.codex_login_shell true omits OpenRig's override, deferring to Codex's own setting, at each seat's next launch; a restart in place keeps the running choice. Fresh launch, fork, resume, legacy resume, restart in place and the launch-wrapper path each pass the setting exactly once, or not at all when it's on. getting-started.md and the config help describe the trade-off.
2026-11-06 22:50:43 -08:00
Wu Shuwen 36b0ae6464 fix(cli): refuse daemon start when a probe is blocked (#2502)
When OS policy prevents a reliable process or port check, daemon startup now reports the uncertainty instead of treating a blocked probe as proof that an address is free. Missing state and preflight cases warn. The auto-start stop is narrowed to the shared PID path for a reused or uninspectable PID. Stop refuses unknown process liveness before probing or signalling and preserves the state file. A denied signal probe yields unknown even when the process state is readable, and a confirmed zombie remains dead. A blocked explicit non-loopback OPENRIG_URL stays on the blocked-status path instead of starting a local daemon (rig up and rig start); loopback targets, including localhost, [::1] and 127.x addresses, keep the existing startup behaviour. Refs #275 and #1650. Contributed by dajiaohuang.
2026-11-06 22:18:20 -08:00
Mike Schwarz 126c52ab50 fix(mcp): route first tasks to a trusted configured receiver (#2638)
Adds --task-destination <member@rig> to rig mcp serve --http --grant-service. For an external caller, rig_queue_create's destination becomes optional; an omitted destination takes the operator-configured receiver. The receiver comes only from that flag, never from caller input, topology, names or history. It is checked against the request's grant with the same team predicate as an explicit destination, before any lookup, and re-resolved on each omitted call to exactly one existing local node; a missing, ambiguous, removed or out-of-grant receiver returns task_destination_unavailable with no write. Explicit destinations, and local and member calls, keep their existing behaviour and still require a destination where they did. The external instructions now teach the create, show, revise and artifact loop. The new option is refused only when misused: it needs --http --grant-service, and takes one local member@rig seat.
2026-11-06 22:06:19 -08:00
Seán McNamara 46ce2bd860 feat: refresh opted-in seat skills on restore for Claude and Codex (#2626)
Adds an opt-in on_restore policy to resources.skills[] entries in agent specs. keep_saved, the default and what an absent policy means, keeps #2337's behaviour: a restored seat keeps its saved skill copy. follow_source refreshes the seat's copy of that skill from its source when the seat is restored, for Claude and Codex seats, keeping the native conversation. Copies edited, linked or not owned by OpenRig in the seat keep their existing protections. An unrecognized on_restore value adds an advisory that names the resource, the value and the two supported spellings, and is normalized to keep_saved rather than invalidating the spec. On a no-op restore a keep_saved skill reads only the projection record, and the following bookkeeping is best effort, so it can't fail a launch. Documented in docs/reference/agent-spec.md. Requested by seanmcn in #2625.
2026-11-06 21:42:35 -08:00
Mike Schwarz 6a46172df1 fix(terminal): keep Herdr views from earlier releases after upgrading (#2634)
Kept Herdr viewer journal entries and follow-view saved tiles store the whole tile attach argv. #2614 changed the writable tile script, and #2464 changed both scripts before it, so views kept from 0.7.5-0.7.9 failed the strict comparison after upgrading: a kept viewer read unconfirmed and a kept follow view stopped adding and closing tiles. earlierTileAttachment accepts a kept argv only when it exactly equals what 0.7.9's or 0.7.5-0.7.8's generator makes for the current session, tmux, server and recorded read-only intent; the entry then moves to the current command. A different target, a changed read-only intent, or any other script (including one sharing the old prefix) still mismatches as before. The earlier generators live in src/domain/terminal/earlier-tile-attach.ts, shared with the tests. The approach follows natefaerber's #2467. No new refusal, error code or setting.
2026-11-06 20:37:10 -08:00
Seán McNamara 5049550203 test: stabilize daemon fixtures under startup contention (#2628)
Makes the daemon test fixtures hold on macOS paths and under startup contention, without weakening any assertion: readiness waits instead of fixed sleeps, temp-root resolution through the real path, a script-file shell for long commands, serialized mutations where tests raced, a long-path command, and the shared hermetic test setup. The refocus-overlap cell still asserts the lost pendingOn race, now through a deterministic handshake (the time budget only scales timeouts). The existing Linux CI job that covers /proc seat environments also runs pi-shell-launch-native.test.ts, and the Linux long-command session tokens stay within their limit. Test and setup only; no product code changes. Reported by seanmcn in #2627.
2026-11-06 20:15:53 -08:00
Seán McNamara d1688735d5 fix(daemon): canonicalize new inherited tmux homes before recording (#2633)
On the first inherited tmux server selection with persistence on, tmux-server-selection creates the home directory (recursively, best effort) before writing the record, so the recorded home is the canonical path a later boot or a later switch to isolation reads it back by, even when the home sits under a symlinked parent. If the home can't be created, inheritance carries on without it as before; inheritance has no writable-home prerequisite. Homes already recorded are unchanged. Reported by seanmcn in #2632. No new refusal, error code or setting.
2026-11-06 20:04:40 -08:00
w4rc0n c271299f55 feat(ui): answer an ask's questions on its For You card (#2612)
For You cards for local asks with humanQuestions show the questions with the card's existing controls: one option per question, or an answer in the person's own words, recorded through POST /api/mission-control/action with the ask's revision, then sent once every question has an answer. The card names who OpenRig records as answering (human@host (unattributed), as the TUI does). Live attention keeps priority in the 50-item window, with answered receipts using spare room, and saved session snapshots are bounded with it. A receipt says "answered and returned" only on the answer outcome or reply evidence; a plain close shows as done with the recorded choices, and no closing-note text is read as an answer. Finished asks without questions move to Shipped again. The shared action hook keeps remote_action_failed detail and status. Cards from a paired remote host keep the TUI hint; CHAT, asks without questions and runner permission asks are unchanged.
2026-11-06 16:38:42 -08:00
Mike Schwarz 9a9205da2a docs(roadmap): name 0.7.9 as the current release (#2623)
ROADMAP.md's released section moves from 0.7.8 to 0.7.9: the heading, the release link, and the published 0.7.9 notes' first two sentences with "OpenRig 0.7.9" written as "it", as the 0.7.8 update did. The pointer to the release notes for what shipped, known issues, upgrade instructions and contributor credits is unchanged. Text only.
2026-11-06 16:16:38 -08:00
Mike Schwarz ce6b1670b0 docs(queue): name cross-host command in custody advisory (#2621)
The two unverified-custody messages a manual close gets (a successor named on another host, or no local row with the source's lineage) now name the route that makes the handoff verifiable: rig queue handoff or handoff-and-complete with --to <seat@rig>, and --host <registered-host> for a successor on another host. That route creates the successor there first and closes with the derived key the stuck sweep already trusts. The sweep, its custody rule and the CLI command definition are unchanged: a registered-host name alone is still not proof that the work was handed on. No new refusal, error code or setting.
2026-11-06 15:24:11 -08:00
Samuel abd515021f feat: add local Pi and Codex launch wrappers (#2609)
Adds an optional member-level launch_wrapper for local built-in Pi and Codex seats: an argv prefix applied at native launch and carried through restore, handover, restart and runtime switch (migration 150 stores it). For Pi the wrapper wraps the child process the runner spawns, inside the unwrapped runner; flat specs carry the field. Once selected, a wrapper is never silently skipped. rig-spec.md says retention applies to existing seats, not newly imported nodes. Native cells cover the wrapped paths with an rc PATH export, a readiness marker and wrapper execution markers. Sandbox, memory and tooling remain the operator's choice. Reported by smota in D#2328.
2026-11-06 15:06:15 -08:00
Mike Schwarz 4697977a17 fix(daemon): stop replaying failed wakes to seats with no session at start (#2620)
drainPendingWakeIntents skips a failed or indeterminate wake intent while its seat has no receiver: the destination is unroutable, or the seat's current session, resolved through physicalSessionName, is exited or detached. The check runs at every claim, including grouped and correlated claims, so a seat that comes back during the pass gets its later wakes, and only notifications nothing in the pass attempted are counted as waiting. A never-tried intent always gets its first attempt; running, unknown and non-pane-bound seats take the old path, and an error in the check fails open. The skipped rows stay pending and owed, with no write, and the start-up log line adds waiting=N and names the seats. The wake ladder and stuck sweep are unchanged. No new refusal, error code or setting.
2026-11-06 13:54:50 -08:00
Mike Schwarz 1ff76d5954 fix(cli): name the safe retry when rig stream emit times out (#2619)
A timed-out or unreadable rig stream emit can still land late. Its error now names the retry that can't add a second item: with --id, repeat the same emit with that --id (the daemon keeps one item per stream item ID) or check first with rig stream show <id>; without --id, check with rig stream list --source <recorded seat> --since <five minutes before the send>, using the seat the daemon records, and pass a stable --id on later emits. queue create is unchanged; its existing --id already makes a retry safe, and the openrig-user skill now says so for both in one paragraph (skill digest updated). No new refusal, error code or setting.
2026-11-06 13:22:01 -08:00
Mike Schwarz d539277a5f fix(terminal): let writable tiles follow their visible size (#2614)
tileAttachArgv chose ignore-size for a writable tile whenever another normal client was attached to the session, and under window-size latest such a tile could never size the pane, so the pane kept an older client's dimensions. Writable initial, fallback and replacement attaches, and the writable recovery command, now attach without ignore-size; read-only attaches keep -r and are byte-identical to before. This covers writable rig, pod and seat views, saved writable members through composeView, and interactive task viewers, locally, over inherited SSH and through the remote helper. The tmux control client's no-output,ignore-size and the CLI's attach-flag recognizer are unchanged. A real tmux test with a competing older client fails on the previous code (14x18 where 101x32 is expected) and passes now (101x32, then 121x43 on resize), with a larger read-only observer keeping its size. Limits: different simultaneous sizes on one shared pane follow tmux's latest-client rule; clients attached before this change keep their flags until re-attached.
2026-11-06 12:38:33 -08:00
Mike Schwarz bf5e8d8c21 fix(daemon): warn about unknown agent profile uses keys (#2618)
validateAgentSpec adds an advisory for any key under a profile's uses that isn't one of the five known categories, naming them, on the existing advisory channel, so it reaches the person at rig up, restore, retry and rig agent validate. It never makes a spec invalid, and hooks stays a validation error with no second advisory. The advisories for shapes that normalization drops now end "; ignored", and a non-string or blank list entry, which is kept and fails resolution when its profile is selected, now ends "; a member using this profile fails to launch". The agent spec reference says so in one sentence. No new validation error, refusal or setting.
2026-11-06 12:21:35 -08:00
Mike Schwarz b75ab56552 docs(changelog): carry the published 0.7.9 section onto main (#2616)
CHANGELOG.md gains the [0.7.9] section from the v0.7.9 tag (ddb9d25b), placed above [0.7.8], with the status line "Published 2026-11-06 as @openrig/[email protected], built from the commit tagged v0.7.9 (ddb9d25b)." Where the tag's section and the published release notes differ, it uses the published wording: the summary (including its credit to the people who reported or contributed), the OpenShell worker section, and the Known limits, including "What we ran end to end under the runner contract". The OpenShell heading is "The OpenShell worker in 0.7.9" so its anchor is unique in the file. Nothing outside [0.7.9] changes; whats-new.md stays as it is, since main's package test pins its heading to main's package version.
2026-11-06 11:43:34 -08:00
Wu Shuwen 033587c042 fix: validate agent profile resource selections (#2585)
A profile's uses.<category> written as a non-blank scalar validated but was silently dropped, so an agent could start without the selected skill. normalizeUses now turns a non-blank string into a one-item list for all five categories, and validation adds an advisory suggesting list syntax. A uses value that isn't an object, a category that isn't an array, and a blank or non-string entry are advisories, not new validation errors, so a malformed unused profile can't stop a spec; arrays reach resolution unchanged. When a profile is selected, its coerced scalar resolves exactly like a one-item list: a resource the pool doesn't hold fails that member's resolution with "not found in resource pool", and a non-string skill ID now returns "has a non-string skill ID" before discovery instead of throwing. The advisories travel with the spec's existing advisory channel. Limits: no trimming of scalar values (as with list entries); unknown uses keys are not yet advisories.
2026-11-06 11:31:36 -08:00
Mike Schwarz ab7f32e096 chore(release): bind the packaged-plugin baseline to published 0.7.9 and advance openrig-core to 0.1.26 (#2615)
scripts/fixtures/packaged-plugins-last-release.json now names release 0.7.9, its published archive (sha256 668b9305...) and the openrig-core 0.1.25 payload it shipped (f1838261...), byte-equal to a --snapshot of the published npm tarball and to the v0.7.9 tag's plugin tree. openrig-core moves from 0.1.25 to 0.1.26 in both harness manifests: its files changed on main after 0.7.9 (permission-stop descriptor, refocus and the trace script), and check-packaged-plugin-versions.mjs requires a greater version when packaged bytes change, so existing installs don't keep old content. The check fails at 0.1.25 and passes at 0.1.26; nothing else pins 0.1.25. No other file.
2026-11-06 11:17:20 -08:00
Mike Schwarz 829ddfccc7 docs(rigs): try a user-writable npm prefix before a project-local install (#2611)
The rigs skill's Install step offered only npm install -g @openrig/cli. When npm's global prefix isn't writable (often a system Node with prefix /usr), an installing agent improvised a project-local install, and Codex seats then reported rig: command not found. The step now has the agent first point npm's prefix at a folder the person owns whose bin is on their login PATH (for example npm config set prefix ~/.local when ~/.local/bin is on it), saying first that this changes their npm settings and, if needed, their shell profile; use the Node of a version manager such as nvm instead, since nvm refuses an npm prefix setting; and install inside the project only if neither is possible. It links getting-started's "Before you start" for setting and checking the login PATH. Both copies, skills/rigs/SKILL.md and packages/daemon/assets/skills/rigs/SKILL.md, stay identical. Text only; no code, command or gate. Limits: it makes the project-local fallback rarer but doesn't fix why managed Codex seats lose OpenRig's command folder after one (separate work); not run with a live agent install.
2026-11-06 09:38:15 -08:00
Mike Schwarz c97cd5c964 fix(events): page the notification drain (#2610)
The post-commit notification drain in EventBus read the whole remaining event tail into one array. It now reads ordered SQL pages of at most 500 rows (seq after the cursor, ordered by seq) and keeps paging until a read comes back empty. The per-row cursor, subscriber fanout, nested standalone and envelope emits, and poison recording are unchanged; replayAll(), SSE replay and client buffering, and event retention are untouched. One test commits a tail larger than two pages through real SQLite, with subscriber emits at page boundaries and a malformed row, and checks the same order for two subscribers, one poison notification, no redelivery, an uncapped replayAll(), and that no SQL read returns more than 500 rows. No new refusal, error code or setting. Limits: this bounds rows per query, not bytes; the drain still runs synchronously to quiescence; no latency change on a live install is claimed.
2026-11-06 08:20:25 -08:00
Mike Schwarz b571206ffd fix(daemon,cli): show agent spec advisories at launch and in rig agent validate (#2606)
validateAgentSpec already produced non-blocking advisories, but loadSpec dropped them, so only rig agent validate --json and the HTTP route ever showed them. loadSpec now carries them on ResolvedAgentSpec.advisories, rigPreflight turns each base or imported spec's advisory into a warning naming the member, the spec and its path, beside the existing import-collision warning, and those warnings ride the existing preflightWarnings that rig up prints. A recipe-backed fresh restore (launchExistingAgentMember) and a successful first-start retry now return the same advisories through their existing returned warnings, once per member, spec and advisory, without doubling where preflight warnings are already merged. Human rig agent validate prints one agent spec advisory line per item, valid or not; --json is unchanged. No new refusal, error code or setting. Limits: a native resume that uses saved context and doesn't reload a spec shows none; unknown uses keys are not yet advisories (a follow-up after #2585).
2026-11-06 06:55:58 -08:00
Samuel f4d1d962a9 docs(telemetry): define API contract and link external exporter (#2589)
docs/reference/telemetry.md gains a boundary: OpenRig's telemetry guarantee ends at the finite HTTP/CLI read envelope, and any projection of it is owned by an external consumer. It describes the separately maintained openrig-telemetry exporter's mapping and limits as that exporter's choices (sampling, observed state intervals, no native execution or trace attribution, downstream freshness), says arbitratedNeedsInput is true, false or null with null meaning no evidence either way, and links the exporter's repository, which documents its installation and supported versions; OpenRig doesn't test or guarantee it. It states OpenRig's own fact that the finite reads are versioned by schemaVersion 1 while the cached rig and node inventory endpoints are operational projections. finite-telemetry.test.ts adds one contract cell through the production telemetry routes: the envelope and limits, decimal IDs above JavaScript's safe-integer range across the archive and active tables, the note-free row shape, an opaque cursor that round-trips, page counters, the cap reason and a retained sequence gap. No exporter code, configuration or tests are included, and no daemon or CLI behaviour changes. Limits: the contract cell pins the envelope strictly, so an additive field under schemaVersion 1 needs the cell updated; the exporter itself wasn't reviewed or run here.
2026-11-06 05:59:17 -08:00
Wu Shuwen f68d28e417 docs: clarify Claude core plugin skill loading (#2584)
The agent-spec reference described plugin skills as always projected into the seat's skill folder. Since the duplicate-loading fix related to #2426, a Claude seat that selects the vendored openrig-core gets that plugin loaded directly, skills and hooks included, and resolveNodeConfig skips projecting its skills; other selected plugins, Codex seats and non-vendored copies still project. The skills section and the plugin table now say so, and that a seat that didn't select openrig-core doesn't get its hooks this way. Docs only. Limits: one sentence says the daemon loads the plugin through CLAUDE_CODE_PLUGIN_DIRS; the daemon sets that variable and Claude loads the plugin, as the table row says. The direct load depends on a Claude Code version that reads that variable, which refocus-channel.md states for the hooks.
2026-11-06 04:40:15 -08:00
Mike Schwarz ff49a2c362 docs: say a config fragment cannot install a Codex profile, and how to keep one (#2604)
agent-spec.md's fragment section now says a fragment only adds tables to config.toml, so it can't install a named Codex profile in either form: Codex reads a profile from its own <CODEX_HOME>/<name>.config.toml with top-level keys and won't load one while a legacy [profiles.<name>] table remains; it points to creating the file and selecting it with codex_config_profile, and notes that the profile preflight checks it before a launch or restore and names the file for a legacy table. getting-started.md says builtin:yolo drops the whole profile, not only its sandbox and approval settings, and how to keep a profile: full access inside the profile with permission_policy absent, or a workspace-write or read-only codex_permissions envelope, which keeps -p and adds its flags; full access isn't an envelope. rig-spec.md's codex_config_profile row says the same in one sentence. Docs only; no behaviour or contract change.
2026-11-06 02:57:33 -08:00
Mike Schwarz 06a0663b42 fix(openshell): use confirmed engine cleanup inventories (#2603)
Podman registers its native /libpod routes only under a version prefix, so the observer's unversioned /libpod container list and inspect calls got the router's plain-text Not Found, which the reader then failed to parse as JSON; an owned delete stopped unconfirmed at the cleanup inventory. The observer now uses the compatible /containers/json?all=true and /containers/<id>/json routes, which Podman serves with or without a prefix and whose Id, Names, Labels and Mounts fields the existing selection already reads. Volume absence no longer comes from a per-volume 404: one /volumes listing must be a 200 with an array of named Volumes and no Warnings, and presence is name membership in it; anything else is environment_cleanup_unconfirmed with the HTTP status. The reader parses JSON only for a 200, and malformed 200 inventory is a typed unconfirmed error. Socket identity, workload and supervisor selection, ownership and lifecycle are unchanged, and nothing is sent to the engine but GETs. Limits: container rows are still checked only for status and array shape, so a 200 of [{}] with an empty volume list would read as absent (unchanged here; Podman emits full records or an error); a thrown cleanup error still omits its stage and dispatch status; this is a snapshot, not an atomic inventory; maintained native owned-delete isn't requalified by this change.
2026-11-06 02:52:35 -08:00
Mike Schwarz 3a31311795 fix(cli): arm rig exec --wait's Ctrl-C before announcing the run (#2601)
rig exec run --wait installed its SIGINT listener only when polling began, after it had printed that the run had started, so a Ctrl-C landing between the two met the default action and killed the CLI with no exit code (the run itself, in its own session, was never affected). armWait now installs the listener before each path announces the run: a run the row already has, a caller that lost the claim to another, and a fresh start right after the connector is spawned, where the start-wait loop also stops when the waiter leaves. report removes the listener in a finally, and waitForEnd takes the waiter instead of arming its own. A tests-only afterAnnounce seam stalls the CLI just after the announcement; the new cell sends a Ctrl-C to the whole process group into that stall and expects exit 3, the stopped-waiting line and the run completing. Limits: a Ctrl-C before the connector is spawned still meets the default; an exception before report would skip the listener cleanup, though the CLI exits on it; the pre-announcement path is traced at source, not separately exercised; a run that has already ended still exits 0 or 2 by its outcome.
2026-11-06 02:32:50 -08:00
Mike Schwarz 6e55271447 fix(openshell): preserve supplied guest CA references (#2602)
The OpenShell guest's launch builds the Claude child's environment from scratch and copied only ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY, so a supervisor-supplied CA never reached the worker and its TLS failed with SELF_SIGNED_CERT_IN_CHAIN. The same explicit list now also carries NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, REQUESTS_CA_BUNDLE and CURL_CA_BUNDLE, each copied only if the guest's exec environment has it; when they're absent the launch is as before. No broad environment copy, no TLS bypass, no credential, and provider references, argv, latch and lifecycle are unchanged. Limits: NODE_EXTRA_CA_CERTS is the demonstrated repair for Claude; the other three are the CA selectors this exec supplies, kept for the agent's own OpenSSL-based tools, and no record isolates each as needed. This preserves the supplied references; it isn't a claim that the maintained OpenShell worker is qualified end to end (export, finite completion and exact restore are unproven).
2026-11-06 02:29:19 -08:00
Devulapalli Naga Sri Vaishnavi b12934a53f fix(cli): route self-qualified sends to the local host (#2600)
resolveCrossHostTarget returns localHost: true only from its self branch, when the qualifier is the daemon's own host ID, and only after the existing check that refuses an explicit --host naming a different host. rig send then leaves opts.host unset for that target, so a persisted remote selection no longer catches a send that names this host; the documented order (explicit --host, then target sugar, then the persisted selection) holds again. The early --context refusal now covers only fan-out, and the remote --context refusal runs after host resolution, so explicit, persisted and @remote sends with --context are still refused before any remote dispatch, now after the local health read. Limits: when the self-host ID can't be read, the qualifier isn't recognized as local and the selected host may still be used, as before; rig capture and rig transcript don't pass the self ID, so @<self> isn't stripped there (unchanged); the tests use a local HTTP fixture, not a real second host.
2026-11-06 01:27:20 -08:00
Mike Schwarz b79c76f0b6 feat(cli): rig exec runs a row's task on Claude Code or Codex in a detached connector (#2597)
rig exec runs a queue row's task on Claude Code or Codex through their maintained ACP adapters, detached: a hidden rig exec connector process owns the run and outlives the calling CLI, the run file under $OPENRIG_HOME/acp/runs records its process identity and typed state (requested, started, finished, interrupted), and the queue row keeps custody and acceptance and is never closed by the run. rig exec harnesses and run --plan show the adapter and native versions and how each harness answers. A row gets one run: claims are published once per generation with a create-exclusive hard link, a retry after an uncertain submit returns the existing run, and --again starts another only after it ended. Each run's start and end are separate slots, each published once, so a reconciler and the connector can't both settle the same transition. --wait only polls and survives Ctrl-C; its exit follows the outcome (0 completed, 2 otherwise, 3 if the waiter leaves). A dead or reused process, or a connector that never recorded itself, reads interrupted, outcome unknown. Cancel is acknowledged, not settled; usage and cost carry their source, and unavailable cost reads unknown. rig acp run stays synchronous and adds only a usage line. Limit: the crash and cancel guarantees are rig exec's; rig acp cancel given an exec run's id while it's requested can signal the requester rather than the connector, so use rig exec cancel.
2026-11-06 00:40:34 -08:00
Aummadour 430b676727 fix(queue): treat informational human updates as readable correspondence (#2586)
QueueRepository.isRegisteredHumanUpdate(row) is true only for a pending row with human_intent update whose destination resolves to a currently registered person; it reads false for an agent, an unregistered destination or an unavailable registry. The waiting view treats such a row as informational correspondence with no claim backstop, and the stuck sweep skips it as an unclaimed obligation, except when its quiet-hours hold has been orphaned (no live release job), which is still reported as before. The new resolution reason closes only the sweep's own unclaimed-obligation finding, never while the row is quiet-held or has a delivery outcome. A shared recovery for such an update can be relabelled to an undelivered human notice instead of being closed, so a later delivery failure stays visible. Posted and on-Steering rows keep today's paths. This covers the informational-update part of #514, not the rest of it.
2026-11-05 23:40:16 -08:00
Mike Schwarz 86c677837b fix(daemon): leave every tmux mode before a delivery (#2594)
With mouse mode on, scrolling a seat's pane puts it in copy mode, and tmux stacks modes: copy mode can sit under run-shell output (view mode), a chooser, customize or clock mode. A single send-keys -X cancel leaves only the top copy or view mode, so a pane with two modes stayed in a mode, and its sends and wakes were refused with pane_in_mode until someone left the mode by hand. cancelPaneMode now runs tmux copy-mode -q, which leaves every mode the pane is in and types nothing on a pane in no mode; when that fails it falls back to send-keys -X cancel, checking the input guard again first, as the Enter fallback does, so input that arrived meanwhile stops it. leavePaneMode reads the result and adds the reason to its refusal. Behaviour change: a send or wake now also leaves a chooser, customize or clock mode a person has open in a seat's pane, where it was refused before; #912 already leaves scrollback.
2026-11-05 23:28:05 -08:00
Mike Schwarz 77d6cfa09c docs: run Claude Code and Codex through rig acp run with their ACP adapters (#2596)
docs/reference/acp-agents.md adds "Claude Code and Codex, through their ACP adapters", checked on 2026-11-06 with OpenRig 0.7.8 on one small file task per case. It covers the install for @agentclientprotocol/claude-agent-acp 0.101.0 and @agentclientprotocol/codex-acp 2.5.0 and the native versions each runs (Claude through CLAUDE_CODE_EXECUTABLE when set, otherwise the SDK's platform binary, unchecked; installing with --omit=optional leaves that binary out, and without CLAUDE_CODE_EXECUTABLE the adapter then refuses to start rather than falling back to a claude on PATH). Each agent keeps its own permission mode; the settings that make each one ask are the caller's choice, not a recommended default. Claude's reject lets the turn continue, while Codex's only refusal, cancel, ends it. A missing session is refused by both with -32002 or -32603, and neither code alone means a missing session. A completed turn isn't finished work: check the work before closing the row; beyond one observed case, long and background work weren't checked. rig acp cancel was acknowledged in 20-30 ms for both, sent while the agent waited on a permission answer; a cancel mid-generation or mid-tool wasn't timed, and an acknowledgement isn't a settled shutdown or zero further spend. Docs only.
2026-11-05 22:51:56 -08:00
Mike Schwarz f78bbe759f docs: say what has run with a real dsh, and at what scope (#2595)
docs/reference/community-harness-examples/dsh/dsh.yaml's first limit now names the two stacks a real dsh 0.2.1-rc.2 has run on with this runner: dsh's Claude route with a Claude Haiku model in 0.7.8's release testing (an allow-once and a reject-once), and dsh's pi-ai OpenRouter chat-completions profile with deepseek/deepseek-v4-flash on OpenRig 0.7.8 (queue work, activity through a quiet tool call, an allow-once and a reject-once, and cancel). In both, the permission answers came from a test answerer registered as a person; dsh's DeepSeek Messages adapter via OpenRouter failed with HTTP 400, cause not established. Its resume limit says what was observed (a real dsh reopened and recalled a conversation with resume added to a copy of the preset; a missing conversation's refusal wasn't run) and resume stays undeclared. harness-runner-contract.md adds a "Since 0.7.8, on one stack" paragraph after its not-shown list. CHANGELOG.md's [0.7.8] Known limits line that said the dsh example was checked only against stand-ins is corrected in place, with the old sentence struck. Docs only.
2026-11-05 22:36:57 -08:00
NIKHILA cfa6118815 fix(daemon): resolve authored recap by bound session (#2579) (#2593)
rig seat set-runtime's handover read the seat's authored recap with authoredRecapResolver(ctx.seatRef), the logical ID (for example dev.impl), while recap folders follow the bound canonical session's member (dev-impl), so a pod seat's switch missed its own recap. It now passes the bound sessionName. A test covers a seat whose logical ID and member differ. Limit: the ordinary handover path still resolves recaps from its own input.seatRef, which already accepts logical aliases; that path is unchanged here.
2026-11-05 21:35:34 -08:00
billbenetti cdac54fef7 perf(queue): build one recovery index per listing instead of a json_each scan per row (#2588)
A queue listing asks findQueueRecovery once per row, and each ask ran json_each over every recovery-tagged row. withRecoveryIndex(db, fn) now opens a scope for one synchronous listing; inside it, the first lookup builds an index with the same json_each, the same recovery tag filter and order and text values only, and later lookups read it, while a compact listing that never asks builds nothing. Outside the scope every lookup is the original per-row query, and nested calls share the scope. QueueRepository.list wraps its existing body. Limits: the index assumes recovery data doesn't change during the listing; nothing a listing calls changes recovery membership, state or its ordering time, although the activity reader it calls can write other events, and a callee made async would simply fall back to the per-row query. Equivalence was traced in source and checked against an offline copy of a real queue; it wasn't measured on a live daemon, and malformed Unicode tag values weren't compared.
2026-11-05 21:34:39 -08:00
billbenetti f520ed5b0d fix(refocus): report a timed-out work lookup as an advisory (#2581) (#2587)
The openrig-core refocus hook marks a `rig queue whoami` lookup that ended in ETIMEDOUT and passes `--work-unknown-timed-out` with `--work-unknown`. With `--unresolved-work-advisory` (passed since #2582), trace-to-root.py prints "TRACE GAP — current work node UNKNOWN: queue whoami failed: spawnSync rig ETIMEDOUT" plus the project-level chain, labelled a broad orientation and not evidence of a current mission, and doesn't fail `--check`, so the timeout no longer counts toward "Refocus failed on N consecutive attempts". Non-zero exits, other spawn errors, unreadable answers, no remaining budget and a run without the advisory flag stay strict. A timed-out refocus now clears the pending post-compaction retry, as #2582's advisory does, so a slow daemon isn't queried again on every prompt. The skill digest is regenerated. Refs #2581: the part #2582 left open.
2026-11-05 21:08:25 -08:00
Mike Schwarz 42d8a74b91 docs: say what the runner contract has shown natively, and what it has not (#2592)
docs/reference/harness-runner-contract.md replaced "No harness has been run end to end under this contract yet, and the native verdict is pending" with what has run natively in 0.7.8's release testing, against a Claude Haiku model: dsh-seat 0.3.0 (dsh 0.2.1-rc.2 over ACP) for launch and first input, queue work, a multiline message with an /abort line as one prompt, working through a 52-second quiet tool interval, cancel by a bare /abort, the same conversation after rig down --snapshot and rig up, and the runner and child gone after the managed down; and the dsh example runner, whose native permission request reached a person, with allow-once taking effect and reject-once not. It also lists what wasn't shown natively: a DeepSeek model, the example runner's own resume, the default permit when nobody can be asked, a typed or Slack answer or a second person's answer, reboot recovery, and the model OpenRig observes. Any other runner, provider or path stays unqualified until it's shown. The status line above now matches. docs/reference/author-contracts.md's dsh/ACP row says the same in one sentence. Docs only.
2026-11-05 20:58:42 -08:00
Wu Shuwen 983c80f3a5 fix(cli): report conflicting roster activity as unknown (#2574)
rig roster show and find can see one seat through more than one route, for example two host aliases. They now build each route's activity (state, reason, sampled time) and compare them. When the routes agree on state and reason, the activity is kept, with the latest time if every route gave a comparable one and no time otherwise. When they disagree, or one route has no activity, the seat reads unknown with the reason "Conflicting activity observations" and no time, whatever the route order. A seat seen through one route reads exactly as before. Only the CLI's roster enrichment changes; the daemon is unchanged.
2026-11-05 19:19:03 -08:00
Wu Shuwen 8fa8e83d46 fix(refocus): report unresolved typed work as an advisory (#2582)
The openrig-core refocus hook passes a new --unresolved-work-advisory flag to the refocusing trace script. With that flag, when the work basis says typed work did not resolve (no matching node, or a mission or slice that resolves to more than one directory), the trace prints "TRACE GAP — no single current work node" with the reason, then the project-level chain labelled as broad orientation, not evidence of a current mission. The hook can then tell that advisory from an operational trace failure. Without the flag, and for conflicting tags, several fully resolved nodes, or an unreadable queue response, the script stays strict and exits nonzero as before. The skill's edge digest is regenerated. Refs #2581; the separate queue whoami timeout case is unchanged.
2026-11-05 18:48:24 -08:00
Wu Shuwen 0ff8482e76 feat(slack): report local relay attempts in flight (#2569)
rig slack status adds "Relay attempts awaiting response: N" for the local Slack relay, beside the retained inbound records. The daemon's inbound router counts each forward to another host while it's awaiting a response (incremented before the send, decremented when it settles, success or not), and the connector status reports that number. It's point-in-time and covers only the current connector instance: after the Slack connector is restarted, a relay the previous instance started can still be awaiting its response (up to the forwarder's 10 s timeout) while the new count reads 0. Zero doesn't prove quiescence across hosts or instances. Routing, retries, retained records and the forwarder are unchanged.
2026-11-05 18:45:13 -08:00
Mike Schwarz bd39da9872 fix(compaction): deliver a held post-compaction step after two idle unreadable polls (#2576)
Since 0.7.7, each managed post-compaction step (the turn boundary, the restore request, the audit) was pasted only into an input box that read empty or held a likely suggestion. A box OpenRig couldn't read as empty, such as one with the cursor outside it or a layout it doesn't recognise, held the step on every poll with no end, so a seat could sit idle with its restore never requested. Now, after two consecutive idle polls that read the box as unreadable (not typed and not typed with a suggestion), the next poll sends that step and records it as delivered over an unreadable input box, never as empty. A typed draft, typed text with a suggestion, a seat whose draft-aware delivery or typing guard holds OpenRig's input, and the step after a /compact whose result was lost all keep holding as before. rig compact --state and rig seat status say which applies. The owner notice names the manual takeover: rig compact <seat> --cancel, then a restore request naming the newest map, the restore marker and its packet; a step already being pasted can still land. An unrecognised draft in an unreadable box may receive the restore text. Refs #2575.
2026-11-05 13:03:09 -08:00
Mike Schwarz 74ca621314 fix(daemon): read the execution view's review registry once per read (#2578)
GET /api/views/execution (the TUI's execution view and rig view show execution) read and parsed every file in the global review registry once for each slice it built, all synchronously. With 450 review files that was 3,600 reads for an 8-slice mission and 13,500 for a 30-slice one, per request. It now reads the registry once per build and shares it across that build's slices and dependency ladders, and it yields to the event loop every 32 files or 8 ms while it walks them. The next build reads it again, so there's no cache across requests. Each slice's join is unchanged: slice-local proof drops come first, then the registry's, with the same slice match, the same local QA rule, and INDETERMINATE when neither root can be listed. A project-scoped view still never reads the global registry. The other synchronous parts of the view (the candidate query, the wave map, the default mission and serialization) are unchanged.
2026-11-05 12:52:54 -08:00
Aummadour 143bac76ef fix(daemon): identify terminal bearer authentication failures (#2577)
A rejected request on a route that uses the terminal bearer token (terminal follow and viewer, transport, compaction, and the terminal-protected session and node guards) now gets a 401 that says "Terminal auth failed" and tells the person to send OPENRIG_TERMINAL_BEARER_TOKEN, or OPENRIG_AUTH_BEARER_TOKEN when terminal auth falls back to it. Before, every such 401 said "Mission Control auth failed" and suggested setting OPENRIG_AUTH_BEARER_TOKEN and restarting the daemon, which was wrong when a separate terminal token was set. An optional diagnostic context on the shared bearer middleware changes only the wording. Token lookup, parsing, constant-time comparison, the access decision, the status code and the response's fields are unchanged, and no token value appears in a response. Mission Control routes keep their wording. Refs #2484.
2026-11-05 12:42:24 -08:00
Mike Schwarz 73cf8138e7 docs: teach what 0.7.9 changed in the map, references and three help texts (#2571)
The packaged capability map takes in 0.7.9's changes, and its header marker moves to capability-delta-v0.7.9. Two lines 0.7.9 made stale are corrected: an emit now opens or notes an intake row for queue.worker, and any registered person can settle an ask asked of someone else, recorded as themselves. It also covers indeterminate receipts, --closure-required-at, guidance placement for fresh Pi and Codex 0.164.0+ seats with the cases that still use the repository's AGENTS.md, what rig down takes back and keeps, the rebuild for wrapper and symlink Claude launches, and the daemon HTTP client reference. rig-spec's managed_blocks and guidance_merge rows and agent-spec's target row say where guidance normally goes. Help text: rig stream emit names the intake row and how to keep emits passive, rig send --verify says exit 1 can mean unconfirmed, and rig down says what it takes back and what it keeps. Docs and help text only.
2026-11-05 09:19:51 -08:00
Mike Schwarz af4aff14fe fix(cli,daemon): name pre-0.7.9 handovers and unconfirmed broadcast recipients (#2572)
rig seat stop, clean and in-place restart still refuse a session recorded as adopted. When the seat's last completed handover was a fresh, fork or rebuild handover, the refusal now says that a successor from before OpenRig 0.7.9 keeps the adopted origin until its next launched handover or managed launch. It no longer suggests rig unclaim, which would end OpenRig's management of the seat. rig seat stop's help says the same. rig broadcast prints UNCONFIRMED instead of FAILED for a recipient whose text or Enter may have reached it (input attempted or written), and points to rig capture before any resend. No refusal, error code or setting is added or removed.
2026-11-05 09:19:07 -08:00
Aummadour 4168fb468d Show sanitized permission request observations in seat status (#2562)
An optional Claude PermissionRequest hook in openrig-core records each permission request's time, actor category and a fixed tool descriptor, plus Claude's own permission_suggestions in their native spelling, labelled as suggestions (missing, unsupported or size-cut entries say so), in memory within the seat's actor, generation and expiry bounds. rig seat status and both seat-status routes show it when an observation exists, escaped as data. Nothing is applied, approved or persisted, and no hook decision or permission write is made. Refs #915. Contributed by @Aummadour.
2026-11-05 07:47:09 -08:00
Mike Schwarz 781b5fbf64 docs(capability-map): teach what 0.7.8 added and drop three sentences it made false (#2568)
The packaged capability map, which agents read on every install, drops three sentences 0.7.8 made untrue (mission following isn't included; a community harness gets no approvals; a later hold notice can show a generic reason) and adds what 0.7.8 shipped: the answered runner permission path with rig seat set-permissions-to, rig environment (with its 0.7.8 not-usable caveat), mission following, rig host edit and remove, terminal.tmux_server isolated, claude_mcp: none, doctor's port-owner warning, rig daemon status's pending-start line, the stopped-seat token switch, and the finished-map expiry states for automatic compaction. Docs only.
2026-11-05 06:46:36 -08:00
Mike Schwarz ec926c2e2d fix(cli): bound doctor cmux status and topology reads (#2564)
rig doctor bounds the cmux status read and the live rig and node reads behind --spec (2 s each, headers and body), through the existing fetchWithTimeout, so a listener that accepts connections but never answers can no longer hold it. A timeout keeps today's outcome: a cmux warning, or conformance unknown, never a matching topology. This finishes the waits #2549 left. Refs #2531.
2026-11-05 05:06:45 -08:00
Mike Schwarz e0a285968d fix(daemon): stream intake reads past default-permit records (#2563)
Stream intake reads past OpenRig's record of a default permit (the runner-permission-permit stream item): it advances its position with no intake row, note or wake, on live intake and after a restart, so the record stays pull-only and wakes no one. Ordinary observations, coalescing and the default permit are unchanged. Refs #2361. Refs #2321.
2026-11-05 04:56:41 -08:00
Wu Shuwen fe7e5393a5 fix(tui): preserve daemon launch failure messages (#2556)
When a daemon write fails with a JSON body that carries a message but no error field, the TUI's error now includes that message, so launch's session-unconfirmed advice reaches the person. An error field still takes priority, and a body that isn't JSON still shows only the status. Contributed by @dajiaohuang.
2026-11-05 04:35:21 -08:00
Wu Shuwen d84aeac91a test(daemon): wait for shell before transport probe (#2559)
The real-tmux transport test waits for a fixture-defined shell prompt before sending input, so a login shell that is still starting can't share a line with the command's output. The fixture's .profile sets that prompt, so the wait holds on Linux and macOS. Test-only. Contributed by @dajiaohuang.
2026-11-05 04:05:32 -08:00
Mike Schwarz 911c407d46 fix(daemon): say what other words do on a runner permission ask (#2561)
A runner permission ask now says what a reply other than yes or no does: it chooses nothing, OpenRig's default permits the request when the agent offered an allow option, and a runner that forwards the words passes them on only after its turn. Text only: answer parsing, the default permit and the optional forwarding are unchanged. Refs #2363.
2026-11-05 03:40:24 -08:00
Wu Shuwen f7113e5a5d fix(cli): bound doctor health header probes (#2549)
rig doctor bounds the wait for /healthz response headers when the daemon port is held by a listener that accepts connections but never replies, and reports an unknown listener instead of waiting forever; the conditional cmux health probe uses the same bound. Reported in #2531. Contributed by @dajiaohuang.
2026-11-05 03:37:15 -08:00
Wu Shuwen 80e568e9d9 fix(activity): expire stale window sampling evidence (#2558)
Window-sampling evidence older than the larger of 15 seconds and three poll intervals no longer decides a seat's activity, the same bound hook evidence already has. A seat whose pane the sweep can no longer read now reads unknown rather than its last idle or working verdict, so a stale idle sample can't support a parked diagnosis or an idle-gate wake. Fresh evidence behaves as before. Contributed by @dajiaohuang.
2026-11-05 03:01:53 -08:00
Mike Schwarz 0042a91277 feat(daemon): record each permission OpenRig grants a runner by default (#2361)
From 0.7.9, each permit OpenRig decides by default on a runner's permission request is recorded once, as a stream item from the seat (tag runner-permission-permit): when no one can be asked, and when an ask is closed without a choice, including a row passed to an agent seat. The record names the tool call, why no person answered (for a closed ask, who closed which row and how), the option the agent got with its kind, and the conversation and request or the ask. A record wakes no one, and one that can't be written doesn't change the permit. For an ask opened before 0.7.9, the record may omit the option kind and whether the harness remembers it. The harness runner contract's section 7 describes it.
2026-11-05 02:51:20 -08:00
Wu Shuwen 9b9e6c4097 docs: clarify kernel provider selection at first boot (#2560)
The quickstart explains that rig setup --providers records the provider choice a kernel uses when it is first created: with both providers signed in but one selected, the first kernel uses the selected provider. It also covers the OPENRIG_PROVIDER_SELECTED override the daemon reads, an existing kernel keeping its provider, and the warning when setup can't save the choice. Contributed by @dajiaohuang.
2026-11-05 02:47:31 -08:00
Wu Shuwen 512040e5c3 fix(cli): report ambiguous spec conformance targets (#2550)
rig doctor --spec no longer reports conformance against the first of several live rigs with the spec's name: it skips the check, says conformance is unknown, and lists the matching rig IDs. A unique name is checked as before. Reported in #2535. Contributed by @dajiaohuang.
2026-11-05 02:39:43 -08:00
Wu Shuwen faccc35040 fix(cli): clarify saved context pressure validation (#2553)
A refused context-pressure set or reset now says what it compared: the config file's pair, with defaults for values the file doesn't set. Environment overrides apply when each process resolves its configuration and are not part of this check. The validation itself is unchanged. Reported in #2529. Contributed by @dajiaohuang.
2026-11-05 02:39:11 -08:00
Wu Shuwen 78afeaa2e2 fix(daemon): show the current reason for a retained recovery (#2552)
A stuck-sweep recovery that stays open now names the reason that remains, so a row that is only overdue no longer carries a stale "stalled after claim" body. Reported in #2533. Contributed by @dajiaohuang.
2026-11-05 02:11:30 -08:00
Wu Shuwen acdf61532f fix(cli): reject ambiguous env rig names (#2548)
rig env down, status and logs resolve an exact rig ID first, and when an active rig name matches more than one rig they list the matching IDs and stop before any service request, instead of acting on whichever rig comes first. Reported in #2532. Contributed by @dajiaohuang.
2026-11-05 02:10:56 -08:00
Wu Shuwen aeb218a112 fix(cli): create namespace for URL context packs (#2546)
rig context add <url> can install into a fresh nested name: the URL installer now creates the missing parent namespace after the staged pack passes validation and just before the rename, the same step the local-directory installer takes. Reported in #2525. Contributed by @dajiaohuang.
2026-11-05 02:10:20 -08:00
Wu Shuwen d33aca86ea fix(daemon): preserve tmux 3.4 default command (#2557)
tmux 3.4's show-options -v output adds a backslash before a dollar sign followed by a letter, an underscore or a brace. When OpenRig reads back a scoped launch's default command from a stable tmux 3.4 server, it now removes exactly that added backslash, so the command runs as configured. Other and unknown tmux versions keep the existing reading, and a failed version read doesn't refuse the launch. Reported in #2555. Contributed by @dajiaohuang.
2026-11-05 01:55:57 -08:00
Mike Schwarz aaaeb8edc6 chore(release): bind the packaged-plugin baseline to published 0.7.8 and advance openrig-core to 0.1.25 (#2554)
The packaged-plugin version check now compares against the published 0.7.8 package (archive f175a5b1, openrig-core 0.1.24), and openrig-core moves to 0.1.25 in both harness manifests. Since 0.7.8, the plugin's seat-continuity-and-handover skill changed (#2420) while its version stayed 0.1.24, and the old 0.7.6 baseline let that pass, so existing installs could have kept the old file under the same version. Against the new baseline the check fails at 0.1.24 and passes at 0.1.25.
2026-11-05 01:53:41 -08:00
Wu Shuwen 59247e7a8f docs: correct installer setup incomplete output (#2551)
The README and docs/reference/getting-started.md describe the installer's real step-4 outcome: when the CLI installs but setup doesn't finish, it prints "INSTALLED, SETUP NOT COMPLETE (exit N)" and points to setup's own details and Fix. The "FAILED [n/4]" form is described for steps 1 to 3 only. Reported in #2538. Contributed by @dajiaohuang.
2026-11-05 01:34:37 -08:00
Mike Schwarz 16946c577a fix(daemon): the Pi runner shows a failed or cancelled compaction as such (#2537)
A Pi seat whose compaction Pi reports as cancelled now shows the compaction as cancelled, and one that failed shows it as failed with Pi's reason, instead of always reporting it done. The reason goes through the runner's existing diagnostic cleaner and is bounded to 400 characters. An event without either field still reads done. Reported in #2521 by @eisenrigbot.
2026-11-04 23:43:22 -08:00
Wu Shuwen 2a1e609cb1 docs(reference): clarify bundle archive determinism (#2543)
The bundle reference's determinism section now matches the packer: portable mode drops machine-specific owner metadata but can keep file mode bits, so identical bytes with different modes can produce different archive hashes. The package digest compares packaged content independently of file modes and manifest metadata. Reported in #2523. Contributed by @dajiaohuang.
2026-11-04 23:07:04 -08:00
Mike Schwarz fa3262d360 docs(roadmap): name 0.7.8 as the current release (#2534)
ROADMAP.md's "Released" section now names 0.7.8, the current release, instead of 0.7.6. Its summary is the published 0.7.8 notes' first sentence. Nothing else on the roadmap changes.
2026-11-04 21:42:45 -08:00
Mike Schwarz 94230b17bb fix(docs): the conformance check's Ctrl-C turn stays open until the cancel arrives (#2530)
The runner conformance check (`docs/reference/runner-conformance/check-runner.mjs`) could falsely fail a correct runner on a busy machine in case 2: Ctrl-C outside a paste must send a second `session/cancel`. The fake agent ended that turn on a fixed timer from the prompt, so a runner that handled Ctrl-C more than about two seconds after the press found the turn already over, and sent nothing. The turn now waits for the cancel, within the check's existing ceiling, so a late but correct runner passes. A runner that ignores Ctrl-C still fails; a new `ctrl-c-ignored` break pins that. 0.7.8's release notes name this false failure as a known limit.
2026-11-04 21:42:25 -08:00
Mike Schwarz 840aeaa687 ci: give the package test jobs 60 minutes instead of 45 (#2526)
The package-tests matrix's job limit in .github/workflows/tests.yml goes from 45 to 60 minutes, for all four package legs. On 2026-11-05 a daemon package job was cancelled at 45.5 minutes on a busy runner, while others ran 33 to 35 minutes, and each such cancellation costs a full rerun. Nothing else in the workflow changes.
2026-11-04 21:27:48 -08:00
Mike Schwarz b21466e8ae fix(preflight): warn when login shells cannot find rig (#2522)
A first user whose Node came from a tarball or a manual install, so it isn't on the login shell's PATH, could see Codex seats report `rig: command not found`. Preflight now runs the seat's login-shell lookup for `rig` with the declared environment, through the existing bounded probe, for Codex seats only, since their tools use a login shell. When it can't confirm `rig`, it prints an advisory warning with the fix, and launch continues. getting-started.md explains putting the Node bin directory on PATH in `~/.profile` (with Bash's profile order), and checking with `bash -lc 'command -v node rig'`. Found in Quality's 0.7.8 first-user test.
2026-11-04 21:27:27 -08:00
Mike Schwarz 190b0de73d test(compaction): keep manual-trigger cells' deadline on a fixed clock (#2527)
Five cells in claude-compaction-enforcer.test.ts now advance the fixture clock instead of waiting on the wall clock, as #2450 and #2463 did for their neighbours. Two of them failed unrelated pull requests' daemon jobs on 2026-11-05 when the runner was busy. The assertions are unchanged; test only.
2026-11-04 21:24:52 -08:00
Mike Schwarz f692215383 fix(daemon): a quoted permission question above Claude's empty input box is not a dialogue (#2520)
A finished Claude reply that quotes a permission question (for example "Do you want to proceed?") above the empty input box no longer makes the seat read as needing input, so a scheduled wake is no longer refused until the quote scrolls away. Above Claude's complete, empty, framed input box with its mode bar, a permission question now counts only when a numbered choice follows it, which every native permission menu draws. Real permission dialogs, including one drawn above the input box, still stop a send. A quote followed by a numbered list still reads as a dialog, as before. Found in the review of #2513.
2026-11-04 20:55:31 -08:00
Mike Schwarz de3b0658a6 docs: show a client reading people and open updates (#2524)
docs/reference/daemon-http-clients.md now has a recipe for a remote client that needs people and their updates. It covers:
- the registered participants and the default person (`GET /api/gateway/participants`);
- a person's open informational updates (`GET /api/queue/open-human-updates`, with its 1-100 limit, person view and `truncated` flag);
- one rig's outbox (`outbox/list?rig=`).

It also states the limits: the default person isn't authentication, an update isn't a delivered receipt, and a person view's window can short a page. A new test drives the documented requests against the real routes.
2026-11-04 20:54:16 -08:00
Luis Araya 826bc1cc5c feat(stream): hand queue worker intake items on emit (#1856) (#2321)
`rig stream emit` now hands each observation to the kernel queue worker. The daemon creates an intake queue row tagged `stream-subscription` for `queue.worker`. While that row is unclaimed, later observations are appended to it as notes; once it's claimed, the next observation opens a new row. An item emitted as `critical` or with `interrupt` makes the row urgent.

The row's id derives from its first stream item, so a restart doesn't duplicate it. Items emitted while the daemon was down are caught up from a committed cursor (migration 149), retried on the next emit if a catch-up was incomplete.

**Defaults to know:**
- The new `stream.intake` setting is on by default, routed to `queue.worker`. Set it to `off` for today's behaviour, with no intake rows.
- On first start, earlier stream history isn't replayed.
- Intake rows wake their owner through the existing queue wake semantics; nothing new interrupts a seat.

The queue worker's role guidance and the `rig stream emit` help describe the routing. Refs #1856. Contributed by @Totopo27.
2026-11-04 20:05:19 -08:00
Wu Shuwen 3f0a5cc744 feat(queue): expose open human updates to clients (#2497)
A remote client can now read a person's open informational updates with `GET /api/queue/open-human-updates`, without depending on Slack delivery. It reuses the existing open-update query and person-view rules, returns open updates only (not asks or delivery receipts), and takes a `limit` from 1 to 100 (other values return 400). With a person view it reads the same 1,001-row window `/api/attention` uses, filters it to that person, and sets `truncated` when more rows exist or the window was full. Refs #2487. Contributed by @dajiaohuang.
2026-11-04 20:02:12 -08:00
Mike Schwarz 7d7634947d docs: 0.7.8 is published (#2519)
CHANGELOG.md's [0.7.8] section now says it was published on 2026-11-05 as @openrig/[email protected], built from the commit tagged v0.7.8 (12df562c), instead of "Not yet published". Its OpenShell wording now matches the published release body: the reference isn't usable in 0.7.8, and the repair ships in the next patch. The section is otherwise byte-equal to what shipped. docs/reference/whats-new.md is unchanged, because main's copy names main's package version.
2026-11-04 20:00:19 -08:00
Kai 7c5c514457 fix(daemon): recognize the current Claude Theme picker (#2514)
OpenRig now recognises Claude Code's Theme picker (the recorded 2.1.312 layout, including its tall preview) as a current selection dialog. Its options are unnumbered, so every existing recogniser missed it, and a wake's Enter could select a theme in a person's open picker. The matcher is pinned to that layout, like the MCP and resume recognisers. A later composer or draft, quoted or partial frames, changed wording and other runtimes don't match. It recognises the dialog only; nothing is answered or dismissed. Contributed by @KM199.
2026-11-04 19:30:39 -08:00
Mike Schwarz 78978dcb21 fix(daemon): accept a proven Claude behind a shell label when a classic resume settles (#2518)
A classic Claude resume whose launch subshell didn't exec Claude (because `claude` is an rc alias or function, or the launch environment correction fell back) keeps the shell as the pane label. On a slow resume, that seat could time out into attention while Claude was fine. Resume readiness now runs the existing process proof in that case without waiting for Claude's prompt: one foreground Claude naming this resume token, stable over two samples, is accepted, as a `claude` label already is, and its launched process is recorded. An idle shell, another token or an unstable process reads as before. Reported in #2508; refs #2508.
2026-11-04 19:27:16 -08:00
Wu Shuwen 2eddb4a2d7 fix: report unconfirmed guarded submissions (#2509)
`rig send --verify` no longer reports `delivered` or `verified: true` when it can't see what the one guarded Enter did. When that Enter's recheck can't capture the pane or finds no input prompt, or the Enter's response is lost, errors or is refused, the result is unconfirmed and the command exits 1. Single and fan-out results drop the delivered claim only for the affected recipient, in human and JSON output, and `effectCheck.remedy` still tells a refusal from an unknown result. The daemon now labels a guarded Enter that failed before the key as `not_reached` with `sent: false`, and one that failed after the key as `attempted`. The one-Enter limit is unchanged, and nothing is resent. Contributed by @dajiaohuang.
2026-11-04 19:01:42 -08:00
Wu Shuwen 91d21bc962 fix(activity): recognize live Codex working frames (#2500)
A Codex seat showing a live working turn now stays `working` in the queue activity oracle when its tmux window timestamp is quiet, so a busy Codex owner isn't diagnosed as parked just because the timestamp is old. The evidence comes from the existing structural pane capture and is positive-only: a working status directly above the empty composer counts for 5 s from the first time that counter value is seen, and otherwise the elapsed counter must advance between two recent captures. A frozen counter, a stale capture or one taken before an occupant change falls through to the existing evidence. Visible needs-input stays independent, and transport's send and idle decisions are unchanged. Refs #331. Contributed by @dajiaohuang.
2026-11-04 19:00:32 -08:00
Mike Schwarz 3448150528 test(daemon): pin a fresh Claude's login refusal through to the node view (#6) (#2405)
Regression coverage for #6: a fresh Claude session that refuses its first request because login is required reports attention_required with an explicit login reason through startup persistence, the node inventory, the nodes route and the ps projection, with no generic readiness timeout. The cases compose the real Claude adapter's delivery and readiness, startup orchestration, SQLite persistence and the nodes HTTP handler (pane text and harness launch mocked), and cover the native Claude 2.1.312 result-row form and the reply form of the refusal. Footer-only use (a gateway may authenticate) and quoted refusal text stay ready, and a Claude that never draws its TUI still ends in the generic readiness timeout. Current production code already behaves this way; nothing in the product changes.

Built and reviewed by the OpenRig Workshop team. Tests +69, no source.
2026-11-04 18:30:52 -08:00
Wu Shuwen 889b804ada fix(daemon): recheck pane after native identity proof (#2512)
When a pane is replaced while the seat attention reconciler is proving its identity, the replacement could be marked verified using the old pane's process. Before accepting Codex identity or a strict Claude resume identity, the reconciler now rechecks the pane PID and that the pane is still the session's only pane, as its Claude proof branch already does; a changed or unavailable pane leaves identity unverified. An unchanged pane verifies as before, and no new capture, loop, restart or refusal is added. Contributed by @dajiaohuang.
2026-11-04 18:19:05 -08:00
Aummadour 863c0f58f2 fix(package): preserve daemon asset modes and relative links (#2511)
The CLI package build now stages `packages/daemon/assets` with Node's `fs.cp` (recursive, `verbatimSymlinks`) instead of `cp -r`. Staged assets keep their source modes, so a build run under a narrower umask than the checkout no longer packs them as 0600 or 0700 (npm keeps owner-only bits when packing). A relative link stays a link in staging and is omitted by npm, rather than being followed and copied in on macOS. Copied bytes and sources are unchanged, and a failed copy still stops the build before the staged root is recorded. Only the assets step changes; the other copy steps still use `cp -r`. Contributed by @Aummadour.
2026-11-04 17:47:27 -08:00
Wu Shuwen 87297f0f8e fix(walk): report unconfirmed delivery and submit outcomes (#2510)
`rig walk` no longer reports a piece as lost, or as typed but not consumed, when it can't see what happened. When the sent piece doesn't appear in the record or the pane within the verification window, or the one guarded Enter's result is lost, errors or is refused, walk now says delivery is unconfirmed and to inspect the recipient before any resend. A generation change after the guarded Enter is reported separately. The walk still stops at that piece with exit 1, after one text send and at most one guarded Enter. Contributed by @dajiaohuang.
2026-11-04 17:33:04 -08:00
Wu Shuwen de47425168 fix: preserve uncertain conditional send receipts (#2505)
An expected-identity send's outbox receipt now reads `indeterminate` when the outcome is uncertain, instead of `delivered` or a receipt left `pending`. That covers a send whose verification came back `rendered-unconfirmed`, and one that failed or threw after input was attempted or written; an attempted or written input phase takes precedence over a `sent: false` flag. A caller deciding whether to retry is no longer misled. Failures before any input stay `failed`, and a failure while finalizing the receipt is reported as a warning. Conditional identity checks, the retained-input path and sending itself are unchanged. Limit: the plain-text CLI failure output doesn't print that warning; API and JSON output carry it. Contributed by @dajiaohuang.
2026-11-04 16:05:51 -08:00
Wu Shuwen 101e5e3ff6 fix: preserve uncertain broadcast delivery receipts (#2507)
An attributed broadcast's outbox receipt for a recipient now reads `indeterminate` when that recipient's text write was attempted or completed but the transport reported an error, instead of `failed`. The text or Enter may already have reached that recipient, so a failed receipt could mislead a retry decision. Delivered receipts and failures before any input are unchanged, as are the route response and the other recipients' receipts. Limit: a successful broadcast whose verification came back unconfirmed still records delivered. Contributed by @dajiaohuang.
2026-11-04 16:04:24 -08:00
Mike Schwarz 4ebb06059a fix(tui): the selected-row highlight shows in 16-colour mode (#2506)
In 16-colour mode, `rig tui`'s selected row is highlighted again. The `selection` token's 16-colour value was already a background code (`40`), and the background path adds 10, so it painted SGR `50`, which isn't a colour and terminals ignore. It's now `30`, so the highlight paints black (`40`), which keeps the row's bright-blue accent text readable and is the colour nearest the truecolor and 256-colour selection. A new cell checks that every token used as a background paints a real background code. Limit: on a dark terminal whose own background is that black, the band blends in, and the selected row is still marked by its arrow, accent colour and bold. Found in the review of #2499.
2026-11-04 16:01:44 -08:00
Mike Schwarz a7bd8fe50f fix: a Slack transport error never carries the bot token (#2503)
A Slack transport error no longer carries the bot token. Node's fetch rejects a token copied with a newline before connecting, and its error message quotes the whole Authorization value, which the readiness route, `rig slack verify`, the inbound and outbound paths and the inbound file download then passed on, the file download into the inbound queue item and the daemon log. `callWebApi` and `downloadPrivateFile` now remove the token and each of its lines, as sent and as Node trims them, from that message in one pass, and keep the rest, so the error still says what went wrong. Limit: a token line shorter than 6 characters, as sent or trimmed, can still appear. Found in the review of #2501, whose new directory routes already return no transport detail.
2026-11-04 15:28:30 -08:00
Mike Schwarz d9b09ec3f2 fix(delivery): route replies to forwarded people through the queue (#2504)
A queue item a person sent from one machine and forwarded to a seat on another now tells that seat how to answer them. The source arrives as `human@kernel@<origin-host>`, which neither message renderer recognized as a person, so the seat was told to `rig send` it, which can't reach a person. Both mirrored renderers now take the two-part person address from a host-qualified source only to recognize it, and offer the working `rig queue create` hint to the full host-qualified destination, which the queue command already splits. The sender, body, destination and origin routing are unchanged, and look-alike seats and malformed addresses behave as before. Limit: the reply still needs a route back to the origin host. Found by @dajiaohuang on #2493.
2026-11-04 15:16:13 -08:00
Mike Schwarz a3e5e92112 feat: list Slack channels and people through the daemon (#2501)
A daemon client can now offer Slack channel and person choices, to fill in a channel map or register a person, without holding the Slack bot token. `GET /api/gateway/slack/channels` returns one page of the workspace's public, unarchived channels as `{ id, name, isMember }`, and `GET /api/gateway/slack/users` one page of people as `{ id, name, realName, displayName, isBot, deleted }`, without email. Both page with Slack's cursor (`nextCursor`, null on the last page) and `limit` (default 100, at most 200), and each request is one live Slack call with no cache or stored copy. Listing needs only a resolvable bot token and config, not inbound delivery. A missing token answers 503 `slack_not_configured`, a missing Slack scope 403 `slack_missing_scope` with the scope named, a rate limit 429 `slack_rate_limited` with Slack's `Retry-After`, a transport failure 502 `slack_unreachable` with no transport detail, and any other Slack error 502 `slack_error`. Access is the same as today's gateway reads. No new scope, private channels, direct messages or migration. Limits: proven against a fake Slack, not a live workspace; an app installed from an older manifest may lack `users:read`. Reported in #2487 (item 4).
2026-11-04 14:52:30 -08:00
Mike Schwarz 7b02076427 fix(tui): body text and the codex mark read on a light terminal (#2499)
`rig tui` body text now reads on a light terminal as well as a dark one, with nothing to configure. The `bright` token, used for a slice's intent, requirements and proof text and for most plain text, and `markInk`, the codex mark's `_`, now use the terminal's own default foreground (SGR 39) in every colour mode wherever the TUI doesn't paint its own background. On a background the TUI paints itself (selected rows, the terminal mark, accent bars) they keep their fixed light ink, so those stay legible, and inverse flash rows now swap the terminal's own colours. Dark terminals look the same to the eye. Limit: in 16-colour mode the selected-row wash isn't painted, an existing issue handled separately. Reported, with the fix, by @adam-nectar.
2026-11-04 14:47:48 -08:00
Mike Schwarz e10b8014f6 fix(daemon): re-apply activity hooks on a contained resume for local: agents too (#2479)
A `local:` Claude seat now gets its current activity hooks back on a contained resume, as a `path:` seat already did, when its saved launch shows where its agent was read. The reader resolves the seat's own `local:` ref against each recorded startup-file owner root, re-anchored for shipped specs as the runtime switch reads them, counts a folder only when the ref lands on a recorded root, and accepts it only when exactly one such folder holds an `agent.yaml` with the seat's resolved spec name. Anything unproven still reads unknown: restore warns, projects nothing and never rewrites the saved context, and `rig restore-check` reads the same way. Resource origins aren't used, so a same-name import can't stand in for the seat's spec, and `builtin:` refs are unchanged. Limits: a seat whose ref can't be tied to a recorded folder still only warns, and one wrong read remains possible when a recorded root that isn't the agent's folder holds a same-name spec and the ref lands on it, for example a seat added later from another rig root with `local:.` and no startup files of its own. Builds on #2338 by @AdityaxDeore. Reported in #1530.
2026-11-04 14:37:46 -08:00
Mike Schwarz 23daf8513a feat: read a rig's outbox in one request (#2496)
A daemon client can now read a rig's messages in one request: `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to that rig's seats, newest first, with the same `limit`. `?senderSession=` still returns one sender's messages, unchanged, and when both are given `senderSession` is used. Migration 148 adds an index on the outbox's dispatch time with both session columns, so the rig read walks newest first, tests the match inside the index and stops at its limit instead of scanning and sorting the whole table. On a copy of a 109,622-row outbox, a busy rig's read went from 378 ms to 0.3 ms and an empty rig's from 30 ms to 19 ms; building the index took 0.37 s. The client reference now says queue SSE events are hints to read `queue/list?rig=` again, since some events name only the destination, and that the rig queue read scans its table, so read it occasionally. Limits: a rig matches by its `@<rig>` suffix with SQLite's ASCII case folding, as `queue/list?rig=` does, and a quiet rig's read still walks the index. Reported by @ziadeh in #2487 (item 1).
2026-11-04 13:28:37 -08:00
Wu Shuwen f7206db6de feat(gateway): expose participant registry to clients (#2495)
A daemon client can now read the registered participants and the effective default person through `GET /api/gateway/participants`, without reading registry files on the host. It returns the same JSON as `rig gateway participants --json`, from one shared projection, so the default, policy and partial-read behaviour match. An optional `organization` query filters the participant list, while the default, organizations and groups still describe the whole registry. An unreadable participant side file still lists people from their fragments and names the error, with organizations and groups null rather than empty; an unreadable person registry returns 503 `human_registry_unavailable`. The response carries no connector bindings or secret pointers, the route uses the same access as the other gateway reads, and the default person isn't authentication. Limits: Slack channel and user reads aren't part of this; with an unreadable side file, an organization filter returns no participants, as the CLI does today. Reported in #2487 (item 4). Contributed by @dajiaohuang.
2026-11-04 12:50:29 -08:00
Mike Schwarz 74501bdf63 docs(slack): name the private-channel route the connector already accepts (#2494)
The Slack setup guide now says how to use a private channel. The generated manifest requests public-channel access only, and a new section gives the route: add groups:history and groups:read, subscribe to message.groups, reinstall the app and invite it to the channel, and optionally run rig slack setup --required-scopes so verify checks the added scopes. The channel-map paragraph says a private channel can be a target once the app has them. Direct messages stay unsupported, and the guide says OpenRig doesn't test private channels. Docs only; no behaviour change. Limits: the --required-scopes example repeats today's baseline scopes, so if the baseline gains a scope that command would need it added; a manifest option and verify mode for a private channel or direct message are not part of this change. Asked about in #313 by @ziadeh.
2026-11-04 12:03:15 -08:00
Mike Schwarz 432a6bcc41 docs: what a daemon client needs that only the source said (#2490)
Someone writing a client for the daemon's HTTP API can find what they need in the reference docs instead of the source. A new page, docs/reference/daemon-http-clients.md, covers finding the daemon: rig status --json is the discovery surface, the status commands check OPENRIG_URL before any state file, start and stop read the state file first, and pid and port appear only when known. It also covers the /healthz fields and when each is present, the two bearer tokens with the routes each checks and their 401 bodies, the three event streams and their replay rules, and route details: the recent-transitions limit rule, urgent on POST /api/transport/send, the scope approve, proof judge and workflow instantiate bodies and refusals, the per-seat Claude provider usage readings (not proof that two seats share an account), and how the slice routes resolve a name two missions share. Existing pages now list every reason a send is retained, say that node inventory reads exited for a verified exited pane, say how to type into a seat without resizing it, and list the files OpenRig writes into a project, with what rig down does to each (protection is per shared file, and legacy block-only files can be removed). Docs only; no behaviour change. Limits: the API changes these reports also ask for are not part of this change; the status lookup summary doesn't name its last fallback, the configured endpoint, which rig status --json already uses. Reported in #2486 and #2487 by @ziadeh.
2026-11-04 12:00:51 -08:00
Mike Schwarz c4b1181dc4 fix(delivery): give human aliases a usable queue reply hint (#2493)
A queue wake from a person alias such as human@kernel or human@host now tells the agent how to reply in a way that works. Only external people got queue guidance before; an alias got a rig send hint, which fails because a person isn't a seat. The CLI and daemon envelopes now give these aliases a rig queue create update hint, with a summary and evidence reference, after the unchanged external and thread branches, so ordinary seats with similar names keep their hints and the acting identity is still derived from the route. gateway-participants.md gains a short paragraph on how people are addressed without Slack and how a client acts for them, removes two stale claims (that another registered person can't answer an ask, and that the terminal impersonates the default person), and its registration example now includes the required --delivery-class. No identity gate is added, and Slack stays optional. Reported in #2480 by @ziadeh.
2026-11-04 11:49:43 -08:00
Mike Schwarz 72a124de82 docs: rig host pair needs a bearer token on the target daemon (#2491)
Pairing two machines now tells you up front what the target needs. A daemon bound to a tailnet address starts without OPENRIG_AUTH_BEARER_TOKEN, but rig host pair hands that token to the other machine, so POST /api/hosts/pair-request answers 409 pair_target_no_bearer when it isn't set, and pair_human_required when a person must approve. The command reference and the rig host pair --help url line now say the target must run its daemon with OPENRIG_AUTH_BEARER_TOKEN set. The bearer 401 and the daemon's startup refusal named an auth.bearerToken config field that doesn't exist; the daemon reads the token only from its environment, and both messages now name OPENRIG_AUTH_BEARER_TOKEN. Text only: no change to which requests are refused. Limits: the website's multiple-machines guide is maintained separately and gets the same correction there; the shared 401 wording is still Mission Control and auth specific, so a 401 from a separately configured terminal bearer token isn't fully explained by it. Reported in #2484 by @ziadeh.
2026-11-04 11:41:38 -08:00
Mike Schwarz 499dfe3ece docs: what a checked stop sent, and what the status words count as (#2489)
Two reference pages now match the code. checked-seat-stop.md said partialEffect true means no kill was sent; the daemon also sets it after the kill, when a selection change is seen while the stop state is saved or the save fails. The page now lists the four stopPhase values and what each says was sent, notes that selection_changed with kill_attempted means the session was killed, and says that a refusal before the stop starts carries neither field and sent nothing. sdlc-conventions.md now says that metadata.status complete is written only by POST /api/missions/:missionId/complete and is outside the readiness rollup, and lists the slice status words, the bucket each counts as in GET /api/slices, and the fallback: an unknown word counts as active, so a slice marked complete counts as active. Docs only; no behaviour change. Reported in #2483 and #2482 by @ziadeh.
2026-11-04 11:22:56 -08:00
Mike Schwarz 9b3f91ecae fix: scope audit names a slice another mission also uses (#2492)
When two missions each have a slice folder of the same name, the slice list (GET /api/slices) keeps only one of them, and the routes that find a slice by name serve that one, so nothing told a person the other slice existed. rig scope audit, and the daemon's audit route behind the dashboard, now add a low-severity slice_name_shared finding to each such slice, naming the other missions and suggesting a rename. It is advice: it doesn't change the audit's result or exit code, which slice the list keeps, or any route. Limits: the check looks for a same-named folder under each other mission's slices directory, which is narrower than every case the indexer can drop; renaming a slice folder unbinds rows that name the old slice; addressing a slice by mission is a separate, larger change. Reported in #2487 by @ziadeh.
2026-11-04 11:01:45 -08:00
Mike Schwarz 8baec13548 fix: host status for an ssh host dials where ssh connects (#2488)
rig host ls and the dashboard's host list (GET /api/hosts) now show an ssh host as reachable when ssh would reach it. OpenRig runs ssh against the host's target and leaves the rest to the person's ssh config, but the status probe dialled the target at port 22, so an ssh-config alias, or a host whose sshd is on another port, read unreachable while ssh reached it. Both probes, in the CLI and the daemon, now ask ssh -G, with the same user and target the transport uses, for the host and port ssh would connect to, and dial those. A host behind a ProxyJump or ProxyCommand reads unknown, since a direct dial can't stand for that route, as do a missing ssh binary, a timeout and a config ssh can't resolve; unreachable now means only a failed dial to the address ssh resolves. http hosts are unchanged. Limits: ssh -G makes no connection, but OpenSSH runs any Match exec command in the person's ssh config while it reads it, so with such a config each list runs that command too; ssh -G and the dial each have their own 1.5 s limit, so one host's status can take up to about 3 s, with hosts still probed in parallel. Reported in #2481 by @ziadeh.
2026-11-04 10:51:49 -08:00
Aummadour f8ba5c4bac fix(daemon): preserve verified bundle file modes under a restrictive umask (#2475)
A bundle file whose owner bits were stripped by the extraction umask, for example a script under a umask that removes the owner's own execute bit, gets its archived owner bits back after content verification. Group and other access stay as the person's umask left them, so nothing is widened. Recognized login files are capped at their archived owner read and write bits, at most 0600. Files are grouped by device and inode after extraction, the last archive header decides each one, and the login cap holds across case aliases, so on a case-insensitive volume a second name can't widen a capped login file. If a chmod fails, extraction continues and returns a named warning with the file, the failed operation and the mode actually observed, never the contents; for a login file it says the owner-only cap couldn't be applied. Those warnings now reach both bundle source resolvers, prelaunch routing, bootstrap and the install result's warning list. Content-integrity, path and link refusals are unchanged. Limits: case-insensitive aliasing is covered by simulated identity, not a native macOS run; when a login file's cap fails, the file can stay wider than 0600, with the warning saying so; Windows is skipped. Contributed by @Aummadour.
2026-11-04 10:04:51 -08:00
Mike Schwarz 3917d119de fix(watchdog): retain undelivered periodic occurrences through bounded recovery (#2478)
A scheduled reminder that couldn't reach its seat, for example after a reboot where the daemon starts before tmux, now stays owed and recovers instead of silently moving on to the next cycle. Each periodic occurrence, including each parked-row digest member, gets its own receipt in the existing outbox and retries through the existing scheduler and wake retry settings (default three retries, five minutes apart). A confirmed delivery is never sent again after interrupted accounting or a restart, and an uncertain one is never replayed. Exhaustion or an unconfirmed result ends only that occurrence, with its cause and next action recorded, and a recurring reminder keeps its normal cadence under a new occurrence; only a permanent transport failure ends the job. A one-shot park timer records its failed wake on its row before it ends, and a pending retry leaves its binding armed with a retry note. The fire stamp and the queue wake receipt and one-shot retirement commit in one transaction, with input outside it. Refusals are read with the transport's own rule (sent false or input not reached), and the external-CLI and interactive-prompt refusals are labelled, which the queue wake classifier, draft-aware delivery, compaction preparation, claim hints and the transport receipt also read. Abandoned occurrence claims are marked indeterminate at startup, before anything sends. Limits: scope is periodic reminders and parked-row digests, while condition watches, the parked-owner policy and explicit sends are unchanged; no schema, API, scheduler or replay of earlier failures is added, and previously recorded failures can't be reconstructed; while a retry waits, the existing one-second loop still evaluates it; a later recurring cycle is a new occurrence, not confirmation of an uncertain one.
2026-11-04 09:49:41 -08:00
Aummadour 7ae36414ed test(daemon): make the recorder timeout fixture deterministic (#2476)
The recorder degradation test in slow-op-recorder.test.ts is deterministic again. A zero timeout could still see a completed acknowledgement from the worker, so the test sometimes rejected a valid healthy result. The fixture now withholds the acknowledgement signal for exactly one named begin record while the real worker still writes that record, and restores normal messaging before the health checks and cleanup. Every earlier assertion is kept: the wrapped value, the begin_barrier_timeout reason, the health response and the single observation. The cell also checks the site and that the begin record reaches disk after a flush. Test only; production code and timeouts are unchanged. Contributed by @Aummadour.
2026-11-04 06:31:55 -08:00
Wu Shuwen a0242b206f docs(reference): correct Pi selector example path (#2477)
The Pi tool-selection recovery guide now gives the selector example's path from the root of the Pi repository: packages/coding-agent/examples/extensions/tools.ts. The old path left out packages/coding-agent/, so a reader starting at the root couldn't find the file. Docs only. A follow-up to the review of #2186. Contributed by @dajiaohuang.
2026-11-04 05:36:12 -08:00
Nash Aschenbath d7e2b5ca63 fix(daemon): retain the kernel variant from bootstrap history (#2471)
The kernel's original template now survives a daemon restart. /api/kernel/status keeps the built-in template the kernel was booted from, so rig daemon start --wait-for-kernel no longer prints variant=(none) on a lost-kernel restore, a deliberate stop, a stale kernel or a --no-kernel start. The selection is read from the rig's latest bootstrap_runs entry with source_kind rig_spec, including a partial launch, so kernels booted before this change are covered when their recorded source still matches the installation. Only the three kernel templates under the current installation's built-in specs directory are recognized. A custom project path with the same suffix, missing bootstrap history, or an old source path that can't be verified, for example after a Node version change moves the global install, reads an unknown variant, never a default. Nothing new is written: no migration, column or rig API field. The live rig spec still describes the current configuration; this value describes its origin. Reported in #21 by @tgrundtvig. Contributed by @Aschenbath.
2026-11-04 05:19:10 -08:00
Mike Schwarz bb007efabc fix(slack): mark original asks handled when their rows settle (#2472)
A Slack ask now shows that it was handled when its row settles anywhere, so a channel no longer fills with asks that look open but aren't. When a posted ask is answered in a pane, closed from the CLI, superseded, canceled, resolved in Mission Control, or a person's park moves to a queue blocker, the connector edits that ask's original primary post: a status line with the outcome, actor, time and closure note, the answer hints removed, and the buttons turned into historical option text. The rest of the post and its reconcile marker are kept, and supplemental posts and separate resolution notices are unchanged. Committed queue events drive the edits, with a catch-up at start from the retained notification receipts and channel stamps. A reopened direct ask reads "Open again"; it offers a thread answer only when it has no committed answer, and otherwise says the earlier answer is retained, since reopening doesn't accept a second answer. "Asked again" doesn't claim the later ask was already posted. One serialized worker uses the recovery budget (four calls per 15 seconds): each primary normally costs one GET read of the original and one JSON chat.update, a success is recorded only when Slack acknowledges the requested message at its current revision, Retry-After holds survive a restart, transient failures back off behind other work, and permanent Slack failures are reported as not done and excluded until the next connector start. Pending edits and the last failure show in rig slack status. Limits: private-channel reads may need groups:history; refused reads and unavailable originals never count as edited; catch-up needs the original receipt and stamp and a retained queue row; the first start after upgrading edits every retained historical ended ask at the edit rate, about an hour for 450 asks; reactions are not part of this change. Reported in #2468 by @ch-davidriott.
2026-11-04 00:20:24 -08:00
Mike Schwarz 8e85902060 fix(daemon): a handover's successor is pointed to its startup files, role first (#2474)
A seat handed over on a fresh install now starts with its role, as a normal launch does. A start types each send_text startup file into the new conversation, the role first; a handover ran no startup delivery, so a fresh or rebuild successor of the built-in development, design and conveyor agents and vault-specialist started without its role file, and nothing said so. The first section of the turn each fresh or rebuild successor already gets, the restore packet or the priming packet, now lists the seat's send_text startup files as absolute paths, role first, before the predecessor's context: the files a fresh start delivers, required and optional. A file whose source is missing is listed as MISSING, and a missing required one joins startedWithoutRequired, so the seat reads as needing attention in rig ps with the file named, and the handover still completes. Fork successors, which carry their parent's conversation, and discovered successors get no list. The runtime switch is unchanged, since its successor's first turn already carries these files' text. Limits: a pointer asks the successor to read the file, rather than pasting its text as a start does; send_text startup actions, such as an after_ready preload, aren't run for a successor. Reported in #2456 by @eisenbergjerry.
2026-11-03 23:30:48 -08:00
NIKHILA c7f5fc9674 fix(daemon): recognize Claude idle footer rows (#2470)
An idle Claude seat now reads idle when Claude Code draws newer rows below its mode footer: a token count, the versioned "current: … · latest: … ✔ Update installed · Restart to update" banner, and /rc. The footer search already skipped known status rows such as the bare "Update installed · Restart to apply" banner and the /clear hint; it now skips these three exact shapes too, so an empty prompt above them is found. So rig compact goes through, managed compaction sends its /compact, and a send that waits for idle sends. An unrecognized trailing row still reads unknown, live work stays active, and a typed draft is never idle. This covers the layout reported on November 3; #1641 stays open for the parts it doesn't cover, such as the /clear hint wrapping onto a second line. Reported by @aanestesia in #1641. Contributed by @Nikhila-Dhevi-R.
2026-11-03 23:26:51 -08:00
Mike Schwarz 5fb816f2b9 fix(daemon): mark a handover's successor that started without a required startup file (#2466)
A coordinator now sees in the seat list when a handover's successor started without a file it requires, and the person who ran the handover sees it in the same turn. An ordinary start fails when a required startup file can't be delivered; a handover's successor started anyway, and the missing file was only a note. The handover now names each required file the guidance file should hold that its successor started without: a projected guidance file, or a guidance startup file marked required, including when an optional startup file shares its block id. The list is on the result as startedWithoutRequired and on the seat.handover_completed event with the successor's session id. needsAttention reads that event while the successor's session is the seat's current one, serving the full paths as evidence; the reason column reads "missing required <file name>". The handover prints "Seat handover complete, degraded: <seat>" and names each file. The mark clears when the seat starts a new session, never by time. The handover still completes, with no new refusal, table or state. Limits: an exact resume clears the mark even if it couldn't put the file back, and its own result names that gap; a discovered successor isn't checked; a missing launch addition doesn't mark the seat; a required send_text startup file, such as an agent's role, isn't delivered to a handover's successor and isn't checked; repairing the file in place doesn't clear the mark before the seat's next session. Reported in #2456 by @eisenbergjerry; #2456 stays open for delivering those startup files.
2026-11-03 22:17:38 -08:00
Mike Schwarz 5f31cb5760 fix(daemon): refresh stale managed guidance blocks, never overwrite a person's edit (#2461)
An edit to a culture or startup file now reaches the next process OpenRig starts for a seat: a launch, an exact resume, a handover or a runtime switch. A managed block a person edited by hand is left in place and named, instead of being silently replaced or left stale. OpenRig records a hash of what it last wrote in each managed block, keyed by the guidance file's real path (so every link to one file shares it) and the block id, in a new managed_block_writes table. Every adapter's guidance merge and the resume and handover put-back decide through the same write. A block OpenRig wrote is refreshed from its changed source. An edited block is kept, with a guidance_block_edited warning. A block written before this change has no record: a start or a runtime switch replaces it if it differs from its source, as before, and names it; an exact resume or a handover keeps it and names it as possibly stale. A record that can't be read is treated as unknown and the differing block is kept; a record that can't be saved is named, and the write still counts. A Codex start on launch developer instructions refreshes the blocks already in AGENTS.md and adds none. When a block is left as it is, the seat is told in one line after a start, in a runtime switch's successor first turn, and in a fresh or rebuild handover's packet; an exact resume types nothing and names the block in its result. Limits: the record is per file and block id, so two rigs whose blocks share an id in one file share it; a writer outside the adapters' guidance merge and the put-back isn't covered, and none writes managed guidance blocks today. Reported in #2456 by @eisenbergjerry; #2456 stays open for its remaining handover work.
2026-11-03 21:33:34 -08:00
Mike Schwarz 886b9d0586 test(daemon): guard health and queue reads during nodes bursts (#2469)
The daemon's burst test now also covers a burst of seat inventory reads. The existing N=24 real-HTTP cell in ps-summary-stall-red.test.ts runs a second case. A child daemon on the host-shaped fixture with today's migrations serves 24 /api/rigs/:rigId/nodes requests across its active rigs while health and an exact queue read are sampled. Every inventory reply must be non-empty and for its own rig, and the worst health and queue reads must stay under the shipped 250 ms responsiveness budget. In the run that added it, the worst reads were about 117 ms and 124 ms, against 6 ms and 9 ms without the burst. Only one paired probe completed during the burst, so this guards against a regression rather than measuring a distribution. The health route is the fixture's event-loop stub, not daemon health. The ps+summary case is unchanged. Test only; production is unchanged.
2026-11-03 20:52:01 -08:00
Nash Aschenbath 1fa46a6118 feat: a Claude member's own plugin channels (--channels) (#2439)
A Claude member in a rig spec can now register just that agent for plugin channel notifications with plugin_channels, a list of plugin:<name>@<marketplace> entries. Every launch of that agent (fresh, resume, fork, restore and handover, in both the pane-shell and managed forms) passes one --channels followed by the entries, after its other options; an agent without the field gets none, and nothing is added for other agents or runtimes. The spec refuses a server: entry, a malformed or repeated entry, a non-list, and the field on a non-Claude member. A non-empty list beside claude_mcp: none produces a schema advisory, since the empty strict MCP configuration excludes plugin servers: it names both remedies, to remove claude_mcp: none to use the channels or remove plugin_channels to keep the MCP opt-out, and both settings remain and the member launches normally. The pane-shell form now writes each launch flag followed by its quoted values, so a multi-value option keeps its values. The entry grammar comes from Claude Code 2.1.316's release binary; no real channel plugin was run. Requested in #2044. Contributed by @Aschenbath, carrying on @todie's fork work.
2026-11-03 17:42:51 -08:00
Nate Faerber be85d82546 fix(daemon): open grid filler panes in the account's login shell (#2465)
Blank filler panes in tiled grid layouts (Herdr and Rex) now open the account's own shell as a login shell, <shell> -l, instead of a hard-coded sh. The shell comes from the account's passwd entry, then $SHELL, then /bin/sh, and only an absolute, executable path is used, so a failed or stale lookup never stops a layout; the passwd entry comes first because a long-running daemon can inherit a $SHELL that's gone stale. A filler now behaves like a new terminal of yours, startup files included, so an rc line that starts or attaches tmux runs in each filler too. An account whose shell refuses a session, such as nologin, closes its filler panes rather than falling back. Remote tiles are unchanged. Contributed by @natefaerber.
2026-11-03 17:13:58 -08:00
Nate Faerber 7cabac4432 fix(daemon): size a fresh seat to its first writable tile (#2464)
A freshly launched seat opened only through writable tiles is now sized to the first tile instead of staying at its 80x24 creation size. Every writable tile attached with tmux's -f ignore-size, and tmux ignores an ignore-size client whenever any client of any session sizes its window, so on a server with another sized session no tile sized the new seat. A writable tile now checks its session's clients just before each attach and keeps -f ignore-size only when one of them already sizes the session; otherwise it attaches without the flag and sizes the window. Read-only tiles keep -r unchanged, and the viewer accepts either writable spelling. Two tiles attaching at once both size, under the session's window-size option, like any two plain clients, and a tile that attached with the flag keeps it until its next attach if the sizing client later leaves. Contributed by @natefaerber.
2026-11-03 16:51:12 -08:00
Mike Schwarz e935df86f1 fix: honor accepted queue closure re-estimates without reclaiming work (#2460)
An accepted re-estimate can now move a claimed row's deadline without unclaiming the work: rig queue update <id> --closure-required-at <ISO>. The existing update transaction records the old deadline, the new one and the accepting actor alongside the note; the header-derived actor wins when present, and without a header the existing body-only attribution applies unchanged. The overdue query reads the revised value at once, and the next stuck sweep closes an already-open overdue finding while the work stays claimed, unless an independent pickup or stall recovery still shares custody. A deadline that passes after an earlier recovery for the row was closed is reported again, whether or not the owner is reporting activity: the renewal follows the row's own expired deadline rather than which finding kind wins, and the kind stays as today, so a row that is both stalled and overdue can report as stalled-after-claim. Open recoveries keep custody, and only a renewed expiry gets the deadline in its recovery ID. Limits: the field accepts a deadline on an in-progress row and doesn't read or backfill ETA prose; other pickup and stall findings stay independent; on 0.7.6 and 0.7.7, unclaim followed by claim remains the workaround and resets the claim. Reported in #2453 by @eisenbergjerry.
2026-11-03 16:45:15 -08:00
Mike Schwarz 6fb1e2c1c5 test(daemon): concurrent same-name import cells wait for the hold, not a 1 s poll (#2463)
The concurrent same-name import cells in yaml-import-replaces-stopped-generation.test.ts are deterministic again. Each cell starts a first import and waited, with vi.waitFor's default one-second poll, for it to reach a held probe or prelaunch hook. The import reaches that hold only after preflight's profile-resolution worker answers, which under vitest loads tsx first, so on a loaded runner the poll could give up with the import still on its way. Each held stub now releases an entered gate, and the cell awaits it raced against the first import settling, so it waits as long as the import takes, within the package's test timeout, and an import that settles before its hold fails at once. Every assertion about the refusal, the single replacement and the rollback is kept. Test only; production is unchanged.
2026-11-03 15:59:29 -08:00
Mike Schwarz 91ad6e719f docs: name the directories the spec library scan skips (#2462)
The instance layout reference now says which directories the spec library scan skips. Below the built-in specs, $OPENRIG_HOME/specs and the legacy library, it skips any directory named .git, .worktrees, node_modules, dist, build, .turbo or .next (SKIP_DIRS in spec-library-service.ts), so a spec such as specs/build/rig.yaml isn't listed and nothing says why. To list it, rename its directory and run rig specs sync; rig specs add keeps a directory's own name, so it doesn't help for a directory named build, and rig up with a path to the rig.yaml still launches it without the library. Text only; the scan itself is unchanged. Reported in #2333 by @EZotoff.
2026-11-03 15:45:28 -08:00
Mike Schwarz fe29a92e44 fix(daemon): put a seat's guidance back before any runtime's handover successor launches (#2458)
A handover now puts a seat's missing managed guidance blocks back for every runtime before its successor launches, as it already did for Codex and as exact resume does. It passes the rig's Claude file, so a Claude seat on a rig that uses CLAUDE.local.md gets its blocks there, not in CLAUDE.md, and the file each seat gets still follows the runtime saved at its last launch. That covers a rebuild into a working directory without the seat's blocks, for example after rig seat set-cwd. Blocks already present are left alone, and a block that can't be put back is named in the result's notes while the handover completes. Not covered: comparing blocks already in the file with their current source, on any route, is not part of this change; a required startup file that can't be delivered fails an ordinary start but is only a note on a handover; Pi, omp and a Claude fork take the same call, and the new cells cover Claude fresh and rebuild. Reported in #2456 by @eisenbergjerry.
2026-11-03 15:24:04 -08:00
NIKHIL ab858f691b fix(tui): read a seat's own window for RECENTLY FINISHED (#2459)
An agent's page in rig tui keeps its RECENTLY FINISHED section however busy the rest of the fleet is. The page used to read the instance's 20 most recently finished items and filter them down to the seat, so 20 finishes elsewhere could hide everything this seat had just done. It now reads GET /api/queue/list with state done and handed-off, sorted by update, limited to 20 and filtered to the seat's own session, which the route applies in one query. The Pulse JUST FINISHED lane keeps its instance-wide read. While a pending drill has every rig's inventory loaded, a seat whose logical ID another rig also uses can show an empty section for that one refresh; it never shows another seat's rows. A failed read shows no section and names the read, as before. Contributed by @Nikhi00718.
2026-11-03 15:16:27 -08:00
Mike Schwarz 18b5d79c9d fix(rigspec): add only the rig layers there are to the spec a seat launches with (#2457)
The spec handed to a seat's startup layering now carries only the rig layers that exist: withRigStartupLayers adds cultureFile and startup only when the stored or declared layer has a value, so a missing layer is no key rather than one holding undefined. The layers still replace the spec's own pair, as before, and OpenRig's own config resolution already ignored the undefined values, so seats launch with the same guidance; callers that inspect the spec's keys now see exactly the layers there are. The rigStartupLayers comment now states the rule as it is: the culture file and startup block are decided as a pair. Reported in #2454 and #2455 by @eisenbergjerry.
2026-11-03 14:34:45 -08:00
870 changed files with 80313 additions and 3281 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
{
"name": "openrig-app",
"description": "The OpenRig plugin: work with your OpenRig team from this app.",
"owner": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"plugins": [
{
"name": "openrig",
"source": "./packages/daemon/assets/plugins/openrig",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns."
}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "openrig-app",
"description": "The OpenRig plugin: work with your OpenRig team from this app.",
"owner": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"plugins": [
{
"name": "openrig",
"source": "./packages/daemon/assets/plugins/openrig",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns."
}
]
}
+3 -3
View File
@@ -79,7 +79,7 @@ jobs:
linux-seat-environment:
if: ${{ github.event_name != 'push' }}
# This integration test reads /proc/<pid>/environ and is skipped on macOS.
# Cover /proc seat environments and Linux canonical-input limits that macOS cannot exercise.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -108,7 +108,7 @@ jobs:
ip link set lo up
exec runuser -u "$1" -- env -i HOME="$2/home" PATH="$3" TMPDIR="$2" TMUX_TMPDIR="$2" \
CI=true TERM=xterm-256color \
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts --maxWorkers=1 --reporter=verbose
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts test/pi-shell-launch-native.test.ts --maxWorkers=1 --reporter=verbose
SUITE
package-tests:
@@ -116,7 +116,7 @@ jobs:
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
# file-inode behaviour and release tags.
runs-on: macos-latest
timeout-minutes: 45
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
+743 -25
View File
@@ -8,14 +8,733 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.10]
Published 2026-11-08 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.10` (`a226b1b6`).
OpenRig 0.7.10 is a patch for people running their own agent teams. After a
managed compaction, a step held because OpenRig can't read the seat's input box
now goes in after two idle polls instead of waiting with no end. Wakes and sends
get through a seat pane left in more than one tmux mode, and `rig exec` hands a
queue row's task to Claude Code or Codex as one finite run that outlives the
command (not yet run with a real adapter). Installing through the rigs skill tries a user-writable npm prefix
before a project-local install, and agent spec advisories now show at launch.
There are fixes across refocus, routing, status and speed under load, many
contributed by people using OpenRig. The OpenShell worker now does real work and
exports results, but its cleanup still fails against OpenShell 0.1.6, so don't
rely on cleanup or recreate yet: see [the OpenShell worker](#the-openshell-worker-in-0710).
### Before you upgrade
- **Restart the daemon after installing:**
```bash
npm install -g @openrig/cli@0.7.10
rig daemon stop && rig daemon start
```
This release adds no migrations.
- **The bundled openrig-core plugin advances from 0.1.25 to 0.1.26.** Its Claude
hooks add a `PermissionRequest` hook that reports permission requests to seat
status, and its refocus hook and the refocusing skill's trace script changed.
Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from that copy. ([#2615](https://github.com/mvschwarz/openrig/pull/2615), [#2562](https://github.com/mvschwarz/openrig/pull/2562), [#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
- **Check your agent specs:** `rig agent validate <path>` now prints each
advisory. A profile's `uses.<category>` written as a single value now selects
that resource instead of being dropped, so a member using that profile fails to
resolve if the resource pool doesn't hold it (see Behaviour that changed).
([#2585](https://github.com/mvschwarz/openrig/pull/2585), [#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **If you run an OpenShell environment, rebuild its guest image** with this
release's `guest.py` (`daemon/assets/openshell/README.md` describes the build).
The CA repair below is in that file, which the image carries. ([#2602](https://github.com/mvschwarz/openrig/pull/2602))
### Finite runs: `rig exec`
- **`rig exec` runs one turn of a queue row's task on Claude Code or Codex**
through its maintained ACP adapter, in a connector that outlives the command.
`rig exec harnesses` shows the versions installed,
`rig exec run <row> --harness claude` (or `codex`) starts it (`--plan` starts
nothing, `--wait` waits), and `rig exec status` and `rig exec cancel` follow
it. ([#2597](https://github.com/mvschwarz/openrig/pull/2597))
- **One run per row, and the row stays open.** Running it again returns the
row's run instead of sending the task twice; `--again` starts another turn once
it has ended. A run whose connector died without recording an outcome reads
`interrupted`, outcome unknown. The outcome is the agent's report, and
accepting the work stays with you. `--wait` exits 0 for `completed`, 2 for any
other outcome and 3 when you stop waiting; a Ctrl-C there stops the wait, not
the run. ([#2597](https://github.com/mvschwarz/openrig/pull/2597), [#2601](https://github.com/mvschwarz/openrig/pull/2601))
- **An ACP run's outcome note,** `rig acp run`'s included, carries the usage and
cost the agent reported, each with its source, or unknown. ([#2597](https://github.com/mvschwarz/openrig/pull/2597))
- **`docs/reference/acp-agents.md`** covers both adapters
(`@agentclientprotocol/claude-agent-acp` 0.101.0 and
`@agentclientprotocol/codex-acp` 2.5.0): installing them, which native
versions run, and how their permissions, refusals and cancels differ. ([#2596](https://github.com/mvschwarz/openrig/pull/2596))
### The OpenShell worker in 0.7.10
**The OpenShell worker hasn't run end to end on 0.7.10. Don't rely on this path
yet.**
0.7.10 changes the two things our release testing on 0.7.9 found. Tested against
OpenShell 0.1.6, the first fix holds and the second doesn't:
- **The guest's CA settings reach its Claude.** The guest launcher built
Claude's environment from scratch with only the provider settings, so a CA the
OpenShell exec supplied never arrived, and API calls failed with
`SELF_SIGNED_CERT_IN_CHAIN`. It now also passes `NODE_EXTRA_CA_CERTS`,
`SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE`, each only when the
exec supplies it. Nothing else is copied, and nothing turns TLS checks off.
With OpenShell 0.1.6 the guest now authenticates, does useful work, makes a
verified export and completes its task.
Rebuild the guest image to get it (above). ([#2602](https://github.com/mvschwarz/openrig/pull/2602))
- **Deleting an owned environment reads Podman's inventory.** The cleanup step
asked for routes Podman serves only under a version prefix, misread the "Not
Found" reply, and never sent the delete. It now uses routes Podman serves
without one, and a volume counts as gone only when one complete volume listing
leaves it out. Any other answer stops as `environment_cleanup_unconfirmed`,
with the HTTP status. The maintained cleanup still fails against OpenShell
0.1.6, which doesn't mount a workspace volume (see Known limits). ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
An exact restore hasn't been shown: recreate and restore can't run while the
cleanup fails.
### Compaction, delivery and terminals
- **A held post-compaction step now goes in.** Each step after a managed
compaction (the turn boundary, the restore request, the audit) went only into
an input box that read empty, so a box OpenRig couldn't read held the restore
request on every poll, and the seat sat idle without its restore. Now, after
two idle polls in a row that can't read the box, the next poll sends the step,
and `rig compact <seat> --state` and `rig seat status` say it went in over a
box OpenRig couldn't read, never that the box was empty. Some cases still hold
(see Known limits). ([#2576](https://github.com/mvschwarz/openrig/pull/2576), refs [#2575](https://github.com/mvschwarz/openrig/issues/2575))
- **Wakes and sends leave every tmux mode.** A pane scrolled into copy mode, with
run-shell output, a chooser, customize or clock mode on top, kept a mode after
one cancel, and the send was refused until someone left it by hand. Delivery
now runs tmux's `copy-mode -q`, which leaves them all, with a single cancel
where that fails (tmux before 3.1a), and a refusal names why leaving failed.
([#2594](https://github.com/mvschwarz/openrig/pull/2594))
- **A scrolled pane with a stale record no longer blocks delivery.** tmux
reuses pane IDs after a server restart, so a stale binding, from another rig
for example, can name the same pane as a live seat. Leaving the mode was
addressed by that raw pane ID, which then matched two seats, so it was
refused before anything was written and the send failed with nothing sent.
Leaving the mode now goes through the seat's address, as the text and Enter
writes already did. The stale binding itself is unchanged.
([#2644](https://github.com/mvschwarz/openrig/pull/2644))
- **A writable rig, pod or seat view sizes the pane it shows,** instead of
keeping an older attached client's size. Read-only views are unchanged. ([#2614](https://github.com/mvschwarz/openrig/pull/2614))
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
as the same attachment when only OpenRig's tile script changed, and moves to
the current command, so the viewer stays confirmed and the follow view goes on
adding and closing tiles. A kept viewer that's already running keeps its
earlier sizing for as long as it runs, its own re-attaches included; writable
viewers opened after the upgrade size their pane. A different session, tmux,
server or read-only setting still holds the view, as before. Checked against
the scripts those releases used, not yet with a live Herdr. It follows
@natefaerber's approach in
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
### Coordination and status
- **Routing and receipts:**
- a send to `<seat>@<rig>@<this host's ID>` goes to this host even when a
remote host is selected;
- `rig broadcast` prints `UNCONFIRMED`, not `FAILED`, for a recipient its text
or Enter may have reached, and points to `rig capture` before any resend;
- `rig seat stop`, `clean` and in-place restart, when they refuse a seat
handed over before 0.7.9, say why and no longer suggest `rig unclaim`;
- `rig roster show` and `find` read a seat's activity as unknown
("Conflicting activity observations") when two routes to it disagree;
- `rig seat set-runtime` finds a pod seat's authored recap.
([#2600](https://github.com/mvschwarz/openrig/pull/2600), [#2572](https://github.com/mvschwarz/openrig/pull/2572), [#2574](https://github.com/mvschwarz/openrig/pull/2574), [#2593](https://github.com/mvschwarz/openrig/pull/2593))
- **Refocus:** unresolved typed work, and a `rig queue whoami` that timed out,
print a named trace gap with project-level orientation, labelled as not
evidence of a current mission, instead of counting as a refocus failure.
([#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
- **Updates to people:** the stuck sweep no longer files an informational update
to a registered person as an unclaimed obligation, unless its quiet-hours hold
was orphaned. ([#2586](https://github.com/mvschwarz/openrig/pull/2586))
- **Slack:** `rig slack status` shows relay attempts to another host still
awaiting a response. It's a count for the current connector at that moment, so
zero doesn't prove nothing is in flight. ([#2569](https://github.com/mvschwarz/openrig/pull/2569))
- **Permission requests in seat status:** `rig seat status` shows a Claude
seat's latest permission request as openrig-core's hook observed it, with
Claude's own suggested rules labelled as suggestions. Nothing is applied,
approved or stored. ([#2562](https://github.com/mvschwarz/openrig/pull/2562))
### Install, specs and errors
- **The rigs skill's install step,** when npm's global prefix isn't writable,
now has the agent point npm's prefix at a folder the person owns whose `bin` is
on their login PATH, or use a version manager's Node, saying first what that
changes, and install inside the project only if neither works. ([#2611](https://github.com/mvschwarz/openrig/pull/2611))
- **Plain `rig` works in managed Codex seats however OpenRig was installed,**
with a Codex that supports `allow_login_shell` (checked in Codex's source at
0.162.1 and 0.166); not yet run with a live Codex seat. OpenRig now passes Codex `-c allow_login_shell=false`, so tool commands run in
a non-login shell and inherit the environment their pane's shell set up,
OpenRig's command folder included. To defer to Codex's own setting instead,
run `rig config set launch.codex_login_shell true`; it applies at each seat's
next launch, and a restart in place keeps the running choice. ([#2613](https://github.com/mvschwarz/openrig/pull/2613))
- **Agent spec advisories show where you'll see them:** `rig up`, a fresh restore
and a first-start retry report each as a launch warning naming the member,
spec and path, and `rig agent validate` prints them whether or not the spec is
valid. They never stop a launch. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **An unknown key under a profile's `uses`,** such as a typo,
gets an advisory instead of being ignored silently. ([#2618](https://github.com/mvschwarz/openrig/pull/2618))
- **Clearer errors:**
- a rejected terminal-token request says "Terminal auth failed" and names
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when
terminal auth falls back to it;
- the TUI shows the daemon's own message when it refuses a launch;
- `rig doctor` gives up on its cmux status and `--spec` topology reads after 2
seconds each, so a listener that never answers can't hold it.
([#2577](https://github.com/mvschwarz/openrig/pull/2577), [#2556](https://github.com/mvschwarz/openrig/pull/2556), [#2564](https://github.com/mvschwarz/openrig/pull/2564))
- **Docs:** a Codex config fragment can't install a named Codex profile, with
how to keep a profile with full access or a `codex_permissions` envelope; and
a Claude seat that selects openrig-core loads its skills with the plugin.
([#2604](https://github.com/mvschwarz/openrig/pull/2604), [#2584](https://github.com/mvschwarz/openrig/pull/2584))
### Speed under load
- **The execution view** (`GET /api/views/execution`, behind the TUI's execution
view and `rig view show execution`) reads the review registry once per request
instead of once per slice, and yields to the event loop while it does. Its
other synchronous steps are unchanged. ([#2578](https://github.com/mvschwarz/openrig/pull/2578))
- **A queue listing** looks recovery rows up through one index per listing
instead of a scan for each listed row. It hasn't been measured on a live
daemon. ([#2588](https://github.com/mvschwarz/openrig/pull/2588))
- **The event notification drain** reads pages of at most 500 rows instead of
the whole remaining tail. It bounds rows per read, not bytes, and still runs to
the end synchronously; no latency change on a live install is claimed.
([#2610](https://github.com/mvschwarz/openrig/pull/2610))
### For runner authors and integrators
- **The runner contract says what has run with a real runner.**
`docs/reference/harness-runner-contract.md` and the dsh example say what has
run natively, at what scope, and what hasn't; any other runner, provider or
path stays unqualified until shown. The 0.7.8 Known limits line below that said
no harness had run end to end is struck and corrected in place.
([#2592](https://github.com/mvschwarz/openrig/pull/2592), [#2595](https://github.com/mvschwarz/openrig/pull/2595))
- **Telemetry:** `docs/reference/telemetry.md` says OpenRig's guarantee ends at
its finite reads, versioned by `schemaVersion` 1, and links the separately
maintained openrig-telemetry exporter, which OpenRig doesn't test or
guarantee. ([#2589](https://github.com/mvschwarz/openrig/pull/2589))
- **Also:** the real-tmux transport test waits for the shell's prompt before
typing. ([#2559](https://github.com/mvschwarz/openrig/pull/2559))
### Behaviour that changed
- **Managed Codex seats run tool commands in a non-login shell** (above).
Inherited setup isn't re-run for each command, so setup that lives only in
login or rc files (such as `~/.bashrc` lines your login file doesn't source, or
a tmux `default-command` that doesn't start a login shell), shell functions and
aliases may be missing from tool commands. A model request for `login: true`
gets Codex's own refusal, "login shell is disabled by config". With
`launch.codex_login_shell` on, OpenRig omits its override rather than forcing
login shells, so a login file that sets `PATH` outright can then hide `rig`.
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
- **A held post-compaction step goes in after two idle polls that can't read the
input box** (above). A draft OpenRig can't recognise in that box may receive
the restore text. ([#2576](https://github.com/mvschwarz/openrig/pull/2576))
- **A send or wake leaves every tmux mode in a seat's pane,** including a
chooser, customize or clock mode a person has open, where before it was
refused. ([#2594](https://github.com/mvschwarz/openrig/pull/2594))
- **A send to `<seat>@<rig>@<this host's ID>`** goes to this host, not the
selected remote host. ([#2600](https://github.com/mvschwarz/openrig/pull/2600))
- **A profile's `uses.<category>` written as a single value** (for example
`uses.skills: review`) used to validate and then be dropped. It now counts as a
one-item list, with an advisory suggesting list syntax: a member using that
profile gets the resource, or fails to resolve with "not found in resource
pool" when the pool doesn't hold it. Other malformed `uses` shapes are
advisories, not new errors. ([#2585](https://github.com/mvschwarz/openrig/pull/2585))
- **`rig up` can print new warnings,** one per agent spec advisory, and
`rig agent validate` prints them. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **`rig broadcast`** prints `UNCONFIRMED` for a recipient it may have reached;
its exit code and `N/M delivered` count are unchanged. ([#2572](https://github.com/mvschwarz/openrig/pull/2572))
- **`rig roster show` and `find`** read conflicting activity as unknown.
([#2574](https://github.com/mvschwarz/openrig/pull/2574))
- **The refocus hook** no longer counts unresolved typed work or a timed-out
`rig queue whoami` as a failure, and a timed-out refocus clears the pending
post-compaction retry. ([#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
- **The stuck sweep** no longer flags informational updates to registered people
as unclaimed obligations. ([#2586](https://github.com/mvschwarz/openrig/pull/2586))
- **A rejected terminal-token request** gets "Terminal auth failed" instead of
"Mission Control auth failed"; its status code and fields are unchanged.
([#2577](https://github.com/mvschwarz/openrig/pull/2577))
### Known limits
- **OpenShell:** maintained cleanup (`delete-owned`) fails against OpenShell
0.1.6, which doesn't mount a workspace volume, so recreate and restore can't
run (above). A cleanup error that's thrown still omits its stage and whether
its delete was sent, and the inventory is a snapshot, not an atomic read.
0.7.8's requirements, cost note
and cleanup limits still apply, including "Never grant the guest access to the
daemon's main API port (normally 7433)." A same-generation reconciled launch
opens no viewer, so run `rig environment connect` yourself; non-text startup
actions aren't supported on environment seats. ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
- **`rig exec`:** no real adapter has run under it yet; one run per row,
detached ownership and reconciling were checked against a stand-in agent.
Concurrent runs, long or background tasks, recovery across a reboot, MCP
servers and Windows aren't covered. A cancel is acknowledged, not settled, and
says nothing about further spend. Cancel an exec run with `rig exec cancel`:
`rig acp cancel` given its ID while it's still requested can signal the wrong
process. A Ctrl-C just before the connector is launched isn't caught by
`--wait`.
([#2597](https://github.com/mvschwarz/openrig/pull/2597), [#2601](https://github.com/mvschwarz/openrig/pull/2601))
- **After a managed compaction, a step can still hold:** when the input box
reads as typed, with or without a suggestion; on a seat whose draft-aware
delivery or typing guard holds OpenRig's input; and after a `/compact` whose
result was lost. To take over by hand, run `rig compact <seat> --cancel`, then
send the seat a restore request naming its newest restore map, its restore
marker and its packet; a step already being pasted can still land. Which
reading the reported seat's box gave isn't established, so the report stays
open. ([#2575](https://github.com/mvschwarz/openrig/issues/2575))
- **Draft-aware delivery to Claude:** on 0.7.8 with Claude Code 2.1.319,
deliveries to idle Claude agents, queue wakes included, were reported held at
an empty prompt when a narrow pane showed
`✔ Update installed · Restart to update` on its own line. 0.7.10 still doesn't
recognize that bare banner, though it recognizes `/rc` and the full versioned
banner. A message the draft-aware delivery path holds isn't replayed
automatically by that path. ([#2605](https://github.com/mvschwarz/openrig/issues/2605))
- **Grant-service assistants aren't woken:** "an item stays queued until its
receiver claims it."
- **Open updates for a person** read a 1,001-row window, so a page can come back
short, marked `truncated`.
- **Slack:** a private channel needs the documented route, which we don't test,
and direct messages aren't supported. The channel and people reads, and a
click on a re-parked ask, haven't been run against a live Slack workspace.
Redaction of the bot token from a transport error can still leave a malformed
token fragment shorter than six characters, so review an error before sharing
it. ([#2503](https://github.com/mvschwarz/openrig/pull/2503))
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
isn't proof the connection is broken. ([#2488](https://github.com/mvschwarz/openrig/pull/2488))
- **Shared asks:** "a click's option written before its answer row … can stand
beside or inside the closer's answer without the first clicker's authorship."
- **Existing seats** last launched fresh before 0.7.9 pick up Codex launch
instructions and the single skill load at their next fresh launch. A
`CLAUDE.md` or `AGENTS.md` that already grew isn't trimmed.
- **Permit records** last as long as stream retention keeps them. A permit a
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
an ask opened before 0.7.9, a record may omit the option's kind. ([#2361](https://github.com/mvschwarz/openrig/pull/2361))
- **Permission requests in seat status** show only the latest one, held in
memory for five minutes and lost on a daemon restart. Claude's suggested rules
can contain sensitive values, and `rig seat status` and both seat-status routes
can return them. ([#2562](https://github.com/mvschwarz/openrig/pull/2562))
- **Agent spec advisories** don't show for a native resume that continues a
saved conversation, since it doesn't reload the spec. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **Seats handed over before 0.7.9** keep their adopted origin until a later
launched handover or managed launch records a new session, so until then
`rig seat stop`, `restart` and `clean` refuse them; the refusal now says so.
([#2440](https://github.com/mvschwarz/openrig/pull/2440), [#2572](https://github.com/mvschwarz/openrig/pull/2572))
- **Codex hooks after an upgrade:** Codex sessions kept running across the
upgrade to 0.7.9 showed "Hook failed — plugin contents changed; hook approval
required" until relaunched; in that upgrade test, one seat restarted with
OpenRig's managed restart showed no review afterwards. 0.7.10 changes
openrig-core's files again.
- **The runner contract** has run natively only at the scope its status section
states (`docs/reference/harness-runner-contract.md`); any other runner,
provider or path is unqualified until shown.
- **Fixed or changed from 0.7.9's limits:** the OpenShell worker's CA settings
(fixed, and shown working with OpenShell 0.1.6) and owned-delete inventory
(changed, but the cleanup still fails against 0.1.6; see above), a
restore request held after a managed compaction, `rig broadcast`'s `FAILED`
for an uncertain recipient, the refusal for a seat handed over before 0.7.9,
where to read what has run under the runner contract, and Codex seats'
`rig: command not found` (now a non-login shell by default; see Behaviour that
changed).
- **The 0.7.8 limits that 0.7.9 didn't fix or change, and the 0.7.7 and 0.7.6
limits, below still apply.**
### Credits
Thanks to @dajiaohuang, @Aummadour, @billbenetti, @smota, @Nikhila-Dhevi-R and
@Vaishnavi220506 for merged contributions, and to @AlephCasara, whose work on a
fork pointed us to the unpaged event notification drain
([#2610](https://github.com/mvschwarz/openrig/pull/2610)), and to @natefaerber, whose
approach in [#2467](https://github.com/mvschwarz/openrig/pull/2467) the Herdr view fix follows. Thanks to
@billbenetti, @KM199, @ziadeh, @dajiaohuang, @jajabong, @george661 and
@aanestesia for reports carried into this release, and to @smota for the
OpenTelemetry discussion behind [#2589](https://github.com/mvschwarz/openrig/pull/2589).
## [0.7.9]
Published 2026-11-06 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.9` (`ddb9d25b`).
OpenRig 0.7.9 follows through on 0.7.8. A person's typed words on a runner
seat's permission ask now reach the agent, each permit OpenRig grants a runner
by default is recorded, and a runner can keep a long, quiet turn reading
working. New stream observations go to the queue worker by default. Fresh Pi
seats, and fresh Codex seats on Codex 0.164.0 or later, can keep OpenRig's
guidance out of your repository's `AGENTS.md`. There are fixes across delivery,
asks, handover, install and recovery, many reported or contributed by people
using OpenRig. The OpenShell worker that wasn't usable in 0.7.8 still isn't
usable in 0.7.9; read [The OpenShell worker](#the-openshell-worker-in-079)
before relying on it.
### Before you upgrade
- **Restart the daemon after installing:**
```bash
npm install -g @openrig/cli@0.7.9
rig daemon stop && rig daemon start
```
Four migrations (146 to 149) record a Pi seat's guidance file, what OpenRig
last wrote in each managed guidance block, an index for reading a rig's
outbox, and the stream intake position.
([#2432](https://github.com/mvschwarz/openrig/pull/2432), [#2461](https://github.com/mvschwarz/openrig/pull/2461), [#2496](https://github.com/mvschwarz/openrig/pull/2496), [#2321](https://github.com/mvschwarz/openrig/pull/2321))
- **Stream intake is on by default.** Each `rig stream emit` now opens an
intake row for the kernel queue worker (`queue.worker`), or adds a note to the
one still unclaimed. The row wakes the queue worker like any queue row, and
earlier stream history isn't replayed. **To keep 0.7.8's behaviour,** with no
intake rows, run this after installing: `rig config set stream.intake off`.
([#2321](https://github.com/mvschwarz/openrig/pull/2321))
- **The bundled openrig-core plugin advances from 0.1.24 to 0.1.25.** Its
seat-continuity-and-handover skill changed. Managed refresh preserves replaced
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
you still need from that copy. ([#2420](https://github.com/mvschwarz/openrig/pull/2420), [#2554](https://github.com/mvschwarz/openrig/pull/2554))
- **If you run an OpenShell environment, rebuild its guest image** with this
release's helper (`daemon/assets/openshell/README.md`). An older helper
rejects the new startup step, and the error names the rebuild. ([#2418](https://github.com/mvschwarz/openrig/pull/2418))
### Runner seats
- **A person's typed words reach the agent, and the ask says what they do.** A
reply of just yes or no counts as the agent's allow or reject. "Any other
words choose nothing": OpenRig's default permits the request when the agent
offered an allow option, and the words come back whole in the read result as
`said`. Section 7 of `docs/reference/harness-runner-contract.md` lets a runner
queue them for the agent "as an ordinary prompt" after the current turn:
"They're never the answer." A runner that drops them still keeps the contract.
([#2363](https://github.com/mvschwarz/openrig/pull/2363), [#2561](https://github.com/mvschwarz/openrig/pull/2561))
- **Each permit OpenRig grants by default is recorded,** once, as a stream item
from the seat: when no one can be asked, and when an ask is closed without a
choice (a row passed to an agent seat included). The record names the tool
call, why no person answered (for a closed ask, who closed which row and how),
the option the agent got with its kind, and the conversation and request or
the ask. Read the records with `rig stream list --tag runner-permission-permit`,
or `--source <seat>`. A record has no destination, and one that can't be
written doesn't change the permit. Stream intake reads past it, so it opens no
intake row and wakes no one. ([#2361](https://github.com/mvschwarz/openrig/pull/2361), [#2563](https://github.com/mvschwarz/openrig/pull/2563))
- **A working beat keeps a long, quiet turn reading working.** A runner may post
`active` / `liveness` while a prompt is outstanding and its agent is alive
(section 4); one that sends no beats behaves as before. The Pi runner and the
dsh example post it. ([#2370](https://github.com/mvschwarz/openrig/pull/2370), [#2336](https://github.com/mvschwarz/openrig/pull/2336), [#2399](https://github.com/mvschwarz/openrig/pull/2399), [#2388](https://github.com/mvschwarz/openrig/pull/2388))
- **The conformance check** covers the beat, and no longer falsely fails a
correct runner on a busy machine (cases 1, 2 and 4b).
([#2368](https://github.com/mvschwarz/openrig/pull/2368), [#2375](https://github.com/mvschwarz/openrig/pull/2375), [#2388](https://github.com/mvschwarz/openrig/pull/2388), [#2530](https://github.com/mvschwarz/openrig/pull/2530))
- **Section 8 names ACP's `session/resume`,** and the dsh example reads
`DSH_SEAT_BIN` and `DSH_SEAT_HOME`, which a member's `env:` can set, and
reports dsh's real version. ([#2364](https://github.com/mvschwarz/openrig/pull/2364), [#2414](https://github.com/mvschwarz/openrig/pull/2414))
### The OpenShell worker in 0.7.9
**The OpenShell worker still doesn't work in 0.7.9. Don't rely on this path.**
0.7.9 repairs the layer that stopped it in 0.7.8: in our release testing the
guest's agent now starts and reaches its first input. Startup reaches the guest
through a hash-checked first-input file, and nothing is typed through the host
viewer; the case where the viewer exits wasn't run in our release testing.
Readiness checks the guest's own execution, and a failed owned delete reports
the stage that failed.
([#2418](https://github.com/mvschwarz/openrig/pull/2418), [#2404](https://github.com/mvschwarz/openrig/pull/2404))
Our release testing on 0.7.9 then found two more problems:
- **The worker can't reach its model.** The guest's Claude starts, but every API
call fails with `SELF_SIGNED_CERT_IN_CHAIN`. The guest launcher builds a clean
environment and leaves out the CA settings the OpenShell exec supplies
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and
`CURL_CA_BUNDLE`).
- **Deleting an owned environment doesn't run.** Its inventory step misreads a
"Not Found" reply, so the delete is never sent and the owned resources stay.
In our test they were removed with the vendor's own cleanup.
Attached resources were kept, as intended, and the worker stopped cleanly. With
the four CA settings passed through by hand, a worker did useful work; a
verified export, an exact restore and a finished task weren't shown. Fixes for
both problems are on `main` ([#2602](https://github.com/mvschwarz/openrig/pull/2602),
[#2603](https://github.com/mvschwarz/openrig/pull/2603)). They aren't in 0.7.9
and haven't been run end to end.
### Assistants connected through a grant service (experimental)
**Experimental and unsupported. Off by default. Use at your own risk.** This path
needs a grant service that you supply and run; ordinary use of OpenRig doesn't
need one. With `rig mcp serve --http --grant-service <url>`, a connected
assistant can now claim, update and park the rows addressed to its own seat, and
see its team with `rig_team`, within the scope its existing tools have. Serving
MCP over HTTP behind your own tunnel "is like opening a port in your firewall."
See `docs/reference/hosted-members.md`. ([#2384](https://github.com/mvschwarz/openrig/pull/2384))
### For client authors
- **New reads:** `GET /api/gateway/participants` (people and the default
person), `GET /api/queue/open-human-updates` (a person's open updates, without
Slack), `GET /api/queue/outbox/list?rig=<rig>` (a rig's messages in one
request), and `GET /api/gateway/slack/channels` and `/users` (a page of public
channels or of people, without the client holding the bot token).
([#2495](https://github.com/mvschwarz/openrig/pull/2495), [#2497](https://github.com/mvschwarz/openrig/pull/2497), [#2496](https://github.com/mvschwarz/openrig/pull/2496), [#2501](https://github.com/mvschwarz/openrig/pull/2501))
- **Settle exactly the decision a person reviewed:** `GET /api/queue/:qitemId`
returns `decisionRevision`, and answer, resolve, approve and deny take an
optional `expectedRevision`. A stale one gets 409 `decision_revision_changed`
with nothing written; a caller that sends none acts as before. ([#2448](https://github.com/mvschwarz/openrig/pull/2448))
- **A client reference:** `docs/reference/daemon-http-clients.md`, with a recipe
for people, open updates and a rig's outbox. ([#2490](https://github.com/mvschwarz/openrig/pull/2490), [#2524](https://github.com/mvschwarz/openrig/pull/2524))
### Seats, delivery and the TUI
- **Delivery:**
- a Claude prompt suggestion isn't taken for a person's draft;
- a long line Claude wraps short of the box is still submitted;
- a guarded wake recovers its own paste instead of holding later deliveries;
- a wake isn't typed into Claude's open Theme picker, or refused because a
reply quotes a permission question;
- a seat that starts, or takes over a seat, with held messages is told about
them; each watchdog job holds one reminder, and handoffs pass a full held
quota;
- a reminder that couldn't reach its seat, for example after a reboot, stays
owed and retries;
- a stalled tmux client is ended after 30 seconds.
([#2435](https://github.com/mvschwarz/openrig/pull/2435), [#2436](https://github.com/mvschwarz/openrig/pull/2436), [#2447](https://github.com/mvschwarz/openrig/pull/2447), [#2514](https://github.com/mvschwarz/openrig/pull/2514), [#2520](https://github.com/mvschwarz/openrig/pull/2520), [#2437](https://github.com/mvschwarz/openrig/pull/2437), [#2442](https://github.com/mvschwarz/openrig/pull/2442), [#2438](https://github.com/mvschwarz/openrig/pull/2438), [#2478](https://github.com/mvschwarz/openrig/pull/2478), [#2452](https://github.com/mvschwarz/openrig/pull/2452))
- **Status and receipts:**
- an idle Claude with newer footer rows reads idle, and a busy Codex with a
quiet window reads working;
- window samples older than their bound no longer decide a seat's activity, so
a seat whose pane can't be read reads unknown, not its last verdict;
- `rig send --verify` and `rig walk` report unconfirmed when they can't see the
result, and uncertain sends and broadcast recipients read `indeterminate`;
- a broadcast that reached nobody doesn't say "Delivered";
- replies to `human@kernel`, `human@host` and people forwarded from another
host get a usable `rig queue create` hint.
([#2470](https://github.com/mvschwarz/openrig/pull/2470), [#2500](https://github.com/mvschwarz/openrig/pull/2500), [#2558](https://github.com/mvschwarz/openrig/pull/2558), [#2509](https://github.com/mvschwarz/openrig/pull/2509), [#2510](https://github.com/mvschwarz/openrig/pull/2510), [#2505](https://github.com/mvschwarz/openrig/pull/2505), [#2507](https://github.com/mvschwarz/openrig/pull/2507), [#2512](https://github.com/mvschwarz/openrig/pull/2512), [#2359](https://github.com/mvschwarz/openrig/pull/2359), [#2493](https://github.com/mvschwarz/openrig/pull/2493), [#2504](https://github.com/mvschwarz/openrig/pull/2504))
- **Guidance and handover:**
- an edit to a culture or startup file reaches the next start, and a block you
edited by hand is kept and named;
- a shared `CLAUDE.md` or `AGENTS.md` stops growing by a line each launch;
- a Claude seat loads openrig-core's skills once;
- a fresh or rebuild handover successor gets its guidance back for every
runtime and a list of its startup files, role first, and is marked when a
required file is missing;
- launched successors can be stopped, restarted and cleaned, and the rebuild
receipt reports its lineage record and the old process truthfully;
- a Claude launched through the native installer's symlink can be rebuilt, a
slow classic resume under a non-exec launch shell no longer lands in
attention, and a `local:` Claude seat gets its activity hooks back on a
contained resume; restores use the seat's launch directory.
([#2441](https://github.com/mvschwarz/openrig/pull/2441), [#2443](https://github.com/mvschwarz/openrig/pull/2443), [#2432](https://github.com/mvschwarz/openrig/pull/2432), [#2461](https://github.com/mvschwarz/openrig/pull/2461), [#2376](https://github.com/mvschwarz/openrig/pull/2376), [#2433](https://github.com/mvschwarz/openrig/pull/2433), [#2457](https://github.com/mvschwarz/openrig/pull/2457), [#2387](https://github.com/mvschwarz/openrig/pull/2387), [#2402](https://github.com/mvschwarz/openrig/pull/2402), [#2474](https://github.com/mvschwarz/openrig/pull/2474), [#2458](https://github.com/mvschwarz/openrig/pull/2458), [#2466](https://github.com/mvschwarz/openrig/pull/2466), [#2440](https://github.com/mvschwarz/openrig/pull/2440), [#2420](https://github.com/mvschwarz/openrig/pull/2420), [#2371](https://github.com/mvschwarz/openrig/pull/2371), [#2518](https://github.com/mvschwarz/openrig/pull/2518), [#2479](https://github.com/mvschwarz/openrig/pull/2479), [#2373](https://github.com/mvschwarz/openrig/pull/2373))
- **Asks and answers:**
- in a shared workspace, any registered person settles another's ask as
themselves, and the first answer wins everywhere;
- a person may have no connector binding, and is then asked on Steering and in
the Feed only;
- a click on a re-parked Slack ask records a new answer and wakes its asker;
- a Slack ask's post shows it was handled once its row settles;
- For You no longer offers Approve on an ask with questions.
([#2386](https://github.com/mvschwarz/openrig/pull/2386), [#2419](https://github.com/mvschwarz/openrig/pull/2419), [#2421](https://github.com/mvschwarz/openrig/pull/2421), [#2472](https://github.com/mvschwarz/openrig/pull/2472), [#2415](https://github.com/mvschwarz/openrig/pull/2415))
- **Compaction:** managed compaction reads a small Claude pane at a readable
size; a manual `rig compact` that times out says whether the restore map was
ready; a Pi seat shows a failed or cancelled compaction as such.
([#2445](https://github.com/mvschwarz/openrig/pull/2445), [#2400](https://github.com/mvschwarz/openrig/pull/2400), [#2537](https://github.com/mvschwarz/openrig/pull/2537))
- **Install, hosts and setup:**
- the openrig-upgrade skill backs up without the sqlite3 CLI when Node has
`node:sqlite`;
- preflight warns when a Codex seat's login shell can't find `rig`;
- the experiment config can be written where Node has no `process.getuid`;
- bundle unpack restores archived owner bits;
- the Pi Node check is bounded, and `rig doctor` no longer hangs on a port held
by a listener that never replies;
- a scoped launch on tmux 3.4 keeps its default command;
- the kernel's template survives a daemon restart;
- `rig host ls` reads your ssh config with `ssh -G`, which runs any
`Match exec` command in it, and `rig host pair` names the bearer token the
target needs;
- the install docs describe the installer's step-4 outcome and the first
kernel's provider choice.
([#2394](https://github.com/mvschwarz/openrig/pull/2394), [#2522](https://github.com/mvschwarz/openrig/pull/2522), [#2449](https://github.com/mvschwarz/openrig/pull/2449), [#2475](https://github.com/mvschwarz/openrig/pull/2475), [#2451](https://github.com/mvschwarz/openrig/pull/2451), [#2549](https://github.com/mvschwarz/openrig/pull/2549), [#2557](https://github.com/mvschwarz/openrig/pull/2557), [#2471](https://github.com/mvschwarz/openrig/pull/2471), [#2488](https://github.com/mvschwarz/openrig/pull/2488), [#2491](https://github.com/mvschwarz/openrig/pull/2491), [#2551](https://github.com/mvschwarz/openrig/pull/2551), [#2560](https://github.com/mvschwarz/openrig/pull/2560))
- **The CLI:**
- unknown `--state`, `--kind` and `--priority` values warn and still run;
- `rig workspace validate` prints a readable refusal, and `rig seat set-runtime`
says what to do for a seat with no pane;
- a cut `rig queue show` body says how to get the rest;
- `rig queue update --closure-required-at` moves a claimed row's deadline;
- `rig seat set-typing-guard` applies from an ordinary terminal, recorded as
unattributed;
- `rig scope` tells shipped, superseded and closed slices apart, flags a slice
name two missions share, and skips fenced examples;
- `rig context add <url>` installs into a new nested name;
- a retained stuck-sweep recovery names its current reason, and a refused
context-pressure setting says what it compared.
([#2357](https://github.com/mvschwarz/openrig/pull/2357), [#2365](https://github.com/mvschwarz/openrig/pull/2365), [#2358](https://github.com/mvschwarz/openrig/pull/2358), [#2367](https://github.com/mvschwarz/openrig/pull/2367), [#2383](https://github.com/mvschwarz/openrig/pull/2383), [#2422](https://github.com/mvschwarz/openrig/pull/2422), [#2460](https://github.com/mvschwarz/openrig/pull/2460), [#2351](https://github.com/mvschwarz/openrig/pull/2351), [#2434](https://github.com/mvschwarz/openrig/pull/2434), [#2492](https://github.com/mvschwarz/openrig/pull/2492), [#2395](https://github.com/mvschwarz/openrig/pull/2395), [#2546](https://github.com/mvschwarz/openrig/pull/2546), [#2552](https://github.com/mvschwarz/openrig/pull/2552), [#2553](https://github.com/mvschwarz/openrig/pull/2553))
- **Launch options:** `specs.builtins_visible: false` leaves built-in specs out
of the inventory (`rig specs ls --include-builtins` still lists them).
`plugin_channels` passes a Claude member's own plugin channels to
`--channels`; beside `claude_mcp: none` it warns that no channel could
register, and the member still launches. We haven't run it with a real channel
plugin. ([#2379](https://github.com/mvschwarz/openrig/pull/2379), [#2439](https://github.com/mvschwarz/openrig/pull/2439))
- **The TUI and web UI:**
- body text reads on a light terminal, and the selected row shows in
16-colour mode;
- JUST FINISHED and RECENTLY FINISHED show what closed last, in the configured
time zone, even on a busy fleet;
- a fresh seat opened in tiles is sized to its tile, and grid fillers open your
login shell;
- in the web UI, the Topology filter no longer hangs, and the kernel card and
maintenance notice display correctly.
([#2499](https://github.com/mvschwarz/openrig/pull/2499), [#2506](https://github.com/mvschwarz/openrig/pull/2506), [#2392](https://github.com/mvschwarz/openrig/pull/2392), [#2393](https://github.com/mvschwarz/openrig/pull/2393), [#2459](https://github.com/mvschwarz/openrig/pull/2459), [#2464](https://github.com/mvschwarz/openrig/pull/2464), [#2465](https://github.com/mvschwarz/openrig/pull/2465), [#2411](https://github.com/mvschwarz/openrig/pull/2411), [#2413](https://github.com/mvschwarz/openrig/pull/2413), [#2412](https://github.com/mvschwarz/openrig/pull/2412))
- **Also:**
- transcript search scans a long line once instead of once per chunk, with the
same results;
- a Slack transport error has the bot token removed (see Known limits);
- the hook-review check recognises a kernel Codex pane that runs under a shell;
- the reference docs cover a Slack private-channel route, missed-message
recovery, checked stops and slice status words, the spec scan, bundle hashes
and the Pi selector path.
([#2397](https://github.com/mvschwarz/openrig/pull/2397), [#2503](https://github.com/mvschwarz/openrig/pull/2503), [#2416](https://github.com/mvschwarz/openrig/pull/2416), [#2494](https://github.com/mvschwarz/openrig/pull/2494), [#2401](https://github.com/mvschwarz/openrig/pull/2401), [#2489](https://github.com/mvschwarz/openrig/pull/2489), [#2462](https://github.com/mvschwarz/openrig/pull/2462), [#2543](https://github.com/mvschwarz/openrig/pull/2543), [#2477](https://github.com/mvschwarz/openrig/pull/2477), [#2369](https://github.com/mvschwarz/openrig/pull/2369), [#2403](https://github.com/mvschwarz/openrig/pull/2403))
### Behaviour that changed
- **Stream intake is on by default** (above). `off` keeps 0.7.8's behaviour.
- **Fresh Pi seats, and fresh Codex seats on 0.164.0 or later, get OpenRig's
guidance outside the repository `AGENTS.md`:** Pi in its own agent folder
(unless that file or folder is a link), Codex as launch developer
instructions. Codex still uses the repository file for a resume, fork,
restore or handover successor, an older or unknown version, or a config layer
that sets `developer_instructions` or can't be read. If only your shell
startup sets `CODEX_HOME`, OpenRig reads `~/.codex`, so a
`developer_instructions` in your home is replaced for that session; declare
the same absolute path as `CODEX_HOME` in the member's or rig's `env:` to have
it checked.
- **`rig down`** removes OpenRig's own additions from a working folder once no
live seat uses them.
- **After upgrading, the Slack connector edits asks that ended earlier** to show
they were handled, at Slack's edit rate: about an hour for 450 asks.
`rig slack status` shows pending edits.
- **A later answer to a settled ask** gets 409 `qitem_already_terminal` and
records nothing.
- **`rig send --verify`** reports unconfirmed and exits 1, not delivered, when it
can't see what its Enter did.
- **`rig export` with no `-o`** writes `rig-export.yaml` beside an existing
`rig.yaml` instead of replacing it. ([#2356](https://github.com/mvschwarz/openrig/pull/2356))
- **`rig workspace validate`** exits 1 for malformed optional frontmatter.
([#2396](https://github.com/mvschwarz/openrig/pull/2396))
- **An active rig name that matches more than one rig** makes `rig env down`,
`status` and `logs` list the matching rig IDs and stop, and
`rig doctor --spec` report conformance as unknown, instead of using the first
match. ([#2548](https://github.com/mvschwarz/openrig/pull/2548), [#2550](https://github.com/mvschwarz/openrig/pull/2550))
- **New default kernels** start in `<workspace.root>/kernel`. Existing kernels
stay where they are; `docs/reference/agent-startup-guide.md` describes the
optional move.
### Known limits
- **OpenShell:** the worker doesn't work in 0.7.9 (see
[The OpenShell worker](#the-openshell-worker-in-079)). 0.7.8's requirements,
cost note and cleanup limits still apply, including "Never grant the guest
access to the daemon's main API port (normally 7433)." A same-generation
reconciled launch opens no viewer, so run `rig environment connect` yourself;
non-text startup actions aren't supported on environment seats.
- **Grant-service assistants aren't woken:** "an item stays queued until its
receiver claims it."
- **Open updates for a person** read a 1,001-row window, so a page can come back
short, marked `truncated`.
- **Slack:** a private channel needs the documented route, which we don't test,
and direct messages aren't supported. The channel and people reads, and a
click on a re-parked ask, haven't been run against a live Slack workspace in
our release testing. Redaction of the bot token from a transport error can
still leave a malformed token fragment shorter than six characters, so review
an error before sharing it. ([#2503](https://github.com/mvschwarz/openrig/pull/2503))
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
isn't proof the connection is broken. ([#2488](https://github.com/mvschwarz/openrig/pull/2488))
- **Shared asks:** "a click's option written before its answer row … can stand
beside or inside the closer's answer without the first clicker's authorship."
- **Existing seats** pick up Codex launch instructions and the single skill load
at their next fresh launch. A `CLAUDE.md` or `AGENTS.md` that already grew
isn't trimmed.
- **Permit records** last as long as stream retention keeps them. A permit a
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
an ask opened before 0.7.9, a record may omit the option's kind. ([#2361](https://github.com/mvschwarz/openrig/pull/2361))
- **Draft-aware delivery to Claude:** on 0.7.8 with Claude Code 2.1.319,
deliveries to idle Claude agents, queue wakes included, were reported held at
an empty prompt when a narrow pane showed
`✔ Update installed · Restart to update` on its own line. 0.7.9 still doesn't
recognize that bare banner, though it recognizes `/rc` and the full versioned
banner. A message the draft-aware delivery path holds isn't replayed
automatically by that path.
([#2605](https://github.com/mvschwarz/openrig/issues/2605))
- **After a managed compaction, the restore request can stay held.** OpenRig
delivers each post-compaction step only when it can read the seat's input box
as empty. When it can't, it holds the restore request and keeps retrying on
its polls, so the seat can sit idle at an empty prompt with its restore not
yet requested. `rig compact <seat> --state` and `rig seat status` show the
restore as owed, and from the second failed attempt the seat's owner gets a
queue item naming it. To restore by hand, send the seat a restore request
naming its newest restore map, its restore marker and its packet, then settle
the owed attempt with `rig compact <seat> --cancel`; a poll can still race
that step. Reported on 0.7.8, and 0.7.9 behaves the same way. ([#2575](https://github.com/mvschwarz/openrig/issues/2575))
- **Seats handed over before 0.7.9** keep their adopted origin until a later
launched handover or managed launch records a new session, so until then
`rig seat stop`, `restart` and `clean` refuse them as before. ([#2440](https://github.com/mvschwarz/openrig/pull/2440))
- **`rig broadcast`:** an uncertain recipient's outbox receipt now reads
`indeterminate`, but the broadcast's own output for that recipient still says
FAILED. Check that seat with `rig capture` before resending. ([#2507](https://github.com/mvschwarz/openrig/pull/2507))
- **Codex hooks after an upgrade:** Codex sessions kept running across the
upgrade show "Hook failed — plugin contents changed; hook approval required"
until relaunched. In our upgrade test from 0.7.8, one seat restarted with
OpenRig's managed restart showed no review afterwards.
- **Codex seats can still report `rig: command not found`** when `rig` isn't on
the login shell's PATH: with Node outside it, or when npm can't install
globally and OpenRig is installed inside the project instead. Preflight now
warns at launch. In our testing the agents recovered by calling `rig` by its
full path; the getting-started guide describes a `~/.profile` fix, which we
didn't run.
- **What we ran end to end under the runner contract:** this release's dsh
example runner, natively, with dsh 0.2.1-rc.2 on DeepSeek's
`deepseek/deepseek-v4-flash` through dsh's pi-ai OpenRouter chat-completions
profile, against 0.7.8 Core: two useful queue tasks, queued and multiline
input, a quiet tool interval of about 26 seconds that still read working,
allow-once and reject-once with their file effects, cancel, a return to the
same conversation with recall, and shutdown. The permission answers came from
an automated test operator, and the run's preset added `resume: true`, which
the shipped preset doesn't declare. dsh's own DeepSeek Messages route through
OpenRouter failed with HTTP 400, and the cause isn't established. Not run: a
missing-conversation refusal, cross-host restore, OpenShell, and other
adapters, models or providers. 0.7.8's release tests had already run its dsh
example end to end, so the 0.7.8 CHANGELOG's "No harness has run end to end
under the contract yet" was out of date.
- **Fixed or changed from 0.7.8's limits:** the dsh example's `env:` names, the
conformance check's false failures, a person's typed words, the README's
operator address, a rebuild behind a symlink, the rebuild receipt, a click on
a re-parked ask, and recording a permit made when no one can be asked: a
person's answer is recorded on the ask, and a default permit as the stream
item above.
- **The other 0.7.8 limits, and the 0.7.7 and 0.7.6 limits, below still apply.**
### Credits
Thanks to @porterchild, @Mohsintl, @Nikhi00718, @oodadoudou, @AdityaxDeore,
@w4rc0n, @genius8267, @Aschenbath, @DeryFerd, @mohitdixit02, @Vaishnavi220506,
@Nikhila-Dhevi-R, @natefaerber, @dajiaohuang, @Aummadour, @KM199 and @Totopo27
for merged contributions, and to @todie and @seanmcn, whose fork work is carried
in [#2439](https://github.com/mvschwarz/openrig/pull/2439) and [#2400](https://github.com/mvschwarz/openrig/pull/2400). Thanks to @ziadeh, @billbenetti, @eisenbergjerry,
@ch-davidriott, @puniserv, @oneandonlydean, @sabnanikl-dev, @gifflen,
@pvl-agent, @EZotoff, @adam-nectar, @bpamiri, @tgrundtvig, @aanestesia,
@AdamWhitehurst and @eisenrigbot for reports carried into this release. Thanks
also to @z4cc, @hitianqi, @jimallen, @m3ac-AllbrittenJ and @lkraider for reports
and field feedback.
## [0.7.8]
**Not yet published.**
Published 2026-11-05 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.8` (`12df562c`).
OpenRig 0.7.8 lets a person answer a runner seat's permission requests, with a
conformance check and an example runner to build against. It adds a maintained
way to run a Claude worker in an NVIDIA OpenShell sandbox, and includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI.
conformance check and an example runner to build against. It includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI. It also includes a
reference for running a Claude worker in an NVIDIA OpenShell sandbox, but that
worker isn't usable in this release: see [the OpenShell reference](#the-openshell-reference-isnt-usable-in-078).
### Before you upgrade
@@ -61,20 +780,15 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
- **A seat activity grant** lets a runner outside the host's hook setup report
activity for one seat and launch, without the install-wide hook token. ([#2295](https://github.com/mvschwarz/openrig/pull/2295))
### Claude workers in OpenShell sandboxes
### The OpenShell reference isn't usable in 0.7.8
- **`rig environment setup` installs a maintained reference** that binds a seat
to one NVIDIA OpenShell sandbox (`rig environment create` or `attach`, then
`rig up`).
- **Context and results:** the seat's guidance, skills, plugins and startup
files go into the sandbox, and verified JSON results come back through
queue work.
- **Stop and delete:** stopping the agent, stopping compute and deleting the
sandbox are separate actions.
- **Restore:** a recreated sandbox resumes the same conversation.
The steps are in `daemon/assets/openshell/README.md`.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300))
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
**In our release testing the worker never became usable:** after the sandbox was
created and the native process started, `rig environment connect` failed when its
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
0.7.8.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300), [#2378](https://github.com/mvschwarz/openrig/pull/2378))
### Seats, delivery and the TUI
@@ -136,8 +850,13 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
### Known limits
- **No harness has run end to end under the contract yet.** The dsh example and
the conformance check were checked against stand-ins.
- ~~**No harness has run end to end under the contract yet.** The dsh example and
the conformance check were checked against stand-ins.~~ *Corrected after
release:* 0.7.8's release testing ran a real dsh 0.2.1-rc.2 through the
shipped dsh example runner for a person's allow-once and reject-once, and
through an outside runner, dsh-seat 0.3.0, for launch, queue work, multiline
input, activity, cancel and exact resume. The conformance check itself runs
against a stand-in agent.
- **The answer owner is a registered person.** "A colleague's answer is
followed on this host. If the ask is handed to another host, the request
waits, shown as needs input, until the agent's turn ends."
@@ -150,14 +869,13 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
- **The dsh example keeps one hold per seat:** "if dsh asks twice in one turn,
settling one ask releases the hold while the other may still wait on a
person."
- **The OpenShell reference requires:**
- NVIDIA OpenShell 0.1.6;
- a Linux image;
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
credentials aren't supported.
- **The worker isn't usable** (above). The reference also requires NVIDIA
OpenShell 0.1.6, a Linux image, and an API-backed provider using
`ANTHROPIC_API_KEY`; subscription or OAuth credentials aren't supported.
"Provider registration and image construction can incur costs." The worker's
activity reads unknown, from pane sampling, never idle.
- **The OpenShell README example uses `operator@kernel`;** the actual kernel
operator is `operator-agent@kernel`.
- **"Never grant the guest access to the daemon's main API port (normally
7433)."** The guest-only listener is what confines a guest's reach. Its grant
only authorizes requests for its own seat and launch.
+1 -1
View File
@@ -75,7 +75,7 @@ curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/in
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
```
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
**Or step by step:**
+2 -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.6
## Released in 0.7.10
[0.7.6 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6): follow a slice's work in one terminal workspace, plus steadier daemon start and delivery. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
[0.7.10 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.10): it is a patch for people running their own agent teams. A step held after a managed compaction now goes in instead of waiting forever. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
## How to help
@@ -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
@@ -214,16 +214,26 @@ inside a caller-managed transaction, `eventBus.persistWithinTransaction(...)`
(`event-bus.ts:70`) with subscribers notified after commit — across domain
services (`stream-store.ts`, `workflow-runtime.ts`, `restore-orchestrator.ts`,
`node-launcher.ts`, etc.) and route handlers. The event log is append-only and
SQLite-backed.
SQLite-backed, and it holds stored events only. `view.changed`, which the view
event bridge (`view-event-bridge.ts`) produces for each queue, inbox, proof or project-classification change,
is transient: `eventBus.publishTransient(...)` delivers it to the live
subscribers without a row, without a `seq` of its own and without moving the
notify watermark. It carries `transient: true` and, as its `seq`, the `seq` of
the stored event that caused it.
Upgrade note: `view.changed` rows stored by older versions stay in the log, so
`/api/events` replays and the telemetry export still return them until an events retention
(a separate change) removes them.
SSE delivery surfaces include the following. The daemon has **11**
`streamSSE(` call sites across **10** files in `packages/daemon/src/routes/`
(`git grep -o 'streamSSE(' -- packages/daemon/src/routes | wc -l`;
`git grep -l 'streamSSE(' -- packages/daemon/src/routes | wc -l`).
- `GET /api/events` — global stream of all events (`server.ts:730`
`app.route("/api/events", eventsRoute)`); `?rigId=` keeps one rig's. Each
event's SSE `id` is its `seq`. Before going live, a connection replays
- `GET /api/events` — global stream of the stored events (`server.ts:730`
`app.route("/api/events", eventsRoute)`); `?rigId=` keeps one rig's.
Transient events (`view.changed`) are not stored, so they appear here
neither live nor in a replay. Each event's SSE `id` is its `seq`. Before going live, a connection replays
stored events, read and written 500 at a time (`routes/events.ts`):
- with a `Last-Event-ID` header (an `EventSource` sends it when it
reconnects), every event after that `seq`, whatever `replay` says;
@@ -234,6 +244,10 @@ SSE delivery surfaces include the following. The daemon has **11**
Either way the replay ends at the newest event stored as the connection
subscribes; the events stored meanwhile were buffered and follow it, each
once and in order, before the stream goes live.
- `GET /api/views/sse` (alias `/api/views/watch`) and
`GET /api/views/:viewName/sse` — `view.changed`, live only (`routes/views.ts`).
An event's `id` there is the `seq` of the stored event that caused it, so it
repeats across the views one change touches; it is not a replay cursor.
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:194`).
- `GET /api/queue/watch` — queue/inbox coordination events
(`routes/queue.ts:1054`, with a `/sse` alias at `:1055`).
@@ -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
@@ -461,6 +463,23 @@ in the current update window. Current-update coverage is independent of the
receipt window, so an unavailable current reader cannot retain closed delivery
history beyond a successful history read. Both reads leave queue state intact.
Clients that need only current FYIs can use `GET /api/queue/open-human-updates`.
It returns `{items, limit, truncated}` with open human-addressed updates, ordered
critical then urgent then routine, without requiring a delivery receipt. `limit`
defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
same person perspective as `/api/attention`; a perspective read scans at most
1000 rows and signals `truncated` when the window may omit matching items.
A finished item's result file is its `evidenceRef`, read through the daemon's
shared roots with `rig_artifact_read` (MCP) or `rig queue artifact <qitemId>
--json` (CLI). For a code result, the assignee writes a JSON file of kind
`openrig.code-result/v1`, with `repo` (an absolute path or a URL), `base` and
`revision` (full commit ids, SHA-1 or SHA-256), and optionally `paths`, `branch`
and `fetch: {remote, ref}`. Both reads then add `codeResult`, the exact pair a
client can diff. A manifest whose base or revision isn't a full commit id gets
`codeResultIssue` instead. `branch` and `fetch` are hints for finding the commits,
never the revision. Other files read as before.
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
@@ -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
+55 -17
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
@@ -356,6 +357,17 @@ the sections that were not read are `null`.
| `rig host list` | `ls` | `--json` |
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
`rig host pair` needs the target daemon to run with `OPENRIG_AUTH_BEARER_TOKEN` set,
even when it is bound to a tailnet address and so starts without one: pairing hands
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
registered person can approve (none, or several with no default; name one with
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
The request stays open on the target for as long as `--timeout` waits (600 seconds by default), and the person's
approval says how long and until when, in UTC. A target older than this CLI ignores the timeout and keeps the request
10 minutes.
Registration makes a host visible in topology; remote FEED attention remains opt-in.
The human-readable success output of `host add` and `host pair` now includes the FEED
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
@@ -379,7 +391,7 @@ do not establish connectivity to a live peer.
|---|---|---|
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`. `startedWithoutRequired` lists the required startup files a handover's successor started without, and the compact read keeps it.
### roster
@@ -395,7 +407,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` |
@@ -407,9 +419,21 @@ do not establish connectivity to a live peer.
|---|---|---|
| `rig acp` | — | — |
| `rig acp run <qitemId> <agent...>` | — | `--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--poll-seconds <n>` |
| `rig acp serve` | — | `--to <member@rig>`<br>`--as <address>`<br>`--poll-seconds <n>` |
| `rig acp cancel <runId>` | — | — |
| `rig acp stop <runId> [stopCommand...]` | — | — |
### exec
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig exec` | — | — |
| `rig exec harnesses` | — | `--json` |
| `rig exec run <qitemId>` | — | `--harness <name>` **required**<br>`--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--wait`<br>`--plan`<br>`--again`<br>`--poll-seconds <n>` |
| `rig exec status <runIdOrQitemId>` | — | `--json` |
| `rig exec cancel <runId>` | — | — |
| `rig exec connector <runId>` (hidden) | — | — |
### agent
| Invocation | Aliases | Declared options |
@@ -455,6 +479,11 @@ do not establish connectivity to a live peer.
| `rig stream read` | — | `--limit <n>`<br>`--reader <session>`<br>`--json` |
| `rig stream act <streamItemId>` | — | `--work <qitemId>`<br>`--decline <reason>`<br>`--duplicate <id>`<br>`--reader <session>`<br>`--json` |
Stream intake: the `stream.intake` setting (default `queue.worker`, or a canonical `<seat>@<rig>`) receives each emitted item.
It opens an intake row for that seat, which wakes it, or adds a note to that seat's intake row still pending; a `critical`
or interrupting item makes the row urgent. `rig config set stream.intake off` keeps emits passive. A runner's
default-permit record (tag `runner-permission-permit`) opens no row.
### queue
| Invocation | Aliases | Declared options |
@@ -463,16 +492,17 @@ do not establish connectivity to a live peer.
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--host <id>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--host <id>`<br>`--full`<br>`--json` |
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue handoff-and-complete <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue whoami` | — | `--session <session>`<br>`--recent-limit <n>`<br>`--work-candidates`<br>`--json` |
| `rig queue fallback <qitemId>` | — | `--destination <session>` **required**<br>`--reason <text>`<br>`--json` |
| `rig queue show <qitemId>` | — | `--full`<br>`--json` |
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full` |
| `rig queue list` | — | `-a, --all`<br>`-A, --all-rigs`<br>`--full`<br>`--owned`<br>`--mine`<br>`-o <format>`<br>`--destination <session>`<br>`--source <session>`<br>`--state <state>`<br>`--target-repo <name>`<br>`--limit <n>`<br>`--json` |
| `rig queue show <qitemId>` | — | `--full`<br>`--json`<br>`--host <id>` |
| `rig queue artifact <qitemId>` | — | `--root <name>`<br>`--json` |
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full`<br>`--host <id>` |
| `rig queue list` | — | `-a, --all`<br>`-A, --all-rigs`<br>`--full`<br>`--owned`<br>`--mine`<br>`-o <format>`<br>`--destination <session>`<br>`--source <session>`<br>`--state <state>`<br>`--target-repo <name>`<br>`--limit <n>`<br>`--json`<br>`--host <id>` |
| `rig queue overdue` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
| `rig queue undelivered` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
| `rig queue inbox-drop <destinationSession>` | — | `--sender <session>`<br>`--body <text>`<br>`--body-file <path>`<br>`--tags <tags>`<br>`--urgency <urgency>`<br>`--audit <pointer>`<br>`--id <inboxId>`<br>`--json` |
@@ -481,6 +511,7 @@ do not establish connectivity to a live peer.
| `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
@@ -540,13 +571,16 @@ do not establish connectivity to a live peer.
| 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
@@ -576,9 +610,10 @@ Herdr session per request, including session-bound previews and window reuse.
| `rig workflow specs` | — | `--json` |
| `rig workflow guidance <instanceId>` | — | `--packet <id>`<br>`--component <id>`<br>`--full`<br>`--json` |
| `rig workflow show <instanceId>` | — | `--json` |
| `rig workflow trace <instanceId>` | — | `--json` |
| `rig workflow trace <instanceId>` | — | `--full`<br>`--json` |
| `rig workflow continue <instanceId>` | — | `--json` |
| `rig workflow run <specPath>` | — | `--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--tags <tags>`<br>`--json` |
| `rig workflow unregister [spec]` | — | `--prune-missing`<br>`--json` |
| `rig workflow watch <instanceId>` | — | `--json` |
| `rig workflow route <instanceId>` | — | `--to <session>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--packet <qitem-id>`<br>`--reason <text>`<br>`--json` |
| `rig workflow resume <instanceId>` | — | `--actor-session <session>` (only when the caller's session can't be derived)<br>`--occurrence <failed-qitem-id>`<br>`--decision <text>`<br>`--json` |
@@ -644,6 +679,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** |
+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. |
+5 -1
View File
@@ -181,7 +181,11 @@ UI event consumption is centralized through one shared `/api/events`
connection (`lib/topology-events.ts`) so related surfaces avoid duplicate
SSE connections. Current consumers: For You feed hydration, topology
activity rings + hot-potato movement, the activity feed/system surfaces,
and rig event surfaces. `useGlobalEvents` reports whether that connection
and rig event surfaces. That connection carries stored events only:
`view.changed` is delivered on the live view streams (`/api/views/sse`) and
is not stored, so it no longer appears in the activity feed, as a For You
observation card or in the telemetry export (entries stored by older
versions still replay until an events retention, a separate change, removes them). `useGlobalEvents` reports whether that connection
is up and refetches review, slice and mission data on reconnect and on
`proof.*` events. Reduced-motion preference is honoured for pulse/packet
animation. (Daemon SSE surfaces: `/api/events`, `/api/stream/watch`,
+176 -2
View File
@@ -13,7 +13,8 @@ The row's notes get, in order:
- **progress,** about once a minute (tool calls so far, output so far); the full transcript is in the run's log file, named on the row;
- **each permission request and its answer** (below);
- **the outcome:** `completed`, `stopped` (for example `max_tokens`), `cancelled`, `failed` or `unknown`, with the
agent's output;
agent's output and the tokens and cost it reported, each labelled with where it came from (unknown when it reported
none);
- **what happened to the agent's processes** after the run ended.
The outcome is the agent's report, not a judgment of the work. The row stays open and yours: read the result, then
@@ -62,6 +63,78 @@ shared one running; stop your own with its own command.
- `failed`: the agent returned an error. The row keeps its original code, message and data.
- `cancelled`, `stopped`: as above.
## Claude Code and Codex, through their ACP adapters
Both have maintained ACP adapters. What follows was checked on 2026-11-06 with OpenRig 0.7.8: one small file task per
case, with `claude-haiku-5-5` and with `gpt-6.1-sol` at low effort. Other models weren't checked.
Install the adapters once; running them through `npx` adds about 10 seconds a turn:
```sh
npm install --prefix <dir> @agentclientprotocol/[email protected] @agentclientprotocol/[email protected]
export PATH="<dir>/node_modules/.bin:$PATH"
rig acp run <row> --cwd <work folder> -- env CLAUDE_CODE_EXECUTABLE=<your claude> ANTHROPIC_MODEL=<model> claude-agent-acp
rig acp run <row> --cwd <work folder> -- codex-acp
```
**Which versions run.** The binding note on the row names the adapter and its version.
- `claude-agent-acp` 0.101.0 runs Claude Code through the Claude Agent SDK 0.3.318. With `CLAUDE_CODE_EXECUTABLE` set,
it runs that `claude`, with that `claude`'s own login; the check ran Claude Code 2.1.310 this way. Without it, an npm
install runs the Claude binary from the SDK's platform package (Claude Code 2.1.318, by the SDK's package metadata),
which wasn't checked. Installing with `--omit=optional` leaves that binary out, and then, without
`CLAUDE_CODE_EXECUTABLE`, the adapter refuses to start; it never falls back to a `claude` on PATH. The adapter also
loads your Claude configuration, skills included.
- `codex-acp` 2.5.0 depends on `@openai/codex` `^0.165.0`, and 0.165.0 was the only release in that range when this
was checked. A later install can resolve a newer 0.165 release; `npm ls @openai/codex` in `<dir>` shows yours. It uses
the machine's existing Codex login.
- `@zed-industries/claude-code-acp` and `@zed-industries/codex-acp` are deprecated on npm in favour of these. The zed
`codex-acp` 0.16.0's Codex was too old for the model in the check.
**Permissions.** Each agent keeps its own permission mode, and OpenRig adds no restriction. In the check, neither
asked anything for file writes or shell commands: Claude started in `auto` mode and Codex in `workspace-write`. To
have the agent ask, so that each request becomes a permission row:
- **Claude:** give it a dedicated `CLAUDE_CONFIG_DIR` whose settings set `permissions.defaultMode` to `"default"`.
- **Codex:** set `INITIAL_AGENT_MODE=read-only` in its environment. Approval and sandbox keys in `CODEX_CONFIG` didn't
make it ask.
These are the settings the check used to make the agents ask. Whether to use them is the caller's choice; they're not a
recommended default. Send permission rows to a seat that can be woken: a row to a `terminal` seat sets off the stuck
sweep.
**Answers differ between the two.**
- **Claude** offers `allow-once`, `allow-with-updates` (allow always) and `reject`. `reject` refuses that one action,
and the turn carries on.
- **Codex** offers `allow_once`, `accept_execpolicy_amendment` (allow always) and `cancel`. Its only refusal, `cancel`,
ends the whole turn, and the outcome is `cancelled`.
**Continuing, and a session that doesn't exist.**
- **Continuing:** `--load-session` continued an earlier session on a second row with both adapters.
- **A made-up session id:** both refused it and started nothing, but they report it differently. Claude returns
`-32002 Resource not found`, and Codex returns `-32603 Internal error` with "no rollout found" in its data. The row
shows `failed` with the agent's own error. Neither code by itself means a missing session, so read the message.
**A completed turn isn't finished work.** In the check, Claude was asked to run a command and wait for it. It moved the
command into a background task, said it would report when it finished, and ended the turn `completed`. The task died
with the adapter, and its file never appeared. Check the work itself before you close the row. Beyond this one case,
long and background work weren't checked.
**Cancel.** `rig acp cancel` was acknowledged in 20-30 ms for both, sent while the agent waited on a permission
answer; a cancel mid-generation or mid-tool wasn't timed. Straight after, Claude's run reported `incomplete`
with one process still running, which was gone a few seconds later; Codex's reported `settled`. An acknowledgement
isn't a finished shutdown, and it says nothing about further spend.
**Usage and cost.** Claude's adapter reports token usage and a cost in USD; Codex's reports tokens only. Both are in
the run's log file, not on the row.
**Not checked:**
- long or multi-tool tasks, and background work;
- concurrent runs;
- `rig acp stop` with a real stop command;
- an adapter that crashes mid-turn;
- continuing after a daemon restart;
- MCP servers.
## Notes for specific agents
- **OpenClaw:** `openclaw acp` is a bridge to a running OpenClaw Gateway, so start the Gateway first, then run, for
@@ -70,8 +143,109 @@ shared one running; stop your own with its own command.
- **Continuing a session:** `--load-session <id>` continues an earlier session when the agent advertises loading. If it
doesn't, or the load fails, the run reports that and starts nothing new.
## Running it in the background: `rig exec`
`rig exec` runs the same single turn on a named harness, `claude` or `codex`, through that harness's ACP adapter, in a
connector that outlives the command:
```sh
rig exec harnesses # the adapter and native versions installed here, and how each differs
rig exec run <row> --harness claude --plan # what would run, with versions; starts nothing
rig exec run <row> --harness claude [--wait] # start it; --wait waits here for the outcome
rig exec status <runId|row> # state, outcome, usage and cost, and what still runs
rig exec cancel <runId>
```
- **The connector owns the run.** Closing the terminal, or leaving `--wait` with Ctrl-C, doesn't stop it. Its record
in `$OPENRIG_HOME/acp/runs/` holds its process and its state: `requested`, `started`, `finished` or `interrupted`.
The row gets the same notes as with `rig acp run`.
- **One run per row.** Running it again returns the row's run, still going or ended, instead of sending the task
twice. `--again` starts another turn once that run has ended.
- **Interrupted.** A run whose connector died without recording an outcome reads `interrupted`, outcome unknown, with
one note on the row, the next time `status`, `run` or `--wait` looks at it. Whether any of the agent's processes
remain is said there too.
- **Missing.** With `--load-session`, a session reads `missing` only on the adapter's own evidence: for Claude,
`-32002` naming that session; for Codex, its "no rollout found for thread id" error for that session. Any other error
reads `failed`, with the agent's code and message, and no new session is started either way.
- **The outcome is the agent's report.** `finished` with `completed` means the turn ended; accepting the work stays
with you, on the row. `--wait` exits 0 for `completed`, 2 for any other outcome, and 3 when you stop waiting.
Without `--wait`, a run that has already ended exits the same way, and one still going exits 0. Either way the code
follows the row's run, including one another caller started.
- **Permissions:** the agent keeps its own mode, and `rig exec` adds no policy; your environment passes through.
`rig exec harnesses` lists what each harness reads.
`rig acp run` is unchanged: it stays in your terminal for the length of the turn.
## Limits
- One run is one turn for one row.
- The client offers the agent no file-system or terminal methods; the agent works in its own environment.
- Spend is whatever the agent's provider charges; OpenRig doesn't measure it.
- Spend is whatever the agent's provider charges. OpenRig doesn't measure it; it records only what the agent reports.
## OpenRig as an agent in ACP apps
The other direction: `rig acp serve`. An app that speaks ACP and can launch a custom agent command can offer your OpenRig team as its agent. The app starts
`rig acp serve` and talks to it on stdin and stdout:
```sh
rig acp serve --to <member@rig>
```
Without `--to`, the receiver is this machine's `acp.to` setting: `rig config set acp.to <member@rig>`, set once. An
app that starts the agent from the ACP registry passes no arguments, so that's how it reaches your team.
- **A prompt** becomes a queue row from you to `--to`, the seat that takes your team's new work. The row's body is your
prompt, plus a line saying which app sent it and that its closing resolution is your answer.
- **Progress:** each row the work reaches shows in the app as a step (`Sent to …`, then `Handed to …` at each handoff),
with the row's notes as they're written, each with the seat that wrote it. The step's title also shows its latest
note, for apps that show steps collapsed.
- **The reply** is the closing resolution, with who closed the row and how (`done`, `done (canceled)`, `denied`, …). The
turn ending says only that the turn ended: read the reply for how the work ended.
- **A follow-up** in the same session goes to `--to` as a new row whose body names the earlier rows and their states,
and the app first shows any earlier row that's still open.
- **A question the team asks you** while a turn is open comes to the app with the ask's own choices, when its row is
tagged for that chat (`acp-session:<id>`; the task's row tells the team how). An app that takes forms (it
advertises ACP elicitation's form mode, as T3 Code does) gets a one-choice form with each choice by its label; any
other app gets a permission request with one option per choice, as Zed shows them. An untagged
question from a seat the work reached is named in the chat with where to answer it, because it could be another
chat's. Your choice is recorded as your answer, as a click in Slack would be, and the seat that asked gets it.
Dismissing it, or cancelling the turn, records nothing: the question stays open in the TUI and Slack, and no further
question opens after a cancel. A question that ends unanswered here (dismissed, cancelled, closed with the work, or
one the app couldn't show) is named in the chat as still open, as well as on its card. A question that wants an
answer in words is shown with where to answer it.
- **Sessions are kept in the queue:** the app can list your earlier sessions (`session/list`) and reopen one
(`session/load`). Reopening replays each prompt, its work, the questions the team asked you during it with your
answers, and the reply, as they stand now. Nothing is asked again on reopening: a question still open comes up in
your next prompt's turn, and a new prompt continues the session.
- **Cancel** stops `rig acp serve` waiting. The team's work carries on, and the reply says so.
- **Work handed to a seat on another machine** can't be followed from here; the reply says where it went.
**Who you are:** `--as <address>`, else `OPENRIG_SESSION_NAME`, else this machine's default person: the only
registered person, or the one chosen with `rig gateway default <id>` when there are several (the address is
`<id>@external`). The app's sign-in step, "OpenRig on this machine", checks
that the daemon answers and that you resolve to an address; until then, signing in and new sessions fail with the
reason and the fix. The agent talks to the same daemon as `rig` on this computer: the local one, so start it first
(`rig daemon start`), unless `OPENRIG_URL` names another.
The team's questions reach you only as a registered person: an `<id>@external` address needs
`rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>` first. Until then, the app says
so at your first prompt, and your work is still sent.
**In Zed**, add the agent to `settings.json`:
```json
"agent_servers": {
"OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] }
}
```
Other apps take the same command and arguments wherever they configure a custom ACP agent.
**Limits:**
- One receiver per agent command (or the `acp.to` setting); the app has no team picker.
- Questions show only while a turn is open; one asked while no turn is open waits in your usual places (the TUI, Slack).
- An app that shows a permission request's options only as fixed buttons, such as Approve and Decline, and doesn't
take forms, can't show the choices: answer those questions in the TUI or Slack.
- Prompts are text. Links and embedded text files are passed on as written; images and audio aren't accepted.
- Progress is read every 2 seconds while a turn is open (`--poll-seconds`).
- Sessions are found among the 2,000 newest rows you've sent, and a reopened session's questions among the 2,000 newest
sent to you, so a caller with a lot of traffic, such as a busy seat, can't list or fully reopen its oldest sessions.
+72 -7
View File
@@ -229,9 +229,10 @@ This gives access to the shared pool: skills such as `systematic-debugging`, `ve
`development-team` and `test-driven-development`; the `openrig-core` plugin, which carries `openrig-user` and OpenRig's
other skills; and runtime resources such as `claude-default-settings`, `claude-default-mcp`, `codex-default-config` and
`claude-activity-hooks`. Agents select the ones they need via profile `uses`, for example
`plugins: [shared:openrig-core]`. That selection projects the plugin's skills into the seat. For a Claude seat it also loads
the plugin's hooks, through a `CLAUDE_CODE_PLUGIN_DIRS` the daemon sets for a seat that selected `openrig-core` (see
`refocus-channel.md`); a seat that didn't select it gets neither.
`plugins: [shared:openrig-core]`. For a Claude seat selecting the vendored `openrig-core`, the daemon loads the plugin
itself through `CLAUDE_CODE_PLUGIN_DIRS`, including its skills and hooks, without projecting a second copy of its skills
(see `refocus-channel.md`). Other selected plugin skills follow the projection rules below. A seat that didn't select
`openrig-core` does not get its hooks through this selection.
---
@@ -287,7 +288,11 @@ Each array contains resource IDs. These can be:
- **Qualified** (`shared:systematic-debugging`) — resolves against a specific imported spec's resources
The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `runtime_resources`. A `uses.hooks` key, even an
empty list, is refused: hooks ship inside plugins.
empty list, is refused: hooks ship inside plugins. Any other key selects nothing and gets a validation advisory, shown at
launch and by `rig agent validate`, so a typo such as `skils` doesn't go unnoticed. A single value written in place of a
category's list (`skills: vault-user`) is read as a one-item list, with an advisory suggesting list syntax, and resolves
exactly like that list: when the profile is selected, a resource that isn't available fails that member's resolution with
"not found in resource pool".
**Skills resolve more widely than other resources.** For Claude Code and Codex seats, a skill ID that no declared
resource provides is looked up at launch in the project's runtime skill folder in the seat's cwd (`<cwd>/.claude/skills` or
@@ -370,10 +375,10 @@ version.
| Category | Fields | Description |
|----------|--------|-------------|
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
| `skills` | `id`, `path`, `on_restore`* | Skill directories containing a SKILL.md (see "Skill Resources"). Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Neither Claude Code nor Codex reads skills from that plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Claude Code and Codex do not read skills from that projected plugin folder, so selected plugin skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. The exception is the vendored `openrig-core` selected by a Claude seat: Claude loads that plugin directly, skills included, so its skills are not projected a second time. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. An entry whose `runtime` isn't the seat's runtime is skipped. Pi and OMP project none. |
*Fields marked with `*` are optional.
@@ -419,6 +424,16 @@ Where a fragment's table is one the user already declares, the user's table
wins: the managed table is dropped, their values are never merged, rewritten or
overwritten, and the rest of the fragment still applies.
**A fragment can't install a named Codex profile.** Codex versions with named
profiles read each one from its own file, `<CODEX_HOME>/<name>.config.toml`,
with top-level keys, and won't load a profile while `config.toml` still holds a
legacy `[profiles.<name>]` table. A fragment only adds tables to `config.toml`,
so neither form works there. Create the profile file yourself and select it with
the member's `codex_config_profile`; see "Codex: select sandbox and approvals
together" in [getting-started.md](getting-started.md#codex-select-sandbox-and-approvals-together).
Before a launch or restore, OpenRig checks that a selected profile loads, and for
a legacy table it names the file to move the settings into.
### Resource Path Rules
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths), except a plugin's
@@ -427,6 +442,55 @@ overwritten, and the rest of the fragment still applies.
- Resource IDs must be unique within their category
- Resource IDs are the identifiers used in `uses` references
### Skill Resources
```yaml
skills:
- id: house-style
path: skills/house-style
on_restore: follow_source # optional — keep_saved by default
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Resource identifier, used in `uses.skills`. |
| `path` | string | yes | — | Relative path to the skill's folder, which holds its `SKILL.md`. |
| `on_restore` | string | no | `keep_saved` | What a restore (`rig up <rig>`) does with a Claude or Codex seat's copy of this skill in the seat's own storage: `keep_saved` or `follow_source`. |
An unknown `on_restore` value produces an advisory and uses `keep_saved`; it does not invalidate the spec.
A restore keeps the skills a seat was launched with. With `keep_saved`, it puts back the saved copy and never takes
today's bytes from the source. With `follow_source`, before the seat's session starts, the restore reads the seat's agent
spec as it is now, using the saved launch recipe and rig folder, and:
- replaces the seat's copy with the source folder as it is now, and records it, so `rig down` removes it and the next
restore puts it back as usual;
- copies in a `follow_source` skill the profile now selects that the seat didn't have;
- removes a skill that followed its source and that the profile no longer selects.
A copy edited in the seat (changed bytes or recorded file permissions, or an added file), or a folder with the same id that OpenRig didn't write, is kept as
it is and named in the restore's warnings. If the agent spec can't be read or resolved, the seat keeps its saved skills
and the restore says why; a source folder without a `SKILL.md` keeps that skill's saved copy. A skill changed to
`keep_saved` stops following at the next restore, and its copy stays. Older seat records without file permissions
retain their byte-only ownership checks until a new projection or refresh records permissions.
Source file symlinks are dereferenced, including links to files outside the skill folder, just as during Claude and
Codex launch projection. The seat receives an ordinary file containing the target's bytes. A broken link or a link to
a directory makes the source unreadable and keeps the saved copy; links in the seat's destination remain protected.
Only a restore does this; a running seat isn't changed, and neither is a Codex seat restarted in place. A Claude seat
loads its seat plugin when its session starts, so it sees the new copies. A Codex seat with following skills receives
its rebuilt catalog after its resumed session is ready. The catalog lists the skills actually present, replaces its
earlier OpenRig skill list, and asks it to read current skill files before using them. This includes newly selected
skills and the removal of the last following skill. An edited or linked catalog is kept and not sent, with a warning;
a delivery failure is reported instead of claiming a complete restore. Neither runtime erases skill instructions
already read into the retained conversation.
Following covers the whole selected skill folder, including scripts and references. It does not install external
tools, change MCP configuration, or update bundled plugins. The saved launch recipe supplies the rig directory,
member and profile; today's agent spec supplies the skill selection. Today's rig YAML and other agent settings are
not applied by this refresh.
### Guidance Resources
```yaml
@@ -440,7 +504,7 @@ guidance:
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Resource identifier. A `rig-role` guidance resource is never merged; deliver a role with a `send_text` startup file. |
| `path` | string | yes | — | Relative path to the guidance file. |
| `target` | string | no | — | Parsed but not used: the runtime decides the file. Claude merges into the rig's `managed_blocks` file (`CLAUDE.md` by default, or `CLAUDE.local.md`); Codex, Pi and OMP merge into `<cwd>/AGENTS.md`. |
| `target` | string | no | — | Parsed but not used: the runtime decides the file. Claude merges into the rig's `managed_blocks` file (`CLAUDE.md` by default, or `CLAUDE.local.md`); Codex, Pi and OMP merge into `<cwd>/AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's `AGENTS.md` and a fresh Codex 0.164.0+ seat normally gets launch developer instructions; the conditions are in [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). |
| `merge` | string | no | `managed_block` | Only `managed_block` is applied. Any other value, such as `append`, merges nothing. |
---
@@ -482,6 +546,7 @@ restore behavior, and the distinction between readiness and verified proof.
9. All resource paths must be safe relative paths (a plugin's `source.path` excepted).
10. Resource IDs must be unique within their category, and each `resources.<category>` must be an array.
11. `runtime_resources` entries require `runtime` and `type` fields.
11a. An unknown skill `on_restore` value produces an advisory naming the known values and falls back to `keep_saved`.
12a. `resources.hooks` and `profiles.<name>.uses.hooks` are refused: "removed in plugin-primitive (Phase 3a)". Declare
a plugin instead.
12b. A plugin needs a unique non-empty `id`, a `source` object with `kind: local` and a non-empty `path`, and a
+64 -1
View File
@@ -63,6 +63,33 @@ delivery has been attempted, it cannot be replayed by `continue`, including
after a daemon restart: inspect `rig seat status <seat>` and the native pane
before deciding how to recover an uncertain delivery.
## After a restored seat's prompt
A restore that stops at a prompt, such as Claude's resume selection, records the
seat's restore outcome as `attention_required`. `rig seat continue` doesn't change
that record: it only delivers pending startup context. While the record stands, it
alone keeps the seat's lifecycle at `attention_required`, even with startup `ready`:
`rig ps --nodes` shows LIFECYCLE `att`, `needsAttention` is true, and the seat counts
in the rig's `attentionCount` and the TUI's attention totals.
After a full restore of a Claude or Codex seat, answering the prompt is enough. When
the seat's activity next turns to running or idle, or when the whole restore completes
after the seat already reads running or idle, the daemon checks the live seat: its tmux
session is alive, and the pane runs the agent resuming the conversation the seat
records. When the check passes, the restore outcome becomes `operator_recovered`, so
the restore no longer makes the seat need attention. When it fails, the restore outcome
stays and the seat still needs attention, which is true when the pane runs another
conversation. A check that started before a newer restore of the seat doesn't settle
the newer outcome.
`rig seat clear-attention <seat>` runs the same check on demand. When it fails, the
command says which check failed, and `--reason` doesn't bypass it. If
the seat records the wrong conversation and you know the right one, set it with
`rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
`clear-attention` again. After a restore of only some seats, or a community
harness's assisted startup, `--reason "<what you saw>"` can acknowledge the
outcome instead.
## Two Categories of Startup
Everything an agent receives at boot time falls into one of two categories:
@@ -367,7 +394,15 @@ Each startup file and action specifies when it applies:
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
- Default: `[fresh_start, restore]` (both)
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: a managed guidance block missing from the guidance file is put back before the harness starts.
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: the guidance file's managed blocks are brought up to date before the harness starts. A missing block is put back, and a stale one is refreshed (see below).
**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance.
**A block you edited by hand is never overwritten.** OpenRig records what it last wrote in each managed block, so a launch, an exact resume, a handover or a runtime switch can refresh a block whose source file changed since. The record follows the file itself, so two links to one file share it. A block you've edited since OpenRig wrote it is left as you wrote it. The start's result names it (`guidance_block_edited`), and the seat is told in one line once it's ready, or in a handover's packet. A block OpenRig has no record of writing (any block written before these records were kept) that differs from its source works like this:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
@@ -386,6 +421,7 @@ running seat only through the action in the last column.
| Reasoning effort | `rig seat set-effort` | `rig seat effort <seat>`: requested, saved, sent at launch, and what the session recorded, which is not verified until it takes a turn | Its next managed continuation; nothing restarts |
| Permissions | `rig seat set-permissions` (`--mode`, `--rules`, or for Codex `--codex-permissions` or `--codex-profile`), shown by `rig seat status` | What it launched with; a status read doesn't prove the running process enforces the selection | Its next managed launch or resume ([per-seat permission mode](getting-started.md#per-seat-permission-mode)) |
| Working directory | `rig seat set-cwd`, shown by `rig seat status` as **Working directory for future launches** | The directory it started in | `rig seat launch --fresh --stop` (a blank conversation) or `rig handover --source rebuild` (durable context); [details](#change-an-existing-seats-working-directory) |
| Launch wrapper (`launch_wrapper`) | Member `launch_wrapper` in `rig.yaml` (`pi`, `codex` and `claude-code` only; `launch_wrapper: []` clears, omitting retains on existing-seat apply; surfaced as `launchWrapper` in `rig ps --nodes --json` when set; no live `rig seat` edit verb) | The literal wrapper argv that prefixes the resolved Codex entry or Claude executable (preserving TTY stdio) or the resolved Pi child executable inside OpenRig's host runner (preserving RPC pipes and runner lifecycle) when that session launched; a configured prefix is not proof of isolation, tooling, memory or governance | Supported spec create/recreate/apply flow, then the seat's next managed launch, resume, restore or handover (fails closed when wrapper support is unavailable; never falls back to bare child launch; [details](rig-spec.md#launch-wrapper-runtime-pi-and-codex)) |
| Startup files and skills | The spec's startup files, skills and actions | What its conversation read when it started; editing a file afterwards doesn't reach it | A fresh launch or a handover delivers them again; for the running seat, send or walk the change (`rig walk <seat> --through <files>`) |
| Tools (MCP servers, plugins) | Selected fragments, merged into the harness's own config (`.mcp.json` for Claude, `config.toml` for Codex) | Unknown to OpenRig: registered isn't active, Claude asks before it uses a new server, and no OpenRig command reports which tools a running harness loaded | Check in the harness itself (Claude Code's `/mcp`); a running session may need a restart to pick up newly written config ([runtime config disclosure](#runtime-config-disclosure)) |
@@ -560,6 +596,10 @@ The guidance still goes to the repository `AGENTS.md`, as before, when:
A seat's role still arrives as startup text, and the seat-skills line, after the person's yes, still goes to
`AGENTS.md`.
A launch that carries developer instructions writes no new blocks to the repository `AGENTS.md`. But blocks an earlier
handover or resume left there are refreshed from their sources, by the same rule as every other managed block. That
matters because the launch text asks Codex to follow `AGENTS.md` where the two differ.
**If your shell sets `CODEX_HOME`.** When neither the seat nor the daemon declares a `CODEX_HOME`, a seat's pane keeps
its own, and OpenRig reads `~/.codex`. A `CODEX_HOME` that only your shell startup sets (in `~/.zshrc`, for example),
with the daemon started without it, isn't seen. A `developer_instructions` set in that home is then replaced by the
@@ -723,6 +763,29 @@ When authoring startup content, note which instructions are runtime-specific. Fo
---
## Files OpenRig writes into a project
These are the files OpenRig writes into a seat's working directory, and what `rig down` does with each. Paths are
relative to that directory.
| Path | When OpenRig writes it | What `rig down` does |
| --- | --- | --- |
| Managed blocks in `CLAUDE.md` (or the rig's `managed_blocks` file, such as `CLAUDE.local.md`) or `AGENTS.md` | Each launch merges the seat's guidance blocks. A fresh Codex launch that takes developer instructions puts them in its launch text instead: in `AGENTS.md` it adds only the seat-skills line, after your yes, and refreshes blocks already there. A fresh Pi seat writes its own agent-directory `AGENTS.md` outside the project, except when that folder or file is a link: then its guidance stays in the repository `AGENTS.md`. | Removes OpenRig's blocks, current or legacy, and keeps everything else. When nothing else is left, it deletes the file if the file began with an OpenRig block, or held only blocks in an older format. A block-only file isn't proof that OpenRig created it. The seat-skills line is kept if you edited it. |
| `.openrig/context-collector.cjs` | Claude seats, at launch. | Removes it while its bytes are still the shipped collector's; otherwise keeps it and says so. |
| `.claude/settings.local.json` | Claude seats, at launch: OpenRig's status line and its activity hooks are merged in, separately. Your own status line is never replaced, but the activity hooks are still merged beside it. A file that doesn't parse gets neither. | Removes OpenRig's status line and hooks and keeps the file, even if `{}` is all that's left. |
| `.claude/settings.local.json` fragments and `.mcp.json` | When a profile or spec declares `claude_settings_fragment` or `claude_mcp_fragment`. | Leaves the merged content in place. |
| `.claude/agents/`, `.claude/plugins/<id>/`, `.claude/extensions/<id>/`; `.agents/<id>.yaml`, `.codex/plugins/<id>/`, `.agents/extensions/<id>/` | When the seat's projection includes subagents, plugins or runtime resources (Claude, then Codex). | Leaves them in place. |
| `.claude/skills/<id>/`, `.agents/skills/<id>/` | Only for a seat without seat storage. New Claude and Codex seats keep their skills under `$OPENRIG_HOME/state/seats/`, outside the project. | Leaves them in place. |
| The managed compaction preparation folder described in [Claude compaction preparation directory](#claude-compaction-preparation-directory), with its `.gitignore` of `*` and each attempt's `RESTORE-MAP.md` | Managed Claude compaction, unless a preparation root is configured elsewhere. | Leaves it in place. |
| `.rigged-checkpoint.md` | A restore that didn't resume a seat's native conversation (by choice or not) and had a checkpoint to hand it. | Leaves it in place. |
`rig down` leaves a file alone while another seat still uses it: a running seat of the same rig, or any seat of
another rig that isn't archived. That is decided file by file. A guidance file is kept while another seat's
guidance targets the same file. The Claude settings and collector are kept while another Claude seat works in that
directory. So another rig's Codex seat keeps `AGENTS.md` without keeping `CLAUDE.md` or the Claude settings. It
removes `.openrig/` and `.claude/` only when they are empty.
`rig down --delete` does nothing more to project files. OpenRig doesn't edit your project's own `.gitignore`.
## Patterns and Anti-Patterns
### Good Patterns
+30 -2
View File
@@ -23,7 +23,10 @@ Only the activity axis is served today. The session and resumability values are
`activity-taxonomy.ts` but no surface reads them: `rig ps --nodes --full` shows the raw session
STATUS, both node views show a LIFECYCLE of `running`, `detached`, `recoverable` or `attention_required` (`run`, `det`, `rec`,
`att`), and node inventory serves `occupantLifecycle` (`active`, `retiring`, `retired`,
`context_walled`, `compacted`, `crashed`, `unknown`).
`context_walled`, `compacted`, `crashed`, `unknown`). Node inventory's `sessionStatus` is the
recorded session status, with two observed corrections: a running session whose pane is verified
to have exited (identity reason `pane_exited`) reads `exited`, with `startupStatus:
"attention_required"`, and a running session confirmed missing reads `detached`.
The axes never blend. Reachability is strict; revival optimism is a different field
(omnigent's strict-liveness split). `unknown` is a first-class honest value — the oracle
@@ -59,6 +62,21 @@ the existing live-hook fallback in effect; lifecycle, startup, held and error si
can independently raise attention. A false attention flag means no known trigger, not
proof that an unobserved input box is clear.
A handover whose successor started without a required file also raises attention. That's
a projected guidance file or a guidance startup file marked `required` still missing from
the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup
file that's missing. The seat serves `startedWithoutRequired` with each file's path, the
table's reason column names each file, and the handover's own output says it's degraded.
The mark belongs to that successor's session, so the seat's next launch or handover
clears it. Time alone never does. Repairing the file in place doesn't clear it either,
because no route delivers guidance into a running conversation.
A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files,
the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the
successor to read the file; it isn't the pasted text a start sends. A fork successor
carries its parent's conversation and gets no list. `send_text` startup actions, such as
an `after_ready` preload, aren't run for a successor.
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
unavailable. Compact JSON retains `needsAttention`; use `--full` or
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
@@ -116,7 +134,10 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
PER-HARNESS when that harness's hook rung earns trust through a measured agreement
window in production — never by fixture pass alone (symmetric admission: a fixture
pass admits a rung to TRIAL, where it is measured but not consulted; production agreement
promotes it). Undeclared and partial-coverage rungs are identity-only, which is also where
promotes it). For Codex, this fallback also accepts a fresh working status adjacent to
the composer, or an advancing counter across two fresh captures when output separates
them. This positive evidence expires after five seconds; unrecognized, frozen, old or
pre-occupant frames add nothing. It uses the existing pane capture. Undeclared and partial-coverage rungs are identity-only, which is also where
a degraded rung goes, with one declared exception: for a pi or omp seat or a community
harness, sampling is positive-only: a changing pane reads working, and a quiet one never reads idle on its
own, since the pane can be quiet mid-turn.
@@ -185,6 +206,13 @@ LIFECYCLE); see the
- **Restore outcome:** `resumed`, `fresh-primed`, `awaiting-decision`, `attention_required`,
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`). A seat joined with
`rig attach` as an external CLI reads `left_attached`: restore leaves it as it is.
An `attention_required` outcome, or `failed` with a running session, makes the seat's
lifecycle `attention_required`, whatever its startup status, so it raises `needsAttention`.
After a full restore, the daemon reconciles it to `operator_recovered` when the seat's
activity next turns to running or idle (or reads so when the restore completes) and the
live seat passes its check, and `rig seat clear-attention <seat>` runs that check on
demand; `rig seat continue` doesn't change it
([startup guide](agent-startup-guide.md#after-a-restored-seats-prompt)).
## Rejected oracles — dated receipts (2026-08-26), so nobody re-derives them expensively
+1 -1
View File
@@ -16,7 +16,7 @@ This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.c
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | OpenRig's side released in 0.7.8; runner side not qualified | OpenRig asks a person for a runner's permission requests and returns the answer. No end-to-end dsh integration is established, and the native verdict is pending. Native reopen capabilities and the tools-off case remain implementer questions. |
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | OpenRig's side released in 0.7.8; runner side shown on two runners, not qualified in general | OpenRig asks a person for a runner's permission requests and returns the answer. In 0.7.8's release testing on a Claude Haiku model, dsh-seat 0.3.0 (dsh 0.2.1-rc.2 over ACP) showed input, activity, cancel, exact resume and shutdown, and the dsh example runner showed a person's allow-once and reject-once; see the [runner contract](harness-runner-contract.md)'s status for what wasn't shown. Native reopen capabilities and the tools-off case remain implementer questions. |
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
| Environment preparation, projection, artifact export and owned-resource cleanup | OpenShell reference driver released in 0.7.8; generic seam proposed | The platform chapter makes the responsibilities reusable. Core ships one maintained reference driver, OpenShell 0.1.6, as `rig environment`. It doesn't provide a generic platform driver or `DeploySpec` loader. |
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
+12 -1
View File
@@ -8,4 +8,15 @@ Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-c
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
A `selection_changed` or `stop_uncertain` response from a checked stop says how far the stop got in `stopPhase`. A refusal before the stop starts carries neither `stopPhase` nor `partialEffect`, and nothing was sent. The stop detaches the seat's clients, checks the selection again, then kills the session:
| `stopPhase` | What was sent to the seat |
| --- | --- |
| `none` | Nothing. |
| `detach_attempted` | The client detach, which may not have finished. No kill. |
| `detached` | The client detach. No kill. |
| `kill_attempted` | The kill. |
`partialEffect` is `true` whenever `stopPhase` isn't `none`, so on its own it doesn't say whether the kill went out. `selection_changed` with `stopPhase: "kill_attempted"` is a change seen after the kill, when the stop state is saved: the session was killed, so re-read the seat rather than offering the stop again.
@@ -7,11 +7,11 @@ runner: true
launch_result: true
first_input: true
limits:
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later, and checked against a stand-in agent only
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later; run with a real dsh on two stacks only, dsh's Claude route with a Claude Haiku model in 0.7.8's release testing (with that release's copy of this runner, for an allow-once and a reject-once), and dsh's pi-ai OpenRouter chat-completions profile with the model deepseek/deepseek-v4-flash on OpenRig 0.7.8 (for queue work, activity through a quiet tool call, an allow-once and a reject-once, and cancel); in both, the permission answers came from a test answerer registered as a person, not a live person; dsh's DeepSeek Messages adapter pointed at OpenRouter failed with HTTP 400 in the second, cause not established; no other dsh route or model is checked
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env; set DSH_SEAT_HOME in the member's env to use a home you prepared, and DSH_SEAT_BIN for another dsh binary (a member's env can't set OPENRIG_* names, and the runner doesn't read dsh's own DSH_HOME)
- DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny
- allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked
- resume isn't declared until a real dsh has reopened a conversation and recalled it; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
- resume isn't declared; a real dsh has reopened a conversation after down, snapshot and up and recalled it, with resume added to a copy of this preset, but a missing conversation's refusal hasn't been run; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
- a prompt whose whole text is /abort can't be sent; a Ctrl-C byte inside a paste also aborts; without a TTY, input is read line by line; this isn't a binary-safe channel
- one prompt at a time; a message that arrives mid-turn waits for the next turn
- the seat's model isn't passed; dsh uses its own configured model
@@ -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
+210
View File
@@ -0,0 +1,210 @@
# Writing a daemon client
What a program that talks to the OpenRig daemon over HTTP needs and can't read from `rig --help`: how to find the
daemon, its health route, which bearer token each route family checks, the event streams, and some route
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
listed here can change.
## Finding the daemon
`rig status --json` is the discovery surface. Its `daemon` object gives `state` and `reachable`, and `pid` and
`port` when they are known: a stopped daemon's response has no `port`, and a running daemon found through
`OPENRIG_URL` has no `pid`. `reachable` is `true` when the daemon answered its health probe, `false` when it is
positively down, and `null` when that isn't confirmed (see `rig status` in
[the command reference](../as-built/cli-reference.md#status)). It reads the recorded state and probes; it never
starts a daemon.
The CLI itself finds the daemon in these orders:
- **Data commands** (everything that sends a request): `OPENRIG_URL` (or the legacy `RIGGED_URL`); else
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
the state file below.
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
`~/.rigged/daemon.json`.
`daemon.json` holds `pid`, `port`, `host`, `db` and `startedAt`. `rig daemon start` writes a new daemon's record
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
contract: prefer `rig status --json`.
## `GET /healthz`
The liveness and identity read. It always returns `status: "ok"` and `pid`. Every other field is present only
in the case named:
| Field | When it is present |
| --- | --- |
| `euid` | On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
| `databaseWarnings` | When the daemon opened its database with warnings. |
| `semver`, `commit`, `dirty`, `builtAt` | On a stamped (released) build; a development run has none. |
| `selfHostId`, `selfHostIdSource` | After the boot reconcile has set this host's id. |
| `eventLoop` | When the event-loop monitor runs: `lagMeanMs`, `lagP99Ms`, `utilization` (0 to 1), `lastTickAgeMs` and `healthy`. |
| `routeTimings` | With `eventLoop`. |
| `slowOperations` | When the slow-operation recorder runs. |
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
## Bearer tokens
The daemon reads two tokens from its environment at start:
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
/api/terminal/follow` and `/viewer/ensure`, and every `GET /api/terminal/<name>` route, the terminal websocket
included. When the daemon is bound to an explicit address (`rig daemon start --host`) that isn't loopback or a
tailnet address, and this token isn't set, these routes check `OPENRIG_AUTH_BEARER_TOKEN` instead.
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A valid configured bearer token in the Authorization header can pass the browser boundary's target-name check
for a well-formed but unaccepted Host. It cannot pass a malformed Host or a refused Origin; see
[Browser access](browser-access.md).
## Event streams
All of them are server-sent events (`text/event-stream`):
- **`GET /api/events`**: the daemon's event log, stored events only. `?rigId=` limits it to one rig. Each event
carries `id: <seq>`. `view.changed` is not stored, so it does not appear here, live or replayed; `view.changed`
rows stored by older versions still replay until an events retention (a separate change) removes them.
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
Live events that arrive during the replay follow it, without duplicates.
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
again; it carries no derived state.
- **`GET /api/views/sse`** (alias `/api/views/watch`) and **`GET /api/views/<viewName>/sse`**: `view.changed`, live
only. Its `id:` is the `seq` of the stored event that caused it, so it can repeat across views and is not a replay
cursor.
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
`inbox.absorbed` and `inbox.denied`.
## People, asks and updates without Slack
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
| Read | Meaning |
| --- | --- |
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
A person's view also includes instance or unassigned items when that person is the default. These are views on a
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
or manufactures a delivery receipt.
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
```js
// client-people-example
const [base, personId, rig] = process.argv.slice(1);
async function get(path) {
const response = await fetch(new URL(path, base));
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
const registry = await get("/api/gateway/participants");
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
if (!person) throw new Error("Choose a registered person id");
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
warnings: registry.warnings, participantFile: registry.participantFile,
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
```
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
`outbox/list?senderSession=<session>` read remains available.
## Route details
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items
(`pending`, `in-progress`, `blocked`) first, then the rest by latest update. `limit` defaults to 100 and has no
upper cap. The list is cut after `limit` rows in that order, so when it comes back full, raise `limit` to read
them all. Each item's own history is `GET /api/queue/<qitemId>/transitions`. This read scans the queue table, so
read it occasionally rather than polling it. For live changes, treat `GET /api/queue/sse` events as hints to read
`queue/list?rig=` again. Not every event names both seats (`queue.claimed` and `queue.unclaimed` carry only the
destination), and not every state change is on that stream, so the events alone don't track a rig's items.
- **A rig's messages:** `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to the rig's
seats, newest first; `?senderSession=<session>` still returns one sender's. One of the two is required; with
both, `senderSession` is used and `rig` is ignored. `limit` defaults to 100. No event is sent when a message is delivered, so read this list to see delivery
states.
- **`GET /api/queue/recent-transitions`** returns at most 20 rows. A positive `limit` above 20 is capped at 20;
a missing, zero, negative or unparseable `limit` means 20.
- **`POST /api/transport/send`** takes `urgent: true`, the same as `rig send --urgent`. The daemon records who
marked it from the sender identity, not from the body.
- **`POST /api/scope/approve`** is what `rig scope slice approve` and `rig scope mission approve` call. The body
takes `scopeTier` (`slice` or `mission`), `scopePath`, and optionally `approvalScope` (`spec` or `delivery`, the
default), `missionsRoot`, `onBehalfOf`, `reApprove`, `reason` and `lockedArtifacts`. The approver is the
request's sender identity (`actorSession` in the body is recorded beside it, and a different value is
superseded). Success is 201 with `scopeTier`, `scopeId`, `scopePath`, `approvalScope`, `approvedBy`,
`approvedAt`, `onBehalfOf`, `actionId`, `freezeFired` and `reApproved`. Refusals include 400 for an invalid
`scopeTier`, `scopePath` or `approvalScope`, or a missing actor (`actor_required`); 503 `action_log_unavailable`;
and, from the approval itself, 404 `scope_not_found`, 409 `already_approved` or `nothing_to_reapprove`, 503
`workspace_not_configured`, 500 `audit_write_failed`, and 400 for its other refusals. An unexpected error is 500
`internal`.
- **`POST /api/proof/judge`** is what `rig proof judge` calls. The body takes `scope`, `item`, `verdict`
(`accept`, `reject` or `withdraw`), `reason`, `expectedRevision` and `expectedPrevious` (a judgment id or
`null`), and optionally `evidence`, `expectedEvidence`, `subject`, `operationId`, `replace`, `project` and
`projectRoot`. The judge is the request's sender identity. The response is `judgment`, the slice's `readiness`,
`replayed` and `notification`: 201 for a new judgment, 200 when an `operationId` replays an earlier one.
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
`scopeIgnored` when a scope wasn't applied. The instance keeps `rootObjective`, which every instance read returns
(`/api/workflow/list`, `/api/workflow/<id>` and its `trace`). An instance started before it was kept reads `null`.
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
readings as one account rests on the deployment assumption the row's `provenance` names (one account per
host), not on a measured identity. Readings whose reset times disagree are reported as `anomalies`, and the
row's `state` can then be `explicit_unknown`. Show the row's `state`, `anomalies` and each reading's seat and
`asOf` as reported, rather than merging readings that happen to match.
- **Slack channels and people:** `GET /api/gateway/slack/channels` and `GET /api/gateway/slack/users` each return
one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are
public and unarchived, as `{ id, name, isMember }`; people are `{ id, name, realName, displayName, isBot,
deleted }`, with no email. Pass `cursor` (the previous page's `nextCursor`, `null` on the last page) and
`limit` (default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503 `slack_not_configured`
when no bot token resolves, which is different from an empty list; 403 `slack_missing_scope` with the scope
Slack names in `needed` (an older app may lack `users:read`); 429 `slack_rate_limited` with Slack's
`Retry-After`; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a
chooser rather than polling it. Private channels and direct messages aren't listed.
- **Slices are found by folder name alone.** `GET /api/slices/:name`, `/:name/doc/*`, `/:name/proof-asset/*` and
`GET /api/review/slice/:name` take only the name; the detail route reads `?mission` only together with
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
across missions, as [Project workspace](project-workspace.md) says.
+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:
+32 -17
View File
@@ -5,15 +5,34 @@ OpenRig's gateway can register **several people** and **shared channels**, group
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
do.
For a remote client, `GET /api/gateway/participants` returns the same participant, organization, group, default and
warning fields as `rig gateway participants --json`. Add `?organization=<id>` to filter the participant list; the default
and organization and group maps still describe the whole registry. The result names the participant file and reports
an unreadable side file without treating it as an empty one. If the human registry itself cannot be read, the route
returns `503` with `error: "human_registry_unavailable"`. The result does not include connector secret pointers.
## Participants
A participant is either:
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
- **a person**, registered with `rig gateway human add`, with or without a connector binding (such as Slack or an HTTP loopback [relay](gateway-relay.md)); or
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
sender.
People and channels share one address space: a channel can't take a person's id.
A registered person is addressed as `<id>@external`, including without Slack: for example,
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
A person can also bind to a local bridge adapter using a `relay` binding:
`rig gateway human add bob --display-name "Bob" --delivery-class B --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops"`
(see [Gateway Relay Reference](gateway-relay.md) for endpoint, secret provisioning, and custody architecture).
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
`human@host` actor, not the person whose queue is being viewed.
```bash
# An organization and a group inside it (one command each, create or update)
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
@@ -59,30 +78,26 @@ never changes `workspace.operator_seat_name`, which keeps its own meaning.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
whatever person's queue it shows or who is configured as the default.
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
install that never chose one, it opens on the topology, as before.
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
person's. The Feed's first line says whose queue it shows and who acts.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
actor is `human@host`.
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
refused (`answer_not_recorded`) and nothing changes.
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
channel.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the unnamed terminal
actor `human@host` and the person asked in the audit notes and transition. The asking seat reads, for example,
"Unattributed (human@host) answered sarah@external's ask".
- **Viewing Sarah isn't acting as Sarah.** A registered person may answer, approve, deny or resolve another person's
ask as themselves; both the actor and person asked are recorded. No connector binding is required, and viewing
someone else's queue doesn't make its viewer that person.
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
such request already is.
- **Where the authority applies:** primary-user authority is accepted only on a request that carries no seat identity
header, which is how the TUI calls the daemon. A headerless client can instead name a person's `actorSession` without
that authority. Seats and the CLI send the identity header, so their acts stay theirs. This is the existing
trusted-network distinction, not an authentication check.
## Shared-channel conversations
+268
View File
@@ -0,0 +1,268 @@
# Gateway Relay: HTTP Loopback Transport Adapter & Custody Reference
OpenRig's Gateway Relay is a local HTTP loopback transport adapter. It enables registered people to receive outbound notifications and questions (asks, updates, and operator digests) through an operator-owned local bridge process, and to post answers back into OpenRig with sender attribution resolved against registered human bindings.
OpenRig does not embed third-party messaging platform SDKs (such as WhatsApp, Matrix, or Signal). An external bridge process translates between OpenRig loopback HTTP requests and external messaging platforms.
```
+--------------------+ HTTP Loopback +--------------------+
| OpenRig Daemon | POST <OPENRIG_RELAY_URL> ---> | Operator Bridge |
| (Gateway Subsys) | <--- POST .../relay/inbound | (Loopback Server) |
+--------------------+ +--------------------+
|
Platform-specific transport
v
+--------------------+
| External Platform |
| (Mobile / Client) |
+--------------------+
```
---
## 1. System Topology & Custody Boundaries
### Outbound Custody & Recovery
- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL` (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`.
- Requests require `Authorization: Bearer <OPENRIG_RELAY_INBOUND_TOKEN>` matching a configured, ready relay profile.
- OpenRig verifies that the caller is a direct loopback peer (`127.0.0.1/8` or `::1`) over the underlying TCP socket. Proxy and forwarding headers (`Via`, `X-Forwarded-*`, `Forwarded`, `X-Real-Ip`) are ignored rather than rejected because caller identity is established directly from the loopback socket peer and the bearer token.
- Sender attribution resolves by matching `sender.handle` against human fragments registered to the authenticated connector. Harmless extra fields on the top-level inbound JSON object or inside `sender` are ignored and can never override handle-to-person attribution or alter the event fingerprint. The handle is asserted by the trusted bridge; it does not provide independent proof of the physical human on the external platform.
- When an inbound reply is accepted (`200 OK`), OpenRig reserves the event in `relay-inbound-seen.jsonl` (`received:<fingerprint>`), durably commits the answer/resolve action and its `mission_control_actions` audit record atomically through its Mission Control write contract (using existing `excludeOperationReply` revision checking without a second answer store or synthetic revision refresh), and marks the event `processed:<fingerprint>`. Exact duplicate replays of the same `eventId` and payload succeed idempotently (`deduplicated: true`).
### Exclusions & Scope
The relay adapter does not handle interactive UI buttons, message attachments, group rooms, unsolicited inbound triggers, multi-bridge failover clustering, or third-party platform SDK backends.
---
## 2. Secrets & Environment Configuration
Each relay connector loads configuration from an absolute file referenced by the binding's `secretsRef` (`file:<absolute-path>`).
### Security & File Requirements
- **Absolute Path Only**: Must begin with `file:/`. Relative paths, bare paths, and `file://` authority URLs are rejected.
- **File Permissions**: The file should restrict access to the owner only (no group or other permission bits set, i.e. `(mode & 0o077) === 0`, such as `0600` or `0400`). Owner-only modes (`0600`, `0400`) produce no warning. If group or world permission bits are set (e.g. `0644`), OpenRig logs a redacted warning in the profile's readiness `warnings` and `reason` (matching the Slack secret-file posture) while keeping the profile `ready`. Missing or unreadable secret files remain `not-ready`.
- **No Environment Fallback**: OpenRig does not fall back to process environment variables (`process.env`). All variables must come directly from the referenced file.
- **Profile Independence**: If a secrets file is missing, unreadable, or missing required keys, only connectors bound to that file report `not-ready`. Other connectors remain functional.
### Required Secrets Keys
**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
```
**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>` or `GET /api/gateway/relay/outbox`)
```json
{
"v": 1,
"deliveryId": "dec-019283-abc",
"recipient": { "handle": "alice-ops" },
"intent": "decision",
"outcome": "interrupt",
"summary": "Database Migration Approval",
"body": "Production migration script 004_add_indexes.sql is ready to execute. Reply YES to approve, NO to abort.",
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>"
}
```
- `v`: Protocol version integer (`1`).
- `deliveryId`: Stable whole-decision delivery identifier (`<decisionId>`).
- `recipient.handle`: Recipient's registered safe alias for this connector.
- `intent`: `"decision"` (actionable question) or `"update"` (informational notice).
- `outcome`: Delivery classification (`"interrupt"`, `"notify"`, or `"digest"`).
- `summary`: Plain text summary preview (up to 1,000 characters on the `summary` field, cut on a Unicode code-point boundary; any summary text exceeding 1,000 characters is preserved in full at the beginning of `body` or `item.body`).
- `body`: Complete plain text content (including full `summary` overflow, `body`, `humanDetail`, and rendered `humanQuestions` headers, prompts, and option labels/descriptions) sent in a single complete JSON envelope without silent character clipping or self-imposed outbound byte splitting.
- `replyRef`: Fixed-size opaque base64url token (`<= 4,096` characters for any supported ID lengths — `246`–`266` characters in protocol v1, hashing the canonical frozen `RelayReplyRefPayload` and binding `connectorHash`, `mode`, and optional `digest: true`) for actionable decisions; `null` for informational updates (and `null` at the top level of multi-item digests).
- `items`: Present when `outcome` is `"digest"`. Array of objects with `qitemId`, `notificationKey`, `summary`, optional `body`, and per-item `replyRef` (or `null`).
### 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
{
"eventId": "evt-relay-20261107-001",
"sender": { "handle": "alice-ops" },
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>",
"text": "Approved. Proceed with rollout.",
"ordering": "after-question"
}
```
- `eventId`: Bridge-assigned identifier (`1..256` chars, matching `/^[A-Za-z0-9._:-]{1,256}$/`).
- `sender.handle`: Sender's alias, matching `/^[A-Za-z0-9._-]+$/`. Must match a human fragment bound to the authenticated connector. Harmless extra fields inside `sender` or on the top-level JSON object are ignored and never override `sender.handle` resolution or affect the event fingerprint.
- `replyRef`: Exact fixed-size opaque base64url token copied from the outbound envelope (maximum 4,096 characters). Bridges must not parse or alter `replyRef`.
- `text`: Plain text response (maximum 32,768 characters, non-empty after trim, no NUL bytes). Processed as unstructured text without automatic natural-language approval parsing.
- `ordering`: Optional enum (`"after-question"` | `"uncertain"`, default `"uncertain"`). When `"uncertain"`, text is accepted as recorded context rather than verified sequence relative to the question.
#### Inbound Statuses & Responses
- **200 OK**: Request accepted (`{ ok: true, deduplicated: false, reconciled: false, qitemId, verb, actorSession, actionId }`). Exact duplicate submissions of the same `eventId` return `ok: true` with `deduplicated: true`.
- **400 Bad Request**: Malformed JSON, non-object payload, or invalid required field syntax/bounds (`eventId`, `sender.handle`, `replyRef`, `text`, or `ordering`). Harmless extra fields are ignored.
- **401 Unauthorized**: Missing, malformed, unrecognized, unready, or cross-profile ambiguous inbound Bearer token, or no humans configured (`noHumans`).
- **403 Forbidden**: Non-loopback TCP socket peer, browser cross-origin request, connector mismatch (`replyRef` emitted by another connector), or unregistered `sender.handle`. (`Via` and `X-Forwarded-*` headers are ignored).
- **404 Not Found**: Queue item referenced in `replyRef` does not exist.
- **409 Conflict**: Stale `decisionRevision` (question cancelled, answered, or re-asked), attempt to answer an informational update, unrecognized `replyRef` when both durable receipts and the pending dispatch buffer are readable, or conflicting payload submitted under an existing `eventId`.
- **413 Payload Too Large**: Request body exceeds 64 KiB (65,536 bytes).
- **503 Service Unavailable**:
- `human_registry_unavailable`: Local human registry (`gateway/humans/*.yaml`) failed to load due to a broken fragment or filesystem read error (normal `noHumans` returns `401`).
- `queue_store_unavailable`: Local SQLite queue/transition store read failed while resolving or verifying `replyRef`, returning `503` with zero seen-store reservation or Mission Control action side effects.
- `dispatch_buffer_unavailable`: An un-receipted `replyRef` required fallback verification against `<openrig-home>/gateway/dispatch-buffer.json`, and reading the buffer failed (`pendingStrict()`), returning `503` with zero seen-store reservation or Mission Control action side effects. (`replyRef`s already vouched for by durable accepted receipts in `queue_transitions` or `queue_transitions_archive` are verified before consulting the buffer and still succeed.)
- `seen_store_unavailable` (response includes `seenPath`): Local seen-ledger (`state/relay-inbound-seen.jsonl`) is unreadable, contains unparseable JSONL or unkeyable records (missing `id`), failed a reservation/processed mark, or has a corrupt/unvouched record for the requested `eventId`'s key. Keyed suspect records (`missing/invalid status` or conflicting recorded fingerprints for the same key; diagnostic `ts` or extra metadata fields are ignored) scope refusal to that `eventId` key only so unrelated `eventId`s continue to operate normally, while never treating unparseable or suspect history as unseen.
---
## 4. Identity & Binding Configuration
Bindings are managed through human fragment files (`gateway/humans/<entityId>.yaml`) and CLI verbs (`rig gateway human`).
### Binding Syntax
```
relay:<connectorRef>:file:<absolute-env-file>:<role>[:handle=<alias>]
```
- `kind`: `relay`.
- `connectorRef`: Identifier for the connector instance (e.g. `local-bridge`).
- `secretsRef`: Absolute file path reference (`file:/path/to/relay.env`).
- `role`: `primary` or `secondary` (at least one primary binding required for outbound dispatch).
- `handle`: Optional platform alias (`handle=<alias>`). Required for inbound reply resolution; omitted means outbound-only.
Handles must match `/^[A-Za-z0-9._-]+$/` and are unique per `connectorRef`. Multiple people can share a connector, each registered with their own handle.
---
## 5. Operational Lifecycle
### Step 1: Provision Secrets File
Create an owner-only secrets file (no group/other access bits, e.g. `0600` or `0400`):
```bash
mkdir -p "$HOME/.config/openrig-relay"
touch "$HOME/.config/openrig-relay/relay.env"
chmod 600 "$HOME/.config/openrig-relay/relay.env"
cat << 'EOF' > "$HOME/.config/openrig-relay/relay.env"
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
OPENRIG_RELAY_OUTBOUND_TOKEN=replace_with_outbound_token
OPENRIG_RELAY_INBOUND_TOKEN=replace_with_inbound_token
EOF
```
### Step 2: Register Human Binding
```bash
# Add a person with a primary relay binding
rig gateway human add alice --display-name "Alice Smith" --delivery-class B \
--binding "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
# Replace an existing binding slot (requires the full binding spec)
rig gateway human set alice binding.0 "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
```
> [!NOTE]
> `rig gateway human set alice binding.<n>` 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:
```bash
rig gateway default alice
```
### Step 3: Activate Daemon
When bindings or secrets files change, restart the daemon process:
```bash
rig daemon stop
rig daemon start
```
*(OpenRig does not provide a `rig daemon restart` command).*
### Step 4: Verify Readiness & Status
```bash
# Check delivery readiness for all participants
rig gateway human list
# Inspect detailed readiness status for an individual
rig gateway human show alice
# Check daemon process status
rig daemon status
```
In `rig gateway human show <entityId> --json`, `deliveryReadiness.state` reports `ready` when the binding configuration and local secrets file are valid. This confirms local readiness, not whether the operator bridge process is currently running.
### Step 5: Removing Bindings or Participants
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
```
*(The remove verb refuses to delete a participant if open conversations or non-terminal items exist, unless `--force` is specified).*
---
## 6. Troubleshooting Common Conditions
| Condition | Observation | Resolution |
| :--- | :--- | :--- |
| **Missing Secrets File** | `deliveryReadiness.state: not-ready`<br>`reason: relay secret file ... is missing or unreadable` | Verify file path in `secretsRef` is absolute and readable. |
| **Insecure Permissions** | `deliveryReadiness.state: ready` (with warning)<br>`reason: ... warning: ... insecure permissions (mode 0644; group/other access bits are set ...)` | Restrict permissions so group/other have no access (`chmod 600 <secrets-file>` or `chmod 400 <secrets-file>`). |
| **Identical Directional Tokens** | `deliveryReadiness.state: ready` (with warning)<br>`reason: ... warning: ... uses the same token for OPENRIG_RELAY_OUTBOUND_TOKEN and OPENRIG_RELAY_INBOUND_TOKEN; separate tokens are recommended ...` | Provision distinct outbound and inbound tokens so outbound capture cannot authenticate inbound replies. |
| **Invalid Loopback URL** | `deliveryReadiness.state: not-ready`<br>`reason: ... OPENRIG_RELAY_URL host must be loopback ...` | Ensure URL uses `http://` and host is `127.0.0.1/8`, `::1`, or `localhost`. |
| **Reused Inbound Token Across Profiles** | `deliveryReadiness.state: not-ready`<br>`reason: ... reuses an ambiguous OPENRIG_RELAY_INBOUND_TOKEN shared with relay profile(s): ...` | Each distinct relay connector profile (`connectorRef`) must declare a unique inbound token. |
| **Stale Question Reply** | HTTP 409 on inbound endpoint | Question was already answered, cancelled, or superseded by a newer revision. |
| **Unregistered Handle** | HTTP 403 on inbound endpoint | The `sender.handle` is not bound to the authenticated `connectorRef`. |
| **Non-Loopback Inbound** | HTTP 403 on inbound endpoint | Connection originated from a non-loopback TCP socket peer or included a cross-origin browser header. (`Via` and `X-Forwarded-*` headers are ignored). |
| **Registry or Buffer Read Failure** | HTTP 503 on inbound endpoint (`human_registry_unavailable` or `dispatch_buffer_unavailable`) | Inspect local `gateway/humans/*.yaml` fragments or `<openrig-home>/gateway/dispatch-buffer.json` for syntax/permission corruption before retrying the bridge event. |
| **Seen-Ledger Corrupt / Suspect** | HTTP 503 on inbound endpoint (`seen_store_unavailable`, includes `seenPath`) | Unreadable/unparseable/unkeyable `relay-inbound-seen.jsonl` refuses all inbound requests; a keyed suspect entry refuses only that specific `eventId`. Follow the conservative recovery procedure below. |
### Conservative Seen-Ledger Recovery (`seen_store_unavailable`)
When `POST /api/gateway/relay/inbound` returns `503` with `error: "seen_store_unavailable"`, the response body and message include the resolved `seenPath` (`<openrig-home>/state/relay-inbound-seen.jsonl`):
1. **Quiesce writers first**: Pause the loopback bridge and stop the daemon (`rig daemon stop`) so no concurrent inbound request or retry appends to the ledger during inspection.
2. **Preserve a full backup**: Copy `<seenPath>` to a timestamped backup outside `state/` before making any edit.
3. **Correlate against durable evidence**: Each ledger entry keys `id` by `eventKey = SHA256(JSON.stringify([connectorRef, eventId]))` (64-character lowercase hex digest) and stores `status` as `received:<fingerprint>` or `processed:<fingerprint>`. Because `eventKey` is a one-way hash, correlate any damaged line or keyed suspect `id` against `mission_control_actions.audit_notes_json` (which records `operationId: "relay:<eventKey>:<fingerprint>"`, `connectorRef`, and `eventId`) and bridge delivery logs by computing `SHA256(JSON.stringify([connectorRef, eventId]))` for candidate events—never by guessing or assuming the hash is reversible.
4. **Apply only evidence-verified, history-preserving repair**: Repair a damaged record only when its exact `id` (`<eventKey>`) and `status` (`received:<fingerprint>` or `processed:<fingerprint>`) are verified from `mission_control_actions.audit_notes_json` or bridge evidence, or restore only from a backup that preserves every subsequently acknowledged event. Do **not** delete, truncate, or skip unknown/unparseable records, and do **not** restore an older backup that omits acknowledged events; if an entry cannot be verified without losing history, leave the ledger in place (continuing to fail closed with `503`) and retain the backup.
5. **Resume and retry**: After a verified, history-preserving repair is saved at `<seenPath>`, start the daemon (`rig daemon start`), resume the loopback bridge, and retry the original inbound delivery with the same `eventId`.
For automated loopback bridge test reference, see `packages/daemon/test/fixtures/recording-relay.ts`.
+61 -8
View File
@@ -76,8 +76,17 @@ You need Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude
Code or Codex account: one is enough, since the kernel operator adapts the
shipped `starter` team to whichever you have. On Linux, the distribution's own
Node.js can be older (Ubuntu 24.04's is 18); install a supported version with
[nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource. On a Mac
with Apple silicon, use Node.js 22 (see the [compatibility
[nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource.
For a tarball or manual Node install on Linux, put its bin directory on PATH in
`~/.profile`, for example `export PATH="/path/to/node/bin:$PATH"`, rather than only
in an interactive `~/.bashrc`. Bash reads the first of `~/.bash_profile`,
`~/.bash_login` and `~/.profile`; if one of the first two exists, source `~/.profile`
there. Other shells use their own login file (for example, zsh's `~/.zprofile`).
Check `bash -lc 'command -v node rig'`: both paths must appear in the output.
Use Codex's tool shell for this check if it differs from Bash.
On a Mac with Apple silicon, use Node.js 22 (see the [compatibility
history](../releases/v0.5.15.md#known-compatibility-limitation)). On a Mac where
you aren't an administrator, or where Homebrew belongs to another account (its
`brew install` is refused), install Node.js in your own home folder with
@@ -200,8 +209,9 @@ The same command without `--dry-run`,
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
`FAILED [n/4] <command or check> (exit <code>)`. Where a provider isn't signed in yet, step 4 ends that way and
`rig setup` lists each sign-in under "Some steps need attention". If the only remaining failures are provider
`FAILED [n/4] <command or check> (exit <code>)` for steps 1–3. If step 4 is not ready, it reports
`INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any Fix, then rerun
`rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider
sign-ins, the install steps finished: sign in to each selected provider as below, then continue at
[Start the kernel](#start-the-kernel-and-check-its-state). To install only what the selected providers need, go step
by step instead:
@@ -260,6 +270,17 @@ opt in with
`rig config set terminal.inherit_tmux_environment true`. Setup points out this option
when it detects custom tmux configuration; it never enables it automatically. The setting
applies to future launches and successors, not running agents or your personal tmux server.
Managed Codex seats run tool commands in a non-login shell: OpenRig passes Codex
`-c allow_login_shell=false`. Tool commands inherit the environment their pane's shell set up,
OpenRig's command folder included, so plain `rig` works however OpenRig was installed.
Inherited setup isn't re-run for each command. Setup that lives only in login or rc files, such as
lines in `~/.bashrc` that your login file doesn't source, or a tmux `default-command` that doesn't
start a login shell, may be missing from tool commands, and so may shell functions and aliases.
To defer to Codex's own setting instead, run `rig config set launch.codex_login_shell true`. That
omits OpenRig's override rather than forcing login shells, and a login file that sets `PATH`
outright can then hide `rig`. It applies at each seat's next launch; a restart in place keeps
the running choice.
If the scoped environment cannot be prepared, launch keeps its previous environment and
reports a warning in fresh launch results and the daemon log. For handovers, check
that log for this warning. This is not a promise that shell startup cannot add or override values.
@@ -291,6 +312,17 @@ fine; if both accounts are authenticated, the kernel uses both even when your
later project uses just one. A request to use only one provider for everything
is a separate choice.
`/api/kernel/status` retains the chosen template (`rig.yaml`,
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
recovery, including starts with `--no-kernel`. It reads the rig's latest rig-spec
bootstrap record, including a partial launch, so an older kernel's recorded
selection is also available. This is the original selection; the rig's live spec
describes its current configuration. The saved source must match a template under
the current installation’s built-in specs directory. A custom project path with
the same directory suffix, an old source path that can no longer be verified
(for example, after a Node version change moves the global install), or a kernel
without bootstrap history reports an unknown variant.
**Started is not ready.** Read the `Kernel:` boot state in `rig status` and the
individual seat state in `rig ps --nodes --rig kernel`. The view may open while
agents finish starting; describe that state, without claiming they are ready.
@@ -483,7 +515,7 @@ different points in the same journey:
| What you see | Why and what to do next |
| --- | --- |
| Codex offers to switch to a cheaper model near a rate limit | This is Codex's own menu. Ask the person to choose in the seat's terminal; do not silently change the team's configured model. OpenRig disables Codex's startup update check for managed launches, but does not answer this model-choice menu. |
| A missing Node.js, npm or tmux; an install download or command fails | The named prerequisite or install step did not finish. Read its error and the [platform and provider instructions](#install-and-sign-in), resolve that step, then recheck it. `FAILED [4/4]` with only selected-provider sign-ins outstanding means the package installed but those logins still need the person. |
| A missing Node.js, npm or tmux; an install download or command fails | The named prerequisite or install step did not finish. Read its error and the [platform and provider instructions](#install-and-sign-in), resolve that step, then recheck it. `INSTALLED, SETUP NOT COMPLETE` means the CLI is installed but setup needs attention; follow its output and any Fix, then rerun `rig setup`. |
| The installing harness refuses a tool call or asks for permission | Its native rules, sandbox or automatic permission decision apply before OpenRig can run. Claude Code's [auto mode can deny calls](https://code.claude.com/docs/en/auto-mode-config) as well as approve them. Show the specific refusal and ask the person to review it in the harness permission controls or run that named step themselves. Keep their chosen restrictions; a refused call does not prove the OpenRig command failed. |
| Herdr or Ghostty installation is unavailable or declined | Herdr is optional for the view: `--window` uses plain tmux when Herdr is absent, and its install failure is a warning. On macOS, Terminal.app is the fallback when supported Ghostty is absent. An explicitly requested Ghostty install can fail setup; resolve that install or decline it with `--no-ghostty`, preserving earlier choices. Address separate required-tool or login failures. Existing cmux remains supported. |
| macOS asks for Automation access, or the window action is denied | The command asks the desktop terminal app to open a new surface. Let the person answer the system prompt. Read the error and inspect any new window before retrying an uncertain action; do not silently switch apps and open a duplicate. |
@@ -784,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>
@@ -815,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
@@ -850,6 +895,7 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| --- | --- |
| Tool missing or login fails | Use the specific setup/auth hint; recheck that executable in the launch shell. Do not send work to an unready seat. |
| "Startup attention" or "Startup details" names a prompt | Answer the login, trust or bypass prompt in that seat's terminal, then run the `rig seat continue <seat>` it shows. |
| A restored seat still counts as needing attention after its prompt was answered | Its restore outcome is still `attention_required`, and answering the prompt or running `rig seat continue` doesn't change it. Once the agent is up in its pane, run `rig seat clear-attention <seat>`; see [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
| "Readiness timeout after 30s — harness did not become interactive" | The harness took longer than the readiness window. Raise it for the next launch with `rig config set runtime.readiness_timeout_seconds <1-600>`, then relaunch that seat. |
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
@@ -1129,8 +1175,15 @@ rig up ./openrig-specs/rigs/launch/starter/rig.yaml --cwd .
OpenRig's `permission_policy: builtin:yolo` setting selects
`-s danger-full-access -a never` on fresh, resume and fork launches, replacing
the named-profile argument. The profile recipe above remains useful when you
want to maintain those choices in native configuration. The legacy
the named-profile argument. That drops the whole profile, not only its sandbox
and approval settings. To keep a named profile with full access, put full access
in the profile itself and leave `permission_policy` absent, as in the recipe
above. To keep it with a workspace-write or read-only posture that OpenRig
records, use the member's `codex_permissions` (see
[per-member Codex permissions](rig-spec.md#per-member-codex-permissions)):
OpenRig passes `-p` and adds that envelope's flags, and for that seat the
envelope replaces a policy posture such as `builtin:yolo`. Full access isn't an
envelope. The legacy
environment-only `OPENRIG_YOLO=1` path remains sandbox-only when no resolved
policy is present. A standalone `codex --yolo` command is not an OpenRig setting.
At full access, Codex can show its full-access and GPT-5.1 migration notices at
+52 -10
View File
@@ -6,13 +6,30 @@
> - **Released in 0.7.8:** seat activity grants (section 4), and OpenRig's side of the answered permission path
> (section 7).
> - **Runner-author behavior, not yet qualified:** the input, cancellation and permission recipe in sections 5-7, and
> the runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
> the runner-side shutdown obligation in section 9. Two runners have shown parts of it natively (below); no
> runner is qualified beyond that.
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
> handoff. These interfaces do not establish that a particular runner implements them correctly.
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
>
> No harness has been run end to end under this contract yet, and the native verdict is pending. The first candidate
> is dsh over ACP.
> **What has run natively, in 0.7.8's release testing, against a Claude Haiku model:**
> - **dsh-seat 0.3.0**, an outside runner for dsh 0.2.1-rc.2 over ACP: launch and first input, useful queue work, a
> multiline message with a `/abort` line inside it delivered as one prompt, activity that stayed working through a
> 52-second quiet tool interval and then read idle, cancel by a bare `/abort` typed in the pane, and the same
> conversation resumed by `session/resume` after `rig down --snapshot` and `rig up`, with the runner and its child
> gone after the managed down.
> - **The dsh example runner** (`community-harness-examples/dsh/`): dsh's native `session/request_permission` reached
> a person, whose allow-once took effect and whose reject-once didn't.
>
> **Not shown natively:** a DeepSeek model, the example runner's own resume (its preset declares none), the default
> permit when nobody can be asked (checked without a model only), a typed or Slack answer, a second person's
> answer, reboot recovery, and the model OpenRig observes. Any other runner, provider or path is unqualified until
> it's shown.
>
> **Since 0.7.8, on one stack:** the dsh example runner has run with a real dsh 0.2.1-rc.2 on dsh's pi-ai OpenRouter
> chat-completions profile with `deepseek/deepseek-v4-flash`: queue work, activity, an allow-once and a reject-once
> answered by an automated test operator, cancel, and an exact return with resume added to a copy of the preset. A
> missing conversation's refusal hasn't been run, and the preset still declares no resume.
>
> Release status and pinned references are in the [joined author guide](author-contracts.md). The
> [community presets reference](community-harness-presets.md) describes the implemented fields, including the runner
@@ -61,7 +78,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
@@ -244,7 +274,7 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|---|---|
| **There's a default person** (chosen with `rig gateway default`, or the only registered person), and no override | They get an ordinary ask in the Feed and on Slack. No setup is needed. |
| **The seat's operator chose someone** (`rig seat set-permissions-to <seat> --to <person> --reason "…"`) | That person gets the ask instead. |
| **No one can be asked** | **Permitted at once**, with the agent's own allow option. No ask is opened. That covers: no registered person; several and none the default; the chosen person no longer registered; the people unreadable; or options a person can't be shown (bad ids, or not 2-4). The agent's `allow_once` is used, else its `allow_always`. If the agent offers only `allow_always`, the harness then remembers a permission nobody chose. |
| **No one can be asked** | **Permitted at once**, with the agent's own allow option. No ask is opened, and OpenRig records the permit (below). That covers: no registered person; several and none the default; the chosen person no longer registered; the people unreadable; or options a person can't be shown (bad ids, or not 2-4). The agent's `allow_once` is used, else its `allow_always`. If the agent offers only `allow_always`, the harness then remembers a permission nobody chose. |
| **The seat's operator opted in to refusal** (`rig seat set-permissions-to <seat> --deny --reason "…"`) | Refused at once and held as needs input, as in the first version. |
**What the ask shows:**
@@ -263,7 +293,7 @@ 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 |
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
| doesn't answer | the request **stays pending and visible** (the seat reads needs input) until the agent's turn ends. It's never timed out into a grant. |
- **An answer reaches only the live request it was asked for,** once. A request from an earlier occupant reads
@@ -271,6 +301,17 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
- **Text typed into the runner's pane, an agent's own text, and `rig send` are never an answer.** While a request waits
on a person, the seat reads needs input, and sends are normally refused. A send that does get through reaches the
agent as a prompt.
- **From 0.7.9, each permit OpenRig decides by default is recorded,** once, as a stream item from the seat. That covers
no one to ask, and an ask closed without a choice (the row passed to an agent seat included). The record names:
- the tool call;
- why no person answered, including, for a closed ask, who closed which row and how;
- the option the agent got, with its kind. An `allow_always` option is marked as remembered by the agent's harness;
- the conversation and request, or the ask.
For an ask opened before 0.7.9, the record may omit the option kind and whether the harness remembers it.
Read the records with `rig stream list --tag runner-permission-permit`, or `--source <seat>`. A record wakes no one,
and one that can't be written doesn't change the permit.
**The runner's protocol:**
1. **Submit** the agent's live request to `POST <base>/api/activity/permission-requests`, with the same token or seat
@@ -330,20 +371,21 @@ answer. Only the seat's opt-in refuses and holds attention.
**When the runner can't get an answer from OpenRig,** nobody can be asked, so it permits with the agent's own allow
option: `allow_once` when offered, else `allow_always`. Prefer `allow_once`, so the harness doesn't remember a
permission nobody chose. That covers:
permission nobody chose. Such a permit is the runner's own, so OpenRig has no record of it: the runner prints it in
its pane. That covers:
- an OpenRig without this path, which answers 404;
- an OpenRig the runner can't reach, at submit or while reading (a runner may retry a read that got no answer a few
times first);
- a refusal the table marks "permits", and any status or code this section doesn't list.
**Limits in 0.7.8:**
**Limits of this path:**
- **The answer owner is a registered person.** Choosing a seat (another agent) to answer is a later change.
- **A colleague's answer is followed on this host.** If the ask is handed to another host, the request waits, shown as
needs input, until the agent's turn ends.
- **An override or `--deny` is set with the command above, never in a rig spec.** A spec field is a possible later
change.
- **Permits and answers aren't recorded beyond the runner's pane and the ask itself.** A permit with no one to ask
opens no ask.
- **A person's answer is recorded on the ask itself,** and a default permit as the stream item above. That item lasts as
long as stream retention keeps it. A permit the runner decides on its own is in its pane only.
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
+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.
+44 -2
View File
@@ -95,7 +95,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
@@ -119,6 +119,7 @@ and nothing else is offered:
| `queue`/`claim`, `queue`/`update` | `rig_queue_claim`, `rig_queue_update` |
| `team`/`list` | `rig_team` |
| `artifact`/`read` | `rig_artifact_read` |
| `question`/`read`, `question`/`answer` | `rig_question_list`, `rig_question_answer`, only when the grant names its person (below) |
- **One seat per connected assistant:** the service gives each connection its own seat and keeps it across that
connection's reconnects. The seat must be one of the rig's nodes; a node created without a launch is enough. The caller
@@ -128,6 +129,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.
@@ -139,8 +144,45 @@ and nothing else is offered:
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
claims it.
### A person answering the team's questions through their assistant
A grant can name the person who owns the connection: `person.entity_id`, their gateway entity id on this instance, so their
address is `<entity_id>@external`. The service sends it only for a connection that person owns. With it and the `question`
pair, the person's assistant can read the team's questions waiting on them and submit their answers, without opening
Steering. The caller's seat is never taken for a person: without `person`, the `question` pair offers nothing, and an
unusable `person` withholds only these two tools while the grant's other tools keep working.
- **Register the person** on the instance, once:
`rig gateway human add <entity_id> --display-name "<name>" --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
them. Each comes with its options when it has any, and the `revision` an answer names. A question that moved to someone
else between Steering's view and the read of its row is left out.
- **`rig_question_answer`** takes the question, the revision the person saw, and either the options they chose (one per
question) or exactly what they typed. It records through Steering's own write path (`answer` for an ask, `resolve` for a
park), as the person, so Steering shows the same answer and the asker's work continues once. The answer's audit names the
route it came by: the caller's seat, the grant's principal and the grant's id and version.
- **What it says back:** a success names what was submitted (`submitted`), never a fresh read of the question's answers.
- `answered`;
- `recorded`, when parts of the question are still unanswered;
- `already-recorded`, for an identical retry whose submission is already on the question, recognised by its operation id
(never by an equal answer). That includes a settled ask or a park that's back at work;
- `question_changed`, with the current question, when someone else changed it since the revision the person saw;
- a retry of a submission that stopped partway confirms the parts already recorded and stops there (`question_changed`,
with `recorded` and `notRecorded`). The rest goes in a new answer to the question as `rig_question_list` shows it now,
so a stale retry never overwrites an answer someone gave in the meantime, including one recorded with no action (a
Slack click);
- a refusal partway names which parts were recorded (`recorded`, `notRecorded`). A send that got no answer, or a server
error, leaves that part `indeterminate`: send the same call again to confirm what was recorded;
- `not_waiting_on_person`, when it was answered or closed, or isn't theirs;
- `person_not_registered`, when the grant's person isn't registered on this instance. While no one is registered, Steering
shows every request, so nothing is answered for anyone.
- **What it never does:** answer another person's question, reach a team-internal row, or record a decision of its own. A
delegated decision (`rig_queue_decide`) isn't offered to a grant-service caller.
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
isn't rung, and its person is told instead.
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one.
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one, except through a grant service's grant that names its person, which records that person's own answers (above).
+11
View File
@@ -110,6 +110,17 @@ limitation: use the live spec-library commands to determine where a spec is
served from, and do not infer convergence merely because the canonical
directory exists.
The spec library scans the built-in specs, `$OPENRIG_HOME/specs` and, when
present, that legacy library. At any depth below them it skips directories
named `.git`, `.worktrees`, `node_modules`, `dist`, `build`, `.turbo` and
`.next`, which usually hold build output or dependencies (`SKIP_DIRS` in
`packages/daemon/src/domain/spec-library-service.ts`). A spec inside one of
them, such as `specs/build/rig.yaml`, isn't listed, and nothing reports that it
was skipped. To list it, rename its directory and run `rig specs sync`.
`rig specs add <directory>` copies a directory under its own name, so adding a
directory named `build` doesn't help. `rig up <path-to-rig.yaml>` still launches
a spec by its path, without the library.
Config updates publish a complete replacement file atomically where directory
permissions and the filesystem allow it. On POSIX systems, replacement preserves
the existing owner, group, and read/write/execute permission bits. If creating or
+25 -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."
@@ -107,6 +109,28 @@ rig queue create --destination <entityId>@external --human-intent update \
- **`posted` means the connector posted it, not that they read it.** Don't say they saw it.
- **If delivery fails or is indeterminate,** inspect that task before anything else. Never resend blindly.
## If the timer cannot reach its recipient
A `periodic-reminder` occurrence that definitely fails before input remains owed. The watchdog
retries through its existing loop, using `queue.wake_retry_interval_seconds` and
`queue.wake_retry_cap` (by default, three retries five minutes apart). A failed attempt does not
advance the reminder to its next regular cycle. A confirmed send advances the normal interval;
recovery does not replay every elapsed cycle.
`rig watchdog show <jobId> --json` includes the failed attempt, its retained outbox IDs, retry
time and next action. If input may have reached the recipient, that occurrence ends unconfirmed
and is not automatically sent again. Inspect the recipient and exact outbox record before any
manual replay. A recurring reminder keeps its next normal cycle under a new occurrence identity. A confirmed receipt survives a daemon restart, so
finishing its accounting does not type the same occurrence twice.
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
correct the destination and register a replacement reminder. A one-shot park timer records its
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
there while the timer stays armed. Deliberately held
input keeps its existing retained-message custody. Condition watches and parked-owner policy
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
## "What are you watching for me?"
List **every active state**, not just set-aside ones. A watch being checked can be in progress, and a new one can be
+2 -1
View File
@@ -25,7 +25,8 @@ that restores nothing is not itself evidence of accidental loss.
## Choose the interface the seat actually has
Pi 1.4.0 includes an opt-in selector at `examples/extensions/tools.ts`. Its
Pi 1.4.0 includes an opt-in selector at
`packages/coding-agent/examples/extensions/tools.ts` in the Pi repository. Its
`/tools` command remembers selection per branch and needs Pi's own TUI. Use it
where that extension and native TUI are available. **Do not send `/tools` to an
agent OpenRig drives over RPC**: that interface cannot operate the selector.
+4
View File
@@ -7,6 +7,10 @@ without daemon-internal knowledge.
This subtree is part of the wider [OpenRig Instance Layout](instance-layout.md).
The instance initializer delegates these exact workspace bytes to this owner.
To inspect or read an exact reconstruction under a selected work home, use
[`rig context reconstruction`](reconstructions.md). It shares catalog and work-home
resolution with `context work-install` and performs no installation or projection.
## Default Shape
`rig config init-workspace` creates the default workspace at
+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.
+6 -2
View File
@@ -90,7 +90,8 @@ Use `codex` or `both` in place of `claude` to match the coding agents you use. A
- it adds an OpenRig block to `~/.tmux.conf` (mouse support and longer scrollback).
It doesn't start anything. It ends with a summary of what's done and what still needs you. A missing sign-in shows
there: run `claude auth login` or `codex login`, then carry on.
there: run `claude auth login` or `codex login`, then carry on. Setup also tries to record the selected providers
for a new kernel's first boot; its summary warns if it could not save the choice.
### 3. Start OpenRig
@@ -100,7 +101,10 @@ rig status
rig ps --nodes --rig kernel
```
The daemon starts and boots the kernel with the coding agents you're signed in to, or both if you're signed in to both.
On the kernel's first boot, the daemon uses the providers selected by `rig setup --providers` that are signed in.
If no provider choice was recorded, it considers every signed-in provider. An existing kernel keeps its seats;
changing the setup choice does not replace them. An `OPENRIG_PROVIDER_SELECTED` value in the daemon's environment
takes precedence over the saved choice.
**What starting OpenRig changes, for the coding agents you use** (paths are the usual defaults):
- **For Codex:** hooks and trust in `~/.codex/config.toml`, when OpenRig or a Codex agent starts.
+90
View File
@@ -0,0 +1,90 @@
# Read a reconstruction of selected work
A reconstruction records an author's working understanding, its evidence routes,
constraints and gaps. `rig context reconstruction` reads an exact immutable
`openrig.reconstruction/v0` revision locally. It does not start a daemon, fetch
remote context, write records or transfer the author's authority.
Inspect the purpose, depth, author-declared coverage, observation dates, complete
source manifest and approximate size before reading a view:
```bash
rig context reconstruction inspect <full-sha256> --project example --mission task --slice 01-example
rig context reconstruction view <full-sha256> --project example --mission task --slice 01-example --item decision --json
```
Without `--store`, the store is `reconstructions/` under the selected project,
mission or slice home. Project selection follows `context work-install`: an
explicit catalog project, the only entry, the calling rig's association, the
deepest project containing `--cwd`, or the only unclaimed entry. Ambiguity is an
error. The project's configured `missions.root`, declared mission membership,
slice directory names and slice frontmatter IDs are honored. A workspace
without a catalog can use its own project home. No skill installation or context
projection happens during this lookup.
Use `--store /absolute/local/store` for an imported store without resolving a
project. Supplying work selectors as well still validates that work home.
Revisions are full lowercase SHA-256 IDs; there is no implicit latest revision.
## Faithful views and gaps
Repeat `--item`, `--category` or `--tag` to select their union. A filtered view
always retains authority, constraint and gap items, plus transitive item
dependencies. It names omitted item IDs and uncited evidence routes, and keeps
declared reconstruction coverage separate from actual view coverage. Inspect
always provides the complete manifest; a filtered view provides cited routes.
A full view also includes uncited registered routes.
Base records, deltas and pinned compositions retain inherited observation dates.
A revision's creation date does not refresh those observations. Composition
members do not imply an atomic capture. Authors are claimed attribution, and
coverage is authored description rather than proof of runtime state or acceptance.
Unavailable records, missing selections, dependencies or citations make the
result `complete: false` and set exit status 1. Available composition members
remain visible with the missing member's exact store and revision. Unknown
schemas, invalid content hashes and unsupported numeric representations are
reported explicitly. The initial reader supports safe integer JSON numbers;
floating-point/exponent and unsafe integer representations are rejected rather
than assigned a different ID. Unicode keys follow v0's codepoint sorting.
## Relocate a store without changing its records
Parent and member pointers include absolute source-store paths in the hash.
Do not edit those paths in immutable records. Copy their closure into local
custody, then supply a separate JSON mapping:
```json
[
{
"sourceStore": "/original/work/reconstructions",
"localStore": "/local/import/reconstructions",
"sourceHost": "original-host"
}
]
```
```bash
rig context reconstruction inspect <full-sha256> --store /local/import/reconstructions --store-map /local/map.json
```
The mapping is read-time location information, not an identity registry or a
remote fetch permission. It preserves source-host provenance. Since v0 pointers
do not contain a host discriminator, duplicate mappings for the same source
store are ambiguous and rejected. A store path that exists on this host can be
read directly; a path that has moved needs a mapping to its local location.
## Bounds and byte checks
Reads are limited to 8 MiB per regular revision file, 32 MiB per closure, 64
record references and 128 JSON nesting levels. Symlink revision files are not
followed. Output defaults to 256 KiB; `--max-bytes` accepts 1 KiB through 8 MiB.
Oversized output fails explicitly rather than silently truncating a purportedly
complete view. Size estimates use UTF-8 bytes divided by four, not a model tokenizer.
`--check-sources` hashes declared local regular files within 128 files and 64 MiB
total. It reports changed, unchanged, unavailable or unchecked bytes without
executing source commands, fetching opaque routes or updating observations.
Discovery declarations are shown but not scanned. Unchanged known files do not
establish semantic freshness or detect new queue decisions. There is no queue
discovery cursor, native writer or continuity-consumer replacement in this reader.
+2 -2
View File
@@ -91,10 +91,10 @@ my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
The packer produces deterministic output:
- Files are sorted alphabetically
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
- Portable mode normalizes uid/gid/mode
- Portable mode omits machine-specific owner metadata; file mode bits can be retained
- Maximum gzip compression (level 9)
Identical staged bytes (including manifest timestamps and provenance) produce the same archive hash. Use the package digest to compare packaged file content independently of manifest metadata.
Identical staged bytes and file modes (including identical manifest timestamps and provenance) produce the same archive hash. A mode change can change the archive hash even when the file contents are unchanged. Use the package digest to compare packaged file content independently of manifest metadata and file modes.
---
+120 -10
View File
@@ -2,7 +2,7 @@
Version: 0.2 (pod-aware)
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; changes to its sources since then checked)
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/startup-validation.ts`, `packages/daemon/src/domain/permission-policy/policy-ref.ts`, `packages/daemon/src/domain/profile-resolver.ts`, `packages/daemon/src/domain/rigspec-preflight.ts`, `packages/daemon/src/adapters/kernel-authority.ts`, `packages/daemon/src/domain/native-permission-store.ts`, `packages/daemon/src/domain/codex-team-workspace.ts`
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/launch-wrapper.ts`, `packages/daemon/src/domain/startup-validation.ts`, `packages/daemon/src/domain/permission-policy/policy-ref.ts`, `packages/daemon/src/domain/profile-resolver.ts`, `packages/daemon/src/domain/rigspec-preflight.ts`, `packages/daemon/src/adapters/kernel-authority.ts`, `packages/daemon/src/domain/native-permission-store.ts`, `packages/daemon/src/domain/codex-team-workspace.ts`
This is the canonical reference for the pod-aware RigSpec YAML format. Every field, validation rule, and default documented here was traced from the actual parser and validator code, not from prior documentation.
@@ -180,7 +180,7 @@ edges:
| `non_interruptive` | boolean | no | — | Pod-aware rig launch-warning default. With full-bypass Claude/Codex seats, true accepts or hides supported harness warnings using launch flags. Explicit CLI choices override this field; this field overrides the machine default. It does not grant permissions, change native settings files, or bypass login. |
| `permission_policy` | string | no | — | Permission policy attached to the rig. Either a built-in (`builtin:locked`, `builtin:standard`, `builtin:open`, `builtin:yolo`, `builtin:auto`) or a safe relative path to a custom policy file (resolved from this spec's directory; no `..`, no absolute, no empty segments, each segment `[A-Za-z0-9][A-Za-z0-9._-]*`), or `none`, a recorded choice of the floor. Absent uses the default floor with the scoped team launch allowances described below. A bare built-in name such as `yolo` is refused ("use 'builtin:yolo'"), and so is an explicit `null`. A custom file that is missing, unreadable or invalid isn't a validation error: it resolves to the floor, and preflight warns. A member may set its own `permission_policy` (not on a terminal member), which takes precedence over the rig-level one; there is no pod level. See "Attaching a permission policy" below. |
| `env` | map | no | — | Launch environment for every seat in the rig: `NAME: value` pairs. A member's own `env` overrides it name by name. Names match `^[A-Z_][A-Z0-9_]*$`, names starting `OPENRIG_` are reserved, and values must be non-empty strings (quote numbers and booleans). Values are not secret. See "Launch environment" below. |
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex, Pi and OMP members use `AGENTS.md`. See "Choosing the Claude instruction file" below. |
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex, Pi and OMP members use `AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's `AGENTS.md` and a fresh Codex 0.164.0+ seat normally gets launch developer instructions; a link, a conflicting or unreadable Codex config layer, or a resume keeps the repository file. See [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). See "Choosing the Claude instruction file" below. |
| `workspace` | object | no | — | The rig's workspace: `workspace_root` (required), `repos[]` of `{name, path, kind}` with `kind` one of `user`, `project`, `knowledge`, `lab` or `delivery` and unique names, an optional `default_repo` naming one of them, and an optional `knowledge_root`. Relative repo paths resolve against `workspace_root`. |
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
@@ -404,6 +404,22 @@ ask rules; help can prompt in that fallback. A helper that disappears or times o
after launch cannot supply a decision, leaving Claude's remaining native rules
in effect. The hook is a convenience policy, not a containment boundary.
Claude sessions launched with a named `team` or `kernel` hook policy read the
installed built-in lists on each Bash hook call. Replacing those files at the
same path changes the hook's lists on its next call; native inline allowances
remain as launched. Sessions launched with an encoded policy keep their launch
lists. A session retaining a versioned runtime path continues to use the files
at that path, so installing another version elsewhere does not change its hook.
This does not reload all permission settings in a running session.
An in-place rollback to a hook from before named policies has a different effect:
that helper cannot read `team` or `kernel` and returns no hook decision. Lifecycle
asks can then disappear under the native `Bash(rig:*)` allowance. Relaunch affected
Claude sessions before relying on those asks after such a rollback. Native
personal, project and managed restrictions are not removed. This applies when
the rollback replaces the hook at the path a running named-policy session uses;
it does not describe every rollback or sessions using a retained versioned path.
Codex keeps `workspace-write` with its existing approval policy and receives the
configured OpenRig workspace root plus its pod's shared state directory as
additional writable directories. The team workspace preparer creates missing
@@ -548,7 +564,7 @@ or local settings. Managed settings aren't inspected. `rig export` writes the se
with an account home or an API-key selection (select those again after import), so a later switch shows there, and `rig provider switch --stored-login` removes it. Only
`claude-code` members and legacy flat nodes accept it, and there's no rig-level default.
**Switching to an isolated account home.** A running Codex or Claude Code seat can change its native account home
**Switching to an isolated account home.** A running Codex, Claude Code or Pi seat can change its account home
while staying in its rig:
```sh
@@ -560,7 +576,24 @@ rig seat set-runtime dev-impl@my-rig --runtime claude-code \
The directory must already exist and contain the runtime's login or API settings. You choose the account and budget;
OpenRig does not choose or create credentials. `--account-home` sets `CODEX_HOME` for Codex or `CLAUDE_CONFIG_DIR` for
Claude Code before the successor starts.
Claude Code before the successor starts. For Pi, the account directory must contain a readable `auth.json`; OpenRig links
that file into a separate managed successor directory. The existing Pi process keeps its credentials and runner state
until the successor confirms. Pi account changes keep the current model and effort unless explicitly overridden.
```sh
rig provider switch --seat pi-worker@my-rig --to-account work \
--account-home /home/me/accounts/pi-work --reason "Use the work account" --dry-run
# Run again without --dry-run to stage the successor and request its confirmation.
rig provider switch --seat pi-worker@my-rig --clear-account-home --reason "Return to configured login"
```
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.
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,
@@ -740,7 +773,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `id` | string | yes | — | Member identifier. Must not contain dots or `@`. Must be unique within the pod. |
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. To keep the profile, put full access in the profile itself, or record a workspace-write or read-only `codex_permissions` envelope, which keeps it. |
| `codex_permissions` | mapping | no | — | Codex-only sandbox and approval envelope, emitted as explicit flags on every managed launch and resume; see [per-member Codex permissions](#per-member-codex-permissions). |
| `claude_token` | mapping | no | — | Claude-only `{ label, file }`: the seat's own subscription token file, by absolute path, used from its first launch; see "A seat's own subscription token" under [launch environment](#launch-environment). |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
@@ -750,11 +783,13 @@ 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 `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. |
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
| `plugin_channels` | list | no | — | Claude members only. Plugin channels this agent registers for, as `plugin:<name>@<marketplace>` entries, passed as one `--channels` on every launch; see [Claude plugin channels](#claude-plugin-channels-runtime-claude-code). |
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
@@ -810,6 +845,52 @@ OpenRig keeps the requested opt-out flags, so startup and resume remain subject
to that native refusal; it does not fall back to loading servers. Use a host
without that managed configuration, or omit the opt-out to use the managed set.
### Claude plugin channels (`runtime: claude-code`)
Set `plugin_channels` on a Claude member to register that agent for channel
notifications (messages a plugin's MCP server pushes into the session):
```yaml
members:
- id: support
agent_ref: local:agents/support
profile: default
runtime: claude-code
cwd: .
plugin_channels:
- plugin:example-channel@example-marketplace
```
OpenRig passes `--channels plugin:example-channel@example-marketplace` to this
agent's Claude on every launch, resume, fork, restore and handover. Several
entries follow one `--channels`, space-separated, and OpenRig's own session
options come after them. Claude Code 2.1.316 hides `--channels` from
`claude --help`, so it isn't listed there. An agent without the field gets no
`--channels`, and nothing is added for other agents or other runtimes; there is
no rig-wide or environment default.
Each entry is `plugin:<name>@<marketplace>`, both names made of letters, digits,
`.`, `_` and `-`, starting with a letter or digit. `server:` entries and Claude's
`--dangerously-load-development-channels` aren't supported. Combining a non-empty
channel list with `claude_mcp: none` produces a validation warning: that setting
starts Claude without MCP servers (plugin servers included), so no channel could
register. OpenRig keeps both settings and launches the member normally. Remove
`claude_mcp: none` to use the channels, or remove `plugin_channels` to keep the MCP
opt-out. OpenRig doesn't install plugins or marketplaces, or change the settings
that admit a channel: whether each channel registers is Claude's decision (the plugin installed, and
admitted by your organisation's channel settings or Claude's approved list).
Selecting a channel for one agent doesn't isolate what the plugin shares with
other agents. A token or other credential the plugin keeps in its own
configuration is the same for every agent that loads that plugin.
The selection is saved with the seat's other launch additions when the seat
starts from the spec, and starting it from a spec without the field removes it.
`rig seat launch --plan` and `rig skill loadout --seat` list it, a fork keeps it,
and `rig bundle create` and `rig export` carry it in the member; a bundle's
before-install view names each channel as a need. A running Claude process is
unaffected.
### Claude Advisor (`runtime: claude-code`)
Set `advisor` independently of the primary `model`:
@@ -840,14 +921,42 @@ 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.
<a id="launch-wrapper-runtime-pi-and-codex"></a>
### 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:
- id: impl
agent_ref: local:agents/impl
profile: default
runtime: codex
cwd: .
launch_wrapper: ["launcher", "exec", "--profile", "seat-a", "--"]
```
- **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)).
- **Fail-closed (never unwraps):** The selected wrapper applies across fresh launch, relaunch, rebuild, resume, fork, restore, handover and supported runtime switches. Invalid stored metadata, non-POSIX panes, unavailable wrapper support, missing session identity or an unusable wrapper refuses launch. Failures after dispatch remain visible in the pane or Pi runner's markers; OpenRig never runs unwrapped, including Pi's long-command (`launch_path_too_long`) host-runner retry.
- **Persistence and clear:** On new seats, absent or `launch_wrapper: []` stores no wrapper. Applying an authored spec to an existing seat retains the stored selection when absent, clears it with `launch_wrapper: []`, and replaces it with a non-empty array. Recreation/import uses the incoming spec; older snapshots preserve the current selection. First-start retry refuses wrapper changes. Runtime switches carry it to supported successors; validation occurs at execution. There is no live edit verb: change the spec and use the supported create/recreate/apply flow. Export and node inventory include the configured value.
- **Boundary:** An argv prefix is not proof of sandbox isolation, tooling availability, memory wiring or governance enforcement. Verify the wrapper and runtime independently.
### Pi (`runtime: pi`)
`runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner.
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`. A confirmed account switch moves the seat into an isolated `state/pi/account-switches/<generation>/<session>/` directory; subsequent launches and resumes use that committed directory.
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file (or the corresponding committed account-switch directory after a switch). A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `NODE_OPTIONS` for the selected-account Node preload, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Account homes:** Select an account with `rig provider switch --account-home`, or declare an absolute `PI_CODING_AGENT_DIR` in the seat’s `env:` as its configured account home; the persisted selection takes precedence. `~` is not expanded. A relative value is ignored for managed account selection (and reported by status), leaving the managed seat login in use when no persisted account is selected. The chosen directory must already contain a readable `auth.json`; env-only credentials and wrappers that create credentials later are outside this prepared-account path. Launches with neither a persisted selection nor a declared absolute account home retain their existing credential behaviour. The runner links that file into its managed agent directory before starting Pi, including launches through `launch_wrapper`; settings, models, extensions and sessions remain seat-local. Selected accounts use a Node preload shim that redirects only their auth-lock filesystem operations to the chosen account’s `auth.json.lock` pathname, matching direct Pi use even when the source `auth.json` is a symlink. The preload removes its own `NODE_OPTIONS` entry before Pi starts tools, preserving other Node options; descendants and unrelated agent directories keep their normal filesystem behaviour. This requires a Node-hosted Pi executable and a wrapper that preserves `NODE_OPTIONS`; compiled executables and wrappers that discard the preload are not covered. Removing the selection removes only the managed auth link, not the source account file. A missing account file stops the launch. Direct launch refuses to replace an existing seat-local login or an unmanaged auth link; use a confirmed account switch to preserve that login. An ambient pane-shell `PI_CODING_AGENT_DIR` does not select an account; only the resolved seat configuration or persisted account selection does. Persisted account selections survive resume and cannot be bypassed by the long-command fallback; explicit selections clear inherited provider-key overrides. Use `rig provider switch --account-home` or `--clear-account-home` to change an existing Pi seat through a confirmed successor with a fresh conversation and handover. The account reference and committed state directory survive daemon restart and resume; unconfirmed successors leave the old account and state intact. Clearing returns to the configured home, or the original managed seat login if no home was configured. Status reports a configured account home when present; without one, its home is null because Pi uses the managed seat directory rather than the operator's default login.
**Upgrade note for prepared accounts:** An existing built-in Pi seat that declares an absolute `PI_CODING_AGENT_DIR` now selects that home on fresh launch, restart, resume and restore, even without a stored account override. Before upgrading, either prepare a readable `auth.json` there or remove that declaration to keep the managed seat login. Env-only accounts and wrappers that create the file after Pi starts are not supported by this path. Migration 151 preserves switch history; it does not create or copy credentials, or rewrite existing seat declarations. Relative values remain available to community harnesses and are not globally removed.
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
@@ -912,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,
@@ -1030,7 +1139,7 @@ file; missing optional files do not trigger that warning.
| Hint | Behavior |
|------|----------|
| `auto` | System chooses based on file type and context. |
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`. Delivered before harness boot. |
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's and a fresh Codex 0.164.0+ seat normally gets launch developer instructions (the conditions are in [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences)). Delivered before harness boot. |
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
@@ -1255,6 +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 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",
@@ -310,7 +318,7 @@ const cases = {
async "2"() {
const r = result("2", "Input framing, queued messages and cancel");
const h = await launch("2", { scenario: { turns: [{}, { holdMs: scale(15000) }, { holdMs: scale(2500), ignoreCancel: true }, {}] } });
const h = await launch("2", { scenario: { turns: [{}, { holdMs: scale(15000) }, { holdMs: scale(15000), ignoreCancel: true, endAfterCancelMs: scale(300) }, {}] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
const block = message(h, "first line\n/abort\nthird line");
h.paste(block);
@@ -339,10 +347,12 @@ const cases = {
r.check("one /abort sends one session/cancel", h.received("session/cancel").length === 1, `got ${h.received("session/cancel").length}`);
r.check("the waiting block becomes the next prompt", await waitFor(() => h.prompts()[2] === queued, scale(4000)), `prompts: ${JSON.stringify(h.prompts().slice(2))}`);
// The agent keeps this turn open until the cancel arrives and then ends it end_turn, so a runner that handles Ctrl-C
// late on a busy machine still has a turn to cancel. Only its delivery is timed, by the per-step bound.
const interrupted = Date.now();
await sleep(scale(200));
h.keys("C-c");
r.check("Ctrl-C outside a paste sends session/cancel", await waitFor(() => h.received("session/cancel").length >= 2, scale(3000)), "no second session/cancel");
r.check("Ctrl-C outside a paste sends session/cancel", await waitFor(() => h.received("session/cancel").length >= 2), "no second session/cancel");
const ended = await waitFor(() => nonExitStops(h, interrupted)[0], scale(6000));
r.check("cancelled is reported only on the agent's own stop reason", ended?.body?.subtype === "end_turn",
`the agent ended the turn end_turn; the runner reported ${ended ? `Stop/${ended.body.subtype}` : "no Stop"}`);
@@ -779,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); }
@@ -17,7 +17,8 @@
// turns one entry per `session/prompt`, in order (the last one repeats):
// holdMs keep the turn open this long; `session/cancel` ends it early as "cancelled"
// silent send no message chunk for this turn, so a held turn is entirely quiet (a long tool or model call)
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway, when
// holdMs runs out or, with endAfterCancelMs, that long after the first cancel if that's sooner
// permission ask `session/request_permission` first, then carry on whatever the answer. As an object:
// options the options offered (default: allow-once, allow_once; reject-once, reject_once)
// waitMs stop waiting for the answer after this long, and end the turn
@@ -41,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" }));
@@ -90,7 +91,12 @@ async function prompt(id, params) {
if (spec.holdMs) {
const cancelled = await new Promise((resolve) => {
const timer = setTimeout(() => { cancelTurn = null; resolve(false); }, spec.holdMs);
cancelTurn = () => { if (spec.ignoreCancel) return; clearTimeout(timer); cancelTurn = null; resolve(true); };
cancelTurn = () => {
if (!spec.ignoreCancel) { clearTimeout(timer); cancelTurn = null; resolve(true); return; }
if (spec.endAfterCancelMs === undefined) return;
cancelTurn = null;
setTimeout(() => { clearTimeout(timer); resolve(false); }, spec.endAfterCancelMs);
};
});
if (cancelled) stopReason = "cancelled";
}
+22 -3
View File
@@ -144,6 +144,23 @@ changes either. `rig scope` reads an `established` or `canonical` slice whose
`verified:` is missing, has no source, names a tool-scaffolded placeholder, or is
older than 90 days as `provisional`.
A slice's frontmatter may also carry `status:`. `GET /api/slices` reports the
bucket each word falls in as the slice's `status`, and its
`?filter=all|active|done|blocked` filters on that bucket:
| `status:` word | Bucket |
| --- | --- |
| `active`, `in-flight`, `ratified` | `active` |
| `draft`, `draft-pending-orch-ratification`, `placeholder` | `draft` |
| `done`, `shipped`, `promoted`, `closed` | `done` |
| `blocked`, `parked-with-evidence` | `blocked` |
Words are matched ignoring case. Any other word is matched by what it contains:
`done`, `ship` or `close` makes it `done`; `block` or `park`, `blocked`; `draft`
or `pending`, `draft`. A word that matches none of these counts as `active`, so a
slice marked `complete` counts as `active`. A slice with no `status:` counts as
`draft`.
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
record an attributed accept, reject or withdraw decision on a contract item,
and `rig proof show` to read derived readiness up through slice, mission and
@@ -178,9 +195,11 @@ missions whose `mission.yaml` carries `metadata.status: active` or
a mission does not change the project's readiness. A `planned` mission stays
outside the rollup, even after its slices are accepted, until someone changes
its status to `active` (or `release-candidate` for a release that is ready to
judge). Nothing changes the status automatically. Missions created before this
default carry no status and are not counted either; add the line by hand to
include them. It also writes `metadata.name`, the mission's folder name, which
judge). Nothing changes the status automatically. A mission can also carry
`metadata.status: complete`, which only `POST /api/missions/:missionId/complete`
writes (no `rig` verb does); a `complete` mission is outside the rollup too.
Missions created before this default carry no status and are not counted either;
add the line by hand to include them. It also writes `metadata.name`, the mission's folder name, which
the project lifecycle compiler requires; a mission created without it refuses to
compile with `lifecycle_field_missing` until you add that line.
+109 -6
View File
@@ -10,7 +10,7 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
| `off` (default) | Existing delivery and prompt checks | Existing send result |
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Hold input and retain this attempt for inspection when bookkeeping is available |
| `openrig-urgent` | OpenRig's own messages (watchdogs, scheduled wakes, notices) and anything marked urgent type, never over a draft | Other agents' messages are held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, `urgent: true` on `POST /api/transport/send`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `sends-only` | Deliberate sends from people and agents type, as with `off`; a terminal member's typing isn't protected, because its shell input can't be read | OpenRig's own notices are held. A terminal member defaults to this level |
| `hold` | No automatic terminal input, even at an empty prompt | Retain for inspection outside the pane |
@@ -41,6 +41,69 @@ nothing to send later.
## Configure and inspect
### Latest permission observation
`rig seat status <seat>` can show the latest observed harness permission request,
with a fixed tool descriptor, source time and actor category. This is one latest
observation across actors, not an aggregate of pending prompts. `request_observed`
leaves the displayed actor's pending state unknown. `completed` applies only to
that actor's later tool batch; other actors' pending state is still unknown. An
unavailable observation proves neither a permission stop nor the absence of a
waiting native prompt. With no observation, human status omits this section;
JSON keeps the explicit unavailable state. The JSON field is `permission_stop`,
and its `pending` value has the same observation-only scope.
An observation may also carry Claude's **suggested rules** as `suggested_rules`.
These are read-only evidence, not applied or approved, not guaranteed effective,
not the exact command, and not a complete list of dialog options. The supported
subset of [Claude's permission update schema](https://code.claude.com/docs/en/hooks#permission-update-entries)
is `addRules`, `replaceRules` and `removeRules`, preserving type, ordered rules
(`toolName`, optional `ruleContent`), behavior and destination exactly. Strings
are neither trimmed nor broadened; absent `ruleContent` remains distinct from
an empty string. Mode/directory updates and malformed entries are withheld.
The envelope distinguishes a missing native field from a present empty array;
`unsupported` and `size_cut` mark incomplete evidence. An older relay without
this field, or a failed formatter, is unavailable rather than proof of native
absence. Bounds are eight entries, sixteen rules per entry, 128 UTF-16 code
units per tool name, 2,048 per rule-content string, and 16,384 UTF-8 bytes of
serialized evidence. Oversized whole entries are dropped with `size_cut`,
never shortened into different plausible rules. Both producer and ingress
validate these bounds independently. Terminal JSON escapes controls and
nonprinting format/separator characters while preserving decoded string values;
its representation can expand, so the byte bound applies to stored/transported
evidence, not the entire formatted status output.
**Suggested rule strings can contain sensitive values.** The relay sends only
this bounded rule subset alongside the fixed descriptor. Rule content can itself
contain sensitive command-like text; raw `tool_input`, arguments outside that
suggestion, and provider reasons are not forwarded. It posts to `/api/activity/hooks` at the
configured `OPENRIG_URL` (or host/port), or the discovered endpoint in
`OPENRIG_HOME/activity-endpoint.json`. Both `GET /api/seat/status/:seatRef` and
`GET /api/seat/status/node/:nodeId` can return the evidence. The daemon adds no
event, log, queue or telemetry persistence for it; a caller can still capture
status output in a transcript or file. Evidence shares the observation's
five-minute lifetime and existing seat/actor limits, is discarded lazily on the
next observation/status read after expiry, and is lost on restart. No rule,
permission/configuration write, hook decision, edited input, prompt submission
or new attention/delivery behavior is produced.
Actor attribution is a convention derived from the [Claude hook schema](https://code.claude.com/docs/en/hooks#common-input-fields): an omitted or null `agent_id`
is treated as main, a valid ID identifies a subagent or in-process teammate, and
an invalid ID is dropped rather than inferred as main. The fixtures are synthetic;
no live PermissionRequest capture qualifies that convention on an installed native
version. A hook denial without a source-bound guard receipt has unknown origin.
This diagnostic adds no guard collector, permission decision or attention policy.
The new PermissionRequest diagnostic is optional for activity-hook readiness.
Every preexisting required event, relay asset and explicit hook-disable check
still applies. An older installed plugin at the same manifest version retains
its bytes and may not send this event: plugin-only delivery needs a future normal
maintainer release refresh, and managed delivery needs normal reprojection. A
manifest or green readiness check does not prove live hook execution. Raw tool
arguments and provider reason text are withheld; diagnostic evidence is bounded,
memory-only and expires after five minutes.
### Who may intervene
`rig seat status <seat>` shows the seat's terminal interaction account alongside
@@ -90,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
@@ -141,8 +208,21 @@ to finish. The API may return HTTP 202 with `pending: true`; wait for
and effective modes and retains the text `Typing guard: on (hold)` for hold.
A held send immediately returns `outcome: "retained"`, `sent: false`, its exact
reason (such as `draft_input_busy`) and the saved attempt's `outboxIds`. No automatic
retry is scheduled. Each explicit draft-aware request is a new attempt, including
reason and the saved attempt's `outboxIds`. No automatic
retry is scheduled. The reasons are:
- `typing_guard_enabled`: the seat's mode holds this send (`hold`, or a level that
holds this kind of message).
- `runtime_switch_in_progress`: the seat is switching runtime.
- In `draft-aware` mode, a refusal before any input was written:
`draft_input_busy` (the input holds unsubmitted text), `draft_input_unknown` (the
input can't be read), `draft_input_changed` (the input or its target changed during
the delivery), `draft_wake_superseded`, `target_needs_input`,
`wait_for_idle_timeout`, `target_activity_unknown`, `target_runtime_unverified`,
`target_runtime_not_running`, `target_runtime_conflict`, `invalid_wait_for_idle`,
`invalid_dangerously_interact`, `dangerously_interact_requires_reason`,
`prompt_override_audit_unavailable`, `transport_unavailable`, `session_missing` or
`tmux_unavailable`. Each explicit draft-aware request is a new attempt, including
one with a reused `deliveryId`; the result warns older callers that this field no
longer deduplicates input. The CLI does not generate a deduplication key.
@@ -160,6 +240,26 @@ false` and the protection reason, rather than claiming durable custody.
Reading never sends. Retirement frees that record's active retention quota while
preserving evidence; it does not deliver a message or close queue work.
A seat holds one message per sender, so one sender can't fill its held quota and
drop everyone else's messages. A sender's newest held message retires its earlier
ones at that seat into itself (`retired_by: outbox@system`, reason `coalesced into
<id>`), in every mode, `draft-aware` included. The newest names them: `rig seat
held-messages` shows `coalesced: { count, outboxIds }` on it, and each coalesced
record stays readable with `--id`. Held messages are never replayed, so this changes
no delivery. A `draft-aware` attempt takes its sender's place only once it settles
as held; one that is then delivered leaves the held messages as they are. A queue
wake to a `draft-aware` seat counts its sender's held message there as room, by the
same rule. At a full quota, a sender with nothing held there is still refused, and
nothing from another sender is touched. A committed wake is never refused, and the
sender's next held message coalesces it like any other. A message that settles as
held late, behind a newer held send, folds into that send's newest part and leaves
its other parts held. An urgent message coalesced under a routine one passes its
marking to the newest. Never coalesced: the other parts of the same send, a
watchdog's reminders (one per job), and an unattributed sender.
A backlog held before this change clears at that sender's next held message to the
seat, or at once with `rig seat retire-held-message`.
## Input evidence and outcomes
The cursor-aware reader lives in `session-transport.ts` and shares the composer
@@ -260,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.
+89 -15
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
@@ -99,8 +106,24 @@ So after installing, compare the granted scopes Slack shows for the app with all
`rig slack manifest --json` lists.
The app subscribes to messages in public channels it is a member of (`message.channels`), to
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). It does not request
direct-message or private-channel access.
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). The manifest does
not request direct-message or private-channel access.
### Using a private channel
The connector doesn't check whether a channel is public or private, so a private channel can be
the default channel or a mapped one once the app can read it. OpenRig doesn't test this setup. In
the app's settings:
1. Under **OAuth & Permissions**, add the bot scopes `groups:history` (read messages in private
channels the app is in) and `groups:read` (read a private channel's details; `rig slack verify`
needs it to confirm the app is a member).
2. Under **Event Subscriptions**, subscribe to the bot event `message.groups`.
3. Reinstall the app to the workspace, then invite it to the private channel.
4. To have `rig slack verify` require the new scopes too, run
`rig slack setup --required-scopes chat:write,channels:history,channels:read,groups:history,groups:read`.
Direct messages are not supported.
A reaction added to an ask reaches the agent that asked, as a note naming who reacted and with
which emoji; the agent decides what it means. Every message OpenRig posted for the ask counts:
@@ -166,6 +189,9 @@ rig slack channel-map list
rig slack channel-map remove pr@my-rig
```
A client can offer the channels to choose from with `GET /api/gateway/slack/channels`, which lists the public
channels the bot token can see, with their ids ([Writing a daemon client](daemon-http-clients.md#route-details)).
- **Which channel an item posts to.** The item's seat is matched exactly first (`pr@my-rig`),
then its rig (`my-rig`). Anything unmapped posts to the default channel, so a config
without a map behaves exactly as before. Aggregate delivery digests have no single seat and
@@ -221,8 +247,9 @@ configuration matches the file.
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
entries, each with an optional `"inbound"` seat. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
connector reads the file, and `rig slack status` names it. Private channels and direct messages
are not supported targets.
connector reads the file, and `rig slack status` names it. A private channel can be a target once
the app has the private-channel scopes ([Using a private channel](#using-a-private-channel)).
Direct messages are not supported targets.
## Named bots (optional)
@@ -329,8 +356,7 @@ decisions, and posts its own replies.
- **Changing forwarding entries is not a drain or cancellation:** relays already retained keep
their original host and remain visible in `rig slack status`, even after the last entry is
removed. An enabled connector retries them; disabling it pauses retry without deleting them.
Let relays finish before removing a forward entry. A zero dead-letter backlog alone does not
prove they have drained: it does not count relay HTTP requests still in flight.
Let relays finish before removing a forward entry; `rig slack status` shows local in-flight attempts separately from retained records, but zero does not prove global quiescence.
- **Who answered:** every post starts `From <seat> on <machine>`, using the machine's host id (set
it with `host.name`).
- **The hub's own channels** are handled on the hub, as before. A channel can be relayed to one
@@ -358,14 +384,53 @@ 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
When a posted ask is answered, closed, canceled or replaced elsewhere, the connector edits its
original primary message with the status, time, actor and closure note. Buttons become option
text and the answer hint disappears; the remaining content and reconciliation marker stay as
posted. Supplemental messages are unchanged. This adds no post or reaction and needs no agent
turn. Separate resolution notices retain their existing behavior.
A deliberately reopened direct ask says **Open again**; its options remain as text. If it has
no committed answer, it tells the person to reply in its thread. Otherwise it says **Earlier
answer retained**: reopening does not accept a second answer. Earlier replaced parks remain
historical; **Asked again** does not claim that the later ask has been posted. Edits reconcile
the current state again after awaited Slack calls, so an obsolete close is not recorded as the
current result after a reopen.
Edits follow committed queue changes, with a startup catch-up for posted asks on retained queue
rows. Each primary costs one exact-message read and one `chat.update`. The serial edit worker
uses the recovery budget helpers (four calls, 100 entries, 15 seconds) and waits for the next
budget window when exhausted. Slack's per-method `Retry-After` is retained across restarts.
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
an archived channel or an uneditable message) remain unfinished. 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
counts as an edit. An unavailable/deleted original cannot be replaced by a new post. Startup
catch-up cannot edit rows already removed by queue retention or posts without their original
notification receipt and channel stamp.
The daemon scans available top-level messages and replies broadcast with **Also send to
channel** in the channels this machine owns: its default channel and its channel map's, except a
channel it relays to another machine. It also scans replies in the threads it tracks in those
@@ -405,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).
@@ -416,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
+64
View File
@@ -115,3 +115,67 @@ successful empty history. The CLI preserves the error body in JSON mode and exit
1 for request errors, 2 for unavailable reads. Existing SSE and usage APIs retain
their behavior. Collector deployment, exporters and durable store epochs are
separate work.
## Independent exporter mapping and limits
OpenRig's telemetry guarantee ends at the finite HTTP/CLI read envelope above. Any
projection of those finite reads (or of cached rig/node inventory) into
OpenTelemetry logs, custom metrics, or spans is defined and owned by an external
consumer or exporter—not guaranteed by OpenRig's core API.
The external OTLP exporter at `smota/openrig-telemetry` applies the following
exporter-specific mapping choices to OpenRig's source facts (other consumers may
make different choices):
- **Recorded queue-state intervals (not execution time)**: In OpenRig, queue
transition rows record state changes (`pending`, `in-progress`, `blocked`, and
departure states), not agent execution duration or model latency; the first
observation of an item has no prior boundary; repeated same-state rows remain
distinct source records; and any page carrying an ID-range or withheld-record gap
(`before_retained_floor`, `retained_sequence_gap`, `conflicting_transition_id`,
`record_byte_limit`, `historical_transitions_not_read`, truncated gap details, or
a boot/watermark discontinuity) cannot prove what happened in the missing range.
In `smota/openrig-telemetry`, the exporter pairs an entering state with a later
departure state to measure only the recorded interval between two observed rows
with valid, ordered UTC timestamps, suppresses or drops open intervals across gap
boundaries rather than inventing durations, and reports cumulative transition
counts only for rows observed from its checkpoint (including historical rows when
started with `start=retained`), never lifetime daemon totals.
- **No current-owner, seat, tenure, or native-session join**: In OpenRig's finite
API, the `queue-transitions` stream does not join transition rows to current seat
owners, mutable node or rig names, occupant tenures (`tenures`), or native
harness sessions, and it never selects queue bodies or transition notes; each
transition row does include `qitemId`, `actorSession`, `closureTarget`, and
`originalTimestamp`. In its exported OTLP output, `smota/openrig-telemetry`
chooses to emit derived queue spans as root/internal observations without
invented parent/child trace hierarchy, cross-stream causal links, or `gen_ai.*`
model/token/cost attributes; places `qitemId` only on opt-in log/span attributes
rather than metric dimensions; and omits the source fields `actorSession`,
`closureTarget`, and raw `originalTimestamp` from exported OTLP logs, metrics,
and spans.
- **Sampling unknowns on cached seat inventory**: OpenRig's cached rig and seat
inventory endpoints (`GET /api/rigs`, `GET /api/rigs?archived=only`,
`GET /api/rigs/:rigId/nodes`) return point-in-time daemon projections
(`activityState.display`, `sessionStatus`, `arbitratedNeedsInput`, and
`activityState.lastSwap`), not a continuous event stream or independent OS
process probe; intermediate activity changes or multiple occupant handovers
between polls are not observable from those snapshots, and initial
observation has no backfilled history. In OpenRig source,
`arbitratedNeedsInput` is a tri-state boolean (`true`, `false`, or `null`):
`true` means the seat needs input, `false` means it was observed clear of
needing input, and `null` means no evidence exists either way (it does not
indicate `false`). Separately, external consumers or metric mappings must
choose how to interpret or project this tri-state value into their own
metrics or counters. `smota/openrig-telemetry` polls these snapshots as
point-in-time observations and relies on downstream metric storage to enforce
freshness/staleness TTL when polling stops or a seat departs.
### External OTLP exporter repository and version compatibility
An independent, standalone OTLP/HTTP exporter is maintained outside OpenRig core at:
- **Repository**: <https://github.com/smota/openrig-telemetry>
Its repository documents installation, configuration, and supported OpenRig versions; OpenRig does not test or guarantee the exporter.
OpenRig's finite telemetry reads are versioned (`schemaVersion: 1`), while cached rig and node inventory endpoints are operational projections rather than a versioned telemetry contract.
+59 -19
View File
@@ -72,11 +72,11 @@ verified viewer mapping.
New tiles keep their place when a seat's tmux session is replaced. They wait up
to 30 seconds for the same session name and managed seat identity to return,
then reattach with the original read-only setting. This uses the shared tile
command for Herdr, cmux, Rex and plain tmux, including SSH tiles. In-place
handover and restart keep the existing attachment.
then show the new session with the original read-only setting. This uses the
shared tile command for Herdr, cmux, Rex and plain tmux, including SSH tiles.
In-place handover and restart keep showing the same session.
A deliberate detach closes the tile after about two seconds. A different seat
Detaching a tile you typed in returns it to showing the seat. A different seat
under the old name is never followed. If no replacement returns in time, or a
session has no managed identity, reopen the rig view with
`rig terminal open rig:<name> --window` (or your explicit provider). Views
@@ -153,6 +153,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 }`.
@@ -222,11 +226,26 @@ after a grace period; the agent and its conversation keep running. The
explanation strip names joins, departures, unavailable terminals and limits.
The first workspace on an empty provider may become focused.
This increment supports the qualified **Linux Herdr 0.9.5/protocol 22**
endpoint. Other platforms/providers and other selector kinds retain ordinary
one-shot open. Use `--provider herdr --json` to operate without opening a
desktop window, and `--session <name>` to select that endpoint. Opening a tile
can change the agent's terminal size; `ignore-size` does not guarantee isolation.
Live following supports **Linux and macOS with Herdr protocol 22**. Admission
checks the live protocol and selected socket, then confirms each pane's IDs,
focus state and foreground attachment. A shell startup `cd` does not change
pane identity; filesystem aliases of the same existing path are accepted.
If a creation response is lost and shell startup changes the directory, Core
may be unable to attribute that pane. It reports uncertainty and does not
repeat the creation.
The detected Herdr version remains visible in provider status. The native
qualification version is 0.9.5; a matching release label alone is not proof of
compatibility, and future protocol numbers are not assumed compatible.
If a new view's platform or protocol cannot support following, Core opens a
labelled **snapshot** instead and disables automatic following. It will not
track later owner changes. Incomplete membership prevents that snapshot from
opening; the result explains why. A failed endpoint/identity read or an existing
canvas stays unconfirmed, preserving it instead of creating a fallback view.
Other providers and selector kinds retain ordinary one-shot open. Use
`--provider herdr --json` to operate without opening a desktop window, and
`--session <name>` to select that endpoint. Typing in a tile can change the
agent's terminal size while you type.
```bash
rig terminal open qitem:example --follow --provider herdr --json
@@ -284,11 +303,17 @@ operation uses the daemon's existing terminal bearer and browser boundary;
a bridge must retain its signed-in owner, Origin and expiry controls. It does
not grant authority based on task membership or accept a caller terminal ID.
On the qualified Linux Herdr 0.9.5/protocol 22 endpoint, it creates one viewer
On a supported Linux or macOS Herdr protocol-22 endpoint, it creates one viewer
or reuses an owned, verified `{workspaceId,paneId,terminalId,readOnly}` mapping.
A repeated/concurrent request does not create another viewer. A lost create
reply is reconciled before starting anything else; a lost run reply is checked
against the actual foreground process, never blindly repeated. Unknown or
A repeated/concurrent request does not create another viewer. Each viewer's
private `attach.sh` is kept for provider restarts. `--no-follow` preserves the
workspace and its scripts; stopping Herdr is not proof that its panes are gone.
Core removes only an exact, unedited owned script after confirming its viewer
is absent. Old views that are never reconciled again can therefore accumulate
scripts; there is no cleanup based only on an unreachable provider.
A lost create reply is reconciled before starting anything else; a lost run
reply is checked against the actual foreground process, never blindly repeated. Unknown or
changed terminal identity remains unconfirmed. The operation does not relayout,
close departed viewers, take over control, focus existing panes or launch/stop
agents. A first workspace on an otherwise empty Herdr server may become focused.
@@ -300,12 +325,27 @@ small presentation provenance, separate from queue membership. Herdr display
labels are not that provenance. Other providers/platforms remain unsupported
for ensure-viewer until qualified; ordinary open retains its existing behavior.
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
`-r` (which also implies ignore-size). This can avoid contributing to sizing
when other clients establish the agent window's dimensions. It is **not resize
isolation**: opening a tile can change the agent's terminal size when it is the
only sizing client. Observers of a Herdr terminal do not themselves resize it;
a controlling browser can. No cross-version resize guarantee is implied.
A tmux window has one size, and a tmux client narrower than it sees only the
left part of each line. So a tile shows its seat without attaching to it: about
once a second it reads the seat's screen with `tmux capture-pane`, redraws only
after tmux records new activity, and lets the tile's own terminal wrap each
line. A tile narrower than the agent's window still shows whole lines, and
showing a seat never changes its size. A rule drawn across the window is cut to
the tile's width. A read-only tile only shows the seat. In a writable tile, the
first thing you type or paste attaches the tile and goes through tmux as it
would in any attached client: a paste arrives as one paste, and tmux's prefix
works (prefix then `[` to scroll back, for example). The tile sizes the window
while you use it, as any client you type in does; after 30 seconds without a
key it detaches and shows the seat again. A tile that's only showing has no
scrollback of its own. If the seat's tmux server restarts, a tile shows the
seat again only when the same managed seat returns. The printed manual attach commands
keep `-f ignore-size`. Observers of a Herdr terminal do not themselves resize
it; a controlling browser can. No cross-version resize guarantee is implied.
To type into a seat without changing its size, send the text through OpenRig
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
buffers and keys, and no client attaches. There is no supported way to attach a
writable client that is sure never to resize the agent's window.
## Following a slice or mission's current work
+48
View File
@@ -87,6 +87,37 @@ server, start the upgraded daemon from that original server to establish its
record, or stop those seats before changing servers. Earlier versions did not
persist enough information to find an arbitrary old socket from a different shell.
## Moving an existing team onto tmux control mode
`OPENRIG_TMUX_CONTROL=1` in the daemon's environment sends its routine tmux commands
through one long-lived control-mode client instead of a new tmux client each time,
which saves a process start per command on a busy host. It takes effect only on a
tmux server at 3.7c or later. The version of the server that is running decides,
not the tmux binary on `PATH`, and a server keeps the version it started with until
it exits. So installing a newer tmux changes nothing for a server that is already up.
On an older server the daemon uses a spawned client for every command. It says so
once in its log (`[tmux] OPENRIG_TMUX_CONTROL=1 is set, but this daemon's tmux server
runs tmux 3.4 …`), and `rig doctor` reports it as `tmux_control_mode`.
To move a team that runs on an older server:
1. Install tmux 3.7c or later where the daemon finds it first on its `PATH`. For a
daemon run as a service, that's the service's environment.
2. Choose the server the team will use. `isolated` gives this OpenRig home a server of
its own, started by the daemon with that tmux, and leaves your own tmux sessions
alone: `rig config set terminal.tmux_server isolated`. In `inherit` mode the team
shares your default server, which runs the new version only after every session
on it, yours included, has ended.
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
turn in progress stops. Queue rows, rigs and seats stay as they are.
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
`rig terminal status --json` should show `migrationPending: false`. If it's still
`true`, `blockingSessions` names the sessions still on the old server.
5. Bring each rig back with `rig up <rig> --existing`. Each seat resumes its
conversation where its harness allows, under the restoration rules above.
6. Check: the daemon's log says `[tmux] Control mode is on`, and `rig doctor` passes
`tmux_control_mode`.
## Attachments, discovery and cleanup
`rig capture`, managed launch/stop/recovery, terminal previews and desktop viewers
@@ -164,3 +195,20 @@ whose version cannot be recognized as a fixed release, OpenRig uses spawned
clients instead: starting a control client while another session is created can
crash those servers (#2304). This avoids creating OpenRig's own control client;
it cannot protect the server from other tools' control clients.
On a large fleet, the daemon's two seat-activity pollers (the activity sampler and the
structural pane cache) are a large share of its tmux traffic: each runs once a second.
The setting `daemon.seat_poll_interval_ms` slows both down, from the 1000 ms default up to
2000 ms. Set it with `rig config set daemon.seat_poll_interval_ms 2000` (saved in
`config.json`; `rig config` shows it with its source) or with `OPENRIG_SEAT_POLL_INTERVAL_MS`,
which overrides the saved value. It is startup-only: the daemon reads it once when it starts,
so a change takes a daemon restart. `rig config set` refuses a value outside 1000..2000; an
environment value outside it is dropped with a warning and the daemon keeps the saved value or
the default.
A slower cadence makes activity and idle changes show up later, and a Codex permission prompt
can also show up as much as one interval later. Keep the default if a structural sweep of your
tmux server can take more than about 2 s (about 3 s at the default): past that, a sweep that
runs over skips the next tick, the Codex working-frame proof expires, and a working Codex seat
can briefly read idle. At 2000 ms the remaining 1 s margin is also what a lagging event loop
uses up. The two settings compose: `OPENRIG_TMUX_CONTROL=1` makes each tmux command cheaper,
and this one makes there be fewer of them.
+19
View File
@@ -89,6 +89,25 @@ after an upgrade it can still hold the previous version's note.
## Behaviour that changed
- **Old history-only events can be pruned, if you choose to.** `retention.events_days` (default `0`, which keeps every
event) turns on a pass in the retention sweep (at daemon start and daily) that deletes `view.changed`,
`snapshot.created`, `watchdog.evaluation_fired` and the `queue.created`, `queue.updated`, `queue.claimed`,
`queue.unclaimed` and `queue.handed_off` events older than that many days; 14 is a reasonable value
(`rig config set retention.events_days 14`). Every other type is kept at any age, because restore, startup proof and
the seat readers fold from them, and the newest event is always kept. What you give up: pruned events no longer
replay from `/api/events` (an older cursor or a large `?replay`), no longer appear in `rig telemetry events` (which
reports the gap) or in older log and feed entries, and a later change of the setting can't bring them back. A new
value is read at the next sweep: the daily one, or the next daemon start. The first sweep after turning it on works
through the old history in short steps while the daemon keeps answering. `retention.enabled false` stops every
retention pass from the next daemon start.
- **The database file does not shrink by itself.** Pruned rows free pages that new events reuse, which slows the file's
growth; other tables still grow. To give the space back to the disk: find the file with `rig config get db.path`, stop the daemon
(`rig daemon stop`), copy the file aside, run `sqlite3 <that file> "VACUUM;"` (it needs free disk about the size of
the file and can take a few minutes), then `rig daemon start`. Never vacuum the file of a running daemon.
- **With pruning on, `rig telemetry events` reports the pruned ranges.** A page that spans pruned events inside the
retained window has a `retained_sequence_gap` for each missing seq range and `coverage.status` `partial` (it used to be
`retained_window`); events pruned from the start of the log show up as `before_retained_floor`.
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
+37
View File
@@ -4260,6 +4260,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
@@ -5230,6 +5237,18 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -5531,6 +5550,16 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -5839,6 +5868,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true,
"license": "ISC"
},
"node_modules/smol-toml": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
@@ -7317,6 +7353,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0",
"@types/tar": "^6.1.13",
"proper-lockfile": "^4.1.2",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
+1
View File
@@ -19,6 +19,7 @@
"typecheck:prep": "npm run build -w packages/daemon",
"lint": "npm run typecheck:prep && tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json && tsc --noEmit --project packages/tui/tsconfig.json",
"build:package": "bash scripts/build-package.sh",
"pack:release": "node scripts/pack-release.mjs",
"test:tui-package": "node scripts/test-tui-package.mjs",
"mirror-skills": "node scripts/mirror-skills.mjs",
"mirror-skills:check": "node scripts/mirror-skills.mjs --check",
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Mike Schwarz
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+36
View File
@@ -0,0 +1,36 @@
# @openrig/acp
OpenRig as an agent in apps that speak the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), such as
Zed. Your prompt goes to your OpenRig team as a queue row. The app shows who has the work
and their notes as it moves, any question the team asks you as a choice, and the team's result as the reply.
This package has one command, `openrig-acp`. It starts `rig acp serve` from the `@openrig/cli` version it depends on,
and warns on stderr if it finds another, so ACP apps and the ACP registry can launch it with `npx`.
## What you need
- OpenRig on this computer, with its daemon running (`rig daemon start`) and a team.
- The seat that receives your team's new work, set once with `rig config set acp.to <member@rig>`, or passed as
`--to <member@rig>`.
- You, as a person OpenRig knows: register with
`rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>` (the only registered person
is the default; with several, choose one with `rig gateway default <id>`), or pass `--as <address>`. The team's
questions reach an `<id>@external` address only once it's registered.
The app's sign-in step, "OpenRig on this machine", checks that the daemon answers and that it can tell who you are.
This agent talks to the same OpenRig daemon as `rig` on this computer: the local one, unless `OPENRIG_URL` names another.
## In an app, without the registry
Add a custom agent whose command is `npx` with the arguments `@openrig/acp@<version>`, plus `--to <member@rig>` if
you haven't set `acp.to`. In Zed's `settings.json`:
```json
"agent_servers": {
"OpenRig": { "type": "custom", "command": "npx", "args": ["@openrig/acp@<version>", "--to", "lead@my-team"] }
}
```
If `rig` is already installed, `rig acp serve` is the same agent without this package.
Guide: `rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`.
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// OpenRig as an agent in ACP apps. The ACP registry starts an agent with `npx <package>@<version>`, and npx needs a
// package with one executable: @openrig/cli has two. This one starts `rig acp serve` from the @openrig/cli this package
// depends on, as Node resolves it (never a `rig` on the PATH), with the app's stdin and stdout, arguments, environment
// and working directory, and exits as it exits. Only ACP messages may reach stdout, so anything this file says goes to
// stderr.
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
let rig;
try {
const manifestPath = createRequire(import.meta.url).resolve("@openrig/cli/package.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
// Module resolution can reach a different @openrig/cli further up when this package's own copy is missing. That copy
// most likely still works, and one that doesn't fails visibly, so say which version runs and run it.
const wanted = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).dependencies["@openrig/cli"];
if (manifest.version !== wanted) {
console.error(`openrig-acp: found @openrig/cli ${manifest.version} at ${dirname(manifestPath)}, not the ${wanted} this package pins; running it anyway. Reinstall @openrig/acp to get ${wanted}.`);
}
rig = join(dirname(manifestPath), manifest.bin.rig);
} catch (error) {
console.error(`openrig-acp: @openrig/cli isn't installed beside this package (${error.message}). Reinstall @openrig/acp.`);
process.exit(1);
}
const child = spawn(process.execPath, [rig, "acp", "serve", ...process.argv.slice(2)], { stdio: "inherit" });
const forward = (signal) => child.kill(signal);
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, forward);
child.on("error", (error) => {
console.error(`openrig-acp: couldn't start rig acp serve: ${error.message}`);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) {
// End the same way the server did, so the app sees the signal rather than an exit code.
process.removeListener(signal, forward);
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@openrig/acp",
"version": "0.7.1",
"description": "OpenRig as an agent in ACP apps: starts rig acp serve from the @openrig/cli it pins.",
"type": "module",
"bin": {
"openrig-acp": "bin/openrig-acp.js"
},
"files": [
"bin",
"LICENSE",
"README.md"
],
"engines": {
"node": ">=22"
},
"dependencies": {
"@openrig/cli": "0.7.1"
},
"license": "Apache-2.0",
"author": {
"name": "mvschwarz",
"url": "https://esoteric.run"
},
"homepage": "https://openrig.dev",
"repository": {
"type": "git",
"url": "https://github.com/mvschwarz/openrig",
"directory": "packages/acp"
},
"keywords": [
"ai",
"agents",
"acp",
"agent-client-protocol",
"openrig"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "openrig",
"name": "OpenRig",
"version": "0.7.1",
"description": "Send work to your OpenRig team of agents and follow it through to the result",
"repository": "https://github.com/mvschwarz/openrig",
"website": "https://openrig.dev",
"authors": [
"mvschwarz"
],
"license": "Apache-2.0",
"license_url": "https://github.com/mvschwarz/openrig/blob/main/LICENSE",
"distribution": {
"npx": {
"package": "@openrig/[email protected]"
}
}
}
+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="8 -754 596 808" fill="currentColor"><g transform="scale(1,-1)"><path d="M306.0 -14.0Q426.0 -14.0 495.0 59.5Q564.0 133.0 564.0 272.0V428.0Q564.0 563.0 495.0 638.5Q426.0 714.0 306.0 714.0Q186.0 714.0 117.0 638.5Q83.32444763183594 601.652099609375 66.08428192138672 550.631591796875L195.21009826660156 523.18505859375Q207.8548583984375 554.14111328125 233.0 573.0Q261.0 594.0 306.0 594.0Q351.0 594.0 378.5 573.0Q406.0 552.0 419.0 516.0Q432.0 480.0 432.0 436.0V264.0Q432.0 194.0 405.0 150.0Q378.0 106.0 306.0 106.0Q234.0 106.0 207.0 150.0Q180.0 194.0 180.0 264.0V436.0Q180.0 461.6716613769531 184.25511169433594 484.6199951171875L55.7750358581543 511.9292907714844Q48.0 473.3169250488281 48.0 428.0V272.0Q48.0 133.0 117.0 59.5Q186.0 -14.0 306.0 -14.0ZM138.0 176.0C138.0 162.7451629638672 127.25483703613281 152.0 114.0 152.0C100.74516296386719 152.0 90.0 162.7451629638672 90.0 176.0C90.0 189.2548370361328 100.74516296386719 200.0 114.0 200.0C127.25483703613281 200.0 138.0 189.2548370361328 138.0 176.0Z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+540
View File
@@ -0,0 +1,540 @@
import { randomBytes } from "node:crypto";
import type { JsonRpcPeer } from "./json-rpc-stdio.js";
// OpenRig as an ACP agent (rig acp serve): an ACP app's prompt becomes a queue row to the team's receiver, the row's
// transitions and handoffs come back as progress, and its closing resolution is the reply. A question the team asks the
// caller while it works comes to the app as a permission request, and the choice goes back through the person's answer
// path. The queue is the only work store: a session is the rows tagged with its id, so it can be listed and reopened.
// A turn's end_turn says only that the turn ended: the reply says whether the team closed the item, and a cancel stops
// this process waiting, never the team.
export const ACP_PROTOCOL_VERSION = 1;
export const AUTH_METHOD_ID = "openrig-local";
export const SESSION_TAG = "acp-session:";
export const CWD_TAG = "acp-cwd:";
/** Where the caller's prompt ends in a row's body; the rest is the note rig acp serve adds for the team. */
const FOOTER = "\n\n---\nSent by ";
/** One queue row as this adapter reads it. */
export interface TeamRow {
qitemId: string;
state: string;
destinationSession: string;
handedOffTo: string | null;
closureReason: string | null;
closureTarget: string | null;
resolution: string | null;
/** Who closed the row, from its closing transition (a note written after the close doesn't change it); null while open. */
closer: string | null;
}
export interface TeamTransition {
transitionId: number;
ts: string;
state: string;
transitionNote: string | null;
actorSession: string;
}
/** A row to the caller that asks for a decision, with the choices it offers (none: words only). */
export interface TeamAsk {
qitemId: string;
sourceSession: string;
state: string;
tsCreated: string;
summary: string | null;
body: string;
tags: string[];
questions: Array<{ id: string; question: string; options: Array<{ id: string; label: string; recommended?: boolean }> }>;
/** What's recorded so far, by question id: the chosen option's id, or the words of a typed reply. */
answers: Record<string, string>;
}
/** A row the caller sent through rig acp serve. */
export interface SentRow {
qitemId: string;
tsCreated: string;
tsUpdated: string;
summary: string | null;
body: string;
tags: string[];
}
/** The daemon answered a create and refused it, so nothing was written. Any other failure of a create leaves it unknown. */
export class TeamRefused extends Error {}
/** The queue verbs the adapter uses, acting as the caller. */
export interface TeamQueue {
/** Create the row under the id the adapter chose, so a create whose answer is lost can be read back by that id. Throws
* TeamRefused only for a refusal the daemon answered with. */
create(row: { qitemId: string; destination: string; body: string; summary: string; tags: string[] }): Promise<void>;
show(qitemId: string): Promise<TeamRow>;
/** The row, or null when this machine has no such row; throws when it can't tell. */
find(qitemId: string): Promise<TeamRow | null>;
transitions(qitemId: string): Promise<TeamTransition[]>;
/** The row a handoff of `qitemId` created on this machine, if any. */
successor(qitemId: string): Promise<string | undefined>;
/** Open asks addressed to `person`. */
openAsks(person: string): Promise<TeamAsk[]>;
/** Every ask to `person` tagged for one chat, open or settled, to replay with it. */
sessionAsks(person: string, sessionId: string): Promise<TeamAsk[]>;
/** Record the caller's choice on an ask, through the person's answer path; a refusal comes back with its reason. */
answer(qitemId: string, questionId: string, optionId: string): Promise<{ ok: true } | { ok: false; reason: string }>;
/** Rows the caller sent through rig acp serve. */
sent(caller: string): Promise<SentRow[]>;
}
/** What a session works through: the queue as the caller, and the seat that receives the caller's tasks (when set). */
export interface TeamConnection {
queue: TeamQueue;
caller: string;
receiver: string | undefined;
/** Something about this connection the person should know, said once in each session before its first prompt's work. */
notice?: string;
}
export interface AcpAgentOptions {
/** Reaches the daemon (`rig`'s: the local one, or OPENRIG_URL's) and resolves the caller; returns guidance when it can't.
* Called lazily, never at initialize. */
connect: () => Promise<TeamConnection | { error: string }>;
version: string;
pollMs: number;
log?: (line: string) => void;
sleep?: (ms: number) => Promise<void>;
/** Settles when the app's connection ends: open turns stop following their work (nothing could carry a reply). */
closed?: Promise<void>;
}
const ACTIVE_STATES = new Set(["pending", "in-progress", "blocked"]);
/** How long a cancel waits for the app to settle questions it was showing (ACP has the client answer them cancelled). */
const QUESTION_SETTLE_MS = 2000;
const AUTH_REQUIRED = -32000;
const INVALID_PARAMS = -32602;
class AcpError extends Error {
constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); }
}
interface Session {
id: string;
cwd: string;
/** The first row each prompt created, oldest first, with the last row its work reached. */
sent: Array<{ root: string; tail: string }>;
turn?: { cancel: () => void };
noticed?: boolean;
}
type ContentBlock = { type: string; text?: string; uri?: string; name?: string; resource?: { uri?: string; text?: string } };
/** The questions one turn has put to the app. Once the turn has ended, nothing from them reaches the app or the queue. */
interface TurnQuestions {
finished: boolean;
/** The person cancelled the turn: no further question opens, though one the app is showing still settles. */
cancelled: boolean;
/** Questions the app is still showing, by tool call id, each with how the chat names it. */
outstanding: Map<string, string>;
tasks: Set<Promise<void>>;
}
type Update = Record<string, unknown>;
/** The prompt as plain text for the row's body: text as written, links and embedded text resources named. */
export function promptText(prompt: ContentBlock[]): string {
const parts: string[] = [];
for (const block of prompt) {
if (block.type === "text" && block.text) parts.push(block.text);
else if (block.type === "resource_link" && block.uri) parts.push(`[${block.name ?? "link"}](${block.uri})`);
else if (block.type === "resource" && block.resource?.uri) {
parts.push(block.resource.text !== undefined ? `${block.resource.uri}:\n\`\`\`\n${block.resource.text}\n\`\`\`` : block.resource.uri);
}
}
return parts.join("\n\n").trim();
}
/** A queue row id in the form the CLI mints (`qitem-<UTC yyyymmddhhmmss>-<16 hex>`). */
export const newQitemId = () => `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
function oneLine(text: string, max: number): string {
const line = text.split("\n").find((l) => l.trim())?.trim() ?? "";
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
}
/** The person's own words in a prompt, for the row's summary and the session's title. An app that wraps them in its
* own instructions marks them (T3 Code sends several blocks of harness text, then `<user_request>…</user_request>`);
* the row's body keeps everything the app sent. Only a prompt with one opening and one closing mark is read this
* way: with more, an example could be taken for the request, so the prompt stands as sent. */
function personsWords(text: string): string {
if (text.split("<user_request>").length !== 2 || text.split("</user_request>").length !== 2) return text;
const marked = /<user_request>([\s\S]*?)<\/user_request>/.exec(text)?.[1];
return marked?.trim() ? marked : text;
}
const toolStatus = (state: string) =>
state === "pending" ? "pending" : ACTIVE_STATES.has(state) ? "in_progress" : state === "done" || state === "handed-off" ? "completed" : "failed";
const transitionLine = (t: TeamTransition) =>
`${t.ts.slice(11, 19)}Z ${t.actorSession} · ${t.state}${t.transitionNote ? `: ${t.transitionNote}` : ""}`;
const textContent = (text: string) => [{ type: "content", content: { type: "text", text } }];
/** Where one prompt's work stands after a read of its rows. */
type ChainState = { kind: "open"; tail: TeamRow } | { kind: "ended"; tail: TeamRow; reply: string };
/** Follows one prompt's work through its rows: each row is a step, and its new notes are emitted as they appear. */
class ChainFollower {
private readonly chain: string[];
private readonly seen = new Map<string, { lines: string[]; lastId: number; status: string; title: string }>();
/** The seats the work has reached; a question from one of them during the turn belongs to it. */
readonly seats = new Set<string>();
constructor(private readonly queue: TeamQueue, private readonly emit: (update: Update) => void, readonly root: string) {
this.chain = [root];
}
async read(): Promise<ChainState> {
for (;;) {
let tail!: TeamRow;
for (const qitemId of this.chain) {
const row = await this.queue.show(qitemId);
const transitions = await this.queue.transitions(qitemId);
tail = row;
this.seats.add(row.destinationSession);
const status = toolStatus(row.state);
let entry = this.seen.get(qitemId);
if (!entry) {
const title = qitemId === this.root ? `Sent to ${row.destinationSession} (${qitemId})` : `Handed to ${row.destinationSession} (${qitemId})`;
entry = { lines: [], lastId: 0, status, title };
this.seen.set(qitemId, entry);
this.emit({ sessionUpdate: "tool_call", toolCallId: qitemId, kind: "other", status, title, rawInput: { qitemId, destination: row.destinationSession } });
}
const fresh = transitions.filter((t) => t.transitionId > entry!.lastId);
if (fresh.length > 0 || status !== entry.status) {
for (const t of fresh) { entry.lines.push(transitionLine(t)); entry.lastId = Math.max(entry.lastId, t.transitionId); }
entry.status = status;
// An app that shows a step collapsed still shows its title, so the latest note leads it: a narrow panel cuts the end.
const noted = fresh.filter((t) => t.transitionNote).at(-1);
this.emit({
sessionUpdate: "tool_call_update", toolCallId: qitemId, status,
...(noted ? { title: `${noted.actorSession}: ${oneLine(noted.transitionNote!, 100)} · ${entry.title}` } : {}),
...(entry.lines.length > 0 ? { content: textContent(entry.lines.join("\n")) } : {}),
});
}
}
// Both handoff forms pass the work on: `handoff` leaves the row handed-off, `handoff-and-complete` closes it done
// with closure reason handed_off_to. Either way the answer is the successor's, not this row's.
if (tail.state === "handed-off" || (tail.state === "done" && tail.closureReason === "handed_off_to")) {
const next = await this.queue.successor(tail.qitemId);
if (next) { this.chain.push(next); continue; }
return { kind: "ended", tail, reply: `${tail.qitemId} was handed to ${tail.handedOffTo ?? tail.closureTarget ?? "another seat"}, whose row isn't on this machine, so this app can't follow it further. Ask your team for its result.` };
}
if (ACTIVE_STATES.has(tail.state)) return { kind: "open", tail };
// A done row's closure reason says how it ended (canceled is a done close too); the ordinary one goes unsaid.
const reason = tail.closureReason && tail.closureReason !== "no-follow-on" ? ` (${tail.closureReason})` : "";
const how = tail.state === "done" ? `done${reason}` : `as ${tail.state}${reason}`;
return { kind: "ended", tail, reply: `${tail.closer ?? tail.destinationSession} closed ${tail.qitemId} ${how}.\n\n${tail.resolution?.trim() || "(No resolution was written.)"}` };
}
}
}
export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void {
const sessions = new Map<string, Session>();
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
const log = options.log ?? (() => {});
let clientName = "an ACP app";
/** The app asks forms (ACP `elicitation/create`, mode form), so a question's choices go to it as a form, each by its
* label. A permission request shows them only in apps that list each option: T3 Code shows a fixed Approve, which
* sends the first. */
let asksForms = false;
let connection: TeamConnection | undefined;
void options.closed?.then(() => { for (const session of sessions.values()) session.turn?.cancel(); });
const connected = async (): Promise<TeamConnection> => {
if (connection) return connection;
const result = await options.connect();
if ("error" in result) throw new AcpError(AUTH_REQUIRED, result.error);
connection = result;
return result;
};
const update = (sessionId: string, body: Update) => peer.notify("session/update", { sessionId, update: body });
const say = (sessionId: string, text: string) => update(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
/** A question the app showed ends unanswered: its card says so, and so does the chat, which every app shows, since an
* app need not show a card's later text. */
const unanswered = (sessionId: string, toolCallId: string, named: string, why: string) => {
update(sessionId, { sessionUpdate: "tool_call_update", toolCallId, content: textContent(`${why} The question is still open in the OpenRig TUI and Slack.`) });
say(sessionId, `\n\n${why} ${named} is still open in the OpenRig TUI and Slack.\n\n`);
};
const newSessionId = () => `acps-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(4).toString("hex")}`;
/** Each of the caller's prompts in a session, oldest first, by the rows tagged with its id. */
const sessionRoots = async (team: TeamConnection, sessionId: string) =>
(await team.queue.sent(team.caller)).filter((row) => row.tags.includes(`${SESSION_TAG}${sessionId}`))
.sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
/** Put one ask's open questions to the person, as a form where the app asks forms and otherwise as permission
* requests, one at a time, and record each choice. A reply that arrives after the turn ended answers nothing here:
* the question stays open in the TUI and Slack. */
const askPerson = async (team: TeamConnection, sessionId: string, ask: TeamAsk, turn: TurnQuestions) => {
const emit = (body: Update) => { if (!turn.finished) update(sessionId, body); };
if (ask.questions.length === 0) {
emit({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: `\n\n${ask.sourceSession} asks you: ${ask.summary ?? oneLine(ask.body, 200)} (${ask.qitemId}). It wants an answer in words, which this app can't send: answer it in the OpenRig TUI or Slack.\n\n` } });
return;
}
for (const q of ask.questions.filter((question) => !(question.id in ask.answers))) {
if (turn.finished || turn.cancelled) return;
const toolCallId = `${ask.qitemId}#${q.id}`;
emit({ sessionUpdate: "tool_call", toolCallId, kind: "other", status: "pending", title: `${ask.sourceSession} asks: ${q.question}`, content: textContent(ask.body) });
const label = (o: (typeof q.options)[number]) => (o.recommended ? `${o.label} (recommended)` : o.label);
const named = `${ask.sourceSession}'s question "${q.question}" (${ask.qitemId})`;
let chosenId: string | undefined;
turn.outstanding.set(toolCallId, named);
try {
if (asksForms) {
// Each choice is offered as its label, the one form every app shows as choices: T3 Code 0.1.7 builds them only
// from `enum`, a value serving as both label and answer, and reads `oneOf` as free text. Labels that repeat
// carry their option's id, so a value names one choice; the answer is mapped back by that exact value.
const shown = q.options.map(label);
const values = new Set(shown).size === shown.length ? shown : q.options.map((o, i) => `${shown[i]} (${o.id})`);
const result = (await peer.request("elicitation/create", {
sessionId, toolCallId, mode: "form", message: `${ask.sourceSession} asks: ${q.question}`,
requestedSchema: { type: "object", required: [q.id], properties: { [q.id]: { type: "string", title: q.question, enum: values } } },
})) as { action?: string; content?: Record<string, unknown> | null } | undefined;
const value = result?.action === "accept" ? result.content?.[q.id] : undefined;
chosenId = typeof value === "string" ? q.options[values.indexOf(value)]?.id : undefined;
} else {
const result = (await peer.request("session/request_permission", {
sessionId,
toolCall: { toolCallId, title: `${ask.sourceSession} asks: ${q.question}`, kind: "other", status: "pending" },
options: q.options.map((o) => ({ optionId: o.id, name: label(o), kind: "allow_once" })),
})) as { outcome?: { outcome?: string; optionId?: string } } | undefined;
chosenId = result?.outcome?.outcome === "selected" ? result.outcome.optionId : undefined;
}
} catch (error) {
if (!turn.finished) unanswered(sessionId, toolCallId, named, `This app couldn't show the question (${(error as Error).message}).`);
return;
} finally {
turn.outstanding.delete(toolCallId);
}
if (turn.finished) return;
// Only one of the question's own options is recorded: the app's reply is checked again here.
const chosen = q.options.find((o) => o.id === chosenId);
if (!chosen) {
unanswered(sessionId, toolCallId, named, "Not answered here.");
return;
}
const recorded = await team.queue.answer(ask.qitemId, q.id, chosen.id).catch((error: Error) => ({ ok: false as const, reason: error.message }));
emit(recorded.ok
? { sessionUpdate: "tool_call_update", toolCallId, status: "completed", content: textContent(`You answered: ${chosen.label}. ${ask.sourceSession} gets it as your answer.`) }
: { sessionUpdate: "tool_call_update", toolCallId, status: "failed", content: textContent(`Your choice (${chosen.label}) wasn't recorded: ${recorded.reason}`) });
log(`session ${sessionId}: ${recorded.ok ? "answered" : "couldn't answer"} ${ask.qitemId} ${q.id}`);
}
};
peer.onRequest("initialize", async (params) => {
const p = (params ?? {}) as { clientInfo?: { name?: string; title?: string }; clientCapabilities?: { elicitation?: { form?: unknown } | null } };
clientName = p.clientInfo?.title ?? p.clientInfo?.name ?? clientName;
// ACP counts a mode as supported only when its field is present and not null.
asksForms = p.clientCapabilities?.elicitation?.form != null;
return {
protocolVersion: ACP_PROTOCOL_VERSION,
agentCapabilities: {
loadSession: true,
promptCapabilities: { image: false, audio: false, embeddedContext: true },
mcpCapabilities: { http: false, sse: false },
sessionCapabilities: { list: {} },
},
authMethods: [{
id: AUTH_METHOD_ID,
name: "OpenRig on this machine",
description: "Sends your work to your OpenRig team, as you, through the same OpenRig daemon as `rig` on this computer: the local one (start it with `rig daemon start`), unless OPENRIG_URL names another.",
}],
agentInfo: { name: "openrig", title: "OpenRig", version: options.version },
};
});
peer.onRequest("authenticate", async (params) => {
const methodId = (params as { methodId?: unknown } | undefined)?.methodId;
if (methodId !== AUTH_METHOD_ID) throw new AcpError(INVALID_PARAMS, `Unknown auth method ${String(methodId)}; this agent offers ${AUTH_METHOD_ID}`);
const team = await connected();
log(`authenticated as ${team.caller}${team.receiver ? `, sending to ${team.receiver}` : ", with no receiver set"}`);
return {};
});
peer.onRequest("session/new", async (params) => {
await connected();
const id = newSessionId();
sessions.set(id, { id, cwd: String((params as { cwd?: unknown } | undefined)?.cwd ?? ""), sent: [] });
return { sessionId: id };
});
peer.onRequest("session/list", async (params) => {
const team = await connected();
const cwd = (params as { cwd?: unknown } | undefined)?.cwd;
const byId = new Map<string, { sessionId: string; cwd: string; title: string; updatedAt: string }>();
for (const row of (await team.queue.sent(team.caller)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated))) {
const sessionId = row.tags.find((t) => t.startsWith(SESSION_TAG))?.slice(SESSION_TAG.length);
if (!sessionId) continue;
const known = byId.get(sessionId);
if (known) { if (row.tsUpdated > known.updatedAt) known.updatedAt = row.tsUpdated; continue; }
byId.set(sessionId, {
sessionId, cwd: row.tags.find((t) => t.startsWith(CWD_TAG))?.slice(CWD_TAG.length) ?? "",
title: oneLine(personsWords(row.body.split(FOOTER)[0] ?? ""), 100), updatedAt: row.tsUpdated,
});
}
const listed = [...byId.values()].filter((s) => typeof cwd !== "string" || s.cwd === cwd).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return { sessions: listed };
});
peer.onRequest("session/load", async (params) => {
const p = (params ?? {}) as { sessionId?: string; cwd?: string };
const team = await connected();
const sessionId = String(p.sessionId ?? "");
const roots = await sessionRoots(team, sessionId);
if (roots.length === 0) throw new AcpError(INVALID_PARAMS, `No session ${sessionId} from ${team.caller} on this machine`);
const session: Session = { id: sessionId, cwd: String(p.cwd ?? ""), sent: [] };
const asks = (await team.queue.sessionAsks(team.caller, sessionId)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
// Replay: each prompt as the caller wrote it, then its work as it stands now, the questions the team asked during
// it with what's recorded, and the reply. Nothing is asked again here; an open question comes up in the next turn.
for (const [i, row] of roots.entries()) {
update(sessionId, { sessionUpdate: "user_message_chunk", content: { type: "text", text: row.body.split(FOOTER)[0] ?? "" } });
const follower = new ChainFollower(team.queue, (u) => update(sessionId, u), row.qitemId);
const state = await follower.read();
session.sent.push({ root: row.qitemId, tail: state.tail.qitemId });
const next = roots[i + 1]?.tsCreated;
for (const ask of asks.filter((a) => (i === 0 || a.tsCreated >= row.tsCreated) && (next === undefined || a.tsCreated < next))) {
for (const q of ask.questions) {
const answer = ask.answers[q.id];
const outcome = answer !== undefined ? `You answered: ${q.options.find((o) => o.id === answer)?.label ?? answer}.`
: ACTIVE_STATES.has(ask.state) ? "Not answered yet. It's still open in the OpenRig TUI and Slack." : `Not answered: ${ask.qitemId} was closed (${ask.state}).`;
update(sessionId, {
sessionUpdate: "tool_call", toolCallId: `${ask.qitemId}#${q.id}`, kind: "other", status: answer === undefined && ACTIVE_STATES.has(ask.state) ? "pending" : "completed",
title: `${ask.sourceSession} asks: ${q.question}`, content: [...textContent(ask.body), ...textContent(outcome)],
});
}
}
say(sessionId, state.kind === "ended" ? state.reply : `${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}.`);
}
sessions.set(sessionId, session);
return {};
});
peer.onNotification("session/cancel", (params) => {
const sessionId = (params as { sessionId?: string } | undefined)?.sessionId;
if (sessionId) sessions.get(sessionId)?.turn?.cancel();
});
peer.onRequest("session/prompt", async (params) => {
const p = (params ?? {}) as { sessionId?: string; prompt?: ContentBlock[] };
const session = p.sessionId ? sessions.get(p.sessionId) : undefined;
if (!session) throw new AcpError(INVALID_PARAMS, `No session ${String(p.sessionId)} in this agent; start one with session/new or session/load`);
if (session.turn) throw new AcpError(INVALID_PARAMS, "This session already has a turn running; wait for it or cancel it first");
const team = await connected();
const text = promptText(Array.isArray(p.prompt) ? p.prompt : []);
if (!text) {
say(session.id, "Nothing to send: the prompt had no text.");
return { stopReason: "end_turn" };
}
if (!team.receiver) {
say(session.id, "This OpenRig agent has no team receiver, so nothing was sent. Choose the seat that takes your team's new work with `rig config set acp.to <member@rig>`, or add `--to <member@rig>` to the agent's command in your app's settings.");
return { stopReason: "end_turn" };
}
if (team.notice && !session.noticed) {
session.noticed = true;
say(session.id, `${team.notice}\n\n`);
}
let wake: () => void = () => {};
const questions: TurnQuestions = { finished: false, cancelled: false, outstanding: new Map(), tasks: new Set() };
session.turn = { cancel: () => { questions.cancelled = true; wake(); } };
let root: string | undefined;
try {
// Each earlier item as it stands; one whose create was never confirmed may not exist, or can't be read: say which.
const earlier = await Promise.all(session.sent.map(async (s) => {
const row = await team.queue.find(s.tail).catch(() => undefined);
return { ...s, row, state: row === undefined ? "state unknown" : row === null ? "not found here" : row.state };
}));
const open = earlier.filter((s) => s.row && ACTIVE_STATES.has(s.row.state));
if (open.length > 0) say(session.id, `${open.map((s) => `${s.row!.qitemId} is still ${s.row!.state} with ${s.row!.destinationSession}`).join("; ")}. Sending this as a follow-up.\n\n`);
const lineage = earlier.length === 0 ? "" : `\nFollows up on ${earlier.map((s) => `${s.root} (${s.tail === s.root ? "" : `reached ${s.tail}, `}${s.state})`).join(", ")}.`;
const body = `${text}${FOOTER}${team.caller} from ${clientName} through rig acp serve (session ${session.id}).${lineage}\n` +
`The app shows this item's notes and handoffs as progress, and its closing resolution as the answer: close it with a resolution written for ${team.caller}. ` +
`A question with choices you ask ${team.caller} while this is open, tagged for this chat, appears in the app as a choice: ` +
`rig queue create --destination ${team.caller} --human-questions-file … --tags ${SESSION_TAG}${session.id}.`;
const turnStarted = new Date().toISOString();
const qitemId = newQitemId();
try {
await team.queue.create({
qitemId, destination: team.receiver, body, summary: `ACP: ${oneLine(personsWords(text), 100)}`,
tags: ["acp-serve", `${SESSION_TAG}${session.id}`, ...(session.cwd ? [`${CWD_TAG}${session.cwd}`] : [])],
});
} catch (error) {
const reason = (error as Error).message;
// A refusal the daemon answered with wrote nothing, so it can be said plainly.
if (error instanceof TeamRefused) throw new AcpError(-32603, `The team's queue refused the task: ${reason}`);
// No answer: the request may still land after any readback, so only a row that reads back settles it.
const landed = await team.queue.find(qitemId).catch(() => undefined);
if (!landed) {
session.sent.push({ root: qitemId, tail: qitemId });
say(session.id, `Sending ${qitemId} to ${team.receiver} got no answer (${reason}), and ${landed === null ? "it wasn't found on the immediate readback" : "it couldn't be read back"}: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${qitemId}\` shows it if it landed.`);
throw new AcpError(-32603, `Couldn't confirm ${qitemId}: ${reason}`);
}
log(`session ${session.id}: ${qitemId}'s create got no answer, but it reads back`);
}
root = qitemId;
const sent = { root, tail: root };
session.sent.push(sent);
log(`session ${session.id}: sent ${root} to ${team.receiver}`);
// ponytail: polls the daemon for the chain's rows, their transitions and the caller's open asks; the
// daemon's event stream is the upgrade path if a poll per open turn ever costs too much.
const follower = new ChainFollower(team.queue, (u) => update(session.id, u), root);
const seen = new Set<string>();
/** Close this turn's questions: any the app still shows are named as open elsewhere, then nothing more comes from them. */
const finishQuestions = (why: string) => {
for (const [toolCallId, named] of questions.outstanding) unanswered(session.id, toolCallId, named, why);
questions.finished = true;
};
for (;;) {
const state = await follower.read();
sent.tail = state.tail.qitemId;
if (state.kind === "ended") {
finishQuestions("The work closed before this was answered here.");
say(session.id, state.reply);
return { stopReason: "end_turn" };
}
const asks = await team.queue.openAsks(team.caller);
// After a cancel, even one that landed during that read, no new question goes to the person.
for (const ask of questions.cancelled ? [] : asks) {
if (seen.has(ask.qitemId)) continue;
// An ask tagged for a chat belongs to that chat alone. An untagged one from a seat this work reached might be
// another chat's, so it isn't asked here; it's named, with where to answer it.
const chats = ask.tags.filter((tag) => tag.startsWith(SESSION_TAG));
if (chats.includes(`${SESSION_TAG}${session.id}`)) {
seen.add(ask.qitemId);
const task: Promise<void> = askPerson(team, session.id, ask, questions)
.catch((error: Error) => log(`session ${session.id}: asking about ${ask.qitemId} failed: ${error.message}`))
.finally(() => questions.tasks.delete(task));
questions.tasks.add(task);
} else if (chats.length === 0 && follower.seats.has(ask.sourceSession) && ask.tsCreated >= turnStarted) {
seen.add(ask.qitemId);
say(session.id, `\n\n${ask.sourceSession}, which this work reached, has a question for you that isn't tagged for this chat (${ask.qitemId}: ${ask.summary ?? oneLine(ask.body, 120)}). Answer it in the OpenRig TUI or Slack.\n\n`);
}
}
if (questions.cancelled) {
// ACP has the app answer the questions it was showing as cancelled; let those answers land before the turn ends.
if (questions.tasks.size > 0) await Promise.race([Promise.allSettled([...questions.tasks]), sleep(QUESTION_SETTLE_MS)]);
finishQuestions("Not answered here: the turn was cancelled.");
say(session.id, `Stopped waiting. ${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`);
return { stopReason: "cancelled" };
}
await Promise.race([sleep(options.pollMs), new Promise<void>((resolve) => { wake = resolve; })]);
}
} catch (error) {
if (error instanceof AcpError || !root) throw error;
say(session.id, `Lost track of ${root}: ${(error as Error).message}. The item stays with the team; its state is unknown from here.`);
throw new AcpError(-32603, `Couldn't follow ${root}: ${(error as Error).message}`);
} finally {
questions.finished = true;
session.turn = undefined;
}
});
}
+53 -9
View File
@@ -1,7 +1,7 @@
import { spawn } from "node:child_process";
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer } from "./json-rpc-stdio.js";
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer, type JsonRpcErrorObject } from "./json-rpc-stdio.js";
import { findRunProcesses, RUN_MARKER } from "./run-processes.js";
// `rig acp run`: one queue row's task to one ACP agent turn, with the outcome back on that row. OpenRig carries the
@@ -48,10 +48,22 @@ export interface AcpRunOptions {
detachRequested?: Promise<void>;
/** How long to wait for the agent to exit after its stdin closes (default 5 s). */
exitWaitMs?: number;
/** A harness's own evidence that a `session/load` error means that session doesn't exist. Without it, or when it
* doesn't match, the load error reads `failed` with the agent's code and message: no code means missing by itself. */
missingSession?: (error: JsonRpcErrorObject, sessionId: string) => boolean;
/** Called once with what the stop found: the agent's exit and whether any process carrying the run's marker remains. */
onSettled?: (report: string) => void;
}
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown";
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string }
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown" | "missing";
/** What the agent itself reported; OpenRig measures none of it. */
export interface AcpUsage {
/** The prompt response's `usage` (an unstable ACP field), as the agent counted it. */
tokens?: { total?: number; input?: number; output?: number; thought?: number; cachedRead?: number; cachedWrite?: number };
/** The last `usage_update` cost: the agent's own cumulative figure for the whole session, in its unit. */
cost?: { amount: number; currency: string };
}
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string; usage?: AcpUsage }
/** Agent text that reports a failed turn while still ending it `end_turn` (OpenClaw #178057; its #44294 is open). */
export const INTERRUPTION_MARKERS = ["[OpenClaw interruption]"];
@@ -87,6 +99,7 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
let sessionId: string | undefined;
let text = "";
let usage: { used?: number; size?: number; cost?: unknown } | undefined;
const reported: AcpUsage = {};
let toolCalls = 0;
let lastTool = "";
let lastProgressNote = Date.now();
@@ -104,6 +117,8 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
progress(`\n[tool] ${lastTool}\n`);
} else if (update.sessionUpdate === "usage_update") {
usage = { used: update.used as number, size: update.size as number, cost: update.cost };
const cost = update.cost as { amount?: unknown; currency?: unknown } | null | undefined;
if (typeof cost?.amount === "number" && typeof cost.currency === "string") reported.cost = { amount: cost.amount, currency: cost.currency };
}
if (Date.now() - lastProgressNote >= 60_000) {
lastProgressNote = Date.now();
@@ -153,10 +168,11 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
const record = async (outcome: AcpOutcome): Promise<AcpOutcome> => {
const shown = outcome.text.length > NOTE_TEXT_LIMIT ? `${outcome.text.slice(0, NOTE_TEXT_LIMIT)}\n… (truncated; the full transcript is in ${options.logPath})` : outcome.text;
const withUsage: AcpOutcome = { ...outcome, usage: { ...reported } };
await queue.update(row.qitemId, {
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.`,
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.\n${describeUsage(withUsage.usage)}`,
});
return outcome;
return withUsage;
};
try {
@@ -166,7 +182,12 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
const loads = init.agentCapabilities?.loadSession === true;
if (options.loadSession) {
if (!loads) return await record({ kind: "failed", summary: `the agent doesn't advertise loadSession, so session ${options.loadSession} wasn't loaded and no new session was started`, text: "" });
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
try {
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
} catch (error) {
if (!(error instanceof JsonRpcRequestError) || !options.missingSession?.(error.error, options.loadSession)) throw error;
return await record({ kind: "missing", text: "", summary: `session ${options.loadSession} doesn't exist for this agent (session/load returned ${describeError(error.error)}); no new session was started` });
}
sessionId = options.loadSession;
} else {
sessionId = (await peer.request("session/new", { cwd: options.cwd, mcpServers: [] }) as { sessionId: string }).sessionId;
@@ -190,13 +211,18 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
void options.detachRequested?.then(() => { void peer.end(0); reject(new DetachedBeforeTurnEnd()); });
});
detached.catch(() => {});
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string };
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string; usage?: Record<string, unknown> };
turnOver = true;
const tokens = response.usage;
if (tokens && typeof tokens === "object") {
const count = (key: string) => (typeof tokens[key] === "number" ? tokens[key] as number : undefined);
reported.tokens = { total: count("totalTokens"), input: count("inputTokens"), output: count("outputTokens"), thought: count("thoughtTokens"), cachedRead: count("cachedReadTokens"), cachedWrite: count("cachedWriteTokens") };
}
return await record(classify(response.stopReason, text, usage, cancelRequestedAt, sessionId));
} catch (error) {
turnOver = true;
if (error instanceof JsonRpcRequestError) {
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${error.error.code} ${error.error.message}${error.error.data === undefined ? "" : ` ${JSON.stringify(error.error.data)}`}` });
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${describeError(error.error)}` });
}
if (error instanceof DetachedBeforeTurnEnd) {
return await record({ kind: "unknown", sessionId, text, summary: "detached before the turn ended; the agent may still be working" });
@@ -212,7 +238,9 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
const settled = left === undefined ? "unknown (the process table couldn't be read)"
: left.length === 0 ? "settled: no process carrying this run's marker remains"
: `incomplete: ${left.length} process(es) carrying this run's marker still run (${left.map((p) => p.pid).join(", ")}); stop them with the runtime's own stop, or rig acp stop ${options.runId} -- <its stop command>`;
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}` }).catch(() => {});
const report = `agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}`;
options.onSettled?.(report);
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: ${report}` }).catch(() => {});
peer.release(); // a still-running agent keeps running, but holds nothing that keeps this process alive
}
}
@@ -231,6 +259,22 @@ export function classify(stopReason: string | undefined, text: string, usage: {
return { kind: "completed", sessionId, text, summary: "the agent ended its turn (end_turn)" };
}
const describeError = (error: JsonRpcErrorObject) => `${error.code} ${error.message}${error.data === undefined ? "" : ` ${JSON.stringify(error.data)}`}`;
/** Usage and cost as the agent reported them, with where each came from; a figure it didn't report reads unknown. */
export function describeUsage(usage: AcpUsage | undefined): string {
const t = usage?.tokens;
const parts = t ? [["in", t.input], ["out", t.output], ["thought", t.thought], ["cache read", t.cachedRead], ["cache write", t.cachedWrite]]
.filter(([, n]) => n !== undefined).map(([label, n]) => `${n} ${label}`) : [];
const tokens = t?.total !== undefined || parts.length > 0
? `${t?.total !== undefined ? `${t.total} tokens` : "tokens"}${parts.length ? ` (${parts.join(", ")})` : ""}, from the agent's prompt response`
: "tokens unknown (the agent's prompt response reported none)";
const cost = usage?.cost
? `cost ${usage.cost.amount} ${usage.cost.currency}, the agent's own cumulative figure for the session (usage_update)`
: "cost unknown (the agent reported none)";
return `Usage as the agent reported it: ${tokens}; ${cost}.`;
}
/** An owner answers with the offered option's id; its kind (allow_once …) works when only one option has it. */
export function chooseOption(offered: PermissionOption[], answer: string | null | undefined): PermissionOption | undefined {
const value = answer?.trim();
+60 -34
View File
@@ -1,9 +1,10 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what an OpenRig client
// needs (a few outgoing requests, incoming requests and notifications); the official SDK would add a dependency for
// a handful of methods, so swap to it only if the method set grows.
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what OpenRig needs on either
// side (a few outgoing requests, incoming requests and notifications): StdioJsonRpcPeer is the client end of an agent
// process OpenRig launched, and JsonRpcPeer over this process's own stdin and stdout is the agent end (rig acp serve).
// The official SDK would add a dependency for a handful of methods, so swap to it only if the method set grows.
export interface JsonRpcErrorObject {
code: number;
@@ -33,27 +34,18 @@ export type NotificationHandler = (params: unknown) => void;
/** Every packet in both directions, in order; lines that aren't JSON arrive as `in-text`. */
export type PacketRecorder = (direction: "out" | "in" | "in-text", packet: unknown) => void;
export class StdioJsonRpcPeer {
/** One JSON-RPC connection over a pair of streams: incoming lines on `input`, outgoing packets written to `output`. */
export class JsonRpcPeer {
private nextId = 0;
private readonly pending = new Map<JsonRpcId, { method: string; resolve: (value: unknown) => void; reject: (error: Error) => void }>();
private readonly requestHandlers = new Map<string, IncomingRequestHandler>();
private readonly notificationHandlers = new Map<string, NotificationHandler>();
private ended = false;
private readonly lines: ReturnType<typeof createInterface>;
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
protected readonly lines: ReturnType<typeof createInterface>;
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly record: PacketRecorder = () => {}) {
this.lines = createInterface({ input: child.stdout });
constructor(input: NodeJS.ReadableStream, private readonly output: NodeJS.WritableStream, private readonly record: PacketRecorder = () => {}) {
this.lines = createInterface({ input });
this.lines.on("line", (line) => this.receive(line));
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
this.ended = true;
for (const [id, request] of this.pending) {
this.pending.delete(id);
request.reject(new JsonRpcTransportClosed(request.method));
}
resolve({ code, signal });
}));
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
}
request(method: string, params: unknown): Promise<unknown> {
@@ -77,25 +69,18 @@ export class StdioJsonRpcPeer {
this.notificationHandlers.set(method, handler);
}
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
this.child.stdin.end();
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
}
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
* keeps running doesn't keep the caller's process alive. */
release(): void {
this.lines.close();
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
this.child.unref();
/** The connection is gone: nothing more is sent, and every request still waiting rejects as closed. */
protected close(): void {
this.ended = true;
for (const [id, request] of this.pending) {
this.pending.delete(id);
request.reject(new JsonRpcTransportClosed(request.method));
}
}
private send(packet: Record<string, unknown>): void {
this.record("out", packet);
this.child.stdin.write(`${JSON.stringify(packet)}\n`);
this.output.write(`${JSON.stringify(packet)}\n`);
}
private receive(line: string): void {
@@ -127,7 +112,7 @@ export class StdioJsonRpcPeer {
private async answer(method: string, id: JsonRpcId, params: unknown): Promise<void> {
const handler = this.requestHandlers.get(method);
if (!handler) {
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Client does not handle ${method}` } });
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
return;
}
try {
@@ -135,7 +120,48 @@ export class StdioJsonRpcPeer {
if (!this.ended) this.send({ jsonrpc: "2.0", id, result });
} catch (error) {
const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : -32603;
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message } });
const data = (error as { data?: unknown }).data;
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message, ...(data !== undefined ? { data } : {}) } });
}
}
}
/** The client end of an ACP agent process OpenRig launched. */
export class StdioJsonRpcPeer extends JsonRpcPeer {
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
constructor(private readonly child: ChildProcessWithoutNullStreams, record: PacketRecorder = () => {}) {
super(child.stdout, child.stdin, record);
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
this.close();
resolve({ code, signal });
}));
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
}
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
this.child.stdin.end();
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
}
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
* keeps running doesn't keep the caller's process alive. */
release(): void {
this.lines.close();
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
this.child.unref();
}
}
/** The agent end over this process's stdin and stdout (or any stream pair): `closed` settles when the input ends. */
export class StreamJsonRpcPeer extends JsonRpcPeer {
readonly closed: Promise<void>;
constructor(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, record: PacketRecorder = () => {}) {
super(input, output, record);
this.closed = new Promise((resolve) => this.lines.once("close", () => { this.close(); resolve(); }));
}
}
+4 -3
View File
@@ -9,9 +9,10 @@ export const RUN_MARKER = "OPENRIG_ACP_RUN";
/** Only the pid: on macOS the listing carries the environment, which may hold secrets, so nothing else leaves here. */
export interface RunProcess { pid: number }
/** The caller's processes carrying this run's marker. Linux reads /proc; elsewhere `ps e` shows own environments. */
export async function findRunProcesses(runId: string): Promise<RunProcess[]> {
const marker = `${RUN_MARKER}=${runId}`;
/** The caller's processes carrying this run's marker (`rig exec` names its connector's own). Linux reads /proc;
* elsewhere `ps e` shows own environments. */
export async function findRunProcesses(runId: string, markerName = RUN_MARKER): Promise<RunProcess[]> {
const marker = `${markerName}=${runId}`;
if (process.platform === "linux") {
const found: RunProcess[] = [];
for (const entry of await readdir("/proc")) {
+2 -1
View File
@@ -12,7 +12,8 @@ const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
// Each `rig` is a new process: Node's on-disk compile cache lets the next call skip compiling the modules
// again. The cache dates from Node v22.1.0, but this JavaScript API from v22.8.0: before it, `?.()` does
// nothing. NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE still control it.
module.enableCompileCache?.();
// A piped bare invocation only prints usage; cold-cache writes can otherwise delay its exit.
if (process.argv.length > 2 || (process.stdin.isTTY && process.stdout.isTTY)) module.enableCompileCache?.();
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
+185 -4
View File
@@ -4,16 +4,20 @@ import { randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { DaemonClient, type DaemonResponse } from "../client.js";
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { daemonMayAnswer, daemonStatusGuard, getDaemonStatus, getDaemonUrl, statusGuardMessage } from "../daemon-lifecycle.js";
import { processIdentity } from "../local-daemon-owner.js";
import { getOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
import { answerCommand, runAcpAssignment, type AcpQueue } from "../acp/connector.js";
import { findRunProcesses } from "../acp/run-processes.js";
import { serveAcpAgent, SESSION_TAG, TeamRefused, type TeamAsk, type TeamConnection, type TeamQueue } from "../acp/agent-server.js";
import { StreamJsonRpcPeer } from "../acp/json-rpc-stdio.js";
import { CLI_VERSION } from "../version.js";
import { ConfigStore } from "../config-store.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const runsDir = () => join(getOpenRigHome(), "acp", "runs");
const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
export const runsDir = () => join(getOpenRigHome(), "acp", "runs");
export const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
interface RunFile { runId: string; qitemId: string; pid: number; processStart?: string; agent: string[]; startedAt: string; endedAt?: string }
@@ -44,6 +48,127 @@ export function daemonAcpQueue(client: DaemonClient): AcpQueue {
};
}
/** rig acp serve's queue verbs over the daemon API, acting as the caller the client was built for. */
export function daemonTeamQueue(client: DaemonClient): TeamQueue {
const ok = <T>(response: DaemonResponse<T>, what: string): T => {
if (response.status >= 400) throw new Error(`${what} returned HTTP ${response.status}: ${JSON.stringify(response.data)}`);
return response.data;
};
const face = (data: Record<string, unknown>) => {
const row = (data.item ?? data) as Record<string, unknown>;
const text = (value: unknown) => (typeof value === "string" ? value : null);
return {
qitemId: String(row.qitemId), state: String(row.state), destinationSession: String(row.destinationSession),
handedOffTo: text(row.handedOffTo), closureReason: text(row.closureReason), closureTarget: text(row.closureTarget), resolution: text(row.resolution),
closer: text((row.closingTransition as { actorSession?: unknown } | null | undefined)?.actorSession),
};
};
const list = async (query: string, what: string): Promise<Array<Record<string, unknown>>> => {
const data = ok(await client.get<unknown>(`/api/queue/list?${query}`), what);
return (Array.isArray(data) ? data : ((data as { items?: unknown[] }).items ?? [])) as Array<Record<string, unknown>>;
};
/** The rows that ask the person for a decision (questions with choices, or a decision in words). */
const asks = (rows: Array<Record<string, unknown>>): TeamAsk[] =>
rows.filter((row) => row.humanIntent !== "update" && (Array.isArray(row.humanQuestions) || row.humanIntent === "decision")).map((row) => ({
qitemId: String(row.qitemId), sourceSession: String(row.sourceSession), state: String(row.state), tsCreated: String(row.tsCreated),
summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "",
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
questions: Array.isArray(row.humanQuestions) ? (row.humanQuestions as TeamAsk["questions"]) : [],
// A click records the option's id; a typed reply records its words.
answers: Object.fromEntries(Object.entries((row.humanAnswers as Record<string, unknown> | null) ?? {})
.map(([id, value]) => [id, typeof value === "string" ? value : String((value as { text?: unknown } | null)?.text ?? "")])),
}));
return {
async create(row) {
const response = await client.post("/api/queue/create", { qitemId: row.qitemId, destinationSession: row.destination, body: row.body, summary: row.summary, tags: row.tags });
// A 4xx is the daemon's answered refusal, with nothing written; a 5xx leaves the write unknown, like no answer.
if (response.status >= 400 && response.status < 500) throw new TeamRefused(`HTTP ${response.status}: ${JSON.stringify(response.data)}`);
ok(response, `queue create for ${row.destination}`);
},
async show(qitemId) {
return face(ok(await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`), `queue show ${qitemId}`));
},
async find(qitemId) {
const response = await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`);
if (response.status === 404) return null;
return face(ok(response, `queue show ${qitemId}`));
},
async transitions(qitemId) {
const data = ok(await client.get<Array<Record<string, unknown>>>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`), `queue transitions ${qitemId}`);
return data.map((t) => ({
transitionId: Number(t.transitionId), ts: String(t.ts), state: String(t.state),
transitionNote: typeof t.transitionNote === "string" ? t.transitionNote : null, actorSession: String(t.actorSession),
}));
},
async successor(qitemId) {
return (await list(`handedOffFrom=${encodeURIComponent(qitemId)}&limit=1`, `queue successor of ${qitemId}`))[0]?.qitemId as string | undefined;
},
async openAsks(person) {
return asks(await list(`destinationSession=${encodeURIComponent(person)}&state=pending&limit=500`, `open asks to ${person}`));
},
async sessionAsks(person, sessionId) {
const rows = await list(`destinationSession=${encodeURIComponent(person)}&limit=2000`, `rows to ${person}`);
return asks(rows.filter((row) => Array.isArray(row.tags) && (row.tags as string[]).includes(`${SESSION_TAG}${sessionId}`)));
},
async answer(qitemId, questionId, optionId) {
// The person's answer path, as a click in Slack records one: the daemon takes the actor from this client's caller.
const bearer = process.env.OPENRIG_AUTH_BEARER_TOKEN;
const response = await client.post<Record<string, unknown>>("/api/mission-control/action", { verb: "answer", qitemId, questionId, optionId },
bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined);
if (response.status < 400) return { ok: true };
const data = (response.data ?? {}) as { message?: unknown; error?: unknown };
return { ok: false, reason: String(data.message ?? data.error ?? `HTTP ${response.status}`) };
},
async sent(caller) {
const rows = await list(`sourceSession=${encodeURIComponent(caller)}&limit=2000`, `rows from ${caller}`);
return rows.filter((row) => Array.isArray(row.tags) && (row.tags as string[]).includes("acp-serve")).map((row) => ({
qitemId: String(row.qitemId), tsCreated: String(row.tsCreated), tsUpdated: String(row.tsUpdated),
summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "", tags: row.tags as string[],
}));
},
};
}
/** The seat that takes this agent's tasks: --to, else this machine's acp.to setting (an ACP registry launch has no args).
* A setting that can't be read comes back as the reason, for the app's sign-in step to show. */
export function acpReceiver(to: string | undefined, store: Pick<ConfigStore, "get"> = new ConfigStore()): { receiver?: string } | { error: string } {
if (to?.trim()) return { receiver: to.trim() };
try {
return { receiver: String(store.get("acp.to") ?? "").trim() || undefined };
} catch (error) {
return { error: `${(error as Error).message} The agent reads acp.to from it; adding --to <member@rig> to the agent's command skips it.` };
}
}
/** Why the team's questions can't reach `address`, when it's a person this machine's OpenRig doesn't know. Says nothing
* for a seat's address, a registered person, or a registry that can't be read (unknown isn't unregistered). */
export async function unregisteredPersonNotice(address: string, home?: string): Promise<string | undefined> {
const suffix = "@external";
if (!address.endsWith(suffix)) return undefined;
try {
const registry = await import("@openrig/daemon/gateway-human-registry");
const people = registry.projectHumans(home);
if (!people.ok || people.entities.some((person) => person.address === address)) return undefined;
} catch {
return undefined;
}
const id = address.slice(0, -suffix.length);
return `Your team can't ask you questions here yet: ${address} isn't a registered person on this machine, so OpenRig refuses a question addressed to it. Your work is still sent. To be asked, register yourself with \`rig gateway human add ${id} --display-name "<your name>" --delivery-class <A|B|C|D>\`.`;
}
/** The person this machine's OpenRig names as its default (`<id>@external`), or why there isn't one. */
async function defaultPersonAddress(): Promise<{ address: string } | { reason: string }> {
try {
const registry = await import("@openrig/daemon/gateway-human-registry");
const people = registry.projectHumans();
if (!people.ok) return { reason: people.error };
const chosen = registry.resolveDefaultPerson(people.entities);
return chosen.person ? { address: chosen.person.address } : { reason: chosen.source === "none" ? chosen.reason : "no default person" };
} catch (error) {
return { reason: (error as Error).message };
}
}
function readRun(runId: string): RunFile | undefined {
try { return JSON.parse(readFileSync(join(runsDir(), `${runId}.json`), "utf8")) as RunFile; } catch { return undefined; }
}
@@ -62,7 +187,7 @@ function liveConnector(run: RunFile): boolean {
}
export function acpCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("acp").description("Hand a queue row's task to an ACP (Agent Client Protocol) agent and keep its outcome on the row");
const cmd = new Command("acp").description("ACP (Agent Client Protocol): hand a queue row's task to an ACP agent, or serve OpenRig as an agent to ACP apps");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const connect = async (): Promise<DaemonClient | undefined> => {
const deps = getDeps();
@@ -135,6 +260,62 @@ Guide: rig context get reference/acp-agents.md`)
}
});
cmd
.command("serve")
.description("Serve OpenRig as an ACP agent on stdio, for an ACP app (Zed and others) to launch: its prompts go to your team as queue rows")
.option("--to <member@rig>", "The seat that receives your tasks: your team's entry point (default: rig config get acp.to)")
.option("--as <address>", "Who the tasks come from (default: OPENRIG_SESSION_NAME, else this machine's default person)")
.option("--poll-seconds <n>", "How often to read the work's progress while a turn is open", "2")
.addHelpText("after", `
An ACP app launches this command and talks to it on stdin and stdout. Each prompt becomes a queue row from you to
--to. The app shows the row's notes and handoffs as progress, and the closing resolution as the reply. A later
prompt in the same session goes to --to as a follow-up that names the earlier items. A question the working seats
ask you meanwhile comes to the app as a choice, recorded as your answer. The app can list and reopen your sessions.
The turn ends when the team closes the item, or when you cancel. Cancelling stops this process waiting; the team's
work carries on. A turn that ended doesn't mean the work was accepted: the reply says how the item was closed.
Without --to, the receiver is the acp.to setting (rig config set acp.to <member@rig>), which is how an app that
starts the agent from the ACP registry, with no arguments, reaches your team.
It uses the same OpenRig daemon as rig: the one on this computer, started with rig daemon start, unless OPENRIG_URL
names another. The app's sign-in step ("OpenRig on this machine") checks that the daemon answers and that you
resolve to an address: --as, OPENRIG_SESSION_NAME, or the default person: the only one registered
(rig gateway human add <id> ...), or with several, the one chosen with rig gateway default <id>.
In Zed's settings.json:
"agent_servers": { "OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] } }
Guide: rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`)
.action(async (opts: { to?: string; as?: string; pollSeconds: string }) => {
// Only ACP messages may reach stdout: anything a helper prints goes to stderr instead.
console.log = console.error;
console.info = console.error;
process.stdout.on("error", () => { /* EPIPE once the app has gone; the input closing ends the server */ });
const connectTeam = async (): Promise<TeamConnection | { error: string }> => {
const status = await getDaemonStatus(getDeps().lifecycleDeps);
if (!daemonMayAnswer(status)) {
const err = statusGuardMessage(status);
return { error: `${err.fact} ${err.action}` };
}
let caller = opts.as?.trim() || self()?.trim();
if (!caller) {
const person = await defaultPersonAddress();
if ("reason" in person) return { error: `OpenRig doesn't know who you are on this machine (${person.reason}). Register yourself with rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D> (the only registered person is the default; with several, choose one with rig gateway default <id>), or add --as <address> to the agent's command.` };
caller = person.address;
}
const target = acpReceiver(opts.to);
if ("error" in target) return target;
return { queue: daemonTeamQueue(new DaemonClient(getDaemonUrl(status), { sessionName: caller })), caller, receiver: target.receiver, notice: await unregisteredPersonNotice(caller) };
};
const peer = new StreamJsonRpcPeer(process.stdin, process.stdout);
serveAcpAgent(peer, {
connect: connectTeam, version: CLI_VERSION, pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
log: (line) => console.error(`[rig acp serve] ${line}`), closed: peer.closed,
});
await peer.closed;
});
cmd
.command("cancel <runId>")
.description("Ask a running rig acp run to cancel its agent's turn (the same as Ctrl-C there)")
+3 -1
View File
@@ -45,7 +45,7 @@ export function agentCommand(depsOverride?: AgentDeps): Command {
const client = deps.clientFactory(getDaemonUrl(status));
const res = await client.postText<{ valid?: boolean; errors?: string[] }>("/api/agents/validate", yaml);
const res = await client.postText<{ valid?: boolean; errors?: string[]; advisories?: string[] }>("/api/agents/validate", yaml);
if (opts.json) {
console.log(JSON.stringify(res.data));
@@ -65,6 +65,8 @@ export function agentCommand(depsOverride?: AgentDeps): Command {
}
const data = res.data;
// Advisories are printed whether or not the spec is valid; they never make it invalid.
for (const advisory of data.advisories ?? []) console.error(`⚠ agent spec advisory: ${advisory}`);
if (data.valid) {
const name = yamlScalar(yaml, "name") ?? "unknown";
const version = yamlScalar(yaml, "version") ?? "unknown";
+1 -1
View File
@@ -62,7 +62,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
.option("--member <name>", "Member name for pod attach mode")
.option("--runtime <runtime>", "Runtime for pod attach mode; optional guard for node mode")
.option("--cwd <path>", "Working directory to record", process.cwd())
.option("--display-name <name>", "Display label inside tmux; external session name outside tmux")
.option("--display-name <name>", "Display label inside tmux; external session address outside tmux (new bare names gain @rig)")
.option("--print-env", "Print shell exports for OPENRIG_NODE_ID and OPENRIG_SESSION_NAME")
.option("--json", "JSON output, including the activity env the attached app needs. Its token is a credential: don't print or paste it, and use rig whoami for identity")
.action(async (opts: {
+4 -1
View File
@@ -222,10 +222,13 @@ async function runCrossHostBroadcast(
* local route does, so per-target honesty prints identically. A partial
* fan-out sets a non-zero exit either way. */
function renderBroadcastResults(data: Record<string, unknown>): void {
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; managedCompaction?: { notice?: unknown } }>) ?? [];
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; inputPhase?: string; sent?: boolean; managedCompaction?: { notice?: unknown } }>) ?? [];
for (const r of results) {
if (r.ok) {
console.log(`${r.sessionName}: sent`);
} else if (r.inputPhase === "attempted" || r.inputPhase === "written" || r.sent === true) {
// The daemon records this recipient's receipt as indeterminate (#2507): its text or Enter may have reached it.
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}UNCONFIRMED — ${r.error ?? "unknown error"}; the text may have reached it, so check with rig capture before resending`);
} else {
// A target that resolved to no sessions has no name to report.
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`);
+14 -2
View File
@@ -55,8 +55,20 @@ manual request. Cancel an active preparation before retrying or using --skip-map
Automatic preparation allows 25 minutes from prep delivery for the map. A completed
map stays armed for idle through the next retry interval (10 minutes by default),
without another preparation prompt; --state includes idleDeadlineAt. A completed
map that never reaches idle reports map_complete_seat_never_idle; an unfinished
map reports preparation_incomplete. Manual requests still share one 180-second budget.
automatic map whose /compact is refused before input (a draft in the box, the pane in
a tmux mode) or that never reaches idle in that time is held instead: --state shows
prepared with preparedAt and preparedReason, and /compact goes in at the next idle
moment with an empty box, with the same map and no new preparation prompt. A held
attempt stops on a fresh sample below the threshold or from another conversation
(below_threshold_while_prepared), once usage has risen 15 points since its request
or two hours after its map was ready (prepared_map_stale), or when its map no longer
reads ready (preparation_incomplete).
After an attempt stops with its request delivered, the next automatic request for the
same conversation waits for an idle seat, across a daemon restart too, for up to the
restart-recovery window (6 hours by default) after that attempt stopped. An unfinished
map reports preparation_incomplete. Manual requests still
share one 180-second budget and stop as before; while an attempt is held, a manual
request reads already_in_progress until --cancel.
Stopped attempts may retry automatically after the cooldown; cancellation does not.
Cancellation disarms future input; it cannot undo a command already submitted. Raw provider /compact and normal messages are unchanged.`);
+48 -6
View File
@@ -11,7 +11,7 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
}
// These settings remain boot-only; capture interval/line settings reload live.
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "db.path", "terminal.tmux_server"];
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "daemon.seat_poll_interval_ms", "db.path", "terminal.tmux_server"];
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
function isBootOnlyKey(key: string): boolean {
@@ -55,7 +55,7 @@ Examples:
rig config init-workspace # scaffold a repo-ready project workspace
Keys:
daemon.* port, host
daemon.* port, host, seat_poll_interval_ms (startup-only seat-pollers cadence, 1000–2000; default 1000)
db.path
transcripts.* enabled, path, lines, poll_interval_seconds
(lines/poll_interval set scrollback CAPTURE depth + cadence.
@@ -103,7 +103,26 @@ Keys:
an unreadable registry does not suppress eligibility; delivery still needs a ready route)
retention.* enabled, transitions_days, watchdog_days,
watchdog_keep_per_job, batch_size
retention.events_days delete the history-only event types (views, snapshots, watchdog firings, queue events) older
than N days; 0 keeps them (default). Removed events no longer replay from /api/events, leave
the telemetry export, the log and the feed, and can't be brought back. Read at the next sweep
(daily and at daemon start)
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
works wherever OpenRig is installed. Inherited setup isn't re-run per command: setup only in
login or rc files (including a tmux default-command that isn't a login shell) and shell
functions or aliases may be absent. On omits that override; it doesn't force login shells,
and a login file that sets PATH outright can then hide rig. Applies at each seat's next
launch; a restart in place keeps the running choice.
terminal.inherit_tmux_environment inherit stored tmux global values for new agents and successors (default off)
POSIX launches otherwise start from daemon + seat environment, preserving shell startup.
Turn on for custom setups that intentionally rely on tmux globals. Running agents are unchanged.
@@ -123,10 +142,13 @@ Keys:
usage.pi_openrouter_charge with usage.pi_cost_facts on, Pi seats look up OpenRouter's actual charge for their requests, shown beside the estimate (default off; changes take effect when a Pi seat next launches or resumes; earlier outcomes stay visible)
transport.verify_input_box with --verify, a send to a Claude Code or Codex seat (also queue wakes) reads verified only when the seat was proven idle before the paste and its input box showed the exact text before Enter and was empty after it, on the same pane process; a busy or unknown seat, and anything else, reads unconfirmed, not failed. Reporting only (default off)
transport.local_user_guard on a Mac, the CLI refuses a local daemon whose reported user, checked with ps, isn't yours; an explicit OPENRIG_URL skips the check. A mitigation for reaching the wrong daemon, not authentication (default off)
transport.draft_delivery deliver (default): a rig send or work notice to a seat whose prompt holds a person's draft goes through: the message is pasted at the current cursor into the draft (normally its end), then Enter is sent to submit the combined input, and the send's result says whether that was confirmed. OpenRig can't read every prompt layout: where it doesn't detect the draft, the result doesn't mention it, and refuse doesn't stop the send. refuse: such a send is refused, and a work notice retried once the prompt is clear, where OpenRig detects the draft. A draft found only by reading the input box may leave that retry waiting for the seat's next activity change or the stuck sweep, rather than the moment the draft clears. Either way, menus and permission prompts are refused, a per-seat typing guard still holds, managed compaction waits for an empty prompt, and a send with --wait-for-idle still waits
decisions.enabled typed decisions (OpenRouter + Jev) for the consumers in decisions.consumers, with OPENROUTER_API_KEY from the
rig process that runs them; off makes no provider calls (default off). rig project decisions status shows the state
decisions.consumers comma-separated consumers that may call (supported: stream; default none). An unknown name makes no calls
decisions.template_dir absolute folder of <name>.yaml templates that replace the shipped ones (default empty: shipped templates only)
acp.to the seat (member@rig) that receives tasks from rig acp serve when it isn't given --to, as when an ACP app
starts it from the ACP registry (default empty: none, so nothing is sent until one is set)
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean; withSource?: boolean }) => {
@@ -193,10 +215,17 @@ Precedence: CLI flag > environment variable > config file > default`)
.action(async (key: string, value: string) => {
try {
store.set(key, value);
console.log(`${key} = ${store.get(key)}`);
const resolved = store.resolveWithSource(key);
console.log(resolved.source === "env"
? `${key} = ${resolved.value} (source: env; file override saved but masked)`
: resolved.source === "default"
? `${key} file override saved; effective value ${resolved.value} (source: default)`
: `${key} = ${resolved.value}`);
// SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly.
if (isBootOnlyKey(key) && (await probeDaemonRunning())) {
console.error(`note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`);
console.error(resolved.source === "env"
? `note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; the saved value remains masked after restart while the environment override is set.`
: `note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`);
if (key === "terminal.tmux_server") console.error("note: surviving sessions keep their previous server until deliberately stopped; rig terminal status shows the effective selection and any pending migration.");
}
} catch (err) {
@@ -212,9 +241,22 @@ Precedence: CLI flag > environment variable > config file > default`)
try {
store.reset(key);
if (key) {
console.log(`${key} reset to default (${store.get(key)}).`);
const resolved = store.resolveWithSource(key);
console.log(resolved.source === "env"
? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).`
: `${key} reset to default (${resolved.value}).`);
} else {
console.log("Config reset to defaults.");
let envKeys: string[] = [];
try {
envKeys = Object.entries(store.resolveAllWithSource())
.filter(([, resolved]) => resolved.source === "env")
.map(([name]) => name);
} catch (err) {
console.error(`note: could not read effective configuration after reset: ${(err as Error).message}`);
}
console.log(envKeys.length
? `Config reset to defaults, except where the environment overrides them: ${envKeys.join(", ")}.`
: "Config reset to defaults.");
}
} catch (err) {
console.error((err as Error).message);
@@ -0,0 +1,69 @@
import { Command } from "commander";
import { join, resolve } from "node:path";
import { ConfigStore } from "../config-store.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { resolveWorkContextHome } from "../lib/work-install.js";
import { ReconstructionError, readReconstruction, readStoreMappings, reconstructionOutput } from "../lib/reconstruction-reader.js";
interface Options {
store?: string; storeMap?: string; project?: string; mission?: string; slice?: string; cwd?: string;
item: string[]; category: string[]; tag: string[]; checkSources?: boolean; maxBytes: string; json?: boolean;
}
const collect = (value: string, previous: string[]) => [...previous, value];
/** A local config/filesystem consumer. This command never starts or contacts a
* daemon, invokes a helper, writes revisions, or takes over the selected work. */
export function reconstructionCommand(): Command {
const cmd = new Command("reconstruction")
.description("Inspect or read an exact immutable reconstruction locally (read-only v0 consumer)");
for (const mode of ["inspect", "view"] as const) {
cmd.command(`${mode} <revision>`)
.description(mode === "inspect" ? "Inspect provenance, dates, size and the complete source manifest" : "Read a faithful selected view with dependencies and explicit omissions")
.option("--store <absolute-path>", "Explicit local v0/import store; default: selected work home/reconstructions")
.option("--store-map <file>", "JSON array of {sourceStore, localStore, sourceHost?}; read-time mapping only")
.option("--project <id>", "Catalog project, with the same inference as context work-install")
.option("--mission <id>", "Mission in the selected project's configured missions root")
.option("--slice <id>", "Slice under the selected mission")
.option("--cwd <path>", "Working directory used for project inference")
.option("--item <id>", "Include item (repeatable; selectors form a union)", collect, [])
.option("--category <name>", "Include category (repeatable)", collect, [])
.option("--tag <name>", "Include tag (repeatable)", collect, [])
.option("--check-sources", "Hash declared local files within 128 files / 64 MiB; never refresh observation dates")
.option("--max-bytes <bytes>", "Maximum complete output, 1024..8388608 UTF-8 bytes", "262144")
.option("--json", "Structured output; inspect always uses JSON")
.addHelpText("after", "\nSupports openrig.reconstruction/v0 with safe integer JSON numbers; other numeric representations are explicitly unsupported. No implicit remote reads. Context does not transfer authority.\n")
.action((revision: string, opts: Options) => {
try {
let store = opts.store;
let resolution: object = { mode: "explicit-store", project: null, home: null };
if (store === undefined || opts.project !== undefined || opts.mission !== undefined || opts.slice !== undefined) {
const config = new ConfigStore();
const home = resolveWorkContextHome({
workspaceRoot: String(config.resolveWithSource("workspace.root").value),
catalogPath: String(config.resolveWithSource("workspace.catalog_path").value),
cwd: resolve(opts.cwd ?? process.cwd()),
...(readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME") ? { sessionName: readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")! } : {}),
...(opts.project === undefined ? {} : { project: opts.project }),
...(opts.mission === undefined ? {} : { mission: opts.mission }),
...(opts.slice === undefined ? {} : { slice: opts.slice }),
});
if ("error" in home) throw new ReconstructionError(home.error.code, home.error.message);
resolution = { mode: store === undefined ? "work-home" : "explicit-store", ...home };
store ??= join(home.home, "reconstructions");
}
const result = readReconstruction({ store, revision,
...(opts.storeMap ? { mappings: readStoreMappings(resolve(opts.storeMap)) } : {}),
items: opts.item, categories: opts.category, tags: opts.tag, checkSources: opts.checkSources });
const response = { ...result, resolution };
console.log(reconstructionOutput(response, mode, Boolean(opts.json), Number(opts.maxBytes)));
if (!result.complete) process.exitCode = 1;
} catch (error) {
const code = error instanceof ReconstructionError ? error.code : "reconstruction_unavailable";
if (opts.json || mode === "inspect") console.log(JSON.stringify({ complete: false, error: { code, message: (error as Error).message } }, null, 2));
else console.error(`${code}: ${(error as Error).message}`);
process.exitCode = 1;
}
});
}
return cmd;
}
+4
View File
@@ -43,6 +43,7 @@ import { realDeps } from "./daemon.js";
import { prepareDaemonAutoStart } from "../daemon-auto-start.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import type { StatusDeps } from "./status.js";
import { reconstructionCommand } from "./context-reconstruction.js";
const contextRuntimeArg = enumArg(["claude-code", "claude", "codex"]);
@@ -194,6 +195,7 @@ async function installPackFromUrl(
// Re-validate the on-disk staged pack, then publish atomically.
validateContextPackManifestForInstall(join(staging, "manifest.yaml"));
assertTreeHasNoSymlinks(staging);
mkdirSync(dirname(targetDir), { recursive: true });
renameSync(staging, targetDir);
return { targetDir, installName };
} catch (err) {
@@ -251,6 +253,8 @@ Examples:
rig context trace --rig factory --pod dev --seat dev-qa --name LEARNED.md
`);
cmd.addCommand(reconstructionCommand());
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
+4 -2
View File
@@ -57,10 +57,12 @@ export function createProcessLiveness(deps: {
readProcessState: (pid: number) => string | null;
}): (pid: number) => ProcessLiveness {
return (pid: number) => {
if (deps.signal(pid) === "missing") return "dead";
const signal = deps.signal(pid);
if (signal === "missing") return "dead";
const state = deps.readProcessState(pid)?.trim();
if (!state) return "unknown";
return state.startsWith("Z") ? "dead" : "alive";
if (state.startsWith("Z")) return "dead";
return signal === "not-permitted" ? "unknown" : "alive";
};
}
+128 -15
View File
@@ -6,6 +6,7 @@ import { execSync } from "node:child_process";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { DaemonClient, ForeignLocalDaemonError, formatDaemonHostForUrl } from "../client.js";
import { ConfigStore } from "../config-store.js";
import { fetchWithTimeout } from "../fetch-with-timeout.js";
import { buildWritableHomeCheck } from "../system-preflight.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
@@ -45,7 +46,7 @@ export interface DoctorDeps {
/** The caller's effective user id, compared with the one the port's daemon reports. Absent: this process's own;
* null: this platform has none. */
callerUid?: number | null;
/** How long the port's /healthz body may take to arrive (default 2000 ms); after that its owner is unknown. */
/** How long each part of /healthz and the cmux status response may take (default 2000 ms). */
ownerReportTimeoutMs?: number;
/**
* Build B — path to a rig spec to check against the RUNNING rig of the same name (`--spec`).
@@ -61,6 +62,7 @@ export interface DoctorDeps {
}
const DEFAULT_PORT = 7433;
const DOCTOR_HTTP_TIMEOUT_MS = 2000;
const CMUX_SOCKET_CONTROL_FIX = 'In cmux Settings > Automation, select "Automation mode" for Socket Control Mode, or set automation.socketControlMode to "automation" in ~/.config/cmux/cmux.json (older versions use ~/.config/cmux/settings.json; current cmux reads that legacy file only for keys absent from cmux.json). cmux reloads saved settings automatically; rerun `rig doctor` to re-probe without restarting the daemon.';
/** The port's OpenRig daemon, judged by the OS user it reports. /healthz's pid and euid are the daemon's own report,
@@ -301,6 +303,15 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
const daemonPort = config.daemon.port ?? DEFAULT_PORT;
const daemonBase = `http://${formatDaemonHostForUrl(daemonHost)}:${daemonPort}`;
const fetchFn = deps.fetch ?? globalThis.fetch;
const healthHeaders = async (controller: AbortController) => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
fetchFn(`${daemonBase}/healthz`, { signal: controller.signal }),
new Promise<null>(resolve => { timer = setTimeout(() => { resolve(null); controller.abort(); }, deps.ownerReportTimeoutMs ?? 2000); }),
]);
} finally { clearTimeout(timer); }
};
const portCheck = deps.checkPort(daemonPort, daemonHost).then(async (available): Promise<DoctorCheck> => {
if (available) {
return { name: "port", status: "pass", message: `Port ${daemonHost}:${daemonPort} available.` };
@@ -308,7 +319,13 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
// Port in use — check if it's an OpenRig daemon via healthz, and whose.
const owner = new AbortController();
try {
const res = await fetchFn(`${daemonBase}/healthz`, { signal: owner.signal });
const res = await healthHeaders(owner);
if (!res) return {
name: "port", status: "warn",
message: `Port ${daemonHost}:${daemonPort} is in use by an unknown listener.`,
reason: "Its /healthz response headers did not arrive in time, so doctor cannot identify it.",
fix: `Check the listener on port ${daemonPort} or configure a free port for the daemon.`,
};
if (res.ok) return await listenerOwnerCheck(`${daemonHost}:${daemonPort}`, daemonBase, res, () => owner.abort(), deps);
} catch { /* not our daemon */ }
finally { owner.abort(); }
@@ -325,19 +342,27 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
const asyncChecks: Promise<DoctorCheck>[] = [portCheck];
if (shellCmuxPassed) {
const daemonCmuxCheck = (async (): Promise<DoctorCheck> => {
const health = new AbortController();
try {
const healthRes = await fetchFn(`${daemonBase}/healthz`);
if (!healthRes.ok) {
const healthRes = await healthHeaders(health);
if (!healthRes?.ok) {
return { name: "cmux_daemon", status: "skipped", message: "Daemon healthz not ok. Skipping daemon cmux check." };
}
} catch {
return { name: "cmux_daemon", status: "skipped", message: "Daemon not reachable. Skipping daemon cmux check." };
}
} finally { health.abort(); }
try {
const cmuxRes = await fetchFn(`${daemonBase}/api/adapters/cmux/status`);
if (cmuxRes.ok && cmuxRes.json) {
const data = (await cmuxRes.json()) as { available?: boolean };
let data: { available?: boolean } = {};
const cmuxRes = await fetchWithTimeout(fetchFn as typeof fetch, `${daemonBase}/api/adapters/cmux/status`, {}, {
timeoutMs: deps.ownerReportTimeoutMs ?? DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's cmux status read timed out.",
consumeResponse: async res => {
if (res.ok && res.json) data = (await res.json()) as { available?: boolean };
else await res.body?.cancel();
},
});
if (cmuxRes.ok) {
if (data.available) {
return { name: "cmux_daemon", status: "pass", message: "Daemon cmux control available." };
}
@@ -350,11 +375,72 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
asyncChecks.push(daemonCmuxCheck);
}
asyncChecks.push(buildTmuxControlModeCheck(deps, daemonBase));
asyncChecks.push(buildSpecConformanceCheck(deps));
return { checks, portCheck, asyncChecks };
}
type TmuxControlReport = { requested?: boolean; state?: "on" | "allowed" | "off" | "undecided"; serverVersion?: string };
/**
* #161 — does the daemon drive tmux through one control-mode client (OPENRIG_TMUX_CONTROL=1)? Asked for on a server
* older than tmux 3.7c, it uses a spawned client for every command, and nothing else says so.
*/
async function buildTmuxControlModeCheck(deps: DoctorDeps, daemonBase: string): Promise<DoctorCheck> {
const name = "tmux_control_mode";
let control = null as TmuxControlReport | null;
try {
await fetchWithTimeout((deps.fetch ?? globalThis.fetch) as typeof fetch, `${daemonBase}/api/adapters/tmux/control`, {}, {
timeoutMs: deps.ownerReportTimeoutMs ?? DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's tmux control mode read timed out.",
consumeResponse: async res => {
if (res.ok && res.json) control = (await res.json()) as TmuxControlReport;
else await res.body?.cancel();
},
});
} catch { /* no daemon, or one from before this route */ }
if (!control) {
return { name, status: "skipped", message: "The daemon didn't say whether it uses tmux control mode (it isn't running, or it's older than this check)." };
}
if (!control.requested) {
if ((deps.env ?? process.env)["OPENRIG_TMUX_CONTROL"] !== "1") {
return { name, status: "skipped", message: "tmux control mode isn't asked for (OPENRIG_TMUX_CONTROL=1 in the daemon's environment)." };
}
return {
name, status: "warn",
message: "OPENRIG_TMUX_CONTROL=1 is set in this shell, but the running daemon wasn't started with it, so it spawns a tmux client for every command.",
fix: "Set OPENRIG_TMUX_CONTROL=1 in the daemon's own environment (the shell or service that starts it), then restart the daemon.",
};
}
const version = control.serverVersion;
if (control.state === "on") {
return { name, status: "pass", message: `tmux control mode is on: the daemon's tmux server runs tmux ${version}.` };
}
if (control.state === "undecided") {
return { name, status: "skipped", message: "tmux control mode is asked for, and the daemon hasn't read its tmux server's version yet. It does at its first tmux command while a session runs." };
}
if (control.state === "allowed") {
return {
name, status: "warn",
message: `tmux control mode is asked for and the server's tmux ${version} allows it, but no control client is connected right now, so tmux commands use a spawned client.`,
reason: "The daemon starts a control client at a tmux command while another session runs on the server. One that can't start or attach is tried again after a while, and commands are spawned meanwhile.",
};
}
if (!version) {
return { name, status: "warn", message: "tmux control mode is asked for but this daemon doesn't use a control client: it runs on Windows (psmux) or with OPENRIG_TMUX_SHELL=1." };
}
const known = /^\d+\.\d+[a-z]?$/.test(version);
return {
name, status: "warn",
message: known
? `tmux control mode is asked for but off: the daemon's tmux server runs tmux ${version}, and control mode needs tmux 3.7c or later.`
: `tmux control mode is asked for but off: the daemon's tmux server runs tmux ${version}, which isn't a tmux release known to be 3.7c or later.`,
reason: "Starting a control client can crash a tmux server older than 3.7c, so the daemon spawns a tmux client for every command instead. The running server's version decides this, not the tmux binary on PATH.",
fix: "Run the daemon's sessions on a tmux 3.7c or later server: rig context get reference/tmux-servers.md#moving-an-existing-team-onto-tmux-control-mode",
};
}
/**
* Build B — does the rig spec still describe the rig that is running?
*
@@ -398,6 +484,12 @@ async function buildSpecConformanceCheck(deps: DoctorDeps): Promise<DoctorCheck>
let liveIds: string[] | null;
try { liveIds = deps.fetchLiveLogicalIds ? await deps.fetchLiveLogicalIds(rigName) : null; }
catch (error) {
if (error instanceof AmbiguousLiveRigError) return {
name, status: "skipped",
message: `Multiple live rigs match '${rigName}': ${error.ids.join(", ")}; conformance unknown.`,
reason: "The spec name does not identify one live rig, so no topology was compared.",
fix: "Inspect the matching rig IDs with rig ps --json; use rig ps --nodes -A --json to inspect nodes.",
};
if (error instanceof ForeignLocalDaemonError) return { name, status: "fail", message: error.message };
throw error;
}
@@ -482,22 +574,43 @@ function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
}
}
class AmbiguousLiveRigError extends Error {
constructor(readonly ids: string[]) { super("Multiple live rigs share the spec name"); }
}
/** Raw topology reads retain their existing headers; the owner policy is shared with the client. */
export async function readLiveLogicalIds(base: string, rigName: string, ownerClient = new DaemonClient(base)): Promise<string[] | null> {
try {
await ownerClient.assertLocalOwner();
const rigsRes = await fetch(`${base}/api/rigs`);
let rigs: Array<{ id?: string; name?: string }> = [];
const rigsRes = await fetchWithTimeout(fetch, `${base}/api/rigs`, {}, {
timeoutMs: DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's live rig read timed out.",
consumeResponse: async res => {
if (res.ok) rigs = (await res.json()) as typeof rigs;
else await res.body?.cancel();
},
});
if (!rigsRes.ok) return null;
const rigs = (await rigsRes.json()) as Array<{ id?: string; name?: string }>;
const rig = rigs.find((r) => r.name === rigName);
const matches = rigs.filter((r) => r.name === rigName);
if (matches.length > 1) throw new AmbiguousLiveRigError(matches.map(r => r.id ?? "(missing id)"));
if (matches.length === 0) return null;
const rig = matches[0];
if (!rig?.id) return null;
await ownerClient.assertLocalOwner();
const nodesRes = await fetch(`${base}/api/rigs/${rig.id}/nodes`);
let nodes: Array<{ logicalId?: string | null }> = [];
const nodesRes = await fetchWithTimeout(fetch, `${base}/api/rigs/${rig.id}/nodes`, {}, {
timeoutMs: DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's live node read timed out.",
consumeResponse: async res => {
if (res.ok) nodes = (await res.json()) as typeof nodes;
else await res.body?.cancel();
},
});
if (!nodesRes.ok) return null;
const nodes = (await nodesRes.json()) as Array<{ logicalId?: string | null }>;
return nodes.map((n) => n.logicalId ?? "");
} catch (error) {
if (error instanceof ForeignLocalDaemonError) throw error;
if (error instanceof ForeignLocalDaemonError || error instanceof AmbiguousLiveRigError) throw error;
return null;
}
}
@@ -529,7 +642,7 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
const base = `http://${formatDaemonHostForUrl(cfg.daemon.host ?? "127.0.0.1")}:${cfg.daemon.port ?? DEFAULT_PORT}`;
return await readLiveLogicalIds(base, rigName);
} catch (error) {
if (error instanceof ForeignLocalDaemonError) throw error;
if (error instanceof ForeignLocalDaemonError || error instanceof AmbiguousLiveRigError) throw error;
return null;
}
},
+5
View File
@@ -147,6 +147,11 @@ Before running it, check who is working (rig ps --nodes --rig <rig>) and that th
don't stop a team you didn't start unless asked. OpenRig snapshots the rig first (best-effort), and
rig up <rig> --existing brings it back, resuming each seat's conversation where its harness allows. With
--delete the rig record is removed too, so it can't be brought back that way.
Once no live seat uses a working folder, rig down also takes back what OpenRig added there: its
guidance blocks, and in a Claude seat's folder the status line, activity hooks and context collector.
It keeps and lists an edited skills-line block, edited team skill files, and settings or a collector
that don't match what OpenRig wrote or that another seat shares by a link. Any other guidance block
goes even if someone edited it. rig up <rig> --existing puts the guidance back.
When your resolved seat belongs to this rig, confirm before stopping it. Without a terminal,
the command warns and exits without stopping; re-run with --yes to confirm explicitly.`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
+8 -3
View File
@@ -30,9 +30,14 @@ Examples:
throw new Error(error ?? `Failed to list rigs (HTTP ${summaries.status})`);
}
if (!Array.isArray(summaries.data)) throw new Error("The daemon returned an invalid rig summary.");
const match = summaries.data.find((r) => r.name === rigRef || r.id === rigRef);
if (!match) throw new Error(`Rig '${rigRef}' not found`);
return match.id;
const exactId = summaries.data.find((r) => r.id === rigRef);
if (exactId) return exactId.id;
const matches = summaries.data.filter((r) => r.name === rigRef);
if (matches.length > 1) {
throw new Error(`'${rigRef}' matches ${matches.length} rigs (${matches.map((r) => r.id).join(", ")}). Nothing was done; pass one of these ids instead.`);
}
if (matches.length === 0) throw new Error(`Rig '${rigRef}' not found`);
return matches[0]!.id;
}
// rig env status <rig>
+581
View File
@@ -0,0 +1,581 @@
import { Command } from "commander";
import { spawn } from "node:child_process";
import { closeSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { delimiter, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { DaemonClient } from "../client.js";
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { describeUsage, runAcpAssignment, type AcpOutcomeKind, type AcpQueue, type AcpUsage } from "../acp/connector.js";
import type { JsonRpcErrorObject } from "../acp/json-rpc-stdio.js";
import { findRunProcesses, RUN_MARKER } from "../acp/run-processes.js";
import { daemonAcpQueue, newId, processStart, runsDir } from "./acp.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
// `rig exec`: one queue row's task as one turn on a named, version-qualified ACP harness (Claude Code or Codex through
// their maintained adapters), run by a detached connector that outlives the calling CLI. The connector owns the run,
// the run file is its registry, and the row stays the custody record: the outcome is the agent's report, and accepting
// the work stays with the row's custodian. `rig acp run` is the synchronous form and is unchanged.
/** Set only in the connector's own environment, so a run's connector can be found before it records its pid. */
export const CONNECTOR_MARKER = "OPENRIG_EXEC_CONNECTOR";
export interface Harness {
name: string;
/** The adapter package, and the version this configuration was checked with. */
package: string;
qualified: string;
bin: string;
native: { package: string; label: string; versionField?: string };
/** Environment the caller may set to choose what runs or what the agent asks; passed through untouched. */
env: string[];
reject: string;
/** The adapter's own evidence that a `session/load` error means the session doesn't exist. */
missingSession: (error: JsonRpcErrorObject, sessionId: string) => boolean;
}
/** The ACP SDK's `sessionNotFound`: -32002 with `data.sessionId` naming the session asked for. A bare -32002 isn't. */
const sessionNotFound = (error: JsonRpcErrorObject, sessionId: string) =>
error.code === -32002 && (error.data as { sessionId?: unknown } | null | undefined)?.sessionId === sessionId;
export const HARNESSES: Record<string, Harness> = {
claude: {
name: "claude", package: "@agentclientprotocol/claude-agent-acp", qualified: "0.101.0", bin: "claude-agent-acp",
native: { package: "@anthropic-ai/claude-agent-sdk", label: "Claude Code", versionField: "claudeCodeVersion" },
env: ["CLAUDE_CODE_EXECUTABLE", "ANTHROPIC_MODEL", "CLAUDE_CONFIG_DIR"],
reject: "a reject refuses that one action, and the turn continues",
missingSession: sessionNotFound,
},
codex: {
name: "codex", package: "@agentclientprotocol/codex-acp", qualified: "2.5.0", bin: "codex-acp",
native: { package: "@openai/codex", label: "Codex" },
env: ["INITIAL_AGENT_MODE", "CODEX_HOME", "CODEX_CONFIG"],
reject: "its only refusal, cancel, ends the whole turn",
// codex-acp 2.5.0 passes a failed thread resume through as -32603 carrying Codex's own text, which the adapter's
// own code reads as a missing rollout. Any other -32603 is an internal error, not a missing session.
missingSession: (error, sessionId) => sessionNotFound(error, sessionId) || (error.code === -32603
&& JSON.stringify(error.data ?? "").includes("no rollout found for thread id") && JSON.stringify(error.data).includes(sessionId)),
},
};
export interface ResolvedHarness {
harness: Harness;
/** The adapter's program as found on PATH; it runs as-is. */
command?: string;
adapterVersion?: string;
native: string;
problem?: string;
}
function readJson(file: string): Record<string, unknown> | undefined {
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>; } catch { return undefined; }
}
function onPath(bin: string, path = ""): string | undefined {
for (const dir of path.split(delimiter)) {
if (!dir) continue;
try { if (statSync(join(dir, bin)).isFile()) return join(dir, bin); } catch { /* not in this folder */ }
}
return undefined;
}
/** The installed package a file belongs to: the nearest folder above it whose package.json has that name. */
function packageRoot(file: string, name: string): string | undefined {
for (let dir = dirname(file); dir !== dirname(dir); dir = dirname(dir)) {
if (readJson(join(dir, "package.json"))?.name === name) return dir;
}
return undefined;
}
/** Node's lookup from the adapter's folder, reading package.json directly (a package's exports map may not list it). */
function dependencyManifest(from: string, name: string): Record<string, unknown> | undefined {
for (let dir = from; dir !== dirname(dir); dir = dirname(dir)) {
const manifest = readJson(join(dir, "node_modules", name, "package.json"));
if (manifest) return manifest;
}
return undefined;
}
export function resolveHarness(harness: Harness, env: NodeJS.ProcessEnv = process.env): ResolvedHarness {
const found = onPath(harness.bin, env.PATH);
if (!found) {
return { harness, native: "unknown", problem: `${harness.bin} isn't on PATH; install it with npm install --prefix <dir> ${harness.package}@${harness.qualified} and put <dir>/node_modules/.bin on PATH` };
}
const root = packageRoot(realpathSync(found), harness.package);
const version = root ? readJson(join(root, "package.json"))?.version : undefined;
const manifest = root ? dependencyManifest(root, harness.native.package) : undefined;
const field = harness.native.versionField ? manifest?.[harness.native.versionField] : undefined;
const bundled = !manifest ? `${harness.native.label}: ${harness.native.package} wasn't found beside the adapter`
: field !== undefined ? `${harness.native.label} ${String(field)}, from ${harness.native.package} ${String(manifest.version)}`
: `${harness.native.label} ${String(manifest.version)} (${harness.native.package})`;
const native = harness.name === "claude" && env.CLAUDE_CODE_EXECUTABLE
? `the Claude Code at ${env.CLAUDE_CODE_EXECUTABLE} (CLAUDE_CODE_EXECUTABLE; its version isn't read here), not ${bundled}`
: bundled;
return { harness, command: found, adapterVersion: typeof version === "string" ? version : undefined, native };
}
/** The harness's caller-set environment, by name; CODEX_CONFIG's contents aren't shown. */
function callerEnv(harness: Harness, env: NodeJS.ProcessEnv = process.env): string[] {
return harness.env.filter((name) => env[name] !== undefined).map((name) => (name === "CODEX_CONFIG" ? `${name} (set)` : `${name}=${env[name]}`));
}
export function describeHarness(resolved: ResolvedHarness): string {
const { harness } = resolved;
const installed = resolved.problem ? `not installed: ${resolved.problem}`
: `${harness.package} ${resolved.adapterVersion ?? "(version unreadable)"} at ${resolved.command}${resolved.adapterVersion === harness.qualified ? "" : `; checked with ${harness.qualified}`}`;
const set = callerEnv(harness);
return [
`${harness.name}: ${installed}`,
` runs: ${resolved.native}`,
` answers: ${harness.reject}`,
` permissions: the agent's own mode decides what it asks; rig exec adds no policy. Read here: ${harness.env.join(", ")}${set.length ? ` (set: ${set.join(", ")})` : " (none set)"}`,
].join("\n");
}
type ExecState = "requested" | "started" | "finished" | "interrupted";
/** A run's registry entry. `pid` is whichever process owns the run now: the requesting CLI until the connector records itself. */
export interface ExecRun {
runId: string;
qitemId: string;
harness: string;
agent: string[];
cwd: string;
self: string;
permissionsTo?: string;
loadSession?: string;
pollMs: number;
adapterVersion?: string;
native: string;
state: ExecState;
pid: number;
processStart?: string;
requestedAt: string;
startedAt?: string;
endedAt?: string;
outcome?: AcpOutcomeKind;
summary?: string;
usage?: AcpUsage;
settle?: string;
transcript: string;
connectorLog: string;
}
const now = () => new Date().toISOString();
const runPath = (runId: string) => join(runsDir(), `${runId}.json`);
/** A row's claims: one file per generation, `000001.json` upward, each published whole and never removed. */
const rowDir = (qitemId: string) => join(runsDir(), "rows", qitemId.replace(/[^A-Za-z0-9._-]/g, "_"));
/** A run's two transitions, each published once: `start` by the connector that runs it, or by whoever settles a run
* that never started; `end` by whichever records how a started run ended. The slots are the run's state; the record
* file is a copy written after each, for readers like `rig acp cancel`, and may lag a crash. */
const slotPath = (runId: string, slot: "start" | "end") => join(runsDir(), `${runId}.${slot}.json`);
type StartSlot = { by: "connector"; pid: number; processStart?: string; startedAt: string } | { by: "settled"; ended: Partial<ExecRun> };
type EndSlot = { by: "connector" | "settled"; ended: Partial<ExecRun> };
let drafts = 0;
/** Publishes `content` at `target` whole, and only if nothing is there: a hard link fails if the target exists. */
function publishOnce(target: string, content: unknown): boolean {
const draft = `${target}.${process.pid}.${++drafts}.tmp`;
writeFileSync(draft, `${JSON.stringify(content)}\n`);
try {
linkSync(draft, target);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
throw error;
} finally { rmSync(draft, { force: true }); }
}
export function readRun(runId: string): ExecRun | undefined {
const base = readJson(runPath(runId)) as ExecRun | undefined;
if (!base || typeof base.state !== "string") return undefined;
const start = readJson(slotPath(runId, "start")) as StartSlot | undefined;
if (start?.by === "settled") return { ...base, ...start.ended };
let run: ExecRun = start?.by === "connector"
? { ...base, state: "started", pid: start.pid, processStart: start.processStart, startedAt: start.startedAt }
: base;
const end = readJson(slotPath(runId, "end")) as EndSlot | undefined;
if (end) run = { ...run, ...end.ended };
return run;
}
function writeRun(run: ExecRun): void {
const tmp = `${runPath(run.runId)}.${process.pid}.tmp`;
writeFileSync(tmp, `${JSON.stringify(run)}\n`);
renameSync(tmp, runPath(run.runId));
}
function topClaim(qitemId: string): { generation: number; runId?: string } | undefined {
let names: string[];
try { names = readdirSync(rowDir(qitemId)); } catch { return undefined; }
const generation = Math.max(0, ...names.filter((name) => /^\d{6}\.json$/.test(name)).map((name) => Number(name.slice(0, 6))));
if (generation === 0) return undefined;
const runId = readJson(join(rowDir(qitemId), `${String(generation).padStart(6, "0")}.json`))?.runId;
return { generation, runId: typeof runId === "string" ? runId : undefined };
}
export const rowRun = (qitemId: string): string | undefined => topClaim(qitemId)?.runId;
/** Makes `runId` the row's run: when the row has none, or when its current run is exactly `previous`. The next
* generation's file is published with a hard link, which fails if it exists, so of two callers that read the same
* current run only one claims, and nothing is ever moved aside. `afterRead` lets a test act between the read and
* the publish. */
export function claimRow(qitemId: string, runId: string, previous?: string, afterRead?: () => void): boolean {
const dir = rowDir(qitemId);
mkdirSync(dir, { recursive: true });
const top = topClaim(qitemId);
if (top?.runId !== previous) return false;
afterRead?.();
return publishOnce(join(dir, `${String((top?.generation ?? 0) + 1).padStart(6, "0")}.json`), { runId, qitemId, replaces: previous ?? null });
}
type Liveness = "live" | "dead" | "unknown";
/** A started run is live while its recorded process is still the one that recorded itself. A requested run is live
* only while a process carries its connector marker: the record then names the requester, which isn't the connector
* and may itself be waiting. */
async function liveness(run: ExecRun): Promise<Liveness> {
if (run.endedAt) return "dead";
if (run.state !== "requested" && run.processStart !== undefined && processStart(run.pid) === run.processStart) return "live";
const found = await findRunProcesses(run.runId, CONNECTOR_MARKER).catch(() => undefined);
return found === undefined ? "unknown" : found.length > 0 ? "live" : "dead";
}
/** Ends `run` as `ended`, exclusively: a run that hasn't started by claiming its start slot, so its connector can no
* longer start; a started run by claiming its end slot, so its connector can no longer record an outcome over this.
* Only the winner writes the copy and the row note; a loser gets the run as whoever won left it. */
export async function settleRun(run: ExecRun, ended: Partial<ExecRun>, note?: string, queue?: () => Promise<AcpQueue | undefined>): Promise<{ won: boolean; run: ExecRun }> {
const won = run.state === "requested"
? publishOnce(slotPath(run.runId, "start"), { by: "settled", ended } satisfies StartSlot)
: publishOnce(slotPath(run.runId, "end"), { by: "settled", ended } satisfies EndSlot);
const after = readRun(run.runId) ?? run;
if (!won) return { won, run: after };
writeRun(after);
const q = note ? await queue?.().catch(() => undefined) : undefined;
await q?.update(run.qitemId, { note: note! }).catch(() => {});
return { won, run: after };
}
/** A run whose connector is gone without recording an outcome reads interrupted, outcome unknown, with one row note. */
export async function reconcileRun(run: ExecRun, queue?: () => Promise<AcpQueue | undefined>, connectorExit?: string): Promise<ExecRun> {
if (run.endedAt || (await liveness(run)) !== "dead") return run;
const current = readRun(run.runId) ?? run;
if (current.endedAt || (current.state !== run.state && (await liveness(current)) !== "dead")) return current;
const left = await findRunProcesses(run.runId, RUN_MARKER).catch(() => undefined);
const agent = left === undefined ? "whether an agent process remains is unknown (the process table couldn't be read)"
: left.length === 0 ? "no process carrying the run's marker remains"
: `${left.length} process(es) carrying the run's marker still run (${left.map((p) => p.pid).join(", ")})`;
const summary = current.state === "requested"
? `interrupted: its connector ${connectorExit ?? "isn't running"} and never recorded itself, so no turn was sent; ${agent}; its log is ${current.connectorLog}`
: `interrupted: its connector ended without recording an outcome, so the outcome is unknown; ${agent}`;
const settled = await settleRun(current, { state: "interrupted", outcome: "unknown", summary, endedAt: now() },
`rig exec run ${run.runId} ${summary}. Its transcript: ${run.transcript}`, queue);
return settled.run;
}
export function describeRun(run: ExecRun): string {
const state = run.state === "requested" ? "requested; its connector hasn't recorded itself yet"
: run.state === "started" ? `running (connector pid ${run.pid}, since ${run.startedAt})`
: run.state === "interrupted" ? run.summary ?? "interrupted; outcome unknown"
: `finished, outcome ${run.outcome}: ${run.summary}`;
return [
`${run.runId} for ${run.qitemId} on ${run.harness} (${run.agent[0]}${run.adapterVersion ? ` ${run.adapterVersion}` : ""}; runs ${run.native})`,
` ${state}`,
run.state === "finished" ? ` ${describeUsage(run.usage)}` : undefined,
run.endedAt ? " The outcome is the agent's report, not acceptance: the row stays open for its custodian to judge and close." : undefined,
run.settle ? ` After the turn: ${run.settle}` : undefined,
` transcript ${run.transcript}; connector log ${run.connectorLog}`,
].filter((line) => line !== undefined).join("\n");
}
/** Tests only: points where a test acts as a concurrent reconciler would. */
export interface ConnectorHooks { afterGuard?: () => Promise<void>; afterStart?: () => Promise<void>; beforeEnd?: () => Promise<void> }
/** The detached connector: claims the run's start, runs the turn as `rig acp run` does, then claims its end. */
export async function runExecConnector(runId: string, queue: AcpQueue | undefined, hooks: ConnectorHooks = {}): Promise<number> {
const run = readRun(runId);
if (!run) { console.error(`No rig exec run ${runId} on this machine`); return 1; }
// Only the connector `rig exec run` started for this record runs it, once: anything else would send the row's task a
// second time. The record is left untouched.
if (run.state !== "requested" || process.env[CONNECTOR_MARKER] !== runId) {
console.error(`${runId} is ${run.state}${run.state === "requested" ? ", and this process wasn't started for it" : ""}; rig exec connector runs only the run rig exec run started it for`);
return 1;
}
delete process.env[CONNECTOR_MARKER]; // the agent doesn't inherit it; this process's own environment still shows it
// Before the run names this process, so a `rig exec cancel` that reads the record can't reach a default SIGINT.
let cancel!: () => void;
let detach!: () => void;
const cancelRequested = new Promise<void>((done) => { cancel = done; });
const detachRequested = new Promise<void>((done) => { detach = done; });
let interrupts = 0;
process.on("SIGINT", () => { interrupts += 1; if (interrupts === 1) cancel(); else detach(); });
process.on("SIGHUP", () => { /* no terminal owns this process; a hangup isn't a cancel */ });
await hooks.afterGuard?.();
// Claiming the start is what lets this process send the turn. A reconciler that judged the run dead may have claimed
// it first; then the run stays settled and nothing is sent.
if (!publishOnce(slotPath(runId, "start"), { by: "connector", pid: process.pid, processStart: processStart(process.pid), startedAt: now() } satisfies StartSlot)) {
console.error(`${runId} was settled before this connector started (${readRun(runId)?.summary ?? "no summary"}); no turn is sent`);
return 1;
}
const started = readRun(runId)!;
writeRun(started);
await hooks.afterStart?.();
/** Records the end only if nothing recorded one first; a reconciler's record stands, and the row hears what happened. */
const end = async (ended: Partial<ExecRun>): Promise<void> => {
await hooks.beforeEnd?.();
if (publishOnce(slotPath(runId, "end"), { by: "connector", ended } satisfies EndSlot)) { writeRun(readRun(runId)!); return; }
const recorded = readRun(runId);
console.error(`${runId} was recorded ${recorded?.state} before this connector finished; its own outcome was ${ended.outcome}`);
await queue?.update(run.qitemId, { note: `rig exec run ${runId}: its connector finished with outcome ${ended.outcome} (${ended.summary}) after the run had been recorded ${recorded?.state}; that record stands` }).catch(() => {});
};
if (!queue) {
await end({ state: "finished", outcome: "failed", summary: "the OpenRig daemon couldn't be reached, so no turn was sent", endedAt: now() });
return 1;
}
let settle: string | undefined;
try {
const outcome = await runAcpAssignment(queue, {
qitemId: run.qitemId, agent: run.agent, cwd: run.cwd, self: run.self, permissionsTo: run.permissionsTo,
loadSession: run.loadSession, runId, logPath: run.transcript, pollMs: run.pollMs,
progress: (text) => process.stdout.write(text), cancelRequested, detachRequested,
missingSession: HARNESSES[run.harness]?.missingSession, onSettled: (report) => { settle = report; },
});
await end({ state: "finished", outcome: outcome.kind, summary: outcome.summary, usage: outcome.usage, settle, endedAt: now() });
return outcome.kind === "completed" ? 0 : 2;
} catch (error) {
await end({ state: "finished", outcome: "failed", summary: (error as Error).message, settle, endedAt: now() });
return 1;
}
}
export interface ExecDeps {
status?: StatusDeps;
/** The queue as the calling seat (default: the daemon's). */
queue?: () => Promise<AcpQueue | undefined>;
/** The detached connector's command line (default: this CLI's own entry, `exec connector <runId>`). */
connectorCommand?: (runId: string) => string[];
pollMs?: number;
startWaitMs?: number;
/** Tests only: acts between the row-claim read and its publish, as a concurrent caller would. */
beforeClaim?: () => void;
/** Tests only: runs right after the CLI has told its caller about the run, as a process descheduled there would. */
afterAnnounce?: () => void;
}
const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms));
const exitFor = (run: ExecRun) => (run.outcome === "completed" ? 0 : 2);
type Waiter = { left: boolean; leave: () => void };
export function execCommand(deps: ExecDeps = {}): Command {
const cmd = new Command("exec").description("Run a queue row's task on a named harness (Claude Code or Codex over ACP) in a connector that outlives the command, with the outcome kept on the row");
const queueFor = deps.queue ?? (async () => {
const d = deps.status ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const status = await getDaemonStatus(d.lifecycleDeps);
return daemonStatusGuard(status) ? daemonAcpQueue(d.clientFactory(getDaemonUrl(status))) : undefined;
});
const connectorCommand = deps.connectorCommand
?? ((runId: string) => [process.execPath, fileURLToPath(new URL("../index.js", import.meta.url)), "exec", "connector", runId]);
const pollMs = deps.pollMs ?? 1000;
/** The wait's own Ctrl-C, armed before the CLI tells its caller anything about the run, so a Ctrl-C from then on
* only stops the wait: armed later, one landing just after the announcement met the default and killed the CLI. The
* connector is in its own session either way. */
const armWait = (wait: boolean | undefined): Waiter | undefined => {
if (!wait) return undefined;
const waiter: Waiter = { left: false, leave: () => { waiter.left = true; } };
process.on("SIGINT", waiter.leave);
return waiter;
};
/** Polls the run file until it ends, or until the waiter leaves. */
const waitForEnd = async (runId: string, waiter: Waiter): Promise<ExecRun | "left"> => {
for (let polls = 1; ; polls++) {
const run = readRun(runId);
if (!run) throw new Error(`the record of ${runId} is gone`);
if (run.endedAt) return run;
if (waiter.left) return "left";
if (polls % 5 === 0) { const checked = await reconcileRun(run, queueFor); if (checked.endedAt) return checked; }
await sleep(pollMs);
}
};
const finish = (result: ExecRun | "left", runId: string) => {
if (result === "left") {
console.log(`\n[rig exec] stopped waiting; ${runId} keeps running. rig exec status ${runId} shows it; rig exec cancel ${runId} cancels it.`);
process.exitCode = 3;
return;
}
console.log(describeRun(result));
process.exitCode = exitFor(result);
};
/** Every path that holds the row's run ends here: a run that has ended exits by its outcome (0 completed, 2 any
* other), --wait waits for one still going (3 if the waiter leaves), and without --wait a run still going exits 0. */
const report = async (run: ExecRun, waiter: Waiter | undefined) => {
try {
if (run.endedAt) process.exitCode = exitFor(run);
else if (waiter) finish(await waitForEnd(run.runId, waiter), run.runId);
} finally { if (waiter) process.removeListener("SIGINT", waiter.leave); }
};
cmd
.command("harnesses")
.description("List the named harnesses: the adapter and native versions installed here, and how each one differs")
.option("--json", "JSON output")
.action((opts: { json?: boolean }) => {
const all = Object.values(HARNESSES).map((harness) => resolveHarness(harness));
if (opts.json) {
console.log(JSON.stringify(all.map((r) => ({
name: r.harness.name, package: r.harness.package, checkedWith: r.harness.qualified, installed: r.adapterVersion ?? null,
command: r.command ?? null, runs: r.native, answers: r.harness.reject, reads: r.harness.env, set: callerEnv(r.harness), problem: r.problem ?? null,
})), null, 2));
return;
}
console.log(all.map(describeHarness).join("\n\n"));
});
cmd
.command("run <qitemId>")
.description("Start one row's task as a single turn on a named harness, in a connector that outlives this command")
.requiredOption("--harness <name>", `The harness: ${Object.keys(HARNESSES).join(" or ")} (rig exec harnesses)`)
.option("--cwd <dir>", "The agent session's working directory", process.cwd())
.option("--permissions-to <session>", "The seat that answers the agent's permission requests (default: this seat)")
.option("--load-session <id>", "Continue an earlier session; nothing new starts when it can't be loaded")
.option("--wait", "Wait here for the outcome; leaving the wait (Ctrl-C) never cancels the run")
.option("--plan", "Show what would run, with its versions, and start nothing")
.option("--again", "Start another turn on a row whose earlier run has ended")
.option("--poll-seconds <n>", "How often the connector looks for a permission answer", "2")
.addHelpText("after", `
A connector owns the run and outlives this command: closing this terminal, or leaving --wait, doesn't stop it.
It runs one turn of the row's task on the harness's ACP adapter, as rig acp run does, and records the binding,
progress, permission requests and the outcome on the row. The outcome is the agent's report: a completed turn isn't
accepted work, so the row stays open for you to judge and close.
One run per row: running it again returns the row's run, still going or ended, instead of sending the task twice.
--again starts another turn once that run has ended. A run whose connector died without recording an outcome reads
interrupted, outcome unknown.
The agent keeps its own permission mode, and rig exec adds no policy: your environment passes through (rig exec
harnesses lists what each harness reads). A permission request becomes a row to --permissions-to.
rig exec status <runId|qitemId> state, outcome, usage and cost as the agent reported them, and what still runs
rig exec cancel <runId> asks the agent to cancel its turn; the acknowledgement isn't settlement
Guide: rig context get reference/acp-agents.md`)
.action(async (qitemId: string, opts: { harness: string; cwd: string; permissionsTo?: string; loadSession?: string; wait?: boolean; plan?: boolean; again?: boolean; pollSeconds: string }) => {
const harness = HARNESSES[opts.harness];
if (!harness) { console.error(`No harness ${opts.harness}; the named ones are ${Object.keys(HARNESSES).join(", ")}`); process.exitCode = 1; return; }
const resolved = resolveHarness(harness);
if (opts.plan) {
console.log(`${describeHarness(resolved)}\n would run: ${resolved.command ?? harness.bin} for ${qitemId} in ${resolve(opts.cwd)}${opts.loadSession ? `, continuing session ${opts.loadSession}` : ", in a new session"}; permission requests to ${opts.permissionsTo ?? "this seat"}`);
if (resolved.problem) process.exitCode = 1;
return;
}
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
if (!seat) { console.error("rig exec run acts as the seat that owns the row, but OPENRIG_SESSION_NAME isn't set"); process.exitCode = 1; return; }
if (resolved.problem) { console.error(resolved.problem); process.exitCode = 1; return; }
const queue = await queueFor();
if (!queue) { process.exitCode = 1; return; }
mkdirSync(runsDir(), { recursive: true });
const previousId = rowRun(qitemId);
const previous = previousId ? readRun(previousId) : undefined;
if (previous) {
const run = await reconcileRun(previous, async () => queue);
if (opts.again && !run.endedAt) {
console.error(`${run.runId} is still ${run.state} on ${qitemId}; cancel it (rig exec cancel ${run.runId}) or let it end before --again`);
process.exitCode = 1;
return;
}
if (!opts.again) {
const waiter = armWait(opts.wait);
console.log(`${qitemId} already has a run, so the task isn't sent twice:\n${describeRun(run)}\nOnce it has ended, --again starts another turn on this row.`);
await report(run, waiter);
return;
}
}
const row = await queue.show(qitemId).catch((error: Error) => error);
if (row instanceof Error) { console.error(`rig exec run: ${row.message}`); process.exitCode = 1; return; }
if (row.destinationSession !== seat) { console.error(`${qitemId} belongs to ${row.destinationSession}; run rig exec from that seat, or hand the row to ${seat} first`); process.exitCode = 1; return; }
if (row.state !== "pending" && row.state !== "in-progress") { console.error(`${qitemId} is ${row.state}; rig exec runs an in-progress or pending row`); process.exitCode = 1; return; }
const runId = newId("exec", 4);
const run: ExecRun = {
runId, qitemId, harness: harness.name, agent: [resolved.command!], cwd: resolve(opts.cwd), self: seat,
permissionsTo: opts.permissionsTo, loadSession: opts.loadSession, pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
adapterVersion: resolved.adapterVersion, native: resolved.native, state: "requested", pid: process.pid,
processStart: processStart(process.pid), requestedAt: now(),
transcript: join(runsDir(), `${runId}.jsonl`), connectorLog: join(runsDir(), `${runId}.connector.log`),
};
writeRun(run);
if (!claimRow(qitemId, runId, previousId, deps.beforeClaim)) {
rmSync(runPath(runId), { force: true });
const winnerId = rowRun(qitemId);
const winner = winnerId ? readRun(winnerId) : undefined;
const waiter = winner ? armWait(opts.wait) : undefined;
console.log(`${qitemId} was started by another caller as ${winnerId ?? "another run"}, so the task isn't sent twice${winner ? `:\n${describeRun(winner)}` : ""}`);
if (winner) await report(winner, waiter);
else process.exitCode = 1;
return;
}
const log = openSync(run.connectorLog, "a");
const [file, ...args] = connectorCommand(runId);
let spawnError: Error | undefined;
let connectorExit: string | undefined;
try {
const child = spawn(file!, args, { detached: true, stdio: ["ignore", log, log], env: { ...process.env, [CONNECTOR_MARKER]: runId } });
child.once("error", (error) => { spawnError = error; });
child.once("exit", (code, signal) => { connectorExit = `exited (code ${code ?? "none"}, signal ${signal ?? "none"})`; });
child.unref();
} finally { closeSync(log); }
const waiter = armWait(opts.wait);
let current: ExecRun = run;
for (const deadline = Date.now() + (deps.startWaitMs ?? 10_000); Date.now() < deadline; await sleep(50)) {
current = readRun(runId) ?? current;
if (current.state !== "requested" || spawnError || connectorExit || waiter?.left) break;
}
if (spawnError && current.state === "requested") {
current = (await settleRun(current, { state: "finished", outcome: "failed", summary: `the connector couldn't be started: ${spawnError.message}`, endedAt: now() })).run;
} else if (connectorExit && current.state === "requested") {
current = await reconcileRun(current, async () => queue, connectorExit);
}
console.log(`[rig exec] ${runId} for ${qitemId} on ${harness.name}: ${current.endedAt ? `ended, outcome ${current.outcome}: ${current.summary}` : current.state === "started" ? "started" : "requested; its connector hasn't recorded itself yet"}`);
console.log(` rig exec status ${runId} shows it; rig exec cancel ${runId} cancels it.`);
deps.afterAnnounce?.();
await report(current, waiter);
});
cmd
.command("status <runIdOrQitemId>")
.description("Show a run's state, the outcome as the agent reported it, usage and cost, and what still runs")
.option("--json", "JSON output")
.action(async (target: string, opts: { json?: boolean }) => {
const runId = readRun(target) ? target : rowRun(target);
const run = runId ? readRun(runId) : undefined;
if (!run) { console.error(`No rig exec run for ${target} on this machine`); process.exitCode = 1; return; }
const current = await reconcileRun(run, queueFor);
console.log(opts.json ? JSON.stringify(current, null, 2) : describeRun(current));
});
cmd
.command("cancel <runId>")
.description("Ask a running rig exec run's agent to cancel its turn")
.action((runId: string) => {
const run = readRun(runId);
if (!run || run.endedAt || run.state !== "started" || run.processStart === undefined || processStart(run.pid) !== run.processStart) {
console.error(`No running rig exec run ${runId}`);
process.exitCode = 1;
return;
}
process.kill(run.pid, "SIGINT");
console.log(`Cancel requested for ${runId}; its row ${run.qitemId} records what the agent acknowledges. An acknowledgement isn't settlement: rig exec status ${runId} shows the outcome and what still runs.`);
});
cmd
.command("connector <runId>", { hidden: true })
.description("The detached connector that owns a run (started by rig exec run)")
.action(async (runId: string) => { process.exitCode = await runExecConnector(runId, await queueFor()); });
return cmd;
}
+40 -16
View File
@@ -175,7 +175,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.requiredOption("--display-name <name>", "Human-readable display name")
.option(
"--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>",
"Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
"Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline; or file:<path> for relay: relay:<connectorRef>:file:<absolute-env-file>:primary:handle=<safe-alias>). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind for Slack, unique per connectorRef+handle for relay); omit it for outbound-only.",
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
)
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
@@ -253,16 +253,24 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.addHelpText("after", `
Binding specification details:
Format: <kind:connectorRef:secretsRef:role[:handle=<id>]>
- kind: Connector kind (e.g. "slack")
- connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal" or "main")
- secretsRef: Pointer or label identifying where secrets are resolved (e.g. "file:/path/to/slack.env" or "vault://slack/user").
NOTE: Never pass credentials or tokens inline here; credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file').
- kind: Connector kind: "slack" or "relay"
- connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal", "main", or "local-bridge")
- secretsRef: Pointer or label identifying where secrets are resolved:
* For Slack: "file:/path/to/slack.env" or "vault://slack/user". Credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file').
* For Relay: "file:<absolute-env-file>" pointing to a chmod 600 file holding OPENRIG_RELAY_URL (HTTP loopback only, e.g. http://127.0.0.1:8088), OPENRIG_RELAY_OUTBOUND_TOKEN, and OPENRIG_RELAY_INBOUND_TOKEN (distinct non-empty values; no environment fallback).
NOTE: Never pass credentials or tokens inline here.
- role: "primary" (exactly one binding must be primary) or "secondary"
- handle: Optional platform user identifier (e.g. "handle=U12345678") for inbound routing
- handle: Optional platform user identifier (e.g. "handle=U12345678" for Slack, or "handle=<safe-alias>" matching /^[A-Za-z0-9._-]+$/ for relay) for inbound routing. For relay, handles are unique per connectorRef.
Example:
Examples:
# Slack binding
rig gateway human add alice --display-name "Alice" \\
--binding "slack:openrig-personal:file:/etc/openrig/slack.env:primary:handle=U12345678" \\
--delivery-class B
# Relay binding (loopback bridge adapter)
rig gateway human add bob --display-name "Bob" \\
--binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops" \\
--delivery-class B`);
// ── S12 (OPR.0.5.5.12): the fragment lifecycle beyond add. Every verb operates through
@@ -559,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") {
@@ -594,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;
}
@@ -732,17 +757,16 @@ Examples:
.option("--json", "Complete records as JSON, including organizations and groups")
.action(async (opts: { organization?: string; json?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
if (opts.json) {
const result = registry.participantsJsonView(undefined, opts.organization);
if (!result.ok) { console.error(`refused: ${result.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify(result));
return;
}
const view = registry.participantsView();
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
if (opts.json) {
const side = registry.readParticipantsFile();
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
warnings: view.warnings, participantFile: view.sideFile }));
return;
}
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
for (const p of shown) {
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
+47 -19
View File
@@ -24,8 +24,9 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { hostname as osHostname, userInfo } from "node:os";
import { connect } from "node:net";
import { execFile } from "node:child_process";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, 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";
@@ -167,15 +168,35 @@ function defaultDoctorDeps(): DoctorDeps {
};
}
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
* direct dial stands for that transport. */
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
return new Promise((resolve) => {
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
if (error) return resolve(null);
const conf = new Map(stdout.split("\n").map((line) => {
const i = line.indexOf(" ");
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
}));
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
const hostname = conf.get("hostname");
const port = Number(conf.get("port"));
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
});
});
}
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
* "remote rig binary missing/old". */
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
* against the host's transport endpoint (ssh: target:22; http: the
* URL's host:port). Coarse by design (the kubectl STATUS / docker
* context ls precedent): reachable | unreachable | unknown — never a
* hang (hard timeout), never a guess (dial failure = unreachable,
* probe error/unparseable endpoint = unknown). */
* against the host's transport endpoint (ssh: the host and port ssh itself
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
* design (the kubectl STATUS / docker context ls precedent): reachable |
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
* failure = unreachable, probe error/unparseable endpoint = unknown). */
export async function probeHostStatus(
host: HostEntry,
tcpProbe: DoctorDeps["tcpProbe"],
@@ -185,8 +206,9 @@ export async function probeHostStatus(
let target: string;
let port: number;
if (host.transport === "ssh") {
target = host.target;
port = 22;
const endpoint = await resolveSshEndpoint(host, timeoutMs);
if (!endpoint) return "unknown";
({ hostname: target, port } = endpoint);
} else {
const u = new URL(host.url);
target = u.hostname.replace(/^\[|\]$/g, "");
@@ -761,9 +783,9 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
cmd
.command("pair")
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port)")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port); that daemon must run with OPENRIG_AUTH_BEARER_TOKEN set")
.option("--id <id>", "Registry id for the new host (default: derived from the hostname)")
.option("--timeout <seconds>", "How long to wait for the target-side approval", "600")
.option("--timeout <seconds>", "How long to wait for the target-side approval; the request stays open that long on the target (a target older than this CLI keeps it 10 minutes)", "600")
.option("--human <address>", "Registered @external approval recipient on the target (required when several exist)")
.option("--json", "JSON output")
.action(async (rawUrl: string, opts: { id?: string; timeout?: string; json?: boolean; human?: string }) => {
@@ -835,11 +857,17 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
try { return JSON.parse(s) as Record<string, unknown>; } catch { return {}; }
};
// The wait is BOUNDED: a non-finite or overflowing --timeout is clamped to
// MAX_PAIR_TIMEOUT_MS, so a target that stops answering can never spin the
// 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 };
try {
const res = await httpPost(`${targetBase}/api/hosts/pair-request`, {
requester: `${userInfo().username}@${osHostname()}`,
...(opts.human ? { human: opts.human } : {}),
timeoutMs,
});
issued = parseJson(res.body) as typeof issued;
if (res.status !== 200 || !issued.pairId || !issued.code) {
@@ -859,25 +887,25 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
console.log(`On the target: rig queue update ${issued.approvalQitemId ?? "<qitem-id>"} --state done --closure-reason no-follow-on`);
}
// The wait is BOUNDED: a non-finite or overflowing --timeout is clamped to
// MAX_PAIR_TIMEOUT_MS, so a target that stops answering can never spin the
// retry loop forever. Values that work today keep their wait. A note names
// the bound actually used so a clamped value is not silently absorbed.
const timeoutMs = resolvePairTimeoutMs(opts.timeout);
// A note names the bound actually used so a clamped value is not silently absorbed.
if (!opts.json && timeoutMs === MAX_PAIR_TIMEOUT_MS) {
console.log(`Waiting up to ~${(MAX_PAIR_TIMEOUT_MS / 86_400_000).toFixed(1)} days for approval on the target (--timeout capped at the maximum).`);
}
// Poll at once, then every 2 s, never sleeping past the deadline: the target keeps the request open only as long
// as this side waits, so a short --timeout still sees an approval that's already there.
const deadline = Date.now() + timeoutMs;
let outcome: { status?: string; token?: string } = {};
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
for (;;) {
try {
const res = await deps.httpGet(`${targetBase}/api/hosts/pair-request/${issued.pairId}`);
outcome = parseJson(res.body) as typeof outcome;
if (outcome.status && outcome.status !== "pending") break;
} catch {
continue; // transient poll failure: keep waiting until the deadline
// transient poll failure: keep waiting until the deadline
}
if (outcome.status && outcome.status !== "pending") break;
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await new Promise((r) => setTimeout(r, Math.min(2000, remaining)));
}
if (outcome.status !== "approved" || !outcome.token) {
+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);
}
+29 -6
View File
@@ -2,7 +2,7 @@ import { Command } from "commander";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps , daemonStatusGuard} from "../daemon-lifecycle.js";
import { createMcpServer } from "../mcp-server.js";
import { createMcpServer, dropUnfilledSeat, unfilledSeatText, type UnfilledSeat } from "../mcp-server.js";
import { addGrant, McpHttpConfigError, mcpTokenFilePath, readGrantServiceHeader, readGrants, revokeGrant, startMcpHttpServer } from "../mcp-http.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { realDeps } from "./daemon.js";
@@ -25,12 +25,20 @@ export function mcpCommand(depsOverride?: StatusDeps): Command {
.option("--bind <host:port>", "With --http: where to listen (default 127.0.0.1:7437). Beyond loopback needs a grant (rig mcp token add)")
.option("--grant-service <url>", "With --http: check each request's key with the grant service at this URL instead of the token file")
.option("--grant-service-header-file <path>", "With --grant-service: a file holding one 'Name: value' header to send with each grant request")
.option("--task-destination <seat>", "With --http --grant-service: operator-configured local team seat for external requests that omit destination")
.option("--oauth-resource <url>", "With --grant-service and --oauth-issuer: the public https URL clients reach this server as (its path ends in /mcp), for OAuth discovery")
.option("--oauth-issuer <url>", "With --oauth-resource: the OAuth authorization server that issues the keys --grant-service checks")
.option("--front-door", "Serve a person's agent in their own app: it hands the team work and brings the team's questions to the person, instead of working its inbox as a member")
.addHelpText("after", `
Both MCP protocol revisions are served, over stdio and --http alike: clients on 2026-07-28 (which may start with
server/discover) and clients that open with 2025's initialize handshake.
An empty OPENRIG_SESSION_NAME, or one an app passed on unfilled (such as \${user_config.seat}), means no seat: the
server acts as none, never as a seat named after the placeholder or one inherited as RIGGED_SESSION_NAME. With no
seat, messages, new tasks and chat still go through as an unknown sender, each with a note on what is known (why there's
no seat, and how the MCP client describes itself); a task is followed by its qitemId, since there's no return address.
Claiming, updating, handing off or deciding a task needs a seat.
With --http and no token file, the server stays on loopback and acts as OPENRIG_SESSION_NAME, declared and not
authenticated, like stdio. Once a grant exists (rig mcp token add), every request needs its bearer credential, loopback
included, and each caller acts as its grant's seat within its rigs and roots. Add a grant before reaching the port
@@ -43,14 +51,23 @@ for this daemon's instance (its self id); the token file isn't read. A caller ac
must already exist here (created is enough; it needn't have been launched), and reaches only what the grant names. If
the grant service can't answer, the request is refused.
With --task-destination <member@rig>, external callers can send their first task without knowing a seat address.
Supply the intended receiver from trusted operator configuration for this server, never from a caller's input.
On every omitted destination, the receiver must be exactly one existing local node and within that request's grant.
Missing, ambiguous or forbidden receivers cause no write. This option doesn't grant access or select a receiver from
topology, names or task history. Explicit destinations keep their existing behavior; local and member calls still
require destination. A server with multiple teams needs a receiver valid for the calling grant, or explicit destinations.
With --grant-service, --oauth-resource and --oauth-issuer (both or neither) add OAuth discovery (RFC 9728) for clients
that sign in with OAuth. The resource is the public URL this server is reached as, and the issuer is the authorization
server that issues the keys --grant-service checks. GET /.well-known/oauth-protected-resource<resource path> then answers
with both, exactly as given, without a credential or a call to the grant service, and every 401 points there. Both must
be https with no userinfo, query or fragment, written as a URL writes them (a lowercase host, no default port). /mcp
stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
.action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; oauthResource?: string; oauthIssuer?: string }) => {
.action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; taskDestination?: string; oauthResource?: string; oauthIssuer?: string; frontDoor?: boolean }) => {
const deps = getDepsF();
const unfilledSeat = dropUnfilledSeat();
if (unfilledSeat) console.error(`rig mcp serve: ${unfilledSeatText(unfilledSeat)}; serving with no seat until it names this app's address on the team.`);
let daemonUrl: string;
if (opts.port) {
@@ -80,6 +97,11 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
process.exitCode = 1;
return;
}
if (opts.taskDestination !== undefined && !(opts.http && opts.grantService)) {
console.error("--task-destination needs --http --grant-service");
process.exitCode = 1;
return;
}
if ((opts.oauthResource === undefined) !== (opts.oauthIssuer === undefined)) {
console.error("--oauth-resource and --oauth-issuer go together: give both, or neither");
process.exitCode = 1;
@@ -92,7 +114,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
return;
}
if (opts.http) {
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth);
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth, opts.taskDestination, opts.frontDoor === true, unfilledSeat);
return;
}
const client = deps.clientFactory(daemonUrl);
@@ -102,7 +124,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
process.once("SIGINT", finish);
process.once("SIGTERM", finish);
// Serves the protocol revision the client opens with: 2026-07-28 per request, or 2025's initialize handshake.
const served = serveStdio(() => createMcpServer(client), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) });
const served = serveStdio(() => createMcpServer(client, { frontDoor: opts.frontDoor === true, ...(unfilledSeat ? { unfilledSeat } : {}) }), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) });
try {
// EOF is a normal stdio disconnect, not an unresolved top-level await.
if (process.stdin.readableEnded) finish();
@@ -169,7 +191,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
}
async function serveHttp(daemonUrl: string, bind: string, grantService?: string, grantServiceHeaderFile?: string,
oauth?: { resource: string; issuer: string }): Promise<void> {
oauth?: { resource: string; issuer: string }, taskDestination?: string, frontDoor = false, unfilledSeat?: UnfilledSeat): Promise<void> {
const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(bind);
if (!match) {
console.error(`--bind takes host:port (for example 127.0.0.1:7437 or [::1]:7437), not ${bind}`);
@@ -181,7 +203,8 @@ async function serveHttp(daemonUrl: string, bind: string, grantService?: string,
let served;
try {
const header = grantServiceHeaderFile ? readGrantServiceHeader(grantServiceHeaderFile) : undefined;
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile,
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile, taskDestination, frontDoor,
...(unfilledSeat ? { unfilledSeat } : {}),
...(grantService ? { grantService: { url: grantService, ...(header ? { header } : {}) } } : {}), ...(oauth ? { oauth } : {}) });
} catch (err) {
console.error(err instanceof McpHttpConfigError ? err.message : `Can't serve on ${bind}: ${(err as Error).message}`);
+13 -5
View File
@@ -163,9 +163,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 +183,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 +204,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 +217,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; }
});
+4 -1
View File
@@ -603,6 +603,9 @@ API-key approval before the first turn. OpenRig does not enforce API budgets or
Codex can select a saved API-key auth profile with --to-account just like a subscription profile. Its shared-home effects
and native credential-store requirements are unchanged. No fresh account home or conversation is created.
Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately.
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. 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
@@ -638,7 +641,7 @@ CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before rel
try {
const status = await client.get<{ runtime?: string }>(`/api/seat/status/${encodeURIComponent(opts.seat)}`);
const runtime = status.data.runtime;
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code")) {
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code" && runtime !== "pi")) {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The seat runtime could not be read; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
+18 -3
View File
@@ -88,6 +88,8 @@ interface NodeEntry {
storedSessionStatus?: string | null;
/** A community harness's seat: its label, maintainer and declared limits. */
communityHarness?: { harness: string; maintainer: string | null; limits: string[]; identity: string; needsInput: string; presetError?: string };
/** Configured literal argv prefix wrapping local built-in Pi/Codex launches; omitted when absent. */
launchWrapper?: string[];
/** Slice 15 — `has-work-to-do` primitive. Derived from queue_items;
* NEVER derived from terminalActive. */
hasAssignedWork?: boolean;
@@ -99,6 +101,8 @@ interface NodeEntry {
blockedWorkCount?: number;
/** OPR.0.3.4.11 — held reason from node.held event. */
heldReason?: string | null;
/** #2456: the required startup files this occupant's handover started it without. */
startedWithoutRequired?: string[];
agentActivity?: {
state: "running" | "needs_input" | "idle" | "unknown";
reason: string;
@@ -215,6 +219,7 @@ const ALLOWED_NODE_FIELDS = new Set([
"nodeKind",
"runtime",
"communityHarness",
"launchWrapper",
"model",
"modelObservation",
"sessionStatus",
@@ -238,6 +243,7 @@ const ALLOWED_NODE_FIELDS = new Set([
"needsInputEvidence",
"contextUsage",
"heldReason",
"startedWithoutRequired",
"storedSessionStatus",
"identityVerdict",
]);
@@ -705,6 +711,11 @@ export function formatDeclaredModel(model: string | null | undefined): string {
return m.length > 0 ? m : "not-declared";
}
/** #2456: the attention reason for an occupant started without a required startup file. */
function startedWithoutLine(files: string[]): string {
return `missing required ${files.map((file) => file.split("/").pop()).join(", ")}`;
}
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
return nodes.map((n) => {
const keepDiagnostics = n.needsAttention !== false;
@@ -725,6 +736,7 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
// 15 claude-code seats — see formatDeclaredModel for the render rule.
runtime: n.runtime ?? null,
...(n.communityHarness ? { communityHarness: n.communityHarness } : {}),
...(n.launchWrapper?.length ? { launchWrapper: n.launchWrapper } : {}),
model: n.model ?? null,
// Compact tuple: [literal model, evidence source, native record time].
// Full JSON / --fields retain the complete labelled object and occupant generation.
@@ -768,6 +780,7 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
}
// held/attention reason (short) when present.
if (n.heldReason) compact.heldReason = n.heldReason;
if (n.startedWithoutRequired?.length) compact.startedWithoutRequired = n.startedWithoutRequired;
if (keepDiagnostics && n.latestError) {
compact.latestError = n.latestError;
}
@@ -921,12 +934,12 @@ contextUsage.percent, contextUsage.state (with --nodes). Other keys are rejected
activeCount, hasWorkCount, attentionCount, status, lifecycleState, uptime,
latestSnapshot.
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, communityHarness, model, sessionStatus,
podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, communityHarness, launchWrapper, model, sessionStatus,
intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
resumeCommand, latestError, terminalActive, hasAssignedWork,
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
agentActivity, needsAttention, arbitratedNeedsInput, needsInputEvidence,
contextUsage, heldReason, storedSessionStatus, identityVerdict.
contextUsage, heldReason, startedWithoutRequired, storedSessionStatus, identityVerdict.
--host runs on a remote host declared in ~/.openrig/hosts.yaml (no current-rig
default applied; the remote host's rigs are shown).
@@ -1358,7 +1371,8 @@ async function handleNodes(
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
for (const n of humanList as NodeEntry[]) {
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
? (n.latestError ? truncate(n.latestError, 40) : n.agentActivity?.reason ?? "—")
? (n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
: n.agentActivity?.reason ?? "—")
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
console.log(padCompactNodeRow(
n.rigName,
@@ -1395,6 +1409,7 @@ async function handleNodes(
formatContextUsage(n.contextUsage),
n.restoreOutcome,
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}`
: n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 30)
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—"),
));
}
+285 -62
View File
@@ -1,8 +1,8 @@
import fs from "node:fs";
import { randomBytes } from "node:crypto";
import { Command, InvalidArgumentError } from "commander";
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError, type DaemonResponse } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
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 { realDeps } from "./daemon.js";
@@ -10,11 +10,13 @@ import { enumArg, positiveIntArg } from "../cli-error.js";
import type { StatusDeps } from "./status.js";
import { resolveContextRef } from "../context-resolve.js";
import { shellQuote } from "../cross-host-executor.js";
import { loadHostRegistry, resolveHost, resolveRemoteBearer } from "../host-registry.js";
import { bearerAuthHeaders, loadHostRegistry, resolveHost, resolveRemoteBearer } from "../host-registry.js";
import { loadHostBindings } from "../host-bindings.js";
import { eligibleQueueBindings, ineligibleBindingDetail } from "@openrig/daemon/learned-host-bindings";
import { omittedReadField, readView } from "../read-view.js";
import { buildWriteReceipt, compactOutputEnabled, type ReceiptContext } from "../queue-receipt.js";
import { TRANSITIONS_SHOWN, compactShow, compactTransitions, showEmptyNotice, transitionsNotice } from "../queue-read-compact.js";
import { codeResultOf, planItemArtifact, type FileRead, type SharedRoot } from "../queue-artifact.js";
/**
* `rig queue` — coordination primitive L3/inbox/outbox commands (PL-004 Phase A).
@@ -36,6 +38,8 @@ export interface DeliveryVerifyDeps {
export interface QueueDeps extends StatusDeps {
deliveryVerify?: DeliveryVerifyDeps;
/** The host registry `--host` resolves against on the verbs that name an existing row; tests give their own. */
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
}
interface DeliveryResultBase {
@@ -558,6 +562,45 @@ async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: st
return { reader, options };
}
/** Where a verb that names an existing row (show, transitions, list, claim, update, block) sends its request with
* `--host`: the remote daemon itself, called by this CLI with the host's bearer, as `send --host` does (#2637). The
* selected daemon has no route that reads another host's queue, and a direct call lets the remote record the actor
* as `<seat>@<rig>@<this host>`, the provenance `send --host` gives. Without `--host`, `local`, or a `--host` naming
* the selected daemon's own host id, the selected daemon, unchanged. Explicit-only: never `rig host select`. */
interface QueueHostTarget { client: DaemonClient; options: { headers?: Record<string, string>; timeoutMs?: number }; remote?: string }
async function queueHostTarget(deps: QueueDeps, local: DaemonClient, hostId: string | undefined, write: boolean): Promise<QueueHostTarget | { error: string; message: string }> {
const remote = remoteHostOf(hostId);
if (!remote) return { client: local, options: {} };
// A --host naming the selected daemon's own id stays local, as create's does (the daemon's resolvesToLocalHost).
if (await daemonSelfHostId(local) === remote) return { client: local, options: {} };
const registry = (deps.hostRegistryLoader ?? loadHostRegistry)();
if (!registry.ok) return { error: "host_registry_unreadable", message: `--host ${remote}: this CLI's host registry can't be read (${registry.error}).` };
// The queue forwarder's rule for learned bindings (one policy): a self-id resolves only through a binding learned at
// its entry's current URL, with no recorded conflict, so a stale binding never sends a write to a replaced endpoint.
const learned = loadHostBindings();
const match = resolveHost(registry.registry, remote, eligibleQueueBindings(registry.registry, learned));
if (!match.ok) {
const unused = ineligibleBindingDetail(registry.registry, learned, remote);
return { error: "unknown_host", message: `--host ${remote}: no such host in this CLI's host registry (see rig host list).${unused ? ` ${unused}` : ""}` };
}
if (match.host.transport !== "http") return { error: "host_not_http", message: `--host ${remote}: its registry entry is SSH, not a daemon URL; queue verbs need an http host.` };
const bearer = resolveRemoteBearer(match.host);
if (!bearer.ok) return { error: "host_bearer_unavailable", message: `--host ${remote}: ${bearer.error}` };
const client = remoteDaemonClient(deps.clientFactory, match.host.url, await resolveOriginSelfHostId(deps.lifecycleDeps));
const headers = bearerAuthHeaders(bearer.token);
return {
client,
options: { ...(Object.keys(headers).length > 0 ? { headers } : {}), ...(write ? { timeoutMs: CROSS_HOST_QUEUE_WRITE_TIMEOUT_MS } : {}) },
remote,
};
}
/** The ` --host <id>` a printed follow-up command needs to reach the same row again. */
function hostFlag(target: QueueHostTarget): string {
return target.remote ? ` --host ${shellQuote(target.remote)}` : "";
}
/** A successful forward isn't a delivery receipt. Never accept a different remote row as this create's receipt. */
function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Pick<DaemonClient, "get"> {
if (!remoteHostOf(hostId)) return client;
@@ -604,8 +647,10 @@ async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: str
}
}
async function printWriteResult(client: DaemonClient, json: boolean, body: unknown, status: number, receipt: ReceiptContext | null): Promise<void> {
const settled = receipt && status < 400 ? await settleReceiptHost(client, receipt) : null;
async function printWriteResult(client: DaemonClient, json: boolean, body: unknown, status: number, receipt: ReceiptContext | null, hostUsed = false): Promise<void> {
// A direct --host write already settled its host when it chose the target: its receipt keeps the host it used.
// create and handoff, which the selected daemon forwards, are settled here as before.
const settled = receipt && status < 400 ? (hostUsed ? receipt : await settleReceiptHost(client, receipt)) : null;
const projected = settled ? buildWriteReceipt(body, settled) : null;
if (!projected) {
printResult(json, body, status);
@@ -622,6 +667,7 @@ async function printQueueItemResult(
body: unknown,
status: number,
receipt: ReceiptContext | null = null,
remoteHost?: string,
): Promise<void> {
if (status >= 400 && body && typeof body === "object"
&& (body as { error?: unknown }).error === "qitem_not_found") {
@@ -636,10 +682,11 @@ async function printQueueItemResult(
body = {
...body,
hint: `Queue item ${JSON.stringify(qitemId)} was not found on ${daemon}.\n`
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(qitemId)} --full --json`,
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(qitemId)} --full --json`
+ (remoteHost ? "" : `\nOr name that host: rig queue show ${shellQuote(qitemId)} --host <host-id> --full --json`),
};
}
await printWriteResult(client, json, body, status, receipt);
await printWriteResult(client, json, body, status, receipt && remoteHost ? { ...receipt, remoteHost } : receipt, remoteHost !== undefined);
}
// OPR.0.4.3.03 — `rig queue show` body preview.
@@ -922,6 +969,9 @@ function emitHostResolutionError(res: { error: string; message: string }, json:
const QUEUE_HOST_OPTION_HELP =
"OPR.0.4.6.MH3: route this queue write to a REGISTERED remote host (see rig host ls). EXPLICIT-ONLY — queue verbs never follow the persisted 'rig host select' selection. Equivalent to the host-qualified destination form member@rig@<host>.";
const QUEUE_HOST_ROW_OPTION_HELP =
"#2637: read or act on a row held by a REGISTERED remote host (see rig host ls). This CLI calls that host's daemon directly with its bearer, as send --host does; a write is recorded there as <seat>@<rig>@<this host>. EXPLICIT-ONLY — queue verbs never follow the persisted 'rig host select' selection. A blocker, watchdog or row id given with it must exist on that host.";
export function queueCommand(depsOverride?: QueueDeps): Command {
const cmd = new Command("queue").description("Coordination L3 — owned-work queue + inbox/outbox");
const getDeps = (): QueueDeps => depsOverride ?? {
@@ -1206,6 +1256,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
// no body claim. Verify the env (the header source) or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
if (!requireSeatSession("destination")) return;
const deps = getDeps();
// No --host here (#2678 review): a row is claimed by its destination seat, on the host that holds it.
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "claim" }));
@@ -1240,13 +1291,15 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--reopen", "Explicitly acknowledge a deliberate terminal-to-active repair; requires --state and --note")
.option("--closure-reason <reason>", "Required for state=done. --state canceled takes no reason, except 'superseded' (with --closure-target = the successor), which records a supersession, distinct from an abandoned cancel")
.option("--closure-target <target>", "Required for handed_off_to, escalation, and superseded; blocked_on park records use state=blocked with --blocked-on")
.option("--closure-required-at <iso>", "Accept a revised closure deadline on an in-progress item; keeps the claim and records the old/new time and accepting actor")
.option("--resolution <text>", "The outcome, stored on the row and shown by rig queue show. Needs --state done, canceled, denied, failed or handed-off, and non-blank text. Stored when the update closes an open row, after anything the row already has (a fallback line, an earlier outcome); a row that is already closed keeps what it has")
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
.option("--wake-after <duration>", "For state=blocked: atomically arm a timer (for example 90s, 15m, 2h)", wakeDurationSeconds)
.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)")
.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("--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: {
@@ -1255,6 +1308,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
reopen?: boolean;
closureReason?: string;
closureTarget?: string;
closureRequiredAt?: string;
resolution?: string;
blockedOn?: string;
wakeWatchdog?: string;
@@ -1262,6 +1316,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
summary?: string;
evidenceRef?: string;
note?: string;
host?: string;
json?: boolean;
full?: boolean;
}) => {
@@ -1285,12 +1340,15 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
// 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;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, true);
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
state: opts.state,
reopen: opts.reopen,
closureReason: opts.closureReason,
closureTarget: opts.closureTarget,
...(opts.closureRequiredAt !== undefined ? { closureRequiredAt: opts.closureRequiredAt } : {}),
resolution: opts.resolution,
blockedOn: opts.blockedOn,
wakeWatchdogId: opts.wakeWatchdog,
@@ -1298,9 +1356,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
summary: opts.summary,
evidenceRef: opts.evidenceRef,
transitionNote: opts.note,
});
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "update" }));
});
}, 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));
});
// OPR.0.4.4.19 FR-6 — the first-class park affordance (C5 leg 1). One verb,
@@ -1321,6 +1379,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--continuation <text>", "What resumes. Workspace deferred/not-imminent work belongs in a mission/slice")
.option("--wake-watchdog <jobId>", "Attach an existing live watchdog id targeting the parked owner")
.option("--wake-after <duration>", "Atomically arm a timer with the park (for example 90s, 15m, 2h)", wakeDurationSeconds)
.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")
.addHelpText("after", `
@@ -1342,6 +1401,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
continuation?: string;
wakeWatchdog?: string;
wakeAfter?: number;
host?: string;
json?: boolean;
full?: boolean;
}) => {
@@ -1349,8 +1409,10 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
// ignored, no body actorSession. Verify the env, else the daemon returns 400 actor_required (no seat identity to record). Matches `resolve`.
if (!requireSeatSession("actor")) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, true);
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
state: "blocked",
blockedOn: opts.on,
summary: opts.summary,
@@ -1358,9 +1420,9 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
wakeWatchdogId: opts.wakeWatchdog,
wakeAfterSeconds: opts.wakeAfter,
transitionNote: opts.continuation ? `continuation: ${opts.continuation}` : (opts.note ?? `parked on ${opts.on}`),
});
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "block" }));
});
}, target.options);
await printQueueItemResult(target.client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "block" }), target.remote);
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
});
// OPR.0.4.4.19 FR-7 — the resolve verb's CLI wrapper: a THIN client of the
@@ -1655,10 +1717,14 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.description("Show one qitem and its derived waiting state (bounded preview; --full for complete body)")
.option("--full", "Complete original record; may be large (use --full --json for lossless JSON)")
.option("--json", "JSON preview with completeness, original byte size and exact full command")
.action(async (qitemId: string, opts: { full?: boolean; json?: boolean }) => {
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
.action(async (qitemId: string, opts: { full?: boolean; json?: boolean; host?: string }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`);
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, false);
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
const client = target.client;
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`, target.options);
const json = opts.json ?? false;
const item = res.data;
// --full is a pure passthrough of today's COMPLETE item shape (the
@@ -1666,7 +1732,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
// passthrough on error responses / non-object payloads, where there is
// no string body to preview.
if (opts.full || res.status >= 400 || !isRecordWithStringBody(item)) {
await printQueueItemResult(client, qitemId, json, item, res.status);
await printQueueItemResult(client, qitemId, json, item, res.status, null, target.remote);
return;
}
// The closing transition is for --full; the bounded preview prints the record as it did before.
@@ -1674,7 +1740,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
const { preview, bodyBytes, bodyTruncated } = previewBody(item.body);
// Append-only additions: keep `body` in place (now the preview) and add
// the honest size + truncation flag. Object otherwise unchanged.
const fullCommand = `rig queue show ${shellQuote(qitemId)} --full --json`;
const fullCommand = `rig queue show ${shellQuote(qitemId)}${hostFlag(target)} --full --json`;
// Everything held back from this preview is declared, so a reader that trusts readView runs --full.
const omitted = [
...(bodyTruncated ? [omittedReadField("body (after preview)", item.body.slice(preview.length))] : []),
@@ -1693,6 +1759,47 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
console.error(`… (bounded preview — complete body is ${bodyBytes} bytes; full record ${view.fullJsonBytes} JSON bytes: ${fullCommand})`);
}
if (compact && compact.emptyOmitted.length > 0) console.error(showEmptyNotice(compact.emptyOmitted, fullCommand));
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
});
cmd
.command("artifact <qitemId>")
.description("Read a qitem's evidenceRef through the daemon's shared roots; a code-result manifest also comes back parsed as codeResult")
.option("--root <name>", "The shared root a relative evidenceRef is under")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { root?: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const json = opts.json ?? false;
const item = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`);
if (item.status >= 400) { await printQueueItemResult(client, qitemId, json, item.data, item.status); return; }
const ref = ((item.data ?? {}) as { evidenceRef?: unknown }).evidenceRef;
const rootsRes = await client.get<{ roots?: SharedRoot[] }>("/api/files/roots");
if (rootsRes.status >= 400) { printResult(json, rootsRes.data, rootsRes.status); return; }
const roots = rootsRes.data?.roots ?? [];
// The same rule as rig_artifact_read: only a file in a shared root is read, through the daemon.
const plan = planItemArtifact(ref, roots, opts.root);
if ("unavailable" in plan) {
if (json) console.log(JSON.stringify({ available: false, qitemId, reason: plan.unavailable, evidenceRef: typeof ref === "string" ? ref : null, roots: roots.map((entry) => entry.name) }));
else console.error(`${qitemId}: ${plan.unavailable}`);
process.exitCode = 1;
return;
}
const res = await client.get<FileRead>(`/api/files/read?${new URLSearchParams({ root: plan.read.root, path: plan.read.path }).toString()}`);
if (res.status >= 400) { printResult(json, res.data, res.status); return; }
const read = res.data ?? {};
const parsed = codeResultOf(read);
if (json) {
console.log(JSON.stringify({ available: true, qitemId, root: read.root, path: read.path, size: read.size, mtime: read.mtime, binary: read.binary === true,
...(read.binary ? {} : { text: read.content ?? "" }), truncated: read.truncated === true, ...parsed }));
return;
}
if (read.binary) console.log(`A binary file (${read.root}:${read.path}): not shown as text.`);
else process.stdout.write(read.content ?? "");
// Notices go to stderr, so stdout stays the file.
if (parsed && "codeResult" in parsed) console.error(`code result: ${parsed.codeResult.repo} ${parsed.codeResult.base}..${parsed.codeResult.revision}${parsed.codeResult.paths.length ? ` (${parsed.codeResult.paths.length} paths)` : ""}`);
else if (parsed) console.error(parsed.codeResultIssue);
if (read.truncated) console.error(`… cut at ${read.truncatedAtBytes} of ${read.totalBytes} bytes, as the daemon reads it`);
});
});
@@ -1701,20 +1808,24 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.description("Show the append-only transition log for a qitem")
.option("--json", "JSON output for agents")
.option("--full", `The whole log, as today, even when output.compact is on (compact shows the latest transitions, ${TRANSITIONS_SHOWN} in all, always including the current disposition)`)
.action(async (qitemId: string, opts: { json?: boolean; full?: boolean }) => {
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
.action(async (qitemId: string, opts: { json?: boolean; full?: boolean; host?: string }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`);
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, false);
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
const client = target.client;
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`, target.options);
// Slice 15 P3: with output.compact on, the recent story; the notice goes to stderr so it stays
// visible when the array is piped through jq.
if (!opts.full && res.status < 400 && Array.isArray(res.data) && compactOutputEnabled()) {
const { transitions, earlier } = compactTransitions(res.data as Record<string, unknown>[]);
printResult(opts.json ?? false, transitions, res.status);
if (earlier > 0) console.error(transitionsNotice(earlier, res.data.length, `rig queue transitions ${shellQuote(qitemId)} --full${opts.json ? " --json" : ""}`));
if (earlier > 0) console.error(transitionsNotice(earlier, res.data.length, `rig queue transitions ${shellQuote(qitemId)}${hostFlag(target)} --full${opts.json ? " --json" : ""}`));
return;
}
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, null, target.remote);
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
});
cmd
@@ -1732,6 +1843,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.option("--target-repo <name>", "PL-007: filter qitems by target_repo (exact match)")
.option("--limit <n>", "Result limit", positiveIntArg, 100)
.option("--json", "JSON output (compact; use --full --json for complete fields)")
.option("--host <id>", `${QUEUE_HOST_ROW_OPTION_HELP} With --host, the rows of every rig on that host unless --destination or --source narrows them; --owned and --mine name this host's seat and are refused.`)
.addHelpText("after", `
Default: active items in your current rig, compact summary (like 'docker ps').
Current rig is derived from OPENRIG_SESSION_NAME's @<rig> suffix.
@@ -1774,45 +1886,62 @@ Examples:
targetRepo?: string;
limit: string;
json?: boolean;
host?: string;
}) => {
const deps = getDeps();
const params = new URLSearchParams();
const sessionName = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
if (opts.owned && !sessionName) {
console.error("Cannot use --owned: checked OPENRIG_SESSION_NAME and RIGGED_SESSION_NAME, but neither caller seat identity is set. Set one to a canonical seat@rig address, or use --destination <session>.");
process.exitCode = 1;
return;
}
const hasExplicitScope = !!(opts.destination || opts.source || opts.owned);
if (opts.owned && sessionName) {
params.set("destinationSession", sessionName);
} else if (opts.mine && sessionName) {
params.set("as", sessionName);
} else if (!opts.allRigs && !hasExplicitScope) {
const rigName = sessionName ? extractRigName(sessionName) : undefined;
if (rigName) {
params.set("rig", rigName);
const remote = remoteHostOf(opts.host);
const useJson = opts.json || opts.o === "json";
// The seat env names a seat of THIS host. On another host's daemon (a --host that resolves remote) it scopes
// nothing that daemon holds: no rig is derived from it, and --owned/--mine are refused.
const seatScope = (onRemote: boolean): URLSearchParams | null => {
const params = new URLSearchParams();
const sessionName = onRemote ? undefined : readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
if (onRemote && (opts.owned || opts.mine)) {
emitHostResolutionError({ error: "host_scope_unsupported", message: `--host ${remote} lists another host's rows: --${opts.owned ? "owned" : "mine"} names this host's seat. Use --destination or --source with the address as that host stores it.` }, useJson);
return null;
}
}
if (opts.owned && !sessionName) {
console.error("Cannot use --owned: checked OPENRIG_SESSION_NAME and RIGGED_SESSION_NAME, but neither caller seat identity is set. Set one to a canonical seat@rig address, or use --destination <session>.");
process.exitCode = 1;
return null;
}
const hasExplicitScope = !!(opts.destination || opts.source || opts.owned);
if (!opts.all) {
params.set("activeOnly", "1");
}
if (!opts.full) {
params.set("compact", "1");
}
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
if (opts.owned && sessionName) {
params.set("destinationSession", sessionName);
} else if (opts.mine && sessionName) {
params.set("as", sessionName);
} else if (!opts.allRigs && !hasExplicitScope) {
const rigName = sessionName ? extractRigName(sessionName) : undefined;
if (rigName) {
params.set("rig", rigName);
}
}
if (!opts.all) {
params.set("activeOnly", "1");
}
if (!opts.full) {
params.set("compact", "1");
}
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
if (opts.state) params.set("state", opts.state);
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
if (opts.limit) params.set("limit", String(opts.limit));
return params;
};
// Without --host, the scope is settled before the daemon is contacted, as before.
if (!remote && !seatScope(false)) return;
warnIfUnknownStates(opts.state);
if (opts.state) params.set("state", opts.state);
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
if (opts.limit) params.set("limit", String(opts.limit));
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
const useJson = opts.json || opts.o === "json";
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, false);
if ("error" in target) return emitHostResolutionError(target, useJson);
const params = seatScope(target.remote !== undefined);
if (!params) return;
const res = await target.client.get<unknown>(`/api/queue/list?${params.toString()}`, target.options);
printResult(useJson, res.data, res.status);
});
}, remote !== undefined, remote);
});
cmd
@@ -1870,6 +1999,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
+14 -2
View File
@@ -60,7 +60,19 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
const node = identities.size === 1 ? matches[0] : undefined;
const sessionStatus = node ? text(node.sessionStatus) : null;
const current = sessionStatus === "running";
const activity = current && object(node?.agentActivity) ? node.agentActivity : null;
const activities = matches.map(n => {
const activity = current && object(n.agentActivity) ? n.agentActivity : null;
return { state: text(activity?.state) ?? "unknown", reason: text(activity?.reason), sampledAt: text(activity?.sampledAt) };
});
const agreed = node && new Set(activities.map(a => JSON.stringify([a.state, a.reason]))).size === 1;
const times = activities.map(a => a.sampledAt);
const sameTime = new Set(times).size === 1;
const comparable = times.every((t): t is string => t !== null && Number.isFinite(Date.parse(t)));
// A later timestamp is meaningful only when every route supplied a comparable time.
const sampledAt = sameTime ? times[0] ?? null : comparable
? [...times].sort((a, b) => Date.parse(b!) - Date.parse(a!) || b!.localeCompare(a!))[0] ?? null : null;
const activity = agreed ? { ...activities[0]!, sampledAt }
: { state: "unknown", reason: matches.length > 1 ? "Conflicting activity observations" : null, sampledAt: null };
const observations = matches.map(n => modelObservation(n.modelObservation, current));
const observation = node && new Set(observations.map(o => JSON.stringify(o))).size === 1 ? observations[0]!
: unknownModelObservation(matches.length ? "conflicting-observations" : "host-or-seat-unavailable");
@@ -75,7 +87,7 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
observedModel: observation.model,
modelObservation: observation,
sessionStatus,
agentActivity: { state: text(activity?.state) ?? "unknown", reason: text(activity?.reason), sampledAt: text(activity?.sampledAt) },
agentActivity: activity,
// A hosted member's last call to this host's MCP server, for a roster row on this host only: another host's member,
// or one whose host can't be told, is unknown (null), never this host's record for a same-named seat. Its last ring
// is in rig_team.
+45
View File
@@ -1236,6 +1236,8 @@ function buildAuditCommand(): Command {
for (const sr of sliceResults) {
const shadow = shadowedNodeFileFinding(path.join(slicesDir, sr.name), "slice");
if (shadow) sr.result.findings.push(shadow);
const shared = sharedSliceNameFinding(missionsRoot, missionName, sr.name);
if (shared) sr.result.findings.push(shared);
}
const allFindings = [
@@ -2156,6 +2158,45 @@ function shadowedNodeFileFinding(dir: string, level: "mission" | "slice"): {
};
}
/**
* Advisory-only: another mission has a slice folder of the same name. The slice list and the routes that
* find a slice by name keep one slice per name, so the others are hidden there with no sign they exist.
* Twin of the daemon's finding of the same name.
*/
function sharedSliceNameFinding(missionsRoot: string, missionName: string, sliceName: string): {
kind: "slice_name_shared"; severity: "low"; path: string; message: string; remediation: string;
} | null {
let others: string[];
try {
others = fs.readdirSync(missionsRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== missionName
&& fs.statSync(path.join(missionsRoot, e.name, "slices", sliceName), { throwIfNoEntry: false })?.isDirectory() === true)
.map((e) => e.name)
.sort();
} catch {
return null;
}
if (others.length === 0) return null;
return {
kind: "slice_name_shared",
severity: "low",
path: path.join(missionsRoot, missionName, "slices", sliceName),
message: `Mission ${others.join(", ")} also ${others.length === 1 ? "has" : "have"} a slice named "${sliceName}". The slice list (GET /api/slices) and the routes that find a slice by name keep one slice per name, so all but one of these slices are hidden there.`,
remediation: "Rename one of the slice folders so each slice name is unique across missions. Advisory only — nothing is blocked.",
};
}
/** 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)")
@@ -2186,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());
+65 -14
View File
@@ -21,6 +21,10 @@ async function defaultReadStdin(): Promise<string> {
}
interface SeatStatusResponse {
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;
suggested_rules?: { state: "missing" | "present" | "unavailable"; entries: unknown[]; unsupported: boolean; size_cut: boolean } };
seat_ref: string;
rig_id: string;
rig_name: string;
@@ -165,8 +169,10 @@ interface SeatHandoverMutationResult {
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 } };
/** The restore packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
/** 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. */
startedWithoutRequired?: string[];
sideEffects: {
departingSessionKilled: false;
/** Absent from an older daemon, whose receipt reported both side effects as a fixed false. */
@@ -206,6 +212,13 @@ const LEVEL_WORDS: Record<string, string> = {
"sends-only": "Deliberate sends from people and agents type; OpenRig's own notices are held.",
};
// JSON escapes C0; also escape C1, format/separator and default-ignorable characters.
// Escaping the serialized representation leaves JSON.parse's rule strings unchanged.
function terminalJson(value: unknown, space?: number): string {
return JSON.stringify(value, null, space).replace(/[\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu,
character => character.split("").map(unit => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`).join(""));
}
/** Show configured seat state, including selected account references, without claiming the running login is verified. */
function printHuman(status: SeatStatusResponse): void {
console.log(`Seat ${status.seat_ref}`);
@@ -241,6 +254,24 @@ function printHuman(status: SeatStatusResponse): void {
else console.log("Hold pauses all automatic input, including writing lifecycle. Changing mode does not replay held messages.");
}
console.log(`Session: ${display(status.session_status, "unknown")}`);
const stop = status.permission_stop;
if (stop && stop.state !== "unavailable") {
console.log(`Latest permission observation at ${stop.observed_at}: ${stop.command}; harness permission request (${stop.source}, ${stop.actor})`);
console.log(stop.state === "completed" ? " Displayed actor's tool batch completed; this observation is no longer pending."
: " Displayed actor's pending status: unknown (an observation does not establish a waiting prompt).");
console.log(" Other actors' pending state: unknown (only the latest observation is shown).");
console.log(" Claude's suggested rules: read-only evidence; not applied or approved, effectiveness unknown.");
console.log(" These are neither the exact command nor a complete list of dialog options.");
const rules = stop.suggested_rules;
if (!rules || rules.state === "unavailable") console.log(" Suggested-rule evidence: unavailable.");
else if (rules.state === "missing") console.log(" Suggested-rule field: missing from the observed hook.");
else {
if (!rules.entries.length) console.log(" Supported suggested-rule entries: empty.");
for (const entry of rules.entries) console.log(` ${terminalJson(entry)}`);
}
if (rules?.unsupported) console.log(" Unsupported or malformed suggested-rule evidence was withheld.");
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}`);
const reason = sessionReason(status.session_reason);
if (reason) console.log(`Reason: ${reason}`);
@@ -325,7 +356,11 @@ function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
}
function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log(`Seat handover complete: ${result.seat.ref}`);
// #2456: a successor that started without a required startup file isn't a plain success; a start would have failed.
const withoutRequired = result.startedWithoutRequired ?? [];
console.log(`Seat handover complete${withoutRequired.length > 0 ? ", degraded" : ""}: ${result.seat.ref}`);
for (const file of withoutRequired) console.log(`The successor started without required file ${file}.`);
if (withoutRequired.length > 0) console.log("rig ps --nodes shows the seat as needing attention until it's next launched.");
console.log(`Rig: ${result.seat.rigName}`);
console.log(`Logical ID: ${result.seat.logicalId}`);
console.log(`Source: ${result.source.mode}${result.source.ref ? `:${result.source.ref}` : ""}`);
@@ -351,7 +386,8 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
const outcome = result.sourceOutcome;
if (outcome.permissions) console.log(`Codex rebuild permissions: ${outcome.permissions.state}. ${outcome.permissions.detail}`);
if (outcome.primedArtifacts.length > 0) {
console.log(`Rebuild primed from ${outcome.primedArtifacts.length} durable artifact${outcome.primedArtifacts.length === 1 ? "" : "s"}:`);
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"}:`);
for (const artifact of outcome.primedArtifacts) console.log(` - ${artifact.address} — ${artifact.label}`);
}
for (const gap of outcome.gaps) console.log(` ! declared but missing on disk: ${gap}`);
@@ -370,8 +406,17 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
} else if (result.restorePacket?.status === "unconfirmed") {
// The packet was pasted and Enter pressed: the successor may already be working on it, so don't send it again.
console.log(`The captured startup context (restore packet) was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`);
console.log("Check the successor's screen before sending the context again; it may already be working on it.");
if (result.source.mode === "rebuild") {
console.log(`The rebuild priming packet was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`);
if (result.restorePacket.reason === "the prompt still looked staged in the input box") {
console.log("Check the successor's current screen. Only if that exact packet is still staged, submit the existing packet once. Do not resend blindly or rerun the handover.");
} else {
console.log("Check the successor's screen before sending the priming packet again; it may already be working on it.");
}
} else {
console.log(`The captured startup context (restore packet) was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`);
console.log("Check the successor's screen before sending the context again; it may already be working on it.");
}
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
} else if (result.source.mode === "fork") {
console.log("No packet delivery: fork context rides the native conversation itself.");
@@ -544,7 +589,7 @@ Examples:
const res = await client.get<SeatStatusResponse | SeatStatusError>(`/api/seat/status/${encodeURIComponent(seat)}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
console.log(terminalJson(res.data, 2));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
@@ -990,13 +1035,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")
@@ -1006,7 +1051,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
@@ -1020,7 +1065,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.
@@ -1392,10 +1437,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). 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.
+63 -33
View File
@@ -1,3 +1,4 @@
import { isHumanSeatSessionRef } from "../session-name.js";
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient, DaemonConnectionError, terminalAuthHeaders } from "../client.js";
@@ -90,6 +91,9 @@ export function wrapSendBody(
// renders EXACTLY AS RECEIVED — bare member@rig locally (copy-paste reply hint), and a
// cross-host caller constructs the origin triple at the forwarding boundary before wrapping.
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
// Only reply classification uses the bare address; From and the destination retain the origin host.
const senderParts = senderLabel.split("@");
const personAddress = senderParts.length === 3 && senderParts[2] ? senderParts.slice(0, 2).join("@") : senderLabel;
const header = [`From: ${senderLabel}`, renderToLine(recipient, meta?.scope)];
if (meta?.stampISO) {
// GHOST-STAGE (g): the sender's occupant generation rides the Sent: line as a short suffix
@@ -102,7 +106,9 @@ export function wrapSendBody(
}
const reply = senderLabel.endsWith("@external")
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
: `↩ Reply: rig send ${senderLabel} "..."`;
: 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} "..."`;
return [...header, "---", body, "---", reply].join("\n");
}
@@ -236,11 +242,11 @@ export function sendCommand(depsOverride?: SendDeps): Command {
.option("--to <sessions>", "Multi-recipient: comma-list or repeated (--to a,b or --to a --to b)", collectSessions, [] as string[])
.option("--pod <name>", "Send to every seat in a pod (fan-out, per-recipient results)")
.option("--rig <name>", "Send to every seat in a rig (fan-out, per-recipient results)")
.option("--verify", "Verify pane only delivery by checking content after send")
.option("--verify", "Verify pane only delivery by checking content after send. Exit 1 can mean unconfirmed, not failed: the message may have arrived, so inspect with rig capture before any resend")
.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")
@@ -316,8 +322,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
@@ -326,7 +334,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.
@@ -385,10 +396,10 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
}
const isFanOut = fanModes.length === 1;
// Slice-03 Atom 6b: --context is single-seat LOCAL in v1 (fan-out and
// cross-host --context are follow-ons). Reject loudly rather than silently
// dropping the requested context.
if (opts.context && (isFanOut || opts.host)) {
// --context is single-seat LOCAL in v1. Reject fan-out here; the
// cross-host check follows target resolution so a self-qualified target
// can override a persisted remote selection.
if (opts.context && isFanOut) {
console.error("--context is supported on a single-seat LOCAL send in v1 (not with --to/--pod/--rig or --host).");
process.exitCode = 1;
return;
@@ -461,14 +472,10 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
session = targetResolution.target;
const crossHostHint = targetResolution.hint;
if (targetResolution.warning) console.error(targetResolution.warning);
opts.host = explicitHost ?? targetResolution.sugarHost ?? opts.host;
opts.host = targetResolution.localHost ? undefined : explicitHost ?? targetResolution.sugarHost ?? opts.host;
// Atom 6b QA fix (root cause): re-reject --context on the cross-host path
// AFTER the agent@rig@host sugar host folds into opts.host. The early guard
// runs before resolveCrossHostTarget, so it cannot see the sugar host — the
// hole that let a sugar-form --context reach the remote argv (shipping a
// literal null with no message, or silently dropping the context with one).
// --context is single-seat LOCAL in v1; it is never handed to a remote send.
// Reject --context after resolving the target, before a remote send can
// silently drop the context or forward an empty message.
if (opts.context && opts.host) {
console.error("--context is supported on a LOCAL send in v1 (not with --host or an agent@rig@host cross-host target).");
process.exitCode = 1;
@@ -565,7 +572,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
const envelope = !effect
? res.data
: effectUnresolved(effect)
? { ...res.data, verified: false, outcome: "staged-not-consumed", effectCheck: effect }
? { ...res.data, verified: false, outcome: effectSubmissionUnconfirmed(effect) ? "submission-unconfirmed" : "staged-not-consumed", effectCheck: effect }
: { ...res.data, effectCheck: effect };
console.log(JSON.stringify(envelope));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
@@ -632,7 +639,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
* recheck, then stops. Pure classification: no printing here.
*/
type EffectCheck =
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused"; detail?: string }
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused" | "submit-unconfirmed"; detail?: string }
| { checked: true; state: "no-staged-residual" }
| { checked: false; why: string };
@@ -683,16 +690,26 @@ async function classifyDeliveryEffect(
const probe = await detectStagedAtPrompt(client, session, identity);
if (probe.state === "unchecked") return { checked: false, why: probe.why };
if (probe.state === "not-staged") return { checked: true, state: "no-staged-residual" };
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session,
submitOnly: true,
expectedStagedText: identity.expectedStagedText,
expectedStagedLineCount: identity.expectedLines,
}, transportRequestOptions(waitForIdleMs));
let enter: { status: number; data: Record<string, unknown> };
try {
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session,
submitOnly: true,
expectedStagedText: identity.expectedStagedText,
expectedStagedLineCount: identity.expectedLines,
}, transportRequestOptions(waitForIdleMs));
} catch (err) {
return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: (err as Error).message };
}
if (enter.status >= 400) {
return { checked: true, state: "staged", remedy: "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
const phase = enter.data?.["inputPhase"];
const reason = enter.data?.["reason"];
const unconfirmed = phase === "attempted" || phase === "written"
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
return { checked: true, state: "staged", remedy: unconfirmed ? "submit-unconfirmed" : "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
}
const recheck = await detectStagedAtPrompt(client, session, identity);
if (recheck.state === "unchecked") return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: recheck.why };
return recheck.state === "staged"
? { checked: true, state: "staged", remedy: "submitted-still-staged" }
: { checked: true, state: "staged", remedy: "submitted-cleared" };
@@ -705,13 +722,17 @@ function effectHumanLines(effect: EffectCheck, session: string): string[] {
return [`Note: the pane-effect check could not run (${effect.why}); the verdict above is transport-level only.`];
}
if (effect.state === "no-staged-residual") return [];
if (effect.remedy === "submit-unconfirmed") return [
`Delivery: submission unconfirmed — the text was staged before one guarded Enter attempt, but the result could not be confirmed (${effect.detail}). Inspect with: rig capture ${session}`,
];
if (effect.remedy === "submit-refused") return [
`Delivery: unconfirmed — the guarded Enter was refused (${effect.detail}); the earlier staged observation may have changed. Inspect with: rig capture ${session}`,
];
const out = ["Delivery: staged, not consumed (checked: post-send pane capture; observed: the sent text is still at the prompt — typed, never submitted)"];
if (effect.remedy === "submitted-cleared") {
out.push("Remedy: one guarded Enter submitted — the staged text left the prompt.");
} else if (effect.remedy === "submitted-still-staged") {
out.push(`Remedy: one guarded Enter submitted, but the text is STILL at the prompt — not consumed; stopping here (one submit is the contract). Inspect with: rig capture ${session}`);
} else {
out.push(`Remedy: the single guarded Enter (submit path) was refused (${effect.detail}); stopping here — one submit is the contract. Inspect with: rig capture ${session}`);
}
return out;
}
@@ -721,6 +742,10 @@ function effectUnresolved(effect: EffectCheck | undefined): boolean {
return !!effect && effect.checked && effect.state === "staged" && effect.remedy !== "submitted-cleared";
}
function effectSubmissionUnconfirmed(effect: EffectCheck | undefined): boolean {
return !!effect && effect.checked && effect.state === "staged" && (effect.remedy === "submit-unconfirmed" || effect.remedy === "submit-refused");
}
/**
* S3 (OPR.0.5.4.6) — the staged-at-prompt detector, walk's staged-evidence
* primitive generalized to plain sends. POSITIVE evidence only: text rendered
@@ -754,7 +779,7 @@ async function detectStagedAtPrompt(
for (let i = lines.length - 1; i >= 0; i--) {
if (PROMPT_LINE_PATTERN.test(lines[i]!)) { lastPrompt = i; break; }
}
if (lastPrompt === -1) return { state: "not-staged" };
if (lastPrompt === -1) return { state: "unchecked", why: "no input prompt was visible in the capture" };
const inputRegionRaw = lines.slice(lastPrompt).join("\n").replace(PROMPT_LINE_PATTERN, "");
// IDENTITY FIRST (round-2 F2, desk-binding): the literal-residual match uses
// the daemon submit-precheck's own normalization (strip ALL whitespace,
@@ -1083,6 +1108,7 @@ async function runFanOutSend(params: {
}
const effectBySeat = new Map((effects ?? []).map((e) => [e.sessionName, e.effect]));
const unresolvedCount = (effects ?? []).filter((e) => effectUnresolved(e.effect)).length;
const unconfirmedCount = (effects ?? []).filter((e) => effectSubmissionUnconfirmed(e.effect)).length;
if (opts.json) {
// Round-2 F1: ONE verdict per recipient — a staged-unresolved recipient's
@@ -1090,7 +1116,7 @@ async function runFanOutSend(params: {
const rawResults = (res.data["results"] as Array<{ sessionName: string; ok: boolean; outcome?: string }> | undefined) ?? [];
const encodedResults = rawResults.map((r) =>
effectUnresolved(effectBySeat.get(r.sessionName))
? { ...r, ok: false, verified: false, outcome: "staged-not-consumed", error: "staged, not consumed (pane effect); the one guarded submit did not clear it" }
? { ...r, ok: false, verified: false, outcome: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "submission-unconfirmed" : "staged-not-consumed", error: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "delivery unconfirmed; inspect the recipient after the guarded submit attempt" : "staged, not consumed (pane effect); the one guarded submit did not clear it" }
: r
);
// Round-3 (r2 row 00fb3a68): the machine-readable AGGREGATE derives from
@@ -1113,9 +1139,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
@@ -1133,6 +1161,8 @@ async function runFanOutSend(params: {
if (ec && ec.checked && ec.state === "staged") {
if (ec.remedy === "submitted-cleared") {
console.log(`${r.sessionName}: sent — staged at the prompt, cleared by one guarded Enter (consumed)`);
} else if (effectSubmissionUnconfirmed(ec)) {
console.log(`${r.sessionName}: ${effectHumanLines(ec, r.sessionName)[0]}`);
} else {
console.log(`${r.sessionName}: staged, not consumed — ${effectHumanLines(ec, r.sessionName).slice(-1)[0]}`);
}
@@ -1145,7 +1175,7 @@ async function runFanOutSend(params: {
}
// The delivered count never includes a staged-unresolved recipient.
const deliveredCount = Math.max(0, ((data["sent"] as number) ?? 0) - unresolvedCount);
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount > 0 ? `, ${unresolvedCount} staged-not-consumed` : ""}`);
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount - unconfirmedCount > 0 ? `, ${unresolvedCount - unconfirmedCount} staged-not-consumed` : ""}${unconfirmedCount > 0 ? `, ${unconfirmedCount} submission-unconfirmed` : ""}`);
// S2 (OPR.0.5.4.3): surface additive advisories (e.g. the unknown-sender
// sign-it notice) — an env-less operator must see it, not "sent" lines alone.
const fanoutAdvisory = data["warning"] as string | undefined;
+11 -3
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",
@@ -251,16 +252,21 @@ export function slackCommand(deps: SlackDeps = {}): Command {
log(`Daemon: ${observation.state}${observation.reason ? ` (${observation.reason})` : ""}`);
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
inboundRouting?: { fallback: string; destination: string };
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string;
settlement?: { pending: number; lastFailure: string | null };
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string; relayAttemptsInFlight?: number;
inbound?: InboundDeliveryStatus & { state?: string; generation?: number; lastEventAt?: string };
recovery?: { state?: string; reason?: string; lastScanAt?: string; acceptedThisProcess?: number; deadLetteredThisProcess?: number;
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) {
if (connector.settlement) {
log(` Ask edits: ${connector.settlement.pending} queued for reconciliation`);
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)`);
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}`);
@@ -283,9 +289,11 @@ 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)`);
if (connector.relayAttemptsInFlight !== undefined) log(` Relay attempts awaiting response: ${connector.relayAttemptsInFlight} (this process, point-in-time; zero does not prove global quiescence)`);
if (connector.deadLetterBacklogState !== undefined) {
const scope = "inbound messages, reactions and click answers";
if (connector.deadLetterBacklogState === "unknown") {
+31 -2
View File
@@ -1,6 +1,8 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { DaemonClient, DaemonResponseError, DaemonTimeoutError } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { shellQuote } from "../cross-host-executor.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { streamOutcome } from "./stream-outcome.js";
@@ -42,6 +44,21 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
/**
* Added to an emit whose reply came too late or unreadable: the item may still have landed, and the
* daemon keeps one item per stream item ID, so only a retry with the same ID can't add a second one.
*/
function emitRetryGuidance(id: string | undefined, source: string, sentAt: number): string {
if (id !== undefined) {
return `The item may still land. To retry safely, repeat the same emit with --id ${shellQuote(id)}; the daemon keeps one item per ID. To check first: rig stream show ${shellQuote(id)}.`;
}
// The daemon records the seat the request came from (its seat env), not --source, when the two differ.
const recorded = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim() || source;
// Five minutes before the send, for a daemon whose clock runs behind this one.
const since = new Date(sentAt - 5 * 60_000).toISOString();
return `The item may still land, and this emit had no --id, so emitting again can add a second item. Check first: rig stream list --source ${shellQuote(recorded)} --since ${since}. Pass a stable --id on emits so a later retry is safe.`;
}
function isWatchedStreamItem(value: unknown): value is WatchedStreamItem {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
@@ -104,7 +121,7 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
.description("Append a stream item")
.requiredOption("--source <session>", "Source session (e.g. dev-build@starter)")
.requiredOption("--body <text>", "Stream item body")
.option("--hint-destination <session>", "Hint at intended destination seat")
.option("--hint-destination <session>", "Hint at intended destination seat (the queue worker routes by this hint)")
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
.option("--hint-tags <tags>", "Comma-separated hint tags")
@@ -112,6 +129,11 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
.option("--interrupt", "Mark item as interrupting")
.option("--id <streamItemId>", "Idempotent stream_item_id (skip if not provided)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Stream intake is on by default: the seat named by stream.intake (queue.worker unless set)
gets an intake row for each item, which wakes it, or a note on its intake row still pending.
--hint-urgency critical or --interrupt makes that row urgent. The reply names only the stream item.
To keep emits passive: rig config set stream.intake off`)
.action(async (opts: {
source: string;
body: string;
@@ -127,6 +149,7 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
const deps = getDeps();
const tags = opts.hintTags ? opts.hintTags.split(",").map((s) => s.trim()).filter(Boolean) : null;
await withClient(deps, async (client) => {
const sentAt = Date.now();
const res = await client.post<Record<string, unknown>>("/api/stream/emit", {
streamItemId: opts.id,
sourceSession: opts.source,
@@ -137,6 +160,12 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
hintUrgency: opts.hintUrgency ?? null,
hintTags: tags,
interrupt: opts.interrupt ?? false,
}).catch((error: unknown): never => {
// The shared renderer reads both as an UNKNOWN outcome; name the retry that can't duplicate.
if (error instanceof DaemonTimeoutError || error instanceof DaemonResponseError) {
error.message += ` ${emitRetryGuidance(opts.id, opts.source, sentAt)}`;
}
throw error;
});
printResult(opts.json ?? false, res.data, res.status);
});
+98 -9
View File
@@ -24,10 +24,39 @@ 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 { 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;
@@ -121,7 +150,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,11 +162,12 @@ 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 Herdr 0.9.5/protocol 22)")
.option("--follow", "Keep one qitem:<id>, slice:<id-or-name> or mission:<id> workspace following current obligation owners (Linux/macOS Herdr protocol 22)")
.option("--no-follow", "Stop following and keep the existing workspace and tiles as a fixed layout")
.option("--max-tiles <count>", "Following tile limit, 1–256; explicitly raise it to show more members", Number)
.option("--pin <seats...>", "Retain these following tiles after they leave the task")
@@ -148,7 +178,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 +187,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, 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, out, 200);
return;
}
let expectedPlan = opts.expectedPlan;
@@ -203,18 +272,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}`);
+26 -10
View File
@@ -273,7 +273,7 @@ initial record is explicitly reported as unverified delivery.`)
let verdict = await pollConsumed();
if (verdict === "generation-rolled") {
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); the walk cannot continue into a different generation.");
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); delivery is unconfirmed. Inspect the recipient before any resend; the walk cannot continue into a different generation.");
return;
}
if (verdict === "timeout") {
@@ -289,23 +289,39 @@ initial record is explicitly reported as unverified delivery.`)
/\[Pasted text #\d+ \+\d+ lines\]/.test(pane)
);
if (stagedEvidence) {
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session: seat,
submitOnly: true,
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
expectedStagedLineCount: piece.content.split("\n").length,
}, { headers: terminalAuthHeaders() });
let enter: { status: number; data: Record<string, unknown> };
try {
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session: seat,
submitOnly: true,
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
expectedStagedLineCount: piece.content.split("\n").length,
}, { headers: terminalAuthHeaders() });
} catch (error) {
failPiece(i, piece.label, `the guarded submit result is unconfirmed (${(error as Error).message}); inspect the recipient before any resend.`);
return;
}
if (enter.status >= 400) {
failPiece(i, piece.label, `typed but not consumed; the single submit retry was refused (${(enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`}).`);
const phase = enter.data?.["inputPhase"], reason = enter.data?.["reason"];
const unconfirmed = phase === "attempted" || phase === "written"
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
const detail = (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`;
failPiece(i, piece.label, unconfirmed
? `the guarded submit result is unconfirmed (${detail}); inspect the recipient before any resend.`
: `the single guarded submit was refused (${detail}); the earlier staged observation may have changed. Inspect the recipient before any resend.`);
return;
}
verdict = await pollConsumed();
if (verdict === "generation-rolled") {
failPiece(i, piece.label, "the seat's generation rolled after the guarded submit; delivery is unconfirmed. Inspect the recipient before any resend.");
return;
}
if (verdict !== "consumed") {
failPiece(i, piece.label, `typed and staged, but not consumed even after the single submit retry — the piece never entered the seat's conversation record. Not re-sending (one retry is the contract).`);
failPiece(i, piece.label, "no matching conversation record was observed within the verification window after the single submit retry; delivery is unconfirmed. Inspect the recipient before any resend.");
return;
}
} else {
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"} but the piece is neither consumed in the generation record nor staged in the pane — delivery lost; consumption verification fails this walk closed.`);
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"}, but no matching record or staged input was observed within the verification window. Delivery and consumption are unconfirmed; inspect the recipient before any resend.`);
return;
}
}
+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 {
@@ -30,6 +30,8 @@ export interface RenderInstance {
hopCount?: number;
/** OPR.0.4.6.FAC1: the instance's bound rig (API-carried; null/absent = unbound, renders nothing). */
boundRig?: string | null;
/** #2730: what the run is for, as its `--root-objective` gave it; null for an instance from before it was kept. */
rootObjective?: string | null;
/**
* WF-1 FR-2's API-carried classification (present since the
* completion fixback at 384e60f1). RAIL 1 (arch ruling): the CLI
@@ -181,6 +183,7 @@ export function renderTraceTree(
const lines: string[] = [];
const name = instance.workflowName ? `${instance.workflowName}${instance.workflowVersion ? ` v${instance.workflowVersion}` : ""}` : "";
lines.push(`${statusGlyph(instance.status)} ${instance.instanceId} ${name} status=${instance.status}${instance.hopCount !== undefined ? ` hops=${instance.hopCount}` : ""}${instance.boundRig ? ` rig=${instance.boundRig}` : ""}`);
if (instance.rootObjective) lines.push(` objective: ${instance.rootObjective}`);
if (instance.createdAt) {
lines.push(` created ${instance.createdAt}${instance.createdBySession ? ` by ${instance.createdBySession}` : ""} age ${humanAge(instance.createdAt, nowIso)}`);
}
@@ -254,6 +257,8 @@ function isStuck(instance: RenderInstance): boolean {
export interface AttentionRow {
instanceId: string;
workflowName: string;
/** #2730: what the run is for; null when the instance doesn't record it. */
rootObjective: string | null;
/** All classes for this instance — exactly-once row, combined. */
classes: string[];
reasons: string[];
@@ -320,6 +325,7 @@ export function composeAttentionRollup(instances: RenderInstance[]): AttentionRo
attention.push({
instanceId: inst.instanceId,
workflowName: inst.workflowName ?? "",
rootObjective: inst.rootObjective ?? null,
classes,
reasons,
affordance,
@@ -345,6 +351,7 @@ export function renderStatus(rollup: AttentionRollup): string[] {
lines.push(
`${fitCell("▲", 2)}${fitCell(row.instanceId, 30)}${fitCell(row.workflowName, 20)}${fitCell(row.classes.join("+"), 16)}${row.reasons.join("; ")}`,
);
if (row.rootObjective) lines.push(` ${fitCell("", 2)} objective: ${row.rootObjective}`);
lines.push(` ${fitCell("", 2)}└ ${row.affordance}`);
}
return lines;
@@ -374,6 +381,7 @@ export function renderInstanceShow(
const lines: string[] = [];
lines.push(`${statusGlyph(instance.status)} ${instance.instanceId} status=${instance.status}`);
if (instance.workflowName) lines.push(` workflow: ${instance.workflowName}${instance.workflowVersion ? ` v${instance.workflowVersion}` : ""}`);
if (instance.rootObjective) lines.push(` objective: ${instance.rootObjective}`);
// OPR.0.4.6.FAC1: the bound rig renders when present (unbound rows unchanged).
if (instance.boundRig) lines.push(` rig: ${instance.boundRig}`);
if (instance.createdAt) lines.push(` created: ${instance.createdAt}${instance.createdBySession ? ` by ${instance.createdBySession}` : ""} (age ${humanAge(instance.createdAt, nowIso)})`);
+76 -6
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:
@@ -637,19 +637,86 @@ exception routes only. Existing obligations keep their owners and evidence.
});
cmd
.command("trace <instanceId>")
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
.command("unregister [spec]")
.description("Take a workflow spec out of the registry: <name> or <name>@<version>, or --prune-missing for versions whose file is gone")
.option("--prune-missing", "Remove every registered version whose source file no longer exists (scoped to [spec] when given)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
A version that unfinished work (an active, waiting or resumable failed instance) is pinned to
is never removed; with --prune-missing it is retained, readable by that work but hidden from
the library, and a later prune removes it. A file still inside the workflows folder or the
built-in starters is kept, because the next scan registers it again: move or delete the file.
A file whose presence can't be checked (an access error, not "no such file") is kept and named.
An empty version (name@) is a usage error.
Examples:
$ rig workflow unregister qa-probe
$ rig workflow unregister pedido-do-board@1
$ rig workflow unregister --prune-missing
$ rig workflow unregister --prune-missing --json | jq '.removed[] | "\\(.name)@\\(.version) \\(.sourcePath)"'
`)
.action(async (spec: string | undefined, opts: { pruneMissing?: boolean; json?: boolean }) => {
if (!spec && !opts.pruneMissing) {
console.error("Name a workflow spec (<name> or <name>@<version>) or pass --prune-missing.");
process.exitCode = 1;
return;
}
let name: string | undefined;
let version: string | undefined;
if (spec) {
const at = spec.lastIndexOf("@");
name = at > 0 ? spec.slice(0, at) : spec;
version = at > 0 ? spec.slice(at + 1) : undefined;
}
if (version !== undefined && version.trim() === "") {
console.error(`The version after @ is empty in "${spec}": use <name>@<version>, or <name> for every version.`);
process.exitCode = 1;
return;
}
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<{
removed?: Array<{ name: string; version: string; sourcePath: string }>;
kept?: Array<{ name: string; version: string; sourcePath: string; reason: "pinned" | "scanned" | "unchecked" }>;
}>("/api/workflow/specs/unregister", { name, version, pruneMissing: opts.pruneMissing === true });
if (opts.json || res.status >= 400) {
printResult(opts.json ?? false, res.data, res.status);
return;
}
const removed = res.data.removed ?? [];
const kept = res.data.kept ?? [];
if (removed.length === 0 && kept.length === 0) console.log("Nothing to remove.");
for (const row of removed) console.log(`removed ${row.name}@${row.version} ${row.sourcePath}`);
for (const row of kept) {
const why = row.reason === "pinned"
? "unfinished work is pinned to this version"
: row.reason === "unchecked"
? "its file couldn't be checked (not ENOENT), so it isn't treated as missing"
: "the file is still in a scanned folder; move or delete it";
console.log(`kept ${row.name}@${row.version} ${row.sourcePath} (${why})`);
}
});
});
cmd
.command("trace <instanceId>")
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
.option("--full", "Return the complete step trail instead of the 50 most recent closures (trailCompleteness: complete)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
The default trail holds the 50 most recent closures; trailCompleteness says
"recent" when older ones were left out. --full asks the daemon for all of them.
Examples:
$ rig workflow trace WF01ABC
$ rig workflow trace WF01ABC --json | jq '.trail[] | {step, actor, exit}'
$ rig workflow trace WF01ABC --full --json | jq '.trailCompleteness, (.trail | length)'
`)
.action(async (instanceId: string, opts: { json?: boolean }) => {
.action(async (instanceId: string, opts: { json?: boolean; full?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<{ instance?: unknown; reconciliation?: unknown; trail?: unknown[]; frontier?: unknown[]; failures?: unknown[]; boundaryObligations?: unknown[]; unknowns?: string[] }>(
`/api/workflow/${encodeURIComponent(instanceId)}/trace`,
const res = await client.get<{ instance?: unknown; reconciliation?: unknown; trail?: unknown[]; trailCompleteness?: string; frontier?: unknown[]; failures?: unknown[]; boundaryObligations?: unknown[]; unknowns?: string[] }>(
`/api/workflow/${encodeURIComponent(instanceId)}/trace${opts.full ? "?full=true" : ""}`,
);
// WF3 FR-2: human mode renders the per-step tree (mini-req 2's
// one-screen bar); --json byte-identical (BR-2).
@@ -667,6 +734,9 @@ Examples:
} as Parameters<typeof renderTraceTree>[0];
const trail = (res.data.trail ?? []) as Parameters<typeof renderTraceTree>[1];
for (const line of renderTraceTree(instance, trail)) console.log(line);
if (res.data.trailCompleteness === "recent") {
console.log(` (the ${trail.length} most recent closures; older ones left out - all of them: rig workflow trace ${instanceId} --full)`);
}
});
});
+119 -7
View File
@@ -14,8 +14,8 @@ import {
export interface RiggedConfig {
specs: { builtinsVisible: boolean };
launch: { nonInterruptive: boolean };
daemon: { port: number; host: string; dbConflict?: "warn" | "stop" };
launch: { nonInterruptive: boolean; codexLoginShell: boolean };
daemon: { port: number; host: string; dbConflict?: "warn" | "stop"; seatPollIntervalMs?: number };
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
// OPR.0.4.6.MH1 FR-4 — the own-host display name (default "localhost").
host: { selected: string; name: string };
@@ -199,6 +199,8 @@ export interface RiggedConfig {
transport: {
verifyInputBox: boolean;
localUserGuard: boolean;
// Whether an ordinary send or work notice reaches a seat whose prompt holds a person's draft (default deliver; refuse opts out).
draftDelivery: "refuse" | "deliver";
};
// Slice 22 (OPR.0.7.2.22) — typed decisions (OpenRouter + Jev), off until enabled; read by the
// invoking rig process. templateDir holds optional <name>.yaml templates. Lockstep with the daemon twin.
@@ -207,6 +209,8 @@ export interface RiggedConfig {
consumers: string;
templateDir: string;
};
// rig acp serve's receiver when --to isn't given (an ACP registry launch has no args). Lockstep with the daemon twin.
acp: { to: string };
// OPR.0.4.6.FS-1 W2 — queue-retention maintenance knobs (twin of the daemon
// settings-store): enabled + the four bounded numeric tunables.
retention: {
@@ -216,6 +220,10 @@ export interface RiggedConfig {
watchdogKeepPerJob: number;
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).
@@ -230,6 +238,10 @@ export interface RiggedConfig {
wakeSwapGraceSeconds: number;
wakeHumanRung: string;
};
// Issue #1856 — stream intake setting
stream: {
intake: string;
};
}
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
@@ -263,8 +275,8 @@ const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigP
);
const DEFAULTS = {
launch: { nonInterruptive: false },
daemon: { port: 7433, host: "127.0.0.1", dbConflict: "warn" },
launch: { nonInterruptive: false, codexLoginShell: false },
daemon: { port: 7433, host: "127.0.0.1", dbConflict: "warn", seatPollIntervalMs: 1000 },
// OPR.0.4.6.MH1 FR-1 — "local" ≡ no remote selection (LOCAL_HOST_ID).
// FR-4 — own-host display name; default "localhost" (PRD-named).
host: { selected: "local", name: "localhost" },
@@ -383,6 +395,7 @@ const DEFAULTS = {
transport: {
verifyInputBox: false,
localUserGuard: false,
draftDelivery: "deliver",
},
// Slice 22 — typed decisions OFF, no consumers, shipped templates only.
decisions: {
@@ -390,6 +403,12 @@ const DEFAULTS = {
consumers: "",
templateDir: "",
},
// No ACP receiver: rig acp serve needs --to until one is set.
acp: { to: "" },
// Issue #1856 — stream intake default queue.worker; read live by the stream intake coordinator.
stream: {
intake: "queue.worker",
},
// OPR.0.4.6.FS-1 W2 — retention defaults (twin of daemon getDefaultValue).
retention: {
enabled: true,
@@ -398,6 +417,10 @@ const DEFAULTS = {
watchdogKeepPerJob: 50,
streamDays: 30,
streamMaxItems: 20000,
eventsDays: 0,
closedContentDays: 0,
closedMetadataDays: 0,
erasureLedger: "",
batchSize: 500,
},
// S04 — pickup-receipt stall threshold default (twin of daemon getDefaultValue).
@@ -417,6 +440,8 @@ export const VALID_KEYS = [
"daemon.port",
"daemon.host",
"daemon.db_conflict",
// #2687: the seat pollers' cadence; startup-only (read once when the daemon starts). Twin of the daemon key.
"daemon.seat_poll_interval_ms",
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer (the
// kubectl current-context shape). ONE static key, lockstep with the
// daemon settings-store twin (parity test pins both). Registry-
@@ -462,6 +487,7 @@ export const VALID_KEYS = [
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
"launch.non_interruptive",
"launch.codex_login_shell",
"health.context_pressure.warning_percent",
"health.context_pressure.critical_percent",
"files.allowlist",
@@ -527,10 +553,13 @@ export const VALID_KEYS = [
"usage.pi_openrouter_charge",
"transport.verify_input_box",
"transport.local_user_guard",
"transport.draft_delivery",
// Slice 22 — typed decisions (OpenRouter + Jev), off by default; lockstep with the daemon twin.
"decisions.enabled",
"decisions.consumers",
"decisions.template_dir",
// rig acp serve's receiver when --to isn't given; lockstep with the daemon twin.
"acp.to",
// OPR.0.4.6.FS-1 W2 — queue-retention maintenance knobs; CLI-settable twin,
// lockstep with the daemon settings-store SETTINGS_VALID_KEYS.
"retention.enabled",
@@ -540,6 +569,15 @@ export const VALID_KEYS = [
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
"retention.stream_days",
"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.
"queue.pickup_stall_threshold_minutes",
@@ -570,6 +608,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"workflow.exception_routing": { primary: "OPENRIG_WORKFLOW_EXCEPTION_ROUTING" },
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
"daemon.db_conflict": { primary: "OPENRIG_DAEMON_DB_CONFLICT" },
"daemon.seat_poll_interval_ms": { primary: "OPENRIG_SEAT_POLL_INTERVAL_MS" },
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
@@ -589,6 +628,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
"launch.non_interruptive": { primary: "OPENRIG_LAUNCH_NON_INTERRUPTIVE" },
"launch.codex_login_shell": { primary: "OPENRIG_LAUNCH_CODEX_LOGIN_SHELL" },
"health.context_pressure.warning_percent": { primary: "OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT" },
"health.context_pressure.critical_percent": { primary: "OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT" },
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
@@ -646,9 +686,11 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"usage.pi_openrouter_charge": { primary: "OPENRIG_USAGE_PI_OPENROUTER_CHARGE" },
"transport.local_user_guard": { primary: "OPENRIG_TRANSPORT_LOCAL_USER_GUARD" },
"transport.verify_input_box": { primary: "OPENRIG_TRANSPORT_VERIFY_INPUT_BOX" },
"transport.draft_delivery": { primary: "OPENRIG_TRANSPORT_DRAFT_DELIVERY" },
"decisions.enabled": { primary: "OPENRIG_DECISIONS_ENABLED" },
"decisions.consumers": { primary: "OPENRIG_DECISIONS_CONSUMERS" },
"decisions.template_dir": { primary: "OPENRIG_DECISIONS_TEMPLATE_DIR" },
"acp.to": { primary: "OPENRIG_ACP_TO" },
// OPR.0.4.6.FS-1 W2 — retention knobs; net-new keys, OPENRIG_* primary only.
"retention.enabled": { primary: "OPENRIG_RETENTION_ENABLED" },
"retention.transitions_days": { primary: "OPENRIG_RETENTION_TRANSITIONS_DAYS" },
@@ -656,6 +698,11 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
"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" },
"queue.stuck_sweep_interval_seconds": { primary: "OPENRIG_QUEUE_STUCK_SWEEP_INTERVAL_SECONDS" },
@@ -678,6 +725,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"workflow.exception_routing": ["workflow", "exceptionRouting"],
"daemon.host": ["daemon", "host"],
"daemon.db_conflict": ["daemon", "dbConflict"],
"daemon.seat_poll_interval_ms": ["daemon", "seatPollIntervalMs"],
"db.path": ["db", "path"],
"transcripts.enabled": ["transcripts", "enabled"],
"transcripts.path": ["transcripts", "path"],
@@ -697,6 +745,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
"launch.non_interruptive": ["launch", "nonInterruptive"],
"launch.codex_login_shell": ["launch", "codexLoginShell"],
"health.context_pressure.warning_percent": ["health", "contextPressure", "warningPercent"],
"health.context_pressure.critical_percent": ["health", "contextPressure", "criticalPercent"],
"files.allowlist": ["files", "allowlist"],
@@ -746,15 +795,22 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"usage.pi_openrouter_charge": ["usage", "piOpenRouterCharge"],
"transport.local_user_guard": ["transport", "localUserGuard"],
"transport.verify_input_box": ["transport", "verifyInputBox"],
"transport.draft_delivery": ["transport", "draftDelivery"],
"decisions.enabled": ["decisions", "enabled"],
"decisions.consumers": ["decisions", "consumers"],
"decisions.template_dir": ["decisions", "templateDir"],
"acp.to": ["acp", "to"],
"retention.enabled": ["retention", "enabled"],
"retention.transitions_days": ["retention", "transitionsDays"],
"retention.watchdog_days": ["retention", "watchdogDays"],
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
"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"],
"queue.stuck_sweep_interval_seconds": ["queue", "stuckSweepIntervalSeconds"],
@@ -969,6 +1025,16 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
"daemon.db_conflict": (raw) => {
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
},
"daemon.seat_poll_interval_ms": (raw, value) => {
// Startup-only: the seat pollers read it once when the daemon starts. Only slower than the 1 s default, up to the
// ceiling the structural observation's expiry allows (seat-activity-service.ts MAX_SEAT_POLL_INTERVAL_MS).
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1000 || value > 2000) {
throw new Error("Invalid daemon.seat_poll_interval_ms: must be a whole number of milliseconds in [1000, 2000]");
}
},
"transport.draft_delivery": (raw) => {
if (raw !== "refuse" && raw !== "deliver") throw new Error(`Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`);
},
"transcripts.lines": (raw, value) => {
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) {
throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]");
@@ -1118,6 +1184,36 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
}
},
"stream.intake": (raw) => {
const val = (raw ?? "").trim();
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
}
},
// 0 (the default) keeps these events; a positive day count turns their pruning on.
"retention.events_days": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
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}"`);
@@ -1138,6 +1234,12 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
throw new Error("Invalid decisions.template_dir: use an absolute path, or an empty string for the shipped templates only");
}
},
// rig acp serve's receiver when --to isn't given: a team seat, member@rig, or empty for none.
"acp.to": (_raw, value) => {
if (typeof value !== "string" || (value !== "" && !/^[^@\s]+@[^@\s]+$/.test(value))) {
throw new Error("Invalid acp.to: use the seat that receives your ACP tasks as member@rig, or an empty string for none");
}
},
};
function validateKeyConstraints(key: ValidKey, raw: string, coerced: string | number | boolean): void {
@@ -1234,6 +1336,7 @@ export class ConfigStore {
port: v("daemon.port") as number,
host: v("daemon.host") as string,
dbConflict: v("daemon.db_conflict") as "warn" | "stop",
seatPollIntervalMs: v("daemon.seat_poll_interval_ms") as number,
},
host: {
selected: v("host.selected") as string,
@@ -1312,7 +1415,7 @@ export class ConfigStore {
auditLog: v("feed.subscriptions.audit_log") as boolean,
},
},
launch: { nonInterruptive: v("launch.non_interruptive") as boolean },
launch: { nonInterruptive: v("launch.non_interruptive") as boolean, codexLoginShell: v("launch.codex_login_shell") as boolean },
runtime: {
readinessTimeoutSeconds: v("runtime.readiness_timeout_seconds") as number,
codex: {
@@ -1366,12 +1469,14 @@ export class ConfigStore {
transport: {
localUserGuard: v("transport.local_user_guard") as boolean,
verifyInputBox: v("transport.verify_input_box") as boolean,
draftDelivery: v("transport.draft_delivery") as "refuse" | "deliver",
},
decisions: {
enabled: v("decisions.enabled") as boolean,
consumers: v("decisions.consumers") as string,
templateDir: v("decisions.template_dir") as string,
},
acp: { to: v("acp.to") as string },
retention: {
enabled: v("retention.enabled") as boolean,
transitionsDays: v("retention.transitions_days") as number,
@@ -1379,6 +1484,10 @@ export class ConfigStore {
watchdogKeepPerJob: v("retention.watchdog_keep_per_job") as number,
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: {
@@ -1391,6 +1500,9 @@ export class ConfigStore {
wakeSwapGraceSeconds: v("queue.wake_swap_grace_seconds") as number,
wakeHumanRung: v("queue.wake_human_rung") as string,
},
stream: {
intake: v("stream.intake") as string,
},
};
}
@@ -1531,7 +1643,7 @@ export class ConfigStore {
const critical = getNestedValue(fileConfig, KEY_TO_PATH["health.context_pressure.critical_percent"])
?? getDefaultValue("health.context_pressure.critical_percent", workspaceRoot);
if ((warning as number) >= (critical as number)) {
throw new Error(`Invalid context-pressure policy: warning (${warning}) must be less than critical (${critical})`);
throw new Error(`Invalid context-pressure policy for the config-file pair: warning (${warning}) must be less than critical (${critical}). Unset file values use defaults; environment overrides apply when each process resolves configuration and are not part of this check.`);
}
}
mkdirSync(dirname(this.configPath), { recursive: true });
@@ -1583,7 +1695,7 @@ export class ConfigStore {
const critical = getNestedValue(fileConfig, KEY_TO_PATH["health.context_pressure.critical_percent"])
?? getDefaultValue("health.context_pressure.critical_percent", workspaceRoot);
if ((warning as number) >= (critical as number)) {
throw new Error(`Invalid context-pressure policy: warning (${warning}) must be less than critical (${critical})`);
throw new Error(`Invalid context-pressure policy for the config-file pair: warning (${warning}) must be less than critical (${critical}). Unset file values use defaults; environment overrides apply when each process resolves configuration and are not part of this check.`);
}
}
pruneEmptyAncestors(fileConfig, parentParts);
+3 -1
View File
@@ -36,6 +36,8 @@ export interface CrossHostTargetResolution {
target: string;
/** The sugar-derived host id (registered suffix), if any. */
sugarHost: string | undefined;
/** The qualifier explicitly names this host, overriding a persisted remote selection. */
localHost?: boolean;
/**
* Loud-failure hint when the target was 3-part-SHAPED but the suffix
* matched no registered host. Callers append it to failure surfaces for
@@ -102,7 +104,7 @@ export function resolveCrossHostTarget(
};
}
// Routes home: strip the self-suffix, no cross-host sugarHost (local send).
return { ok: true, target: base, sugarHost: undefined, hint: undefined };
return { ok: true, target: base, sugarHost: undefined, localHost: true, hint: undefined };
}
const loader = registryLoader ?? loadHostRegistry;
+39 -5
View File
@@ -39,6 +39,8 @@ export interface DaemonEventLoopEvidence {
}
export interface DaemonStatus {
/** The selected OPENRIG_URL names a non-loopback endpoint. */
explicitRemote?: boolean;
databaseWarnings?: string[];
/** RULING 1ae863d2 — C3 3-state semantics (canonical: daemon crash-cart-detect.ts, kept in
* lockstep): "stopped" requires POSITIVE evidence (connection refused); a probe TIMEOUT or
@@ -432,9 +434,13 @@ function resolveLifecycleFile(deps: LifecycleDeps, filename: "daemon.json" | "da
* - "openrig" — healthz responded (ok or not) → this is our daemon
* - "not_openrig" — connection refused → PID is alive but not listening on our port
* - "unresponsive" — pid is alive but healthz probe timed out
* - "dead" — PID not alive */
async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openrig" | "not_openrig" | "unresponsive" | "dead"> {
if (!deps.isProcessAlive(state.pid)) return "dead";
* - "dead" — PID not alive
* - { blocked } — the OS refused the probe; identity is unknown */
async function checkPid(state: DaemonState, deps: LifecycleDeps, knownLiveness?: ProcessLiveness): Promise<"openrig" | "not_openrig" | "unresponsive" | "dead" | { blocked: string }> {
const liveness = knownLiveness ?? (deps.processLiveness
? deps.processLiveness(state.pid)
: deps.isProcessAlive(state.pid) ? "alive" : "dead");
if (liveness === "dead") return "dead";
const host = state.host ?? DEFAULT_HOST;
try {
await fetchDaemonProbe(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
@@ -442,6 +448,8 @@ async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openr
return "openrig";
} catch (err) {
if (err instanceof HealthProbeTimeoutError) return "unresponsive";
const code = connectionErrorCode(err);
if (isBlockedConnectionCode(code)) return { blocked: code };
// Connection refused → PID alive but not our daemon
return "not_openrig";
}
@@ -487,6 +495,13 @@ const ENV_SCRUB_EXACT = new Set([
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
"TERMINFO",
// The herdr-core terminal this command runs in (HERDR_CORE_TERMINAL_ENV_KEYS in the daemon's
// parent-session-env.ts). Core ends every process started with that terminal's HC_SCOPE when the
// terminal closes, and it reads the environment the process was started with, so the daemon must
// never be started with it: closing the terminal would end the daemon, its tmux server and every seat.
"HC_SCOPE",
"HC_TERMINAL_ID",
"HC_TERMINAL_SOCKET",
]);
export function buildDaemonEnv(
@@ -679,6 +694,10 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
const existing = readState(deps);
if (existing) {
const pidState = await checkPid(existing, deps);
if (typeof pidState === "object") {
const guidance = blockedConnectionGuidance(pidState.blocked);
throw new Error(`${guidance.consequence} ${guidance.action}`);
}
if (pidState === "openrig") {
// Our daemon is running (possibly unhealthy, but alive on our port)
throw new Error(`Daemon already running (pid ${existing.pid} on port ${existing.port})`);
@@ -693,6 +712,10 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
await fetchDaemonProbe(deps, `http://${formatDaemonHostForUrl(probeHost)}:${port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
recoveredRunning = true;
} catch (error) {
const code = connectionErrorCode(error);
if (isBlockedConnectionCode(code)) {
console.warn(`Warning: daemon health probe was blocked (${code}); the port could not be checked and a daemon may already be running.`);
}
// WSL2 can silently drop probes to an unused port. A slow live daemon can
// also time out: do not run a second child's initialization against its DB.
if (error instanceof HealthProbeTimeoutError && await deps.isPortOccupied?.(probeHost, port)) {
@@ -972,7 +995,16 @@ export async function stopDaemon(deps: LifecycleDeps): Promise<"stopped" | "no-t
}
const stateFile = resolveLifecycleFile(deps, "daemon.json");
const pidState = await checkPid(state, deps);
const liveness = deps.processLiveness
? deps.processLiveness(state.pid)
: deps.isProcessAlive(state.pid) ? "alive" : "dead";
if (liveness === "unknown") {
throw new Error(`Cannot stop safely: PID ${state.pid} liveness is unknown; no signal sent, state preserved.`);
}
const pidState = await checkPid(state, deps, liveness);
if (typeof pidState === "object") {
throw new Error(`Cannot stop safely: the identity probe was blocked (${pidState.blocked}); no signal sent, state preserved.`);
}
if (pidState === "not_openrig") {
throw new Error(`Cannot stop safely: PID ${state.pid} is present but identity is not confirmed at ${check}; no signal sent, state preserved.`);
}
@@ -1107,7 +1139,9 @@ export async function getDaemonStatus(
const endpoint = (() => {
try {
const url = new URL(openrigUrl);
return { port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST };
const host = url.hostname || DEFAULT_HOST;
const loopback = host.toLowerCase() === "localhost" || host === "[::1]" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host);
return { port: Number(url.port) || DEFAULT_PORT, host, ...(!loopback ? { explicitRemote: true } : {}) };
} catch {
return {};
}
Loaded 100 of 870 files, more files were not shown because too many files have changed in this diff. Show more