Compare commits

...
Author SHA1 Message Date
Mike Schwarz 5ea35e93bc fix(daemon): recognize Claude effort in identity proof (0.6.5 port of #681) (#687)
Port of #681 (main 6bdbed2b) onto release/0.6.5 for 0.6.5. A Claude seat launched with --effort now passes exact identity proof: both argv readers skip --effort <value> and --effort=<value> like the other launch options. Refs #197
2026-10-04 01:55:28 -07:00
Mike Schwarz f633b2310e release: 0.6.5 version (#672)
Set the release version to 0.6.5 in the root, cli, daemon and ui package manifests and the matching package-lock entries. No code, dependency or plugin version change.
2026-10-03 23:57:41 -07:00
Mike Schwarz 852e3d550f docs(onboarding): teach the route to a project's declared context (#679)
The shipped start-up text and the restore and reorientation skills now teach how an agent reaches its project's declared context: rig context work-install lists what the project declares (intent, context files, skills), the agent reads the ones its task needs, and --deliver prints them all. When several projects are declared it stops with project_required, rig context work-install --json lists their ids, and the agent picks one with --project instead of guessing. The placement skill gains a section on project knowledge: product facts, project judgment and machine-local state, and where each belongs.

(cherry picked from commit 9e24dff936)
2026-10-03 23:56:36 -07:00
Mike Schwarz 4b43921b9c fix(daemon): save the current Claude conversation before shutdown (#658)
Before the shutdown snapshot, a classic Claude seat's current conversation ID is read from Claude's own session file for the stable foreground process, so after /clear a later restart resumes the conversation the user is actually in rather than the pre-clear one. The read requires a matching seat name, a file written at or after the process started, a valid token and an unchanged process observation. Missing or ambiguous evidence keeps the existing shutdown behavior, and the existing provenance rules still protect hook, operator and adoption tokens. An archived rig whose name is now used by another live rig is not refreshed, so it keeps its own saved conversation. An unchanged token keeps its provenance. Shutdown now adds two process observations and synchronous file reads, which are not time-bounded.

(cherry picked from commit 3425e9d207)
2026-10-03 21:09:19 -07:00
Mike Schwarz 1374258c90 fix(launch): preserve Claude function lookup in fish panes (#657)
In a fish pane, a classic Claude launch now sources the staged launch script in the pane's own fish when a fixed pre-launch probe shows fish accepts leading VAR=value assignments (fish 3.1 and later), so a claude defined only as a fish function or alias launches as it did when the launch was typed directly into the pane. Older fish, launch commands or paths containing a backslash, and wrappers too long for the safe input bound keep the existing /bin/sh staging, so anything that launched before still launches the same way. The probe uses /bin/sh -c :, which exists wherever the fallback already works, including macOS. Other shells, other runtimes and managed launches are unchanged; nushell stays on /bin/sh.

(cherry picked from commit b4a68d1c0b)
2026-10-03 20:47:49 -07:00
Mike Schwarz 4cbba04137 Keep the assigned session ID for fresh Claude launches (#656)
A fresh Claude launch now records the session ID OpenRig assigned with --session-id, instead of the first session file in the Claude config folder whose name matches. Two live seats with the same name sharing one config folder could otherwise record each other's ID, so a later resume opened the wrong conversation. The fork path, which has no assigned ID, still matches by name; that limit is unchanged. Tests cover a single matching file, another same-name seat listed first, and only the other seat's file.

(cherry picked from commit f475fc3cff)
2026-10-03 19:09:59 -07:00
Mike Schwarz cf6d82be1e fix: keep acknowledged continuity unverified without restore proof (#652)
Acknowledging a working seat made inventory infer resumed, even when no evidence established that its original conversation resumed. This keeps the restore acknowledgment (operator_recovered) but leaves inferred continuity null unless the winning reconciliation carries strict restore proof bound to the same rig, node, attempt and completion receipt. rig seat status shows null as "unverified", including for seats that were never restored and have no explicit continuity outcome.

Explicit stored continuity values keep their precedence. A running acknowledged seat stays running, and applicable identity mismatches still require attention. Clear-attention decisions, recovery, delivery, tokens and lifecycle actions are unchanged. Restore-start reads are eligible for the existing node/type index, with a scan fallback when that optional index is absent, and no per-seat receipt queries are added. The user guide sentence that described the old behavior is corrected; plugin versions are unchanged.

(cherry picked from commit 3c8a20e3d8)
2026-10-03 17:27:31 -07:00
Mike Schwarz 94d2da21ef Show daemon and recovery guidance for missing queue items (#654)
When a queue item ID is not found on the daemon the CLI is talking to, the ten queue item commands (claim, unclaim, update, block, resolve, handoff, handoff-and-complete, fallback, show and transitions) now add a hint to the existing qitem_not_found error. The hint names the daemon by the host ID it reports, or says the ID is unavailable, and adds: if the item came from another host, run rig host list, then OPENRIG_URL='<daemon-url>' rig queue show '<id>' --full --json.

The error field, HTTP status and exit code are unchanged; the hint is an additional field. The host ID comes from one bounded read of the same daemon's health endpoint, made only after a not-found answer. Nothing is searched, no host is inferred from the ID, and writes are never replayed.

(cherry picked from commit f0113149b7)
2026-10-03 17:26:47 -07:00
Mike Schwarz 5503930448 Restore explicit HTML and inline SVG asset previews (#653)
Explicit render=1 HTML previews again load without the script-free CSP, frame restrictions or nosniff, and SVG assets are served inline without forced attachment. This also restores the earlier header behavior for external script assets used by a preview.

Rendered HTML and directly opened SVG can run scripts in the daemon's origin. Default HTML remains text/plain. Authorization, the allowlist, path containment, response bodies, content types and range handling are unchanged from this change's base.

Validation: 63 focused route and header controls passed on the equivalent reviewed files, including full and partial JavaScript responses; the two new JavaScript controls failed against the previous route. No real-browser run was performed.

(cherry picked from commit f8c070a5bf)
2026-10-03 17:21:29 -07:00
Mike Schwarz b345706b65 docs: explain seat attention checks and token correction (#651)
rig seat clear-attention now explains which attention class remains unresolved and how to correct a known native token before checking the live evidence again. The CLI help and the openrig-user skill distinguish operator acknowledgment from verified conversation continuity, describe the earlier identity-check path, and say which clears write audit events. The token example supplies stdin explicitly.

This is guidance only: clear decisions, error codes and recovery behavior are unchanged. It does not fix the separate projection that can still show resumed after an acknowledgment. The plugin version is unchanged.
2026-10-03 16:44:24 -07:00
Mike Schwarz 3929e6d6a6 fix(cli): restore numeric-prefix chatroom timeout compatibility (#650)
rig chatroom wait --timeout accepts numeric-prefix values again. It tries Number first and uses parseFloat only when that produces NaN. Blank, non-finite and negative values are still rejected before any request; plain numeric, scientific, hexadecimal and fractional values keep their current behavior.

These are numeric-prefix seconds, not unit conversion: 2m means 2 seconds, 500ms means 500 seconds, and 1h means 1 second. Use plain numeric seconds, for example --timeout 120 for two minutes.

Refs #355.
2026-10-03 16:37:16 -07:00
Mike Schwarz 28ced738de fix(daemon): use one browser boundary and remove the pairing cap (#648)
Browser clients could pass the daemon's primary Host/Origin check and then be rejected by a second API or terminal WebSocket Origin check with different rules. This change uses the existing primary boundary consistently and removes the fixed refusal at 20 active host-pairing requests on both sides of pairing.

The removed checks are not equivalent predicates. Requests they rejected can now reach the existing route authentication, including explicitly configured non-HTTP Origin entries and configured cross-origin clients, normalized parse-equivalent Origins, and the enabled daemon UI's terminal upgrade on an accepted non-loopback host or IP without a terminal token. OPENRIG_ALLOWED_ORIGINS is now read once when the primary boundary is constructed.

The primary boundary still runs before the affected routes and token authentication is retained; its accepted IP/host rules, configured allowances and no-Origin behavior are unchanged. This is not a claim of universal DNS-rebinding protection or browser isolation. The standalone terminal registrar is token-only; its production mount supplies the browser boundary.

Pairing keeps its authorization, human approval, single-use handover, expiry, requester sanitation and failure cleanup, with no replacement cap. Pending durable approval rows can accumulate: expiry and arrival-triggered pruning clean the in-memory maps, not those rows. Notification URL handling is unchanged.

Not exercised: a real browser, a real tailnet, live pairing, Windows, sustained load.
2026-10-03 16:24:36 -07:00
Mike Schwarz db3aef8f2a fix(package): remove redundant deferred role-hook warnings (#649)
rig package validate and rig package plan stop printing the duplicated warning about deferred role-hook references, and the optional warnings field leaves the validate/plan API and CLI output. Manifest validity, exit status, manifest errors and meaningful deferred plan entries are unchanged.

This package path does not install hooks today. A stale hook reference again gets no warning and no deferred entry. The role-hook validation requested in #477 is not implemented here.

Refs #477, #478.
2026-10-03 16:23:51 -07:00
Coder8124 85ed428291 fix(queue): reject a non-numeric whoami recentLimit with 400, not 500 (#647)
A non-numeric recentLimit on the queue whoami route (for example abc) reached SQLite as LIMIT NULL and failed with a datatype mismatch. It now returns 400 "recentLimit must be a number".

Values that parseInt reads today (5, 1.5, 2x, -1) behave exactly as before, with the existing 1..200 clamp. An absent or empty value still uses the default. The new route test covers abc (400) and 5, 1.5, 2x and -1 (200).

Contributed by Coder8124.
2026-10-03 16:07:11 -07:00
Mike Schwarz 53f2b5d9d0 fix(queue): resume wake retries after prompt clears (#644)
After an operator-route prompt alert was positively cleared by its producer, the wake ladder's ownership check handed the original row back, but the executing ladder still skipped every terminal recovery and the readback still reported it resolved. A claimed failed-class original, or a pending failed or retained baton, then received no further retries. Ownership, execution and readback now share one predicate: a terminal recovery is skipped unless it is a prompt alert retired by its own producer with the exact retirement note, in which case the original's ordinary wake continues.

Manually closed alerts, unknown prompt states and every other terminal recovery remain held as before. A continued wake draws from the original row's existing retry count, cap and destination budget, and an exhausted original stays exhausted; nothing is reset and no watcher is added. The ordinary claimed case still gets one wake, and the readback now reports the actual eligible retry with the same interval and cap as execution. Timings in the tests are virtual; no production timing is claimed.

Refs #81
2026-10-03 15:24:57 -07:00
Mike Schwarz 69d3aa9d49 fix(compaction): diagnose missing generation before preparation (#645)
A map-required compaction preparation for a seat whose current occupant has no generation could never succeed: the map it waits for cannot be matched to a null generation, so the request ran out its full budget and failed. Preparation now checks both existing generation resolvers first and, when neither yields an identity, stops immediately with a reason that names the condition and the supported recovery: run rig reconcile-session '<session>' --no-launch, then start a new rig compact attempt. No preparation prompt is sent in that case.

Nothing else changes: skip-map still works with no generation, an active attempt still cannot be taken over, the 120 s manual and 180 s HTTP budgets are unchanged, and cancel, expiry, disable, late-map and stale-generation handling stay as they were. The fix never invents an identity, reconciles a seat on its own, or rearms a stopped attempt; only a new deliberate attempt after reconciliation freezes a new generation and map.

Refs #610
2026-10-03 15:23:06 -07:00
Mike Schwarz 67e81ec9bb perf(slack): defer full queue views until notification selection (#642)
The Slack outbound sweep built the full queue view (pickup, waiting and recovery state, activity and delivery ledger) for every active row before checking whether that row's owner notification had already been delivered. Selection now reads active row IDs with the same filter, order and million-row cap, keeps the existing latest-notification and exact-receipt checks, and builds the full view only for rows that still need delivery. Slack projection, human-alert filtering, receipts, seen state and dispatch are unchanged; a row that is gone or no longer active is not posted, and a failed read still fails the sweep rather than counting as delivered.

In a synthetic 200-row queue with 2 undelivered notifications, full views fall from 200 to 2 and the median sweep from about 335 ms to about 27 ms. When every active row carries an undelivered notification the extra point reads make the sweep roughly 9-11% slower. It is still a scan of all active rows, delivery remains at-least-once, and no live CPU or health improvement is claimed. Enable-time seeding and the delivery digest use the same selection and keep their behavior.

Refs #161

Based on korallis/agent-stack#139 by korallis and Lee.
2026-10-03 14:35:23 -07:00
Rudy Mizrahi Celekli 7748b87d43 fix(cli): retain saved rig specs after failed export writes (#640)
A failed rig export write could replace the saved rig.yaml with partial output. Export now uses the config store's existing staged write, moved into one shared helper: complete content is written beside the target and renamed into place, so a failed data write leaves the previous file intact and removes the staged copy. File modes, POSIX ownership, symlink following and the fallback for unwritable directories or busy targets are kept, and write-only destinations are accepted.

A destination that already exists and is not a regular file, such as a pipe behind /dev/stdout, a FIFO or a device, is written directly as before, so streams and devices are never replaced. The config store keeps the same behavior through the shared helper. Tests cover partial-write failure through the real export command, FIFO and stream destinations, symlinks and long file names. No ACL preservation or power-loss durability is claimed.

Contributed by @rudycelekli.
2026-10-03 13:59:45 -07:00
Mike Schwarz 20b3b48855 fix(restore-check): share restore preconditions and report unavailable inputs (#636)
restore-check now runs restore's own pre-restore validation instead of reporting green for a rig that restore would refuse. The validator moves unchanged into a shared module; restore keeps calling it at the same point with the same messages, order and refusals. restore-check runs it once per rig on the snapshot that rig up --existing would choose: refusals show red, warnings alone show yellow, and an empty result is green, bounded to what was checked. It never captures or invents a snapshot.

When there is no usable current-occupant snapshot, restore-check reuses ordinary up's existing current-state rehydrate eligibility: an eligible stopped or partial rig keeps its down or partial status and the rig up --existing suggestion, with the restore inputs shown yellow as not inspected; an ineligible rig shows up's own blockers in red; a missing rig or a read error stays a named unknown for that rig only. In the overall verdict a known red now wins over an unrelated unknown (exit 1 before exit 2), while each rig's own status and the counts remain visible.

Composed rig status skips the added validation for rigs whose seats are all running and ready; explicit restore-check always runs it. Query counts per assessed rig are documented in the PR; the full snapshot history is still read for stopped rigs, which is follow-up work. P2 spec-root corrections and launch-record rig_id are out of scope.

Refs #130
2026-10-03 13:46:33 -07:00
Mike Schwarz b89fcad7c0 fix(codex): honor explicitly selected home across managed paths (#638)
An explicit CODEX_HOME was honored by daemon startup writers but not by every managed path. Launch, resume, fork and legacy commands, profile and capability probes, thread-id and context-usage readers, and plugin-cache discovery now all use the same selected home, including when a pane's shell rc sets a different value. An empty CODEX_HOME is treated as unset at the first startup read, so every consumer uses the default home; whitespace and relative values keep their existing startup refusal. The setup disclosure names the selected config path and its default.

This is opt-in: with CODEX_HOME unset, a second install still shares ~/.codex. Auth commands keep the caller's root, and no authentication, history or settings are copied or migrated.

Upgrade note: if your shell rc sets a CODEX_HOME different from the daemon's, align them (or unset the daemon's) before upgrading; existing seats otherwise resume under the daemon's home, where their earlier Codex thread isn't.
2026-10-03 13:17:00 -07:00
Rudy Mizrahi Celekli 1ed5bcceda fix(cli): preserve blank lines in transcript rendering (#639)
rig transcript printed the daemon's captured text with every empty line removed, collapsing paragraphs and blank lines inside code blocks. The renderer now prints every line except the trailing split sentinel, whose newline console.log already supplies, so leading, consecutive and interior blank lines survive and no extra final blank line is added. The near-empty-transcript hint still counts only non-empty lines. --json and --grep output are unchanged; the remote --host path shares the renderer and gets the same fix. A regression test covers leading, consecutive and interior blank lines, including inside a fenced code block.

Contributed by @rudycelekli.
2026-10-03 12:35:53 -07:00
JUSHUANGHUI LI 47ae99d74e fix(cli): surface chat history HTTP errors (#476)
rig chatroom history and rig chatroom wait now surface HTTP errors from the chat history API instead of hiding them.

- history: with --json, the body is printed as before and the exit code is 1. In plain mode, the error (or "Failed (HTTP n)") goes to stderr and the exit code is 1, instead of "No messages." with exit 0.
- wait: the first HTTP error is reported and the command exits 1, instead of polling until the timeout.
- Successful output is unchanged.
2026-10-03 11:35:38 -07:00
Mike Schwarz 8bed067878 fix(daemon): describe the actual compaction preparation deadline (#633)
Compaction preparation prompts now describe what is actually happening.

- Manual requests name manual mode and make no threshold claim. They give the preparation deadline in UTC and the time remaining when the request was built (not guaranteed on receipt), and say that delivery, writing the full restore map and becoming idle share that deadline.
- Automatic requests say their 25-minute ceiling starts after preparation delivery returns, with no invented absolute time.
- The preparation_incomplete reason now covers the map and the idle wait, including expiry after a completed map.
- Timing values, the restore-map prerequisite and marker, guards, cancellation, expiry and disarming are unchanged.

Refs #610.
2026-10-03 10:37:35 -07:00
Mike Schwarz acd31a90b5 fix(startup): distinguish unrecognized composer boundaries (#634)
When OpenRig can't verify that a startup prompt was submitted, the warning now says why. "Unrecognized composer boundary" means the capture was available but the composer's marker or closing rule wasn't found. The existing extracted-text-mismatch wording is kept for real mismatches. A text-free reason label is added to the startup diagnostics.

- Applies at the initial and the after-retry observation.
- Wording and diagnostics only: the composer classifier, transport, guarded-Enter matching, verdicts, waits, retries and captures are unchanged.
- No prompt or reply text in the warning or the event fields.

Recording footer-shape metadata for unrecognized layouts is a possible follow-up, not part of this change.
2026-10-03 10:19:53 -07:00
Mike Schwarz 350eb7ba98 fix(bootstrap): record the rig a pod-aware launch produced (#632)
Pod-aware launches now record which rig they created. The bootstrap run row gets rig_id on the pod-aware success path, as the legacy flat and all-attention paths already did, so a modern rig can be traced back to the spec it was launched from.

- Applies to completed and mixed (partial) pod-aware runs. Failed runs have no rig and are unchanged.
- Launch behavior, responses and side-effect order are unchanged. GET /api/bootstrap now shows rigId for these runs.
- No migration. Earlier runs stay without a rig_id.

Refs #130.
2026-10-03 10:18:37 -07:00
Mike Schwarz d116467692 fix(restore-check): report an unlocated rig spec as not checked, not red (#631)
restore-check no longer reports red when a rig's spec isn't under <shared-docs>/rigs/<rig>. That folder is the only place it looks, nothing in the product writes there, and restore itself never reads the spec, so the red was false.

- A missing <root>/rigs/<rig> is now yellow "not checked", naming the path it looked for and saying no launch spec location could be established.
- A present spec stays green. A folder without rig.yaml stays yellow.
- No new status value, and every other check reaches the verdict unchanged. When this was the only finding, rig restore-check now exits 0 (restorable with caveats) instead of 1, per its documented exit contract.
- Restore, its refusals and the recovery plan are unchanged.

Refs #130 (its spec-location part).
2026-10-03 10:04:10 -07:00
Mike Schwarz 7de30c9592 fix: preserve interactive shell lookup for classic Claude launches (#628)
Classic (non-managed) Claude launches resolve claude through the pane's interactive shell again, so a claude defined only as an rc alias or function starts, as it did before #618.

- For recognized POSIX pane shells (bash, zsh, sh, dash, ksh), the staged command is sourced in a subshell of the pane shell, and public seat metadata uses leading assignments. #618's metadata correction and its rig-only PATH entry are kept, and the variables don't leak into the pane shell.
- Every other or unreadable shell (fish, nushell, unknown, failed read) keeps #618's /bin/sh staging. An rc-only claude alias or function there stays unresolved, as at #618.
- Long commands stay protected: the typed invocation is about 115 bytes for a 4 KiB command, and the full command stays in a private 0600 file that is removed after use.
- Managed Claude and Codex launch paths are unchanged.

Bash only was executed (real tmux, offline recorder, native identity stubbed). Fish and nushell were checked through reported-shell selection, not native runs.

Refs #618.
2026-10-03 09:43:50 -07:00
Mike Schwarz c550bfaa39 chore: make the asset CSP comments match the shipped headers (#630)
Comment-only follow-up to #462: the asset route's comments now match the shipped headers. Every asset response carries nosniff. Rendered HTML gets a script-free CSP under the web UI's fail-closed rule. SVG is served as an attachment, so opening one directly downloads it, while <img> embedding is unaffected. The previous "advisory, not a gate" wording and the threat narrative are removed. No code changes.

Refs #462.
2026-10-03 09:24:39 -07:00
Sunil 3dd8bc776f security: add response headers to /api/files/asset for active content types (#462)
/api/files/asset responses now carry X-Content-Type-Options: nosniff.

- Rendered HTML (the ?render=1 view of .html files) is served with a Content-Security-Policy that runs no scripts, opens no connections and can't be framed. Inline styles and same-origin images still render.
- SVG files are served as attachments with a UTF-8 filename*, so opening one directly downloads it. <img> embedding is unaffected.
- Image, video and range replies are otherwise unchanged.

The route serves only the web UI. The transcript-search change from an earlier revision is not part of this PR.
2026-10-03 08:51:30 -07:00
Mike Schwarz ec5e77aaff fix: escalate prompt-blocked work through the existing operator route (#619)
When a wake to a parked owner is refused because the seat is at an interactive prompt, the outstanding work now reaches the existing orchestrator/operator escalation path, and nothing is typed into the blocked seat. One aggregate per episode carries the affected rows and owns their continuation. The original is not retried or exhausted by the generic ladder, and its waiting readback reports the delegated recovery. The alert retires when the prompt positively clears or the work closes.

- Clearing needs a newer state from an authoritative needs-input-capable observation. Default Codex trial hooks and generic sampling-only seats never report a blocked prompt, so they get no prompt alert yet.
- Shared operator seam: missing human bindings, unavailable registry reads and gateway dispatch refusals now stay unresolved and are re-resolved on the existing cadence, instead of exhausting as undeliverable. Unchanged unresolved decisions add no sends or notes, and old exhausted episodes stay closed. Aggregate bodies include member summaries. Accepted or deferred delivery stays gateway-owned.
- Robustness: prompt-pass faults are logged without stopping the existing ladder. New JSON-membership reads ignore malformed tags. An unreadable candidate or member holds only its own destination for that tick and can't retire an alert.
- Composes with the guard-refused retry path from #623: an open prompt recovery owns both retry and escalation.

Refs #81.
2026-10-03 08:05:00 -07:00
Mike Schwarz f027fdebb5 fix(health): preserve available findings when one source fails (#626)
Health sources are read independently, so one source that fails or exceeds its limit no longer makes every health read fail. A failed source is reported as unavailable, with its name and reason. A bounded source is reported as partial, with its actual census. The other sources keep reporting, and a missing source is never shown as healthy or zero. Disabled detectors are skipped and leave no stale result.

- Attention reads the coverage of the same health read. It marks health unavailable or partial, names the affected source, and keeps the working sources' current items.
- The TUI keeps current items. During an outage it re-shows earlier items that depend on the unavailable source, under the named outage. A successful partial window replaces the previous one, and recovery clears the outage.
- No limit, threshold, policy, schema or collector changed.

Known limit: earlier items are retained under the overall health dependency, not per source, so during one source's outage an item another source has since dropped can stay visible, marked as from an incomplete feed.

Refs #613.
2026-10-03 08:01:44 -07:00
Mike Schwarz 393297e10c fix: resolve built-in core plugin from configured OpenRig home (#627)
The built-in shared agent spec now names its core plugin as openrig-home:plugins/openrig-core, so it resolves under the daemon's configured OpenRig home (OPENRIG_HOME, else RIGGED_HOME, else ~/.openrig) instead of always under ~/.openrig.

- Default installs resolve to the same path as before.
- An instance with a non-default home now loads the plugin copy the daemon seeded there.
- A planner regression test loads the real built-in spec under a temporary home and checks the resolved path. The starter-library expectation follows the new spelling.

The openrig-home: form needs a daemon that supports it. The built-in spec ships with that daemon.
2026-10-03 07:25:01 -07:00
Dery Ferdika 4f3b766aa5 fix(daemon): keep a guard-refused wake's retry path for unclaimed batons (#344) (#623)
When the typing guard refuses a wake (the seat is protected from typed input, so OpenRig retains the nudge instead of typing it), an unclaimed baton keeps its retry path instead of sitting stranded until someone nudges it by hand.

- queue-recovery counts a retained: nudge attempt as newer evidence, like failed:, so a terminal recovery written before the refusal no longer hides the row.
- The wake ladder treats retained: as a retryable failed-mode wake only for pending, unclaimed batons. Claimed rows keep the previous classification, so the parked-owner path still owns them.
- Retries reuse the row's delivery id. The first retry re-probes the guard, later retries read back as retained, and the row then escalates through the existing rungs. Retry cadence, budget, attempt cap and exhaustion are unchanged. Nothing is typed into the guarded seat.

Refs #344.
2026-10-03 07:19:21 -07:00
Shravan Sumanthanan 5862bafdce fix(daemon): sweep seat-target closures without successors (Refs #318) (#604)
The stuck sweep's custody detector now also covers seat-address closures. A row closed done or canceled with closure_reason handed_off_to toward a seat (not a qitem id), and with no successor carrying lineage to it, raises a custody finding instead of going unnoticed. Handed-off rows are skipped, because a transactional handoff always creates its successor.

- Scope: each tick scans done and canceled closures updated within a fixed 24-hour window, plus rows that still carry an open finding, so history from before an upgrade doesn't raise waking findings. There's no configuration key for the window.
- Successor detection requires direct lineage (handed_off_from or chain_of_record), so unrelated work for the same seat doesn't suppress a finding.
- The verification recipe uses rig queue list --destination.

Refs #318.
2026-10-03 07:14:33 -07:00
Mike Schwarz 043b1bb3b4 fix: preserve OpenRig seat environment after shell startup (#618)
Seat launches re-assert OpenRig's own public variables after the user's shell startup files run, so a pane rc can no longer override OPENRIG_HOME, OPENRIG_URL, the node id, session name, runtime or occupant generation. Only an explicit allowlist is typed; credentials and provider keys keep their tmux environment channel, and an OPENRIG_URL carrying userinfo, a query or a fragment is left out of the typed line.

- PATH: one OpenRig-owned directory, $OPENRIG_HOME/run/seat-bin/<sha256 of the daemon's paired CLI path>, holding only a rig link to the daemon's own CLI, goes first; the rest of the user's PATH order is unchanged. Unexpected contents in that directory are preserved and the launch falls back.
- Codex: an npm-installed Codex and its env-node interpreter resolve on the capability-probe PATH; child tools get the user's PATH.
- Fail-open: if the paired CLI, the tmux environment read, the identity or the node check is unavailable, the launch uses the previous command with one fixed log line. No new refusal.
- Nushell panes keep the previous literal command.

Limits: non-managed Claude launches now run from a /bin/sh script, so `claude` must be an executable on PATH (the native installer's ~/.local/bin/claude is); a legacy install reached only through a shell alias or function is not resolved, fallback included. A stray entry in the seat-bin directory disables the correction for that install (it fails open). Not exercised on a real provider, tmux, macOS or Nushell session.

Reviewed independently: Claude source review and Codex executed review (red/green controls including the npm Codex interpreter case, concurrent alias creation and rc overrides), both MERGE-READY at this head; CI 8/8.
2026-10-03 06:47:15 -07:00
Mike Schwarz a24471af62 fix(daemon): prevent remaining bare tmux targets from selecting prefix siblings (#625)
Refs #612. The remaining tmux commands that address a seat by name now use exact targets, completing the change begun for capture and probes. respawnPane, setRemainOnExit, isPaneDead, getPaneCursorPosition, readPaneLastActivity, setSessionOption and getSessionOption use the exact pane form (set-option and show-option take a target pane even for session-scoped options), and switchClient uses the exact session form. The seat-switch service qualifies its window lookup and its final switch target, so a session that disappears after the probes cannot switch a client to a prefix-named sibling; its public target is unchanged. Before this, a missing session could have a sibling respawned, read, re-optioned or switched to. Live names, tmux ids and explicit targets behave as before.
2026-10-03 06:28:16 -07:00
Mike Schwarz 63838b2ad2 fix: show unavailable idle ceiling when no seats capture (#624)
Refs #556. When no seat is rotating transcripts, the transcript capture stats report the idle ceiling as null, like the active interval, and rig ps --resources shows it as unavailable instead of 0 ms. The host resources reference explains the default 3-second idle ceiling: the 10-second freshness window minus the 1-second hint lookup, the 5-second capture result deadline and 1 second of timer spacing, never below the configured active interval. It makes no CPU or child-process lifetime promise.
2026-10-03 05:57:06 -07:00
Rudy Mizrahi Celekli 7e9452d266 fix(config): preserve usable settings after partial writes (#576)
Settings writes from the CLI config store and the daemon settings store keep the existing file usable when an update fails partway. Each store stages the new content beside the target and renames it into place, preserving owner, group and mode, so a failed staged write or rename (including ENOSPC or EIO) leaves the original byte-identical. An existing target is first opened for writing without truncation, so a read-only settings file is refused as before. Where the directory doesn't allow temporary entries, ownership, mode or rename (EACCES, EPERM, EBUSY), the update falls back to writing in place, as before, with that path's existing partial-write limit. instance-layout.md describes the behaviour, including that a hard-linked second name keeps referring to the previous file after an atomic replace.
2026-10-03 05:45:28 -07:00
Rudy Mizrahi Celekli 835e420608 feat(daemon): adaptive transcript capture and host resource visibility (#556)
Transcript capture adapts to seat activity, and rig ps can show host resource use. Active seats keep the configured poll cadence. Idle seats back off, but the next idle capture is scheduled from the last successful completion and capped by a ceiling derived from the shared 10-second freshness window, the hint lookup and a 5-second capture result deadline, so an idle seat's transcript stays fresh even when one capture is slow. A capture that fails or outlives its deadline counts as a failure, never refreshes freshness and is not written late, and captures for a seat cannot overlap. The existing capture_stale response is unchanged. rig ps --resources reports capture cadence and host resource figures.
2026-10-03 05:41:03 -07:00
Mike Schwarz b20af404be fix(daemon): address the exact tmux session when capturing, reading its environment or piping it (#614)
Refs #612. tmux commands that address a seat by name now use tmux's exact target forms, so a session that has ended can no longer be resolved by tmux's prefix fallback to a sibling whose name starts the same way. Pane commands use =name: and session commands use =name, for capturePaneContent, capturePaneScreen, hasSessionEnv, startPipePane, stopPipePane, getPaneCommand, getPanePid, setWindowOption, resizeWindow, and the unguarded detach-client and kill-session in killSessionUnchecked. Before this, with the target gone, a capture read the sibling's screen, readiness and resume probes read the sibling's command and pid, window options and size were changed on the sibling, and an unguarded kill could kill the sibling. tmux ids (%N, @N, $N) and explicit targets containing ':' pass through unchanged, so the guarded kill still uses the immutable session id. listWindows, isPaneDead, getPaneCursorPosition, readPaneLastActivity, setSessionOption, getSessionOption, setRemainOnExit, respawnPane and switchClient are listed for follow-up.
2026-10-03 05:25:40 -07:00
Rudy Mizrahi Celekli 39ee4751dc fix(compact-plan): reject incomplete daemon inventory (#579)
rig compact-plan no longer treats an incomplete daemon inventory as a complete one. For a fleet plan, a rig whose node read fails is left out and reported in incompleteRigs with its HTTP status, invalid_inventory or request_failed; the plan is built from the rigs that read successfully, text output prints a partial-plan warning on stderr, JSON always carries incompleteRigs, and the exit code is 1 when anything is incomplete. A failed read for an explicit --rig, or of the process list, still fails with exit 2 and no plan.
2026-10-03 05:21:51 -07:00
Mike Schwarz 3650524101 fix: resolve plugin paths from configured OpenRig home (#620)
AgentSpec plugin resource paths gain an openrig-home: form, resolved under the daemon's configured OpenRig home (OPENRIG_HOME, else the legacy RIGGED_HOME, else ~/.openrig) rather than the user's home directory, so a rig running under a non-default OpenRig home loads the plugin copy its daemon seeds. The suffix must be relative; an absolute suffix is refused, and the planning error reaches the instantiation result. Absolute, ~ and spec-relative paths are unchanged. The agent-spec reference documents the four path forms.
2026-10-03 05:18:37 -07:00
Mike Schwarz f88f590f75 test(queue): make the closure-overdue dedup test bite on any date; fix two doc line refs (#622)
Refs #490. The minimal-schema closure-overdue dedup test now sets the claim to max(UTC day start, now minus 5 minutes), so the events written with datetime('now') always fall on the claim's UTC date. A text timestamp comparison or an unescaped LIKE match therefore fails the test on any date, not only on the day it was written. The architecture event-system doc's types.ts citations are updated to the RigEvent union's end at line 311 and RestoreNodeResult.status at line 473.
2026-10-03 05:11:14 -07:00
Mike Schwarz 050878b027 test(scenarios): run the transcript and capture stub scenarios in CI (#621)
The installed-scenario CI job now also runs the transcript-reads-addressed-seat and capture-returns-addressed-seat stub scenarios once each, healthy only, after the six baton runs and through the same per-container block. A new verifier accepts only a clean PASS of the named scenario: one ledger record equal to the result, the scenario SHA-256, exit 0, and no error, seed or fault. The baton verifier is unchanged. run-pr-scenarios.sh gains --case transcript|capture, and a lost-baton mode with a passing-only case is refused. Planted failures for these scenarios stay as per-PR evidence and are not run in CI. The job name and required checks are unchanged; on this PR the job took 4m08s against a 3m42s baseline. test-layers.md and the CI README describe the new CI and local split.
2026-10-03 05:10:59 -07:00
Mike Schwarz 12cef7594d fix(startup): retain bounded submission mismatch evidence (#617)
When a fresh Claude seat's startup check cannot positively match its staged prompt, node.startup_ready now carries bounded diagnostics for each unverified startup send, and node.startup_failed carries them if a later step fails. Each record names the startup attempt, the send's order and source, the observation phase and what the single guarded retry did, and compares expected and observed text by normalized byte count and SHA-256 with the first differing byte offset and the marker and closing-rule positions in the capture. No prompt text or excerpt is stored, because startup actions and files are not classified for credentials. The full positive match before the one guarded Enter, the unverified result for ambiguous or unavailable observations, and the startup outcome are unchanged, and a failing diagnostic sink cannot change them. No extra capture or pane write is added.
2026-10-03 05:06:33 -07:00
Shravan Sumanthanan c0ca7d8759 fix(daemon): produce qitem.closure_overdue and retire mission_control.view_refreshed (#490) (#593)
Resolves #490. The daemon now produces qitem.closure_overdue: the stuck sweep records it once per claim episode for an overdue claimed item, with a closure-overdue transition by daemon@system, and a re-claim starts a new episode. Deduplication matches the exact qitemId with json_extract and compares timestamps with julianday on the path without queue_transitions. A failed overdue write is logged and does not drop the sweep finding. The overdue-claim finding shows the claimant's last note rather than the daemon's overdue note. mission_control.view_refreshed, which nothing produced, is retired from the declared events and its consumers, and the event-system and mission-control docs are updated.
2026-10-03 04:48:31 -07:00
Toan Nguyen 73e8ef81e5 fix(ps): count arbitrated needs-input in the rig attention total (#180)
Refs #81. The rig attention total in rig ps now counts a seat waiting at needs-input using the same arbitrated answer as the seat row. A positive needs-input count counts. An authoritative clear from needs-input chrome, lifecycle hooks or self-report does not count. When no authoritative source has answered, the hook store decides, as before, so a live permission request on a seat with hooks at trial or sampling only still counts. A stale hook no longer outweighs an authoritative clear. Display only: no delivery, refusal or notification behaviour changes.
2026-10-03 04:38:02 -07:00
Mike Schwarz 42c60fa466 docs: say the capture scenario overlaps member names, not full seat names (#615)
The capture stub scenario's wording now matches what it tests. The member names overlap by prefix (impl and impl2, or dev-impl and dev-impl2 with the pod); the full seat names do not. The scenario catches a capture that resolves the seat by a prefix match on its member name and takes the first hit, or any resolver that returns the same pane for both seats. It does not exercise a prefix match on the full seat name. The text changes are in test-layers.md, the scenario header and the stub topology comment.
2026-10-03 04:37:48 -07:00
Mike Schwarz a4eb66f9fb test(scenarios): cover rig capture with a prefix-overlap stub scenario (#612)
Adds a stub scenario for rig capture with two seats whose member names overlap by prefix (dev-impl and dev-impl2). Each seat's stub script prints its own token, and each expect step captures one full seat name through the shipped rig capture --json and requires that seat's token, so a capture that resolves the seat by member-name prefix or by first match fails at the intended step. A capture that returns both panes is not detected, because contains has no absence form. The scenario uses env.stub_scripts, so it runs locally in host mode; the installed-scenario CI job and container mode do not execute it, like the transcript scenario. test-layers.md now counts 15 scenarios and 7 topologies, separates the scenarios that run in CI from those run locally, and lists the capture row's remaining gaps.
2026-10-03 03:32:32 -07:00
amyrmahdy c513420164 docs: note why rig setup changes cmux's socketControlMode (#609)
The README's rig setup row now says why setup changes cmux's automation.socketControlMode: cmux defaults it to cmuxOnly, which only accepts processes started inside cmux and so blocks OpenRig's control, so setup switches it to automation.
2026-10-03 03:32:19 -07:00
Mike Schwarz c6e90d69fe fix(compaction): wait for an attempt-bound restore map (#610)
Managed Claude compaction could submit /compact before its preparation request had produced a restore map: automatic mode treated delivery as completion, and manual mode only waited for idle. Preparation is now bound to an attempt and occupant and waits for the exact map named in the prompt, published atomically with an attempt-specific completion marker that establishes identity, not map quality. Unfinished attempts end on expiry, cancellation or an observed policy disable, and a usage dip below the threshold keeps the same attempt and deadline. Ordinary messages stay available while preparation waits, and the final paste and Enter recheck the attempt and recipient. Automatic preparation waits up to 25 minutes on the existing polls; manual map and idle waits share the existing 120-second budget, and the CLI HTTP timeout stays 180 seconds. rig compact <session> --state shows the attempt and deadline with the usual terminal auth, --cancel ends preparation, and an explicit retry creates a fresh attempt. --skip-map skips only the artifact requirement for one manual request. Known pre-write failures retry a bounded number of times; uncertain delivery or compact results are not replayed. Cancellation is checked before each observation and after it returns, inside the single wait-for-idle deadline; it cannot interrupt a read or undo input already issued. An unprepared seat may now reach its context limit rather than compact automatically, and attempt state stays in memory, so a disarm does not survive a daemon restart.
2026-10-03 03:21:07 -07:00
Mike Schwarz 887063afdd fix(daemon): allow Claude sends after conversation rotation (#607)
Fixes #596. Ordinary Claude delivery now warns and proceeds when the only disagreement is between the stored conversation token and the historical launch argv, so a seat stays reachable after an in-process conversation reset. Neither token is promoted to verified identity, and the warning says the current conversation is unverified. This also applies to a settings-before or settings-after session-id mismatch, a plain wrong token and a lone mismatched launcher shim when token disagreement is the only objection; some of those can be a different conversation. Positive conflicts still refuse: a different native runtime, mixed named identities or incompatible live lineages, a changed process fingerprint, occupant, pane or send-time binding changes, and positive idle-shell evidence. Strict startup and resume selectors and hook precedence are unchanged, and restore can remain attention_required while an ordinary send proceeds with a warning. A change detected after paste can still leave one paste and block Enter.
2026-10-03 02:14:36 -07:00
Mike Schwarz e198f97da6 fix(daemon): producer-link advisory reports only what it observed (#606)
When a send cannot tell what a seat is doing, the producer-link advisory now reports only what it observed. OPENRIG_RUNTIME is checked as a relay prerequisite alongside the URL and token. Names missing from the tmux session environment are reported as seat-env UNKNOWN rather than DOWN with a relaunch instruction, because a managed launch passes them through an env-prefixed command; the agent process environment is not read. Hook age is checked before recency, so an old prior-tenure hook reads as STALE with its generation stated. "OK" applies only to a recent hook from the live occupant's own generation; a recent hook from a prior or unverifiable generation says so and keeps the environment note. With no stored hook, the advisory no longer blames ingest and lists the possible causes instead. A node-inventory comment is corrected, and AgentActivityStore.freshnessMs becomes a public read-only field so the diagnostic uses the store's own window. Attribution, generation and freshness rules for decisions, the endpoint-file fallback and the send behaviour are unchanged, and checks remain presence-only.
2026-10-03 02:11:47 -07:00
Mike Schwarz 4fb037ae20 fix(daemon): wait-for-idle retries an unknown observation within its deadline (#603)
waitForIdle used to fail on the first unknown activity reading, so rig send --wait-for-idle and the compaction stages refused idle Claude seats whose last hook was old or from a prior generation. An unknown reading is now retried like busy within the caller's one absolute deadline, and it never authorizes a send. No observation starts after the deadline, each awaited observation is raced against the remaining time, and a result that completes late is discarded rather than accepted (the read is abandoned, not cancelled). At expiry the result names the last state actually observed in time: target_activity_unknown or wait_for_idle_timeout. Positive needs-input still refuses at once, and the identity, binding and final-send checks still run before paste and Enter. Ordinary sends without a wait and the startup and restore probes are unchanged. The context monitor's serial post-compact stages can now wait up to their existing timeouts on unknown readings.
2026-10-03 01:55:01 -07:00
Duc Nguyen 4552b1613f fix(cli): preserve full rig names in ps table (#600)
rig ps --full (and --verbose) clipped rig names over 24 characters and ran columns into the footnotes. With --full, each column now widens to its content's display width, measured with string-width so CJK, emoji and combining marks align; the default compact table keeps its previous total width, takes the new two-space column separators out of the fixed widths, and cuts by display width on grapheme boundaries with an ellipsis. A blank line now separates the table from its footnotes only when footnotes follow. JSON output is unchanged.

Adds the runtime dependency string-width ^8.3.0 (with get-east-asian-width, strip-ansi and ansi-regex; all MIT, no install scripts).

Fixes #590
2026-10-03 01:49:16 -07:00
Mike Schwarz 1a3ffc245e feat(daemon): give Codex seats network inside workspace-write unless Codex reports an opt-out (#608)
Codex seats launched with OpenRig's plain workspace-write floor could not reach the local daemon, because Codex's sandbox defaults network access off. Before such a launch, OpenRig now runs one short-lived codex app-server with the seat's own executable, HOME, CODEX_HOME and cwd, reads config/read (with layers) and configRequirements/read, and adds -c sandbox_workspace_write.network_access=true to that launch only when Codex reports no explicit setting from any layer and no requirement pins it. Any explicit user, project, managed or requirements value wins.

The reader is reader-only (plugins and apps disabled), has a finite deadline, checks RPC results rather than exit codes, cleans up only its own descendants, and never blocks the daemon's event loop. Named profiles, legacy YOLO and full bypass are not read and their commands are unchanged. A deadline, RPC error, unknown shape, missing binary or unsupported selection adds nothing and the original launch proceeds, which may leave network off. Codex startup may write its own state files under CODEX_HOME, read its auth store and fetch managed policy; the read and the launch are not atomic. These are disclosed in the docs.

Refs #275
2026-10-03 01:42:12 -07:00
Shravan Sumanthanan 236b1b17b4 fix(daemon): resolve same-second checkpoint queries by insertion order (#474) (#601)
Checkpoint created_at has one-second precision, so getLatestCheckpoint and getCheckpointsForNode returned an arbitrary row when several checkpoints shared a second. They now order by created_at and then rowid (latest insert last for the list, first for latest), the same tie-break the snapshot and package repositories already use.
2026-10-03 00:49:37 -07:00
Solomon Wakhungu 22d5b62f23 test(daemon): make the real-Codex e2e tests opt-in (#599)
Two daemon e2e tests (seat-handover-model-fidelity and seat-lifecycle-set-model-resume) used to launch a real Codex whenever tmux, codex and a Codex auth file happened to exist, so an ordinary package test run could spend the signed-in account's quota. Each preflight now returns false first unless OPENRIG_E2E_REAL_CODEX is exactly 1, before any tmux, codex or auth probe, and afterAll starts nothing when the run did not opt in. With the variable unset, 0 or true, both files are discovered and skipped; with 1, the original checks and assertions run unchanged. Test files only; no product code.

Thanks to @1solomonwakhungu for the fix, carried over from their fork.
2026-10-03 00:25:12 -07:00
Mike Schwarz 72fedeed7a fix(daemon): retry staged Claude startup prompts (#598)
A fresh Claude seat could be reported ready while its startup prompt was still sitting unsent in the composer, so a person had to press Enter. After the existing paste and Enter, startup now captures the pane once (200 lines) and, only when the whole whitespace-normalized expected body is positively matched inside a recognized composer, sends one guarded submit-only Enter through SessionTransport with the existing target and lease checks, then observes once more. There is no re-paste, no loop and at most one extra Enter.

A prompt still staged after that is not a startup failure: StartupResult and node.startup_ready carry submission.status=staged with the warning 'Startup prompt still staged in <pane>; press Enter in that pane', and the seat keeps running. A different body sharing the header, stale echo, partial or opaque renders, unknown layouts and unavailable captures get no automatic retry and are reported as unverified. The challenge-only prompt stays best-effort. Other submit-only callers keep their 50-line capture and their unchanged matcher.
2026-10-03 00:11:06 -07:00
Coder8124 ad74c81426 fix(daemon): answer 400, not 500, for a malformed JSON request body (#587)
A malformed JSON request body used to surface as a bare 500 from any daemon route. A middleware registered first now tracks SyntaxErrors thrown by c.req.json() for the current request, and the app error handler maps only those to 400 invalid_json. Every other error follows Hono's default path unchanged: errors carrying getResponse (such as the delivery guard's typed 409 refusals) keep their status, body and headers, and anything else, including a SyntaxError from stored data, stays a logged 500. Routes that catch their own bad body keep their own response, and the existing sub-app error handlers are unchanged.
2026-10-03 00:05:44 -07:00
Mike Schwarz 1c6de1255c fix(daemon): keep a queue write addressed to this daemon's own host id local (#588)
A queue handoff or handoff-and-complete addressed to the receiving daemon's own exact host id (case-sensitive, never a null self id) now takes the ordinary local path instead of forwarding to itself over HTTP. Cross-host forwarding to any other host is the byte-identical call.

Upgrade compatibility: when a self-addressed retry finds the deterministic successor that a pre-upgrade self-forward already committed, it runs the receiving create in process (the same input checks via a shared createInputRefusal, the primary-key absorb with qitem_id_reuse for a different sender, and the qitem_body_not_saved warning for a changed body) and then the existing cross-host close, so a retry gives 2 rows and 1 send. Refusals on that path return the receiver's 4xx directly (409 qitem_id_reuse, 400 body is required or unknown_target_repo) instead of the old 502 wrapper, so the CLI exits 1 instead of 2 for them; both are non-zero. The /create route's own behaviour is unchanged. coordination-primitive.md is updated.
2026-10-02 23:58:58 -07:00
Mike Schwarz 8b79355963 feat(skills): claude-compaction-restore writes a restore map and reads by ranked tiers (#591)
Rewrites the claude-compaction-restore skill around a restore map. Before compacting, a seat writes a short summary, how things connect, and a ranked reading list in its seat folder, and publishes the summary as its seat recap. After compacting, it re-enters the world, reads its newest map, and reads down the ranked list to its class's tier: about 100k of real context for most seats and about 200k for orchestrators, planners and leads, scaled for smaller windows. Restore packet files and the session JSONL are lookups, not reading lists. The read-depth audit counts harness re-attached files as not yet read.

The daemon's post-compaction restore and audit prompts are aligned with the skill: the restore prompt says to read the newest restore map and use the packet as a lookup, the JSONL rebuild fallback applies only when there is no map, and the audit prompt names the required set instead of asking for every file. The previous skill text is kept as reference/SKILL-v1.md, marked superseded. openrig-core goes from 0.1.2 to 0.1.3 so upgraded installs receive the change; edge digests are regenerated.
2026-10-02 23:41:04 -07:00
Mike Schwarz 193253a657 fix(context): post-compaction profile skips an absent seat recap with a warning (#597)
A post-compaction context profile used to fail entirely when the seat had no RECAP.md, so a freshly compacted seat got none of its world atoms. In the post-compaction situation only, a seat recap that is genuinely absent (lstat ENOENT under an existing seat directory) is now skipped: the response lists it in skipped and adds the warning 'no seat recap for <seat>@<rig>; read your newest restore map'. A dangling link, a missing seat directory, an unconfigured root and every other atom still fail, and a handover with no recap still fails loud. The recap piece now carries its file write time (writtenAt), shown in the CLI framing header as 'written <ISO>', so an old recap is visible as old. rig context profile and rig walk --through-profile print profile warnings to stderr; stdout piece text is unchanged.
2026-10-02 23:38:39 -07:00
Mike Schwarz e5c5ded7cb fix(daemon): leave an already-provisioned Claude state file unwritten (#595)
provisionClaudeState used to rewrite the whole Claude state file on every startup delivery, including after launch, even when nothing changed. Since #592, a classic seat can provision the daemon-selected CLAUDE_CONFIG_DIR/.claude.json, which live Claude processes and other seats share. A changed flag now decides the write: a trust key is updated only when hasTrustDialogAccepted is not true, hasCompletedOnboarding only when it is not true, and with neither changed the function returns before mkdirp and writeFile. A real change still writes once, preserving every other field. Malformed-state preservation, the managed and two-home classic paths and the launch command are unchanged. Temp-file-plus-rename is not added, because the adapter fs interface has no rename; the remaining writes keep the existing cross-process risk.

Refs #592
2026-10-02 23:07:51 -07:00
Mike Schwarz b4b3c96f17 fix(daemon): bootstrap a classic Claude seat in the daemon's selected home too (#592)
A classic (no permissionMode) Claude seat types claude into its pane shell, which inherits the tmux server environment, so the bootstrap cannot see which config home the child reads. When the daemon selects CLAUDE_CONFIG_DIR, bootstrap now provisions both HOME/.claude.json and the selected directory's .claude.json (de-duplicated when equal), setting only hasCompletedOnboarding and the workspace trust flag and preserving every other field. Malformed state in one file is preserved with a warning and does not stop the other. The managed explicit-mode path still writes only its selected home, and the launch command is unchanged. This replaces the exactly-one-file rule from #565 for classic seats.

Known follow-up: the state write is unconditional and not atomic, which was already true for HOME/.claude.json; skipping unchanged writes is tracked separately.
2026-10-02 22:49:07 -07:00
NIKHIL a8c1c37581 fix(daemon): recover stale same-name rig generations (#513)
Recover stale same-name rig generations. A failed rig up could leave an archived same-name duplicate that blocked or confused later runs. rig up now prefers an active same-name rig and falls back to a sole archived one; tearing down an archived duplicate never probes or kills a session owned by a live rig, and shared guidance files are protected by physical identity, recomputed at each cleanup step. Teardown no longer treats an unreachable tmux as proof a session is absent: it keeps the rig and reports the cause. Fixes #487.
2026-10-02 22:37:38 -07:00
Rudy Mizrahi Celekli 0d794d0baa fix(agent-images): publish complete round-trippable snapshots (#580)
Publish complete, round-trippable agent images. Agent image install wrote its discovery manifest before its members, so an interrupted install could be discovered as a complete image, and unquoted notes could lose scalar identity on round trip. Members are now written before the manifest, string fields are written so they round-trip exactly, and a creator that wins a race after the existing-target check gets the existing typed image_referenced refusal instead of overwriting another install.
2026-10-02 22:29:44 -07:00
Rudy Mizrahi Celekli f0fcc2840e fix(profiles): distinguish unreadable sources from dangling links (#585)
Distinguish unreadable profile sources from dangling symlinks. Profile loading reported any read failure on a symlinked source as a dangling symlink. It now reports a dangling symlink only when the path is a symlink and its target is missing, and otherwise reports the actual read error.
2026-10-02 22:22:22 -07:00
Rudy Mizrahi Celekli a5d9a1cf25 fix(context): stage local installations before publishing them (#583)
Stage local context-pack installs before publishing them. A local rig context add copied straight into the library, so a failed copy could leave a partial pack. It now copies into a temporary folder inside the library root and renames it into place, removing the staging folder on failure; an existing destination is still refused before anything is written.
2026-10-02 22:22:06 -07:00
Rudy Mizrahi Celekli b95d976002 fix(telemetry): derive burn rates from measured in-window counters (#582)
Derive burn rates from measured intervals. Usage burn-rate computation treated a missing counter as zero, producing a fake reset followed by a whole-total burn. Samples with missing counters are now excluded before deltas are computed, too few measured samples report insufficient_samples instead of a rate, and the time window's upper bound is inclusive.
2026-10-02 22:21:47 -07:00
Rudy Mizrahi Celekli 3be4cd50d4 fix(start): reject failed restore discovery (#578)
Report failed restore discovery in rig start. A non-200 from the rig summary read during rig start was treated as the list of rigs to restore. It now goes to the existing error path, which prints the HTTP status and exits 1; daemon and kernel startup before discovery are unchanged.
2026-10-02 22:21:32 -07:00
Rudy Mizrahi Celekli c2329136b8 fix(cli): reject failed library inventory reads (#577)
Report failed library inventory reads instead of listing them as empty. plugin list, agent-image list, context list and specs ls treated any daemon response as the inventory, so a 401 or 503 error object read as an empty or garbled list. They now raise on a non-200 status, and each command's existing error path prints the message and exits 1.
2026-10-02 22:21:06 -07:00
Rudy Mizrahi Celekli d5a255ef96 fix(context): resolve legally indented Markdown headings (#584)
Resolve legally indented Markdown headings in context lookups. CommonMark allows up to three leading spaces before an ATX heading, but heading-address lookups only matched headings at column 0. Indented headings now resolve; lines inside fenced code blocks are still ignored, and existing lookups are unchanged.
2026-10-02 22:16:53 -07:00
Rudy Mizrahi Celekli 852b480628 fix(transcripts): ensure tail reads progress on damaged UTF-8 (#581)
Ensure the transcript tail reader makes progress on damaged UTF-8. The backward chunked tail reader skipped any number of leading UTF-8 continuation bytes and pushed them back, so a chunk of continuation bytes, or a file starting with one, re-read the same range forever, blocking the synchronous read. The skip is now capped at three bytes and nothing is pushed back at offset 0, so every pass makes progress; valid UTF-8 output is unchanged.
2026-10-02 22:16:28 -07:00
Rudy Mizrahi Celekli 1670e6e611 fix(tui): bound activity stream header waits (#546)
Bound the TUI activity stream's opening wait and reconnect after temporary opening failures. Opening the daemon activity stream had no header deadline, and any fetch error turned live updates off for the rest of the TUI process. Opening now has a 5 s header deadline; timeouts, network errors and aborts reject into the existing drop, backoff (1 s doubling to 30 s) and reconnect loop, while definitive non-OK or non-event-stream answers still stop retrying. An established stream is not bound by the opening deadline.
2026-10-02 20:53:40 -07:00
Wu Shuwen f45e0cef51 fix(daemon): reject separators in session components (#473)
Reject @ in pod and member session-name components. A pod or member ID containing @ produced a session name whose pod/member part could not be separated from the rig name. Preflight, member creation and launch now refuse such pod and member names with a message naming the field; rig names may still contain @. Schema validation is unchanged, and docs/reference/rig-spec.md now explains that rig spec validate passes such a spec while preflight and import refuse it. Fixes #472.
2026-10-02 20:26:13 -07:00
Wu Shuwen 66fe3d2716 Warn about stale deferred role hook references (#478)
Warn about stale deferred role hook references. A role's hooks: entry that names a hook absent from exports.hooks used to pass validation silently and drop out of the plan. Package validation and planning now report it as a warning naming the role and the reference; the manifest stays valid, because hooks in the package path are deferred and never installed, so validate, plan, install, bootstrap and bundle flows are unaffected. Refs #477.
2026-10-02 20:25:33 -07:00
Wu Shuwen 977537aa00 fix(daemon): keep read-only bundle sources writable in staging (#575)
Keep read-only bundle sources writable in staging. Pod bundle staging copied each file with its source mode, so a read-only (0444/0555) agent.yaml or script produced a read-only staged copy, and a later rewrite of that vendored path (for example import-ref rewriting) failed with EACCES. Staged copies now get the source mode plus owner read/write. Execute and group/other bits are preserved, source files are never modified, and archived modes are unchanged because the portable tar writer already normalizes them. Fixes #574.
2026-10-02 20:16:25 -07:00
Coder8124 9bd60b8c01 fix(daemon): treat a Claude launcher shim chain as one runtime for delivery (#563) (#567)
Treat a Claude launcher shim chain as one runtime for delivery identity. A Claude seat started through a launcher shim was refused for every rig send, because delivery inspected only a single process. Delivery now reads the whole shim chain: the session is verified when the real Claude process names the expected session, a shim token that agrees cannot certify an unknown child (unknown still delivers with a warning), and any explicitly different session token, whether on the process or a lone shim, is refused as a conflict. The strict identity check used at startup, resume and restore is unchanged; --settings parsing applies to delivery only. Fixes #563.
2026-10-02 20:15:16 -07:00
Vaibhav Hajare 0f687e63d9 test(daemon): pin exact-match session probe (#573)
Test-only: pin tmux has-session's exact-match target (=<name>) in the adapter tests, so a future refactor cannot silently fall back to prefix matching. Also covers the absence classification for a missing exact session. No production code changes.
2026-10-02 20:13:54 -07:00
Vaibhav Hajare a5b8f84e81 fix(daemon): hash same-second check the way the cache hashes (#572)
The workflow spec scanner's same-second unchanged check hashed raw file bytes while the cache stores a UTF-8 text digest, so a file that is not clean UTF-8 never matched and was re-parsed on every scan. Hash the decoded text the same way the cache does. Clean UTF-8 files produce the same digest as before. Adds a scanner test with a non-UTF-8 byte pinned into the cached second. Refs #445.
2026-10-02 20:13:31 -07:00
Mike Schwarz 595b705f2d docs(as-built): refresh six maps after source drift (#571)
Re-verifies six as-built pages against b6d37bfa after recent merges: moved source anchors, the local spec validation helper, 93 migration files ending at 094, footprint and test-file counts. Docs only.
2026-10-02 18:49:53 -07:00
Mike Schwarz b6d37bfadc docs(as-built): align selected maps to one verified source snapshot (#570)
Re-verifies and restamps 19 as-built pages against one source commit (25412287): moved anchors, counts and named-Enter mentions updated, and portable grep in the README inventory commands. arteries.md, test-layers.md and the five web UI pages keep their own verification stamps. Docs only.
2026-10-02 18:30:13 -07:00
Mike Schwarz eeca3e02a7 fix(daemon): avoid sorting latest usage-sample lookups (#569)
Adds migration 094 with two partial indexes so the latest usage sample lookups (context and window lanes) use an index search instead of a temporary sort. Query text and insertion-order deduplication are unchanged. Cost: one extra index entry per sample and a one-time index build on upgrade. Based on the query-plan finding in korallis/agent-stack#137. Refs #161.
2026-10-02 18:17:31 -07:00
Mike Schwarz 0a4f761d5d fix(plugins): deliver bundled updates with openrig-core 0.1.2 (#568)
Advances the bundled openrig-core plugin to 0.1.2 so upgraded installs receive every bundled hook and skill change made since 0.6.4; with equal versions, version-preserving vendoring kept the old installed files, so those changes reached fresh installs only. Removed bundle files are not pruned, existing seats and projections do not hot-reload, and later plugin content changes after 0.1.2 ships need their own version decision.
2026-10-02 18:16:19 -07:00
Mike Schwarz 9a89e1732d fix(cli): validate rig specs without a daemon (#566)
rig spec validate now validates locally without a running daemon, using the same validator as the daemon HTTP /validate route; help says so, and rig spec preflight still uses the daemon. Unexpected validator errors are reported as internal errors, never as an invalid spec, and the HTTP route keeps its existing status codes. Docs and help only otherwise.
2026-10-02 18:09:23 -07:00
Mike Schwarz 35a255237a Retain per-seat role orientation without shared kernel role blocks (#562)
Adds an optional orientation: role marker for per-seat role startup files. Queue whoami returns the calling seat's marked role entries, and the refocus hook renders them within its existing budget. The three kernel specs stop using a shared role resource. Failures read as unknown rather than missing. An unknown orientation value is rejected because the field selects role bindings. This proves emitted instructions and paths offline only; whether a native model retains its role is unverified. Refs #64.
2026-10-02 17:57:22 -07:00
Mike Schwarz 254122872c fix: align Claude bootstrap with the selected configuration home (#565)
When a Claude seat uses an explicitly selected CLAUDE_CONFIG_DIR, OpenRig now writes its onboarding and workspace-trust bootstrap to that selected home, so the seat is not stranded at first-run screens. The classic launch (permissionMode omitted) keeps its existing home; a binding without a working directory uses the same stored-node selection as the launch; malformed existing state is preserved with a warning. The rig setup and README disclosures now name the right file. Native sign-in and Keychain behaviour are not verified. Refs #154.
2026-10-02 17:50:20 -07:00
Mike Schwarz b2bda7443c fix: preserve USER and LOGNAME in seat launch environments (#564)
Seats now receive the daemon's USER and LOGNAME at launch and on handover successors, including Pi and OMP runners. Nothing is derived when the daemon lacks them, and provider auth and HOME policy are unchanged. Whether this resolves the macOS Keychain sign-in symptom reported on issue 154 is not verified. Refs #154.
2026-10-02 17:49:41 -07:00
Mike Schwarz 07f3367d8a docs: refresh CLI inventory and backend as-built maps (#560)
Refreshes the CLI reference with a command inventory derived from the registered command tree, plus the workflow-runtime, living-notes-review, content-surfaces and frontmatter-schema as-built modules against the current source. Docs only.
2026-10-02 17:33:18 -07:00
Luis Araya 207a7ffbca fix(daemon): submit with named Enter key instead of C-m (#496) (#540)
Submits with tmux's named Enter key instead of C-m at every submit site (shared transport, startup adapters, claim, handover, startup orchestrator), so terminals that encode C-m differently from Enter still submit. Message text is still sent literally; only the submit key changes. Thanks to @Totopo27.
2026-10-02 17:31:21 -07:00
Mike Schwarz 073b93a92d docs(as-built): refresh packaging and content library maps (#561)
Refreshes the as-built packaging/bootstrap/bundles and plugin/agent-image/context-pack/compaction maps against the current source, including sibling routing after bundle install, version-preserving plugin vendoring and generation-scoped compaction continuation. Docs only.
2026-10-02 17:09:02 -07:00
Mike Schwarz 3606452a8a docs(as-built): refresh module indexes and architecture navigation (#558)
Refreshes the as-built README module index, codemap and architecture navigation page against the current source. Web UI pages are explicitly marked as not reverified by this update; each module keeps its own verification stamp. Docs only.
2026-10-02 17:08:39 -07:00
Mike Schwarz be95cb035b test: cover addressed stub transcript reads (#559)
Adds a stub scenario proving that each of two seats exposes its own scripted output through the shipped rig transcript --tail 200 --json path, with a planted content-omission control that fails at the intended assertion. Updates the test-layers help-wanted map to show transcript as partially covered. Test and docs only; no production change.
2026-10-02 16:56:01 -07:00
Mike Schwarz 1389de2bab fix(daemon): recognize Claude wrappers when clearing attention (#554)
Manual clear (rig seat clear-attention) now recognises a native Claude process behind its shell wrapper. It carries the saved resume token when one exists, and with no saved token it accepts stable positive runtime evidence for the current pane, which proves occupancy, not conversation identity. Strict restore and exact-token checks are unchanged, a wrong saved token never falls back, and periodic sweeps keep using one batched tmux read. Refs #197.
2026-10-02 16:08:43 -07:00
Mike Schwarz 4cb9f23248 fix(views): keep requests responsive during execution Git checks (#557)
The execution view's git checks now run as awaited asynchronous calls, so a slow git command no longer blocks the daemon's other requests (health, queue, other views). The 5-second per-call timeout and the merged, unknown and N/A results are unchanged; SQL views stay synchronous.
2026-10-02 15:56:45 -07:00
Mike Schwarz 712a61fbcc fix(tui): separate current evidence from retained proof rounds (#553)
The TUI's proof view now shows the current judgment's evidence first and lists every retained proof drop, including superseded rounds, under 'Retained proof drops — all rounds'. Only an explicit previous chain marks a judgment as corrected; no proof history is changed.
2026-10-02 15:28:34 -07:00
Mike Schwarz 384223e9a5 fix(views): distinguish project progress from daemon adoption (#552)
For a selected project, the execution view's daemon-adoption ("live") rung now reads N/A with an explicit basis, because that rung describes the OpenRig daemon's own source, not project deployment. The unscoped view keeps its daemon lineage rules, and a missing git object never establishes N/A.
2026-10-02 15:06:56 -07:00
Mike Schwarz 474df87f87 fix(daemon): keep managed Claude restore readiness truthful (#520)
Managed Claude restores recognise a headerless ready prompt, including bypass mode, only when the exact launched process and saved conversation identity agree, and readiness checks reuse that proof. If native identity disappears or stored resume metadata conflicts during the final startup/join checks, restore reports attention consistently; the final join updates readiness only on the exact still-current running session, preserving protected metadata and replacement sessions. Component and adapter tests with synthetic native observations; installed Claude Code behaviour is not verified here. Refs #86.
2026-10-02 15:03:29 -07:00
Rudy Mizrahi Celekli c78c980a06 fix(transcripts): retain repeated boundary markers once (#545)
Transcript rotation now keeps each distinct structural boundary marker once instead of re-promoting every matching line, so an unchanged pane no longer grows its transcript file on every capture tick. Captured scrollback is left untouched.
2026-10-02 15:00:53 -07:00
Rudy Mizrahi Celekli 2b63d69145 fix(shutdown): keep capture until seat termination is confirmed (#538)
Rig teardown now stops a seat's transcript capture only after its tmux session is confirmed terminated or already gone. When a kill fails and the seat is still alive, its transcript keeps updating; deletion is still blocked by kill failures as before.
2026-10-02 14:39:57 -07:00
Rudy Mizrahi Celekli b1e271e7ce fix(tmux): keep daemon transcript defaults out of seat overrides (#536)
New tmux seats no longer inherit the daemon's resolved transcript line-count and poll-interval values as overrides: those two variables start empty in each new session, so a seat's config.json takes effect at the next daemon restart. Explicit seat values still win, and running sessions and the daemon's own capture settings are unchanged. Refs #161.
2026-10-02 14:39:12 -07:00
Rudy Mizrahi Celekli 0c24607bcc fix(shutdown): report unsuccessful Compose teardown (#533)
Rig teardown now reports a Compose teardown that returns failure, in both the already-stopped and live branches, as a 'Service teardown warning'. Teardown and deletion still proceed; the CLI shows the warning and exits 2, where it used to report success.
2026-10-02 14:31:06 -07:00
Rudy Mizrahi Celekli d546d07f04 fix(tui): close rejected activity stream responses (#543)
The TUI now cancels the body of a rejected activity-stream response and of a page read that fails with 5xx, so a response the server keeps open no longer holds its connection. Accepted streams and returned results are unchanged.
2026-10-02 14:30:02 -07:00
Rudy Mizrahi Celekli 8a2602275a fix(tui): preserve standard SSE notification framing (#535)
The TUI's live-event stream parser now follows standard SSE framing: CRLF, bare CR and multi-line data fields are handled, and a frame larger than 1 MiB is dropped and the stream reconnects. The daemon's current single-line notifications parse exactly as before.
2026-10-02 14:19:43 -07:00
Rudy Mizrahi Celekli f8c30f13fd fix(tui): require primary clicks for control activation (#549)
Only a primary-button press now activates TUI and startup controls. Mouse wheel, middle and right presses over a control no longer activate it; wheel scrolling in the normal view and keyboard input are unchanged.
2026-10-02 14:04:26 -07:00
Wu Shuwen c720e2053f fix(daemon): reject out-of-range usage windows (#483)
An out-of-range window_hours on the usage top route now returns a 400 explaining the supported range, instead of a 500 from an invalid date. Normal windows take the same path as before.
2026-10-02 13:55:01 -07:00
Rudy Mizrahi Celekli 8b75297b17 fix(snapshots): preserve long periodic snapshot intervals (#548)
Periodic snapshot intervals longer than the native timer limit (about 24.8 days), such as monthly, no longer overflow and fire about every millisecond. Long intervals are now scheduled in chunks within the timer limit, and shorter intervals keep their existing timer.

Closes #547.
2026-10-02 13:17:59 -07:00
Mike Schwarz ecc936014c docs: add a contributor roadmap (#539)
Adds ROADMAP.md: a short public roadmap of community-suggested and small items, with the issues and Discussions where each is tracked and how to help.
2026-10-02 13:15:59 -07:00
Wu Shuwen 8135136953 fix(daemon): skip auto-compaction for stale context (#464)
Automatic compaction no longer acts on a stale context sample. A high reading older than the freshness window sends nothing; a fresh reading exactly at the threshold still sends the preparation and then /compact; and a pending restore stage pauses while the sample is stale and resumes the same stage when it is fresh again. A sample timestamped in the future is not treated as fresh.

Closes #463.
2026-10-02 12:58:39 -07:00
Wu Shuwen 783d22b50c fix(daemon): scope Slack inbound dedup by channel (#452)
Slack inbound dedup is now scoped by channel: the in-flight guard, the durable seen mark and the dead-letter skip all use channel plus timestamp, so identical timestamps in two channels are no longer treated as one event. Attachment files carry a per-message identity digest, so same-timestamp uploads with the same filename in different channels keep separate bytes. Queue item IDs are derived as before.

Closes #450.
2026-10-02 12:52:48 -07:00
Devulapalli Naga Sri Vaishnavi 88165fb77e fix(queue): preserve shared watchdogs across park transitions (#505)
A watchdog shared by several parked rows now survives the park transitions of the row that created its timer. Only rows still parked whose latest unfired wake is this job keep it alive; after a re-park, retirement and backoff follow the current owner. A failed delivery from a shared repeating timer keeps its retry instead of being consumed, and successful delivery and same-job reattachment behave as before.
2026-10-02 12:37:38 -07:00
Mike Schwarz decc0bbe93 fix(bundle): contain rig files by resolved directory boundary (#507)
Pod bundle assembly now contains rig files by their resolved directory boundary. A culture, docs or startup file that is a symlink resolving outside the selected rig folder (including a sibling folder whose name only shares the rig's prefix) is refused, with a message to copy the file into the rig. Links that stay inside the rig and a symlinked rig root still bundle, and external agent references keep their existing behaviour.
2026-10-02 12:32:57 -07:00
Rudy Mizrahi Celekli 9a26e3afcb fix(gateway): preserve humans when queue enumeration fails (#531)
rig gateway human remove no longer treats a failed or malformed queue read as an empty board. An HTTP error reply or a non-array body from the queue list now takes the command's existing unchecked-board path and refuses, with or without --force, as connection failures already did; --force still archives despite known in-flight items and records each one. A healthy empty board still removes as before.

Closes #529.
2026-10-02 12:27:45 -07:00
Rudy Mizrahi Celekli 447dd828f7 fix(events): serialize subscriber-generated fanout (#527)
A subscriber's nested emit no longer reorders or duplicates live events: nested notifications and envelope drains wait until every current subscriber has received the parent event, then the existing durable-row drain delivers children in order. A throwing subscriber is still isolated, and an ordinary outer emit still drains before returning.

Closes #526.
2026-10-02 12:24:22 -07:00
ookami ed275c360b fix(daemon): avoid premature readiness timeouts (#530)
Readiness polling during seat start and handover successor launch now uses the whole configured allowance: the backoff is capped at the time remaining, and a final probe runs at the deadline, instead of giving up at the 15-second backoff boundary inside a 30-second window. Initial delay, cap and default timeout are unchanged; an unknown or false probe still never counts as ready, and a late attention result is now classified as attention.

Closes #525.
2026-10-02 12:23:33 -07:00
Rudy Mizrahi Celekli f4ea676d62 fix(paths): recognize contained dot-prefix directories (#524)
Paths under a directory whose name starts with dots (for example ..cache) now count as inside their root, in launch cwd resolution, rig up's path-form spec handling and restore-check evidence. A real parent traversal (..) and a sibling that only shares the root's name prefix still count as outside.

As a result, the existing rule for a launch cwd inside the OpenRig installation now also applies to dot-prefixed folders there when no cwd override is given; an explicit --cwd still works as before.

Closes #494.
2026-10-02 11:58:06 -07:00
ookami bdfc58aeab fix(daemon): report unknown CLI capabilities without false stale warnings (#523)
Mission Control reports an unknown CLI version as unknown instead of a hard-coded 0.2.0, so the fleet view no longer flags every rig as running a stale CLI. A rig counts as stale only when a known version is reported as outdated; field support and version age are tracked separately, and the fleet views gain an unknown-capability count. No new lookup runs at startup.

Closes #488.
2026-10-02 11:52:24 -07:00
Wu Shuwen 70d0bdbf1d fix(daemon): recognize workspace paths starting with dots (#459)
Workspace paths whose directory names start with dots (for example ..cache) are now recognised as inside their workspace root, in both the workspace resolver and the workspace doctor. Paths that really sit outside, including a sibling whose name only shares the root's prefix, still count as outside.

Closes #458.
2026-10-02 11:39:19 -07:00
Mike Schwarz 658bfcb4f6 fix(daemon): keep workflow versions that unfinished work still uses (#512)
When a workflow file is deleted or renamed, the spec cache now keeps every version that an unfinished workflow (active, waiting or failed) still uses, instead of deleting it. Kept versions stay out of the library and discovery, and a later scan removes them once no unfinished work needs them. A rename moves the cached version to its new path.

A parse error at a kept version's path is recorded as a separate diagnostic (named by its full path if the basename is taken) and never rewrites the kept version, and one file's diagnostic failure no longer stops the rest of the folder scan. Versions already removed on existing installs are not restored, and a version blanked by the earlier diagnostic writer is still not readable by name and version.

Refs #511.
2026-10-02 11:12:01 -07:00
Mike Schwarz cd435a5a99 fix(refocus): fewer rig calls per trace so it fits its 2 s budget (#499)
The refocus hook spends fewer rig calls per trace so it fits its time budget. Roots come from one rig config read, made only when they are missing; the seat is taken from the canonical session name when its folder exists. The config read gets only the time left in a shared budget that reserves the configured-content lookup, and is skipped when little is left. No timeout value changes.
2026-10-02 11:00:31 -07:00
Nishil Rathod 6949b0e1a2 fix(daemon): keep literal = session names exact in tmux discovery (#515)
Discovery keeps literal session names that start with "=" exact: window and pane lookups use tmux's exact-target form, so sessions named "lit" and "=lit" each resolve to their own panes. The reported session name stays the raw name.

Closes #492.
2026-10-02 10:56:19 -07:00
Luis Araya 5e90ad4407 fix(daemon): bracket IPv6 hosts in daemon-side endpoint URLs (#493) (#509)
Daemon-side endpoint URLs now bracket IPv6 hosts through one shared formatter: the crash-cart health and guard checks, the startup-proof request, the activity endpoint, and the Pi and stub runners. IPv4, DNS and already-bracketed hosts keep their form; scoped and malformed hosts stay rejected.

Closes #493.
2026-10-02 10:55:55 -07:00
Wu Shuwen f16a0f3376 fix(daemon): bound active HTTP streams during shutdown (#324)
End active and late-opening SSE responses during shutdown while allowing ordinary requests the existing drain budget. Preserve timed-out receipts when connections do not finish, and update startup tracking tests. Closes #166.
2026-10-02 10:52:43 -07:00
Wu Shuwen a02e25aed9 fix(daemon): preserve terminal env for Claude seats (#326)
Forward the managed Claude launch terminal and locale values from the receiving pane while preserving the isolated environment and capability-query boundary.
2026-10-02 10:49:07 -07:00
Mike Schwarz 90970a9275 fix(daemon): avoid stuck findings for posted human asks (#518)
Skip the unclaimed sweep for pending asks whose current human-notification episode has a posted receipt. Keep the original ask visible and preserve other recovery paths. Existing receipt-trust semantics remain unchanged. Refs #514.
2026-10-02 10:02:48 -07:00
Mike Schwarz 716d13a968 fix(daemon): keep cached workflow versions through a spec parse error (#508)
Recording a workflow spec parse error no longer rewrites an existing cached version; it updates or adds a separate error row. Repairing the file clears only diagnostic-only rows; error rows that still hold a stored spec are kept so the old version stays recoverable. A file restored to its cached bytes is skipped again on the next scan. Refs #503.
2026-10-02 06:49:13 -07:00
Mike Schwarz 098f4848f9 docs(skills): remove unshipped inherited-seat references (#510)
The shipped orienting-to-an-inherited-seat skill no longer names two companion skills that do not exist; its edge digest is regenerated.
2026-10-02 06:37:20 -07:00
Mike Schwarz 23c1c701c0 fix(cli): tell a blocked daemon connection apart from a stopped daemon (#504)
When a rig command cannot reach the daemon because the machine blocked the connection (EPERM or EACCES, for example a Codex sandbox), the CLI now says so, keeps the OS cause code, and points to the offline help section instead of telling the user to start a daemon that may be running. A stopped daemon still gets the start advice. Refs #275.
2026-10-02 06:37:04 -07:00
Mike Schwarz 8c242e979b fix(daemon): keep a user's Claude status line and settings intact (#497)
A Claude seat whose cwd is a shared repository no longer replaces that repository's own statusLine command. Only commands OpenRig itself wrote are refreshed; anything else, including multi-line commands, is left as the user wrote it. Refs #421.
2026-10-02 06:36:23 -07:00
Mike Schwarz d0d1113f7c fix(daemon): skip a Claude sidecar older than the pane's Claude process (#500)
Resume-token capture, the refresher's null-fill and its periodic re-stamp ignore a status-line sidecar sampled before the Claude process now running in the seat's pane started, so a seat cannot record or keep refreshing an earlier session's id. Unknown timing captures as before. Stored tokens are not checked, cleared or repaired.
2026-10-02 06:36:05 -07:00
Luis Araya d7cbe27d67 fix(daemon): select newest bounded page in recent-observations (#489) (#506)
The mission-control recent-observations view reads the newest 50 stream items instead of the oldest 50.
2026-10-02 06:07:20 -07:00
Luis Araya cb863a056e fix(daemon): return 400 on unknown audit action verb (#491) (#501)
GET /api/mission-control/audit with an unknown action verb returns a 400 with a clear message instead of a 500; known verbs are unchanged.
2026-10-02 06:06:40 -07:00
Mike Schwarz 37dccd6612 test(scenarios): cover rig down and rendered rig send with stub scenarios (#502)
Adds two behavioural stub scenarios to the library: rig down stops every seat of a rig, and rig send renders the message in the addressed pane (rendered text only). The test-layers help-wanted list is updated, including the runner's current limits.
2026-10-02 06:04:54 -07:00
Shravan Sumanthanan 88f1ef78f7 feat(spec): add per-seat reasoning effort configuration for Claude and Codex (#75) (#320)
Seats can declare a reasoning effort for Claude Code and Codex. It is stored on the seat (migration 092) and survives restore, fresh relaunch, handover and set-model; removing it clears it; a first-start retry with effort is no longer refused. Seats without effort behave exactly as before.
2026-10-02 05:37:46 -07:00
Mike Schwarz 9146d034f1 fix(queue): preserve create identity and report unsaved retry bodies (#495)
rig queue create allocates its ID before sending and, if the outcome is unknown, prints that ID with how to check for the row and retry safely with the same ID and payload; a retry with the same ID but a different body returns the original row with a warning that the new body was not saved. A stopped daemon gets the rig daemon status / start hint.
2026-10-02 05:15:40 -07:00
Mike Schwarz 2dd2c3bc61 docs(as-built): regenerate daemon-core against current source (#446)
The as-built daemon-core module is regenerated against current source: file and route counts, migrations, MCP tools and every code anchor match the stamped commit.
2026-10-02 05:03:53 -07:00
Mike Schwarz ca3fb37370 docs(reference): say pull-request CI runs the UI suite (#485)
docs/reference/developing.md says every pull request runs the UI package tests as a required check, while local npm test leaves them out (run npm run test:ui); the portability report is the advisory check.
2026-10-02 05:03:39 -07:00
Mike Schwarz f463030202 fix(skills): use topology.root in the shipped composer (#486)
The shipped openrig-operating-model composer reads topology.root, the configuration key the CLI supports, so its roots and up commands resolve the topology tree without an environment override.
2026-10-02 05:02:45 -07:00
Mike Schwarz c883c721c9 fix(refocus): explain an unresolved work node and fall back to the work root when no baton is held (#484)
The refocus hook names why a seat's current work could not be resolved, and falls back to the work root only when the seat holds no typed in-progress work; a failed lookup is reported as unknown.
2026-10-02 05:02:27 -07:00
Mike Schwarz bba9648bbd fix(daemon): isolate runtime version probes from stale cwd (#471)
Runtime version checks run from the current working directory when it still exists and fall back to the filesystem root when it has been removed, so preflight no longer reports a missing install for a healthy Pi or OMP. Covers rig up preflight, import and bootstrap planning.
2026-10-02 05:02:02 -07:00
Vaibhav Hajare 79ed7ff588 fix(cli): bracket IPv6 hosts in daemon endpoint URLs (#461)
The CLI brackets IPv6 daemon hosts in every daemon URL it builds, so commands like rig doctor work against a daemon listening on ::1.
2026-10-02 05:01:38 -07:00
Rudy Mizrahi Celekli fb31f39650 fix(tmux): probe canonical session names without prefix matching (#449)
tmux session lookups are exact: a session whose name literally starts with '=' resolves to its own pane, so removing it kills the right pane and leaves a plain-named neighbour alone.
2026-10-02 05:01:02 -07:00
Rudy Mizrahi Celekli 78ab5ceb93 fix(workflow): round-trip colon-bearing versions without Library ID collisions (#441)
Workflow versions that contain a colon get an opaque library ID, so two such versions stay distinct and round-trip; every other ID is unchanged.
2026-10-02 05:00:36 -07:00
Mike Schwarzanddev50-planner bff0142eae docs(as-built): regenerate four work and coordination modules against current source (#470)
coordination-primitive, mission-control, workspace-primitive and
lifecycle-snapshot-restore are checked at e6a391b6: every cited path, symbol,
line and count, with each count beside the command that produces it. Corrects
the qitem id format, cross-host closure targets, closure reasons and outbox
states; the mission-control verbs, web route and notification defaults; the
workspace, missions and projects routes and workspace_json writers; and the
restore outcomes, session selection and restore-check shape. Removes
citations of the superseded architecture.md.

Co-authored-by: dev50-planner <[email protected]>
2026-10-02 03:50:13 -07:00
Mike Schwarzanddev50-planner 75dba3a878 docs(reference): document the Pi seat agent directory, custom models and environment (#469)
Adds a Pi section to rig-spec.md: each seat's agent directory under
$OPENRIG_HOME/state/pi/<session>/agent (set as PI_CODING_AGENT_DIR), where
Pi reads an operator-authored models.json, and the environment the Pi
process receives, as the runner builds it.

Co-authored-by: dev50-planner <[email protected]>
2026-10-02 03:49:46 -07:00
Mike Schwarzanddev50-planner c9c9e200a5 docs: correct developer docs, the capability page and known problems against source (#468)
* docs: correct developer docs, the capability page and known problems against source

Checked against source at e6a391b6:
- developing.md: pull requests run .github/workflows/tests.yml; test:repo
  also builds the daemon and checks context-pack generation.
- rig-spec.md: lists the stub runtime and what it is.
- The onboarding capability page: execution view --project, the three
  first-project recipes, per-seat permission and typing-guard verbs, the
  rig policy permissions namespace, and the web UI being off by default.
- help.md known problems: matched to the open issues and to what main fixes.
- test-system README, scripts README, ROUTING and evals README: match the
  scenario runner, stub launch scripts and eval cases as they exist.

* docs(test-system): say the scenario runner has no proof binding

rig proof show exists. The proof surface is reserved because no scenario
surface reads it (readSurface throws UnboundSurfaceError) and the validator
rejects it (RESERVED_SURFACES).

---------

Co-authored-by: dev50-planner <[email protected]>
2026-10-02 03:49:11 -07:00
Rudy Mizrahi Celekli d410075e48 fix(workflow): recover repaired diagnostic rows in place (#442)
* fix(workflow): recover repaired diagnostic rows in place

Signed-off-by: Rudy Celekli <[email protected]>

* fix(workflow): type optional diagnostic cache columns

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 02:05:50 -07:00
Mike Schwarzandv-openrig-build b7ec2f8092 docs(skills): repair references in shipped public guidance (#467)
Co-authored-by: v-openrig-build <[email protected]>
2026-10-02 02:05:28 -07:00
Rudy Mizrahi Celekli 875d359a3f fix(workflow): refresh changed files within the cache second (#435)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 01:52:20 -07:00
Rudy Mizrahi Celekli 6872516960 fix(execution): include supported README mission and slice nodes (#438)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 01:38:44 -07:00
Rudy Mizrahi Celekli 032a5e2f29 fix(queue): rebase approved handoff advisory onto current main (#341)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 01:38:24 -07:00
Mike Schwarzanddev50-planner ad59ac9d0c docs(as-built): regenerate four daemon architecture modules against current source (#454)
adapters-and-runtimes, agent-spec-and-startup, architecture-rules-and-event-system
and transport-and-transcripts are checked at 264fade9: every cited path, symbol,
line and count, with each count beside the command that produces it. Corrects
the runtime adapter set, startup and restore states, the RigEvent union, the
transcript capture mechanism, the chat and send behaviour and the MCP tool count,
and removes citations of the superseded architecture.md.

Co-authored-by: dev50-planner <[email protected]>
2026-10-02 01:30:47 -07:00
Rudy Mizrahi Celekli 0b14b07329 fix(terminal): preserve UTF-8 across pipe polling boundaries (#456)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 01:30:28 -07:00
Rudy Mizrahi Celekli f37b25f4ef fix(provider): report when all exhausted windows reset (#455)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 01:30:05 -07:00
Wu Shuwen e6a391b66c fix(daemon): verify Claude resume lineage when pane identity is opaque (#335)
* fix(daemon): trust exact Claude resume lineage

* fix(daemon): verify wrapped Claude composer identity

* fix(restore): require strict foreground resume proof

* fix(daemon): reject failed headerless reconciliation proof

* test(daemon): retain inconclusive resume after main update
2026-10-02 01:17:14 -07:00
Wu Shuwen 0a108305f4 fix(restore-check): use SQLite queue and selected Claude hooks (#130) (#328)
* fix(restore): inspect current queue and hook stores (#130)

* fix(restore-check): match the exact projected Claude hook command

* fix(daemon): retain caveats for unusable activity hooks
2026-10-02 01:14:12 -07:00
Wu Shuwen ebfd05f13a fix(daemon): preserve executable modes in rig bundles (#321)
* fix(daemon): preserve executable modes in rig bundles

* fix(daemon): preserve restrictive bundle file modes
2026-10-02 00:52:21 -07:00
GoheerandClaude Opus 5 e41f4653dd fix(daemon): parse the Host header so IPv6 same-host Origin checks match (#411)
Both Origin checks took the hostname from the Host header with
`split(":")[0]`. For a bracketed IPv6 authority like `[fd7a::1]:7433` that
yields `[fd7a`, so the same-host comparison could never match and the web UI
opened at a non-loopback IPv6 address had its own requests refused.

Add `hostHeaderHostname()` next to the /api guard and use it in both places.
It parses the header with URL rules, matching the normalization
`new URL(origin).hostname` already applies to the Origin side, so brackets,
case and zero compression all compare equal. Values that are not a bare
host[:port] (userinfo, a path, a query or a fragment) return "", which never
matches an Origin, so a crafted Host cannot widen the same-host comparison.

Fixes #405

Co-authored-by: Claude Opus 5 <[email protected]>
2026-10-02 00:49:17 -07:00
Mike Schwarzandv-openrig-build 1cb80c3d6b test(proof): wait for the source watcher to report a status-only change (#448)
"invalidates proof sources when only mission status changes" starts
watchProofSources and writes the manifest in the same tick. On macOS, fs.watch
arms its recursive FSEvents stream asynchronously, so a write made before the
stream is live is never reported. The test then saw zero events for the whole
5-second window, which failed intermittently on loaded CI runners.

Rewrite the unchanged draft bytes until the watcher reports. The proof basis
changes once, so the test still asserts exactly one proof.sources_changed
event and one invalidation. Product behaviour is unchanged; the watcher's
quiet refresh already repairs a missed early event.

Claude-Session: https://claude.ai/code/session_0111rhBDUYTL2n3Dbr2HDJf1

Co-authored-by: v-openrig-build <[email protected]>
2026-10-02 00:48:58 -07:00
Rudy Mizrahi Celekli 72191c5729 fix(tui): preserve literal command argument whitespace (#443)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:43:20 -07:00
Duc Nguyen fed59d6d14 fix(tmux): detach seat views before destroying the session (#187)
* fix(tmux): detach seat views before destroying the session

An operator detach-on-destroy off switches a herdr attach onto another
live session when that seat is killed. Pin the option on fresh sessions
and detach clients before kill-session. An unexpected detach failure
stops that session's kill instead of being reported as success.

* test(tmux): expect detach-client before kill on the argv path

The #167 argv sequence test still listed only kill-session, so daemon package tests failed after detach-first teardown.
2026-10-02 00:22:12 -07:00
Rudy Mizrahi Celekli 264fade906 fix(cli): honor external queue sources as declared body claims (#440)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:19:40 -07:00
Rudy Mizrahi Celekli 5887d3b996 fix(proof): read slice-local review artifacts in project views (#427)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:11:54 -07:00
Rudy Mizrahi Celekli 48f6cce716 fix(tmux): confirm exited session absence during termination (#431)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:10:33 -07:00
Rudy Mizrahi Celekli e39060db49 fix(cli): preserve daemon health and home evidence in guards (#439)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:08:06 -07:00
Rudy Mizrahi Celekli b27190f1aa fix(cli): distinguish kernel wait deadlines from terminal failures (#437)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:07:48 -07:00
Rudy Mizrahi Celekli 263e4dd82d fix(context): preserve physical pack custody across configured root aliases (#436)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:07:08 -07:00
Rudy Mizrahi Celekli 62d548a9d9 fix(queue): advise on unmatched local destination seats (#337)
* fix(queue): validate persisted destination seat membership

Signed-off-by: Rudy Celekli <[email protected]>

* fix(queue): advise on unmatched local destination seats

Signed-off-by: Rudy Celekli <[email protected]>

* test(queue): keep missing rig fixtures independent of human registry

Signed-off-by: Rudy Celekli <[email protected]>

* fix(queue): advise on dotted pod logical ID destinations

Signed-off-by: Rudy Celekli <[email protected]>

* fix(queue): keep destination advice best effort

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:06:35 -07:00
Rudy Mizrahi Celekli 51dc6c97e5 fix(cli): recognize dotted registered hosts in file operands (#368)
* fix(file): resolve dotted host qualifiers in copy operands

Signed-off-by: Rudy Celekli <[email protected]>

* test(pi): publish offline fixture readiness after its receipt

Signed-off-by: Rudy Celekli <[email protected]>

* fix(cli): preserve local dotted file operands

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:03:32 -07:00
Rudy Mizrahi Celekli 74b8b46f5c fix(slack): reconcile ambiguous posts across bounded history pages (#395)
* fix(slack): reconcile across bounded history pages

Signed-off-by: Rudy Celekli <[email protected]>

* fix(slack): retry after bounded partial reconciliation scans

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:01:44 -07:00
Rudy Mizrahi Celekli 5197fb9483 fix(workflows): restrict cache removal to the scanned folder (#433)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:00:49 -07:00
Rudy Mizrahi Celekli 75acf5510e fix(tui): keep refreshed local reader selection in bounds (#434)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:00:25 -07:00
Rudy Mizrahi Celekli 4fbf56c0c6 fix(tui): decode application-cursor arrow keys (#429)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-02 00:00:07 -07:00
Rudy Mizrahi Celekli 907d104339 fix(tui): show superseding arrangement in source cards (#428)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 23:59:30 -07:00
Rudy Mizrahi Celekli 1c89408e38 fix(proof): watch the owning project for nested mission roots (#373)
* fix(proof): watch the owning project for nested mission roots

Signed-off-by: Rudy Celekli <[email protected]>

* fix(proof): constrain recursive source watching

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 23:59:07 -07:00
Rudy Mizrahi Celekli e16076bd3a fix(scope): recognize CRLF frontmatter delimiters (#378)
* fix(scope): read CRLF frontmatter without losing authored IDs

Signed-off-by: Rudy Celekli <[email protected]>

* test(scope): pin complete frontmatter closing delimiters

Signed-off-by: Rudy Celekli <[email protected]>

* fix(scope): retain whitespace on frontmatter delimiters

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 23:58:27 -07:00
Rudy Mizrahi Celekli ccf4027e11 fix(cli): report failed operating-mode citation reads (#426)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 23:58:04 -07:00
008c9faada fix(daemon): bring 0.6.4's browser boundary, legacy-launch retention and browser docs to main (#407)
* fix(restore): retain inconclusive legacy Claude launches (#346)

Co-authored-by: v-openrig-build <[email protected]>
(cherry picked from commit eef01465a3)

* fix(daemon): check target name and browser origin once on /api (#358)

The daemon now checks every /api request once, before any route or cross-host read-through runs, including WebSocket upgrades.

- Target names: localhost, IP literals, this machine's own names and its own MagicDNS name are accepted without setup; other names are refused with a remedy (OPENRIG_ALLOWED_HOSTS, or a configured bearer token, which waives only the name check).
- Browser origins: when present, an Origin must be the built-in UI's own origin (with the web UI enabled) or listed in OPENRIG_ALLOWED_ORIGINS; a token never overrides an origin refusal.
- Host and Origin are read from the raw request headers, so duplicate or empty values are refused rather than collapsed.
- Refusals are a 403 with a one-sentence remedy and code; rig ps and the TUI now show it. Refusal logging is sanitized, bounded and deduplicated.
- Route-level authorization is unchanged.

Adapted for main: the existing /api Origin guard (apiOriginProtection) and the
terminal WebSocket Origin check stay in place. The boundary is mounted before the
Origin guard, so its refusal codes and remedies are what callers see; the terminal
route and its tests are unchanged from main.

(cherry picked from commit 7d77d73b9c)

* docs: add browser access reference (#372)

Adds docs/reference/browser-access.md (which addresses and browser pages the
daemon accepts, the two allowances, refusal remedies and limits) and links it
from the help guide's daemon-access section.

Co-authored-by: dev50-planner <[email protected]>
(cherry picked from commit a3cc6c8a10)

* test(daemon): expect the browser boundary's refusal from the real app

On the real daemon app the /api browser boundary now runs before the Origin
guard, so a cross-origin browser request is refused with the boundary's code
(browser_origin_refused) and remedy. The status stays 403; the middleware-level
cases of the Origin guard are unchanged.

* docs(reference): name the allowed origin the web UI needs at other names

On main the terminal WebSocket check and the /api Origin guard stay in place
alongside the browser boundary, so the web UI opened at a non-loopback name or
address needs that page's exact origin in OPENRIG_ALLOWED_ORIGINS.

---------

Co-authored-by: v-openrig-build <[email protected]>
Co-authored-by: dev50-planner <[email protected]>
2026-10-01 21:19:03 -07:00
Mike Schwarzanddev50-planner 5b0a8ccc96 docs: the scenario runner drops an ambient TMUX instead of refusing it (#408)
run-scenarios.mjs builds the scaffold environment from HOME, PATH and TERM
only, so an ambient TMUX and daemon variables are dropped and running from
inside tmux is safe. ARCHITECTURE.md and the runner's header said it refuses
to start inside tmux.

Co-authored-by: dev50-planner <[email protected]>
2026-10-01 21:15:12 -07:00
Mike SchwarzandOpenRig contributors 1f555fb84b docs(skills): route simple seat additions to rig grow (#406)
The topology skill named rig expand four times and rig grow never, and
openrig-user only showed fragment-based additions. Point simple additions
(default agent spec, one runtime, one cwd, optionally a new pod) at rig grow,
and say when a fragment with rig expand or rig add is still needed.

openrig-user also documented rig add with the wrong arguments and a
rig add-member alias that does not exist; both are corrected.

Co-authored-by: OpenRig contributors <[email protected]>
2026-10-01 20:59:43 -07:00
Rudy Mizrahi Celekli d7e716c909 fix(cli): probe IPv6 hosts without URL brackets (#398)
* fix(host): probe IPv6 status addresses without URL brackets

Signed-off-by: Rudy Celekli <[email protected]>

* test(pi): publish offline fixture readiness after its receipt

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:48:23 -07:00
Rudy Mizrahi Celekli 34277827c8 fix(cli): preserve remote daemon health verdicts in host doctor (#396)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:44:37 -07:00
Rudy Mizrahi Celekli d000fb2bb8 fix(slack): preserve complete inbound human briefs (#397)
* fix(slack): retain complete inbound human message bodies

Signed-off-by: Rudy Celekli <[email protected]>

* test(slack): type the inbound event retry store

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:33:33 -07:00
Rudy Mizrahi Celekli 67c4851ea3 test(pi): publish offline fixture readiness after its receipt (#392)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:28:58 -07:00
Rudy Mizrahi Celekli b5e2947a45 fix(cli): report environment status HTTP failures (#382)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:28:49 -07:00
Rudy Mizrahi Celekli 81c4d1054f fix(cli): bound workflow stream connection waits (#381)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:28:39 -07:00
Rudy Mizrahi Celekli 68ea8325cf fix(tui): back off repeated notification stream drops (#380)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:28:27 -07:00
Rudy Mizrahi Celekli 570f362748 fix(cli): parse YAML context names when installing local packs (#370)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:28:18 -07:00
Rudy Mizrahi Celekli b9ed6bf79b fix(missions): complete the authored manifest status (#386)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:28:07 -07:00
Rudy Mizrahi Celekli 14c37af4f6 fix(proof): prepare evidence against the owning project root (#394)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:27:57 -07:00
Rudy Mizrahi Celekli 529ccd61ff fix(proof): remove temporary receipts after write failures (#383)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:27:47 -07:00
Rudy Mizrahi Celekli 9cb0934846 fix(proof): include mission status in readiness revisions (#366)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:27:38 -07:00
Rudy Mizrahi Celekli d797d63f8e fix(skills): retain fenced examples in shipped markdown resolver (#388)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:22:26 -07:00
Rudy Mizrahi Celekli b5bbdfcf2d fix(terminal): preserve remote session quoting across SSH shells (#369)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:22:08 -07:00
Rudy Mizrahi Celekli b75e9cde04 fix(slack): settle inbound completion when stopping reconnect backoff (#387)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:21:21 -07:00
Rudy Mizrahi Celekli a46f7312c0 fix(slack): retain concurrent dead letters across retry snapshots (#377)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:20:58 -07:00
Rudy Mizrahi Celekli 50f747bc4a fix(slack): contain dead-letter retry storage errors (#390)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:16:12 -07:00
Rudy Mizrahi Celekli e6828b73e1 fix(slack): resolve matching quoted env-file secrets (#376)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 18:15:41 -07:00
Rudy Mizrahi Celekli 6b60035416 fix(hosts): reject unreadable remote response bodies (#336)
* fix(hosts): reject unreadable remote response bodies

Signed-off-by: Rudy Celekli <[email protected]>

* fix(queue): preserve indeterminate forwarded write guidance

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 17:35:31 -07:00
Luis Araya b43ff0d6fd fix(cli): bound kernel readiness HTTP fetch and reads to remaining deadline (#359) (#363) 2026-10-01 17:32:32 -07:00
Mike SchwarzandOpenRig contributors 71e66bfa25 fix(daemon): evaluate the busiest ceremony families past the family limit (#357)
* fix(daemon): evaluate the busiest ceremony families past the family limit

When more than 200 handoff families had queue activity in the observation
window, the passive ceremony source threw health_passive_family_limit. Health
reads combine all sources, so the whole projection failed: `rig health` and
the TUI HEALTH view returned an error instead of any finding, including
context pressure, and the scheduled evaluation recorded only that error.

Past the limit the source now evaluates the 200 families with the most queue
transitions in the window, breaking ties by lineage ID. A family qualifies by
its transition count, so this keeps every family that could qualify unless
more than 200 do. Families at or below the limit are evaluated exactly as
before.

The result says it is partial. The health list and `rig health policy`
(engine.lastEvaluation.coverage) report the source's total, evaluated and
omitted counts, the limit and the order. `rig health` and the TUI HEALTH view
mark it PARTIAL. Omitted families were not evaluated and are never presented
as healthy.

* fix(daemon): report the scheduled evaluation's own coverage

The scheduled evaluation read the passive source's latest coverage after the
whole evaluation finished. A health read served while it awaited delivery
replaced that coverage, so the evaluation reported another read's counts.
When diagnosis policy is disabled, no read happens, and it reported coverage
left by an earlier request.

The evaluation now snapshots coverage right after its own read and returns it
with the result; a skipped evaluation returns null. The TUI summary line puts
PARTIAL before the variable-length finding summary, so a narrow line cannot
truncate it away.

---------

Co-authored-by: OpenRig contributors <[email protected]>
2026-10-01 16:34:09 -07:00
71506f6ed9 fix(cli): validate rig ask --wake-timeout before handing it to execFile (#356)
* fix(cli): validate rig ask --wake-timeout before handing it to execFile

`--wake-timeout abc` became NaN and `--wake-timeout 1.0005` became
1000.5ms. execFile rejects both with ERR_OUT_OF_RANGE, so the wake
crashed with a stack trace (and printed no JSON under --json). Reject a
non-finite value with a clear message before any daemon or runtime
call, and round the timeout to whole milliseconds.

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

* fix(cli): bound rig ask --wake-timeout to what a Node timer honours

Review follow-up: `--wake-timeout 1e308` passed the finite-seconds check
but overflowed to Infinity ms, which execFile still rejects. And any
value of 2^31 ms or more (about 24.8 days) is run by Node's timer after
1ms, so the wake was killed at once and reported as timed out. Check the
converted milliseconds against 2^31-1 before any daemon or runner call.

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

---------

Co-authored-by: Coder8124 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-01 16:19:59 -07:00
8983aece22 fix(cli): reject a non-numeric chatroom wait --timeout instead of spinning (#355)
`rig chatroom wait --timeout abc` parsed to NaN. Neither timeout check
ever fired and the poll slept Math.min(3000, NaN), which Node runs as
~1ms, so the command polled the daemon's chat history in a tight loop
forever. Validate --timeout up front and exit 1 with a clear message
before contacting the daemon.

Co-authored-by: Coder8124 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-01 16:19:37 -07:00
Luis Araya fecd5979c1 docs(gateway): clarify connectorRef and secretsRef in gateway human add (#312) (#354) 2026-10-01 15:27:04 -07:00
Luis Araya f9e0df86ec fix(cli): clarify that rig seat handover performs mutation unless --dry-run (#315) (#351) 2026-10-01 14:58:17 -07:00
Luis Araya 60ffa7dd8b fix(tui): distinguish retired and deferred slices from planned (#343) (#350) 2026-10-01 14:57:12 -07:00
Luis Araya fde9a50eb2 fix(cli): return early on unverified daemon status (#342) (#349) 2026-10-01 14:09:29 -07:00
Mike Schwarzandv-openrig-build 5c13e08fd6 test(omp): give the OMP adapter test the staged launch path (#347)
OmpRuntimeAdapter and OmpResumeAdapter extend the Pi adapters. Since #212,
Pi-family launches send the runner command through
TmuxAdapter.sendShellCommand instead of sendText followed by Enter. #35's
CI ran before #212 merged, so its test mock only had sendText and sendKeys.
On main, all five OMP adapter tests fail with "this.tmux.sendShellCommand
is not a function".

OMP shares the Pi runner, so it should inherit Pi's launch transport. This
is a test-only change: the fake tmux now exposes sendShellCommand, and the
three assertions read the launch command from it.

Refs #35
Refs #212

Claude-Session: https://claude.ai/code/session_0111rhBDUYTL2n3Dbr2HDJf1

Co-authored-by: v-openrig-build <[email protected]>
2026-10-01 13:01:55 -07:00
MelonSmasher 93f8d4abba feat: add isolated Oh My Pi runtime adapter (#35)
Add `runtime: omp` as a managed runtime that runs Oh My Pi through
OpenRig's RPC runner, separate from `runtime: pi`.

Runner and launch:
- Share the Pi RPC runner with OMP-specific launch arguments, approval
  mode (`always-ask` floor, `yolo` under a full-bypass policy), and a
  deny-by-default environment allowlist.
- Isolate each seat under `$OPENRIG_HOME/state/omp/<seat>` with a per-seat
  HOME so OMP never reads the operator's `~/.omp` or `~/.env`. Resolve the
  real `omp` binary first so version-manager shims keep working.
- Cancel headless approval/input requests and keep the seat in
  needing-attention until the next agent run; surface rejected prompts and
  in-turn model/provider errors once per turn.

Identity and resume:
- Persist the OMP session file as the resume token only once it exists on
  disk and matches the seat's own session file; retry identity delivery
  until the daemon confirms the token.
- Validate the persisted seat runtime and session name on identity hooks,
  and report `tokenPersisted` from the stored state for RPC runtimes.
- Capture and resume OMP session files through claim, handover, and
  restore paths.

Recognition and docs:
- Recognize OMP in RigSpec validation, preflight (`omp --version`),
  runtime verification, session fingerprinting, and discovery.
- Document OMP state, credentials, approval posture, model errors, and
  restore in the RigSpec reference; add OMP UI branding.
2026-10-01 10:54:19 -07:00
Rudy Mizrahi Celekli a7e7598f69 fix(seat): resolve status by durable canonical coordinates (#340)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 10:43:12 -07:00
Rudy Mizrahi Celekli 8ef26dc009 fix(scope): align progress scaffolds with proof acceptance (#338)
* fix(scope): align progress scaffolds with proof authority

Signed-off-by: Rudy Celekli <[email protected]>

* test(scope): preserve legacy progress editing with outcome scaffolds

Signed-off-by: Rudy Celekli <[email protected]>

* test(progress): retain persisted scaffold classifier fixtures

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 10:34:44 -07:00
Rudy Mizrahi Celekli 28ca2476a6 fix(scope): refuse stale mission composition plans (#339)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 10:17:34 -07:00
Rudy Mizrahi Celekli e0de9f1bc4 fix(cli): report missing optional Slack feature scopes (#334)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 10:11:25 -07:00
Rudy Mizrahi Celekli b83b97fa44 fix(slack): enforce inbound file limits while streaming (#333)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 10:07:54 -07:00
Rudy Mizrahi Celekli a45e18ecf1 fix(slack): return actionable registry enable errors (#332)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 10:07:45 -07:00
Rudy Mizrahi Celekli a2ebf97719 fix(daemon): Preserve recap continuity when publication fails (#209)
* fix(daemon): preserve current recap when replacement publication fails

Signed-off-by: Rudy Celekli <[email protected]>

* fix(daemon): copy recap archives when hard links are unsupported

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 10:03:46 -07:00
Rudy Mizrahi Celekli 95d7a1b878 fix(pi): stage long launch commands outside canonical tty input (#212)
* fix(pi): stage long launch commands outside canonical tty input

Signed-off-by: Rudy Celekli <[email protected]>

* test(pi): use staged command helper in fork fixture

Signed-off-by: Rudy Celekli <[email protected]>

* fix(pi): preserve live readiness and short-command launch paths

Signed-off-by: Rudy Celekli <[email protected]>

* fix(pi): fall back to direct launch under long temporary paths

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 10:00:16 -07:00
Rudy Mizrahi Celekli 3ff5eff0fc fix(daemon): retire stopped session oracle evidence (#288)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 09:57:18 -07:00
Rudy Mizrahi Celekli 4068baf1a2 fix(plugin): include built-in agents in reverse usage (#262)
* fix(plugin): include library roots in reverse usage

Signed-off-by: Rudy Celekli <[email protected]>

* fix(plugin): retain cold legacy spec roots at startup

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 09:56:10 -07:00
Wu Shuwen e1baed7b87 fix(cli): render remote up error bodies (#329) 2026-10-01 09:50:11 -07:00
Rudy Mizrahi Celekli 1d250d7420 fix(daemon): Ignore watchdog receipts for superseded queue parks (#210)
* fix(daemon): ignore superseded park bindings in watchdog receipts

Signed-off-by: Rudy Celekli <[email protected]>

* fix(queue): preserve shared watchdog current bindings

Signed-off-by: Rudy Celekli <[email protected]>

* fix(queue): retain timer lifecycle after watchdog reattachment

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 09:48:31 -07:00
cdc22865fd refactor(daemon): use the shared SQLite time parser in three more places (#330)
health-detectors.ts, snapshot-repository.ts and restore-plan-preview.ts each
kept a private copy of the SQLite-stamp-to-UTC conversion that sqlite-time.ts
now provides. The copies had drifted:

- restore-plan-preview always appended "Z", so an already-zoned ISO
  last-verified stamp became "...ZZ", parsed to NaN, and skipped the
  freshness check: a 2h-old token showed as present instead of stale.
- health-detectors treated any value containing "T" as zoned, so a
  zone-less ISO value would have been read as host-local time.

All writers currently store datetime('now') stamps, so neither drift was
reachable in production; this removes the traps rather than fixing a live
bug. Adds a restore-plan-preview case for the ISO stamp (fails before this
change) and a helper case for the T-separated zone-less form.

Co-authored-by: Coder8124 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-01 09:47:25 -07:00
Rudy Mizrahi Celekli 7d43151ec5 fix(daemon): Compile lifecycles under configured mission roots (#211)
* fix(daemon): resolve lifecycle projects from configured mission roots

Signed-off-by: Rudy Celekli <[email protected]>

* fix(daemon): preserve missing lifecycle manifest response

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 09:46:25 -07:00
Rudy Mizrahi Celekli aeb8816010 fix: Preserve UTF-8 commands split across control socket chunks (#203)
* fix(tui): decode control commands across UTF-8 chunks

Signed-off-by: Rudy Celekli <[email protected]>

* test(tui): keep UTF-8 control sockets in private temporary directory

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 09:46:18 -07:00
Rudy Mizrahi Celekli 2a697d45ba docs(services): clarify HTTP readiness response semantics (#214)
* fix(daemon): require 2xx service readiness responses

Signed-off-by: Rudy Celekli <[email protected]>

* docs(services): clarify HTTP readiness response semantics

Signed-off-by: Rudy Celekli <[email protected]>

* docs(services): distinguish HTTP wait success from app readiness

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 09:46:09 -07:00
Rudy Mizrahi Celekli a36fa1a4e2 fix(restore): skip non-object JSONL records (#291)
* fix(restore): skip non-record JSON values in both transcript parsers

Signed-off-by: Rudy Celekli <[email protected]>

* fix(cli): skip non-object records during restore runtime detection

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 09:44:16 -07:00
Mike Schwarzandv-openrig-build bd41d7b181 fix(daemon): carry native Claude delivery onto main (#327)
Carry the accepted repair from #310 while preserving main's Codex activity guards and exact restored-identity reconciliation.

Refs #197

Co-authored-by: v-openrig-build <[email protected]>
2026-10-01 09:35:03 -07:00
Wu Shuwen 6f7d6a4ee4 fix(daemon): relaunch after stopping the last seat (#325) 2026-10-01 08:51:21 -07:00
Wu Shuwen dc6ab9eed5 docs(cli): clarify context trace seat value (#323) 2026-10-01 08:03:13 -07:00
Wu Shuwen e5681fc4c8 docs(cli): clarify broadcast pod scope (#322) 2026-10-01 08:02:51 -07:00
Shravan Sumanthanan 065df99929 fix(security): prevent temporary file permission leaks, DNS rebinding on terminal WS, and path traversal in bundle unpacking (#292)
* fix(security): enforce mode 0600 and exclusive creation on sendText temporary files

* fix(security): prevent DNS rebinding terminal hijack and add OPENRIG_ALLOWED_ORIGINS support in terminal WebSocket

* fix(security): reject backslash path traversal and Windows drive absolute paths in bundle unpacking

* fix(security): tighten loopback IP and URL origin matching in terminal-ws

* fix(security): only unlink temporary file in sendText if created by this call
2026-10-01 07:40:25 -07:00
KorallisandClaude Opus 5.5 51676aa204 perf(daemon): capture every seat's pane in a few tmux calls per structural sweep (#308) (#309)
* perf(daemon): capture every seat's pane in a few tmux calls per structural sweep (#308)

The structural activity sweep forked one `tmux capture-pane` per running seat
every tick; each fork blocks the event loop ~12-20 ms at ~1 GB RSS, and a
73-seat host spent about a third of its main thread in spawn (#308).

TmuxAdapter.capturePanesContent: one `list-sessions`, then the live sessions
in chained calls of at most 24 (`display-message` marker unique to the call,
then `capture-pane`, exact `=<name>:` targets), each pane's text identical to
capturePaneContent's. A missing session maps to null without a fork. tmux
stops a chain at its first failing command, so a failing chunk is split in
half and retried: an output over the exec buffer fits, and a session gone
since the listing is isolated and left to the per-seat read. No listing ->
null -> the sweep reads per seat as before.

The sweep uses it once per tick: 1 + ceil(live/24) calls instead of one per
seat (72 live seats: 4 instead of 72). pollSeat takes the prefetched text and
otherwise captures per seat; MF1 (a null capture invalidates) and MF2
(single-flight) are unchanged. Exact targets also resolve a session name with
a ".", which `-t <name>` reads as window.pane.

Tests: the adapter against a fake tmux with real chain semantics in both the
shell-string and argv modes (listing + one call, missing -> null, chunks of
24, a vanished session isolated, an over-buffer chunk split, no listing ->
null, marker nonce), and the sweep (one batch, MF1, per-seat fallback).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(daemon): doc comments on the batched capture helpers (review)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(daemon): a batched capture keeps its own time; a slow later chunk can't renew it (review)

The sweep waited for every batch and then stamped each pane's text as observed
"now", so a slow later chunk renewed older captures as fresh: an idle pane's
5.2 s-old active text was projected as running where main lets it expire.

capturePanesContent returns { text, capturedAt } per target, capturedAt being
when that target's own chunk was read (missing sessions: the listing time);
it takes the service's clock. pollSeat stores that time as observedAt, so the
freshness window ages each capture from when it was actually read.

Tests: a slow second chunk gets its own later capturedAt while the first
chunk keeps its earlier one (both exec modes); after a 5.2 s sweep an early
capture is past the 5 s window and is not served, while a fresh one is.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:36:51 -07:00
KorallisandClaude Opus 5.5 9dc0c3f1e3 fix(queue): a human-closed row's resolved notice opens no delivery episode (never-posted after a posted ALERT) (#296)
* fix(queue): a human-decision-resolved notice on a closed row opens no delivery episode

Rows a human closed by a direct Slack reply read deliveryOutcome=never-posted although their ALERT was posted. The close
writes a human-decision-resolved owner notification, which the Slack outbound never posts (listHumanAlerts lists active
rows only). The ledger took it as the current episode, found no receipt for its key, and reported never-posted after the
post window. On a row that is no longer active, the episode now falls back to the latest outbound notice
(human-required / human-update). Active rows are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(queue): judge a resolved notice by its own transition, and resolve the closing human (review)

Maintainer review: the fallback used the row's CURRENT state, so a resolved
NOTICE that was really posted (or transport-failed) while the row was active
lost its outcome once an agent later closed the row. The fallback now applies
only when the resolved notice's own transition closed the row, i.e. a human's
direct reply closing it; a notice written while the row stayed active keeps
its own outcome whatever happens to the row later.

CodeRabbit: after the fallback the recipient came from the row's current
destination, so an agent's row that had been parked on the human (its
destination the agent, blocked_on cleared by the close) read inactive. The
recipient is now the human who closed it (the resolved notice's actor).

Tests: "notice posted while active, then the agent closes the row" for posted
and transport-failed receipts; an agent's row parked on the human, ALERT
posted, closed by the human's reply -> posted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:22:42 -07:00
Mike SchwarzandOpenRig contributors 451e373165 fix(cli): replace proof artifacts without writing through links (#314)
* fix(cli): replace proof artifacts without writing through links

`rig proof add --replace` opened the existing artifact name with the "w"
flag. That follows a symlink and truncates a shared inode, so replacing
an artifact name that was a symlink or hard link overwrote the other
path's bytes, including files outside the slice and proof media.

--replace now writes a uniquely named staging file next to the artifact,
created exclusively, and renames it over the name. The old link or inode
is never opened for writing, so other paths keep their bytes. A failed
write or rename leaves the target as it was and removes only the staging
file this call created; the original error is reported. A self-sourced
replace still works because the source is read before the write. The
non-replace path keeps its exclusive-create refusal.

The replaced artifact is a new inode with default permissions. There is
no fsync, so this is a directory-entry swap, not a crash-durability
guarantee.

* fix(cli): keep artifact permissions and the first failure on proof replace

An explicit --replace of an existing regular artifact now carries its
permission bits onto the staging file before the swap, so a replacement
never broadens access (a 0600 artifact stays 0600; a read-only artifact
is replaced and stays read-only). A symlinked or new name still gets
default permissions.

The write and close failures are collected separately: the first one is
reported and a later close failure is a warning. A close failure on its
own still refuses the rename, and the target keeps its bytes either way.

* fix(cli): create the proof replace staging file no wider than the artifact

The exclusive staging open now passes the replaced artifact's permission
bits (or the default 0666 for a symlinked or new name) as its creation
mode, so the staging file is never created wider than the artifact it
replaces. The fchmod still sets the exact bits regardless of umask.

---------

Co-authored-by: OpenRig contributors <[email protected]>
2026-10-01 06:23:13 -07:00
6a2b804c23 fix(daemon): Codex 0.157/0.158 idle composer (#100) plus a far Working row reads active (#295)
* fix(daemon): recognise the Codex 0.157 empty-composer placeholder as idle

Codex 0.157 renders a fixed placeholder in the empty composer
(`› Ask Codex to do anything`, codex-rs/tui/src/chatwidget.rs `PLACEHOLDER`)
and its footer (`← for agents · ? for shortcuts`) no longer carries the
`· Context [` status bar. Once the activity hook ages out
(AGENT_ACTIVITY_FRESHNESS_MS), send readiness falls back to the pane probe,
which classifies an idle Codex 0.157 seat as `unknown`; with --wait-for-idle
every send to such a seat is refused as `target_activity_unknown`.

The placeholder and footer look the same idle and mid-turn. Mid-turn the
status row (`Working … esc to interrupt`) normally sits above the composer,
so the pattern only counts through MID_WORK_PATTERNS. Codex hides that row
while it streams assistant output, so classifySendReadiness also refuses to
let a placeholder-only idle verdict override a display-fresh (<5min)
running/needs_input runtime hook: a running turn stays running until its hook
ages out. An `unknown` hook (e.g. SessionStart) does not block.

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

* fix(daemon): a Codex placeholder composer with a Working row anywhere above it is active (0.158)

Codex 0.158 keeps the 0.157 placeholder (`› Ask Codex to do anything`); its
footer is a status line plus `? for shortcuts`, so the placeholder pattern
covers it. But its status row (`Working … esc to interrupt`) can sit more than
8 non-blank lines above the composer when queued or incoming message blocks
come in between. The mid-work check only looks at the last 8 lines, and an idle
prompt line then falls through to idle_prompt, so a busy seat read idle, and
with its hook aged out a --wait-for-idle send landed mid-turn.

Under the placeholder, a mid-work line anywhere in the capture now gives
agent_active / mid_work_pattern with that line as evidence. A bare prompt keeps
today's stale-scrollback behaviour. Tests: 0.158 idle and busy pane tails
(redacted from live seats), the far Working row (classifier and a
--wait-for-idle send with an aged-out hook), and a bare prompt with stale
Working text beyond the window still idle.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(daemon): only Codex's turn-status row counts as busy beyond the 8-line window (review)

The long-range scan under the placeholder matched the generic Working pattern
anywhere in the capture, so completed output such as "• Working directory:
/tmp/project" or "• Working tree is clean." more than eight lines up made an
idle composer read busy, and a --wait-for-idle send timed out. At that range
only the turn-status row now counts: a bullet, a header (Working, or the
reasoning summary Codex shows in its place), then "(<elapsed> • esc to
interrupt)". The 8-line generic check is unchanged.

Tests: both completed-prose cases read idle; a far status row under a
reasoning-summary header reads active; a send with "Working tree is clean."
far above the placeholder is delivered.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Oskar Stępniak <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-01 05:42:45 -07:00
KorallisandClaude Opus 5.5 394613e95e fix(slack): read a local attachment through one descriptor (follow-up to #298) (#305)
CodeRabbit on #298: defaultReadLocalImage resolved the path twice, statSync
for the checks and readFileSync for the bytes, so the file checked need not be
the file sent. Now the path is opened once (O_RDONLY | O_NONBLOCK, so a FIFO
can't block the open; it is refused as not a regular file), fstat'ed, and read
from the same descriptor, bounded by the size just checked; the descriptor is
always closed.

Tests: the reader opens the path once and never calls statSync/readFileSync on
it; a FIFO with an attachment extension returns "not a regular file" without
waiting for a writer.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 05:39:56 -07:00
Mike Schwarzandv-openrig-build 3fba553a2f docs: describe OpenRig teams as persistent in the npm and Context7 descriptions (#304)
Co-authored-by: v-openrig-build <[email protected]>
2026-10-01 05:11:25 -07:00
Sahil 7ca2724101 feat(slack): clickable questions on human decisions (#193) (#195)
* feat(slack): structured questions with clickable options on human decisions (#193)

- `rig queue create --human-questions-file`: 1-4 questions, 2-4 options each
  (one may be recommended), validated at create; stored by migration 091.
- Slack renders each question as a button row with a complete text fallback;
  a typed thread reply still answers ("Other").
- A block_actions click records that answer on the item; once every question
  is answered the answers are final, one reply row lands on the seat and the
  decision resolves like a typed reply. Failed hand-backs are dead-lettered
  and retried; each click is confirmed in the thread.
- App manifest turns on interactivity (Socket Mode, no request URL); setup
  doc and messaging-the-human skill updated.

* fix(slack): own-key answer lookup; require a human destination for questions(#193)

* fix(slack): redact secrets in answer acknowledgements (#193)
2026-10-01 04:51:28 -07:00
Mike Schwarzandv-openrig-build 29a72ad324 fix(gateway): redact secrets in the Slack file-upload title (#302)
The file title sent with files.completeUploadExternal was the queue summary
(or the file name) as is, while the message text carrying the same summary
already went through redactSecrets. A secret in a row summary was therefore
hidden in the text but shown as the uploaded file's title. The title now
goes through the same redaction. Slack does not parse file titles as mrkdwn,
so the text's control-syntax escaping is not applied to it.

Fixes #300

Claude-Session: https://claude.ai/code/session_01V8RzdrMhGkYLV4rn96kNMN

Co-authored-by: v-openrig-build <[email protected]>
2026-10-01 04:37:17 -07:00
Mike SchwarzandOpenRig contributors d0623e06cb feat(daemon): turn the web UI and its terminal WebSocket off by default (#301)
The daemon no longer serves the web UI's pages, assets and SPA routes, or
registers the web-terminal WebSocket, unless `ui.enabled` is true. Turn them
on with `rig config set ui.enabled true`, then stop and start the daemon.
While the UI is off, a page request answers 404 with those steps, and
`rig ui open` asks the running daemon and prints the same steps instead of
opening the page.

The /api routes, /healthz and the HTTP terminal routes (open, views, preview,
status) answer as before. The guard that ran in front of the HTTP terminal
routes through the WebSocket route still runs when the WebSocket is absent.

Co-authored-by: OpenRig contributors <[email protected]>
2026-10-01 04:34:43 -07:00
KorallisandClaude Opus 5.5 9648c59fe5 fix(slack): form-encode the external-upload calls; attach local video and PDF (#298)
An update row's local evidenceRef screenshot delivered as text only: the daemon
logged "ATTACHMENT upload-url FAILED ...: invalid_arguments (text delivered;
attachment missing)". files.getUploadURLExternal was sent as a JSON POST.
Slack's reference lists JSON for it, but the method reads form fields only and
answers a JSON body with invalid_arguments ("missing required field: length /
filename").

- callWebApi gets a "form-post" shape: url-encoded fields, objects/arrays as
  JSON strings, the way Slack's own Web API client sends every call. Both
  files.getUploadURLExternal and files.completeUploadExternal use it; the
  JSON-POST family (auth.test, apps.connections.open, chat.postMessage) is
  unchanged.
- Local attachments (LOCAL_ATTACHMENT_EXT): the image types plus .mp4, .webm,
  .mov and .pdf, at most 50 MiB (stat before read). LOCAL_IMAGE_EXT still gates
  https Block Kit image blocks (#47), so a video URL never becomes one.
- The byte upload's default timeout grows with the size (15 s + 1 s / 512 KiB).
- An attachment that can't be sent (over the cap, missing, not a regular file)
  logs "ATTACHMENT skipped ... (text delivered; attachment missing)" instead of
  being silently dropped; a non-attachment ref (e.g. PROOF.md) stays silent.

Tests: the slack-images fake now answers a JSON getUploadURLExternal with
invalid_arguments, as live; form shapes for both upload methods; video through
the three legs; the reader's allowlist, cap boundary and skip reasons.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 04:29:30 -07:00
Mike Schwarzandv-openrig-build 9480978770 docs: describe OpenRig as a network of agents in the README, npm and Context7 (#297)
The README opening now says what OpenRig is in one sentence (open-source
software for building and running your own network of agents) and closes
with the line about the AI civilization experiments. The npm package
description and the Context7 description use the same short summary:
"Build your own network of agents from Claude Code, Codex and Pi:
long-running teams with roles, shared context and owned work."

Co-authored-by: v-openrig-build <[email protected]>
2026-10-01 04:15:12 -07:00
Sahil 5fe41efa7c feat(slack): let an update post into an earlier item's thread (--reply-to) (#96) (#155)
* feat(slack): let an update post into an earlier item's thread (--reply-to) (#96)

`rig queue create --human-intent update --reply-to <qitemId>` posts the
update under the earlier item's Slack thread instead of a new top-level
post. Decisions keep their own threads.

- Refused with named errors: without --human-intent update
  (reply_to_requires_update), unknown item (reply_to_not_found), or while
  the earlier item still waits on the human (reply_to_open_decision).
- The thread choice is recorded once, before the first post, so retries
  and --verify agree. Only daemon-written records are trusted.
- A missing, closed, other-channel or other-human root falls back to a
  new post; --verify reports threaded: false with the reason.
- If an item is parked on the human again after an update shared its root,
  the new decision gets a fresh root, and a reply in the older root no
  longer answers it.

* fix(slack): address #155 review — seat check, thread_ts ordering, multi-thread tests

- --reply-to falls back with root-other-seat when the root belongs to a
  different seat, so a human reply can't reach the wrong agent. The
  messaging-the-human skill now says to send the update from the seat
  that owns the thread.
- The newest root of a conversation is Slack's latest thread_ts, not
  opened_at, which a rebuild resets. This applies to all outbound thread
  reuse, not only --reply-to.
- Pin that a reply in an older thread of a conversation no longer
  answers its current decision, without --reply-to.
- Pin that the resolved-decision notice never takes the root away from a
  later --reply-to update (Slack-answered decision and resolved park).

* fix(slack): post a --reply-to update top-level instead of refusing it (#155 review)

An update whose --reply-to item still waits on the human is no longer
refused at create time (reply_to_open_decision is gone). Delivery already
falls back: it posts the update as a new top-level message and records
why on the row, so --verify reports threaded: false with
reference-has-live-gate. A missing thread already fell back the same way
(root-missing).

- Still refused: --reply-to without --human-intent update
  (reply_to_requires_update), and an id naming no qitem on this host
  (reply_to_not_found).
- Tests: create accepts a reference with an open decision or a row parked
  on the human; delivery posts both top-level with the fallback reason.
- CLI help and the messaging-the-human skill describe the fallback;
  skill edge digests regenerated.
2026-10-01 04:12:53 -07:00
KorallisandClaude Opus 5.5 c40dbac0c1 perf(daemon): one tmux call per sweep for seat window activity and pane identity (#161) (#293)
The 1 Hz seat-activity sweep ran one `tmux display-message` per seat, and the
identity sweep a getPanePid + getPaneCommand per seat (plus getPanePid again per
native-process sample). At 87 seats that was ~85 tmux spawns/s from the
activity sweep alone and most of the daemon's event-loop time.

- TmuxAdapter.readAllSessionWindowActivity(): one `list-windows -a`, each
  session's CURRENT window activity: the value `display-message -t <session>`
  reads.
- TmuxAdapter.readAllPaneProcesses(): one `list-panes -a`, pane pid + current
  command.
Both use the adapter's argv seam and TMUX_FIELD_SEPARATOR, with the free-text
field (session name, command) last so a separator inside it stays intact.

The sweeps read once and look each seat up, falling back to the per-seat read
on a miss or a failed batch. Direct pollSeat calls still read per target.
Cadence, debounce, arbitration and #169's generation checks are unchanged; each
native-process sample takes its own list-panes, so samples stay independent.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 03:58:23 -07:00
Rudy Mizrahi Celekli 3e1c3b0108 fix(scope): reject self dependencies before scaffolding (#286)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 03:20:16 -07:00
Mike Schwarzanddev-driver bb5d17223a fix(daemon): retain bound Claude auto identity across restore (#294)
Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-10-01 03:01:43 -07:00
Mike Schwarzandv-openrig-build 8c4b30bee8 docs(onboarding): tell both doghouse failures and hand over intent, not instructions (#289)
Onboarding piece 01 told only half of the doghouse story: the build that
over-grows (now a light that needs power, not a lock) and then asked "How
big is the dog?", which belongs to the other half. It now tells both: the
moon base, stopped by "does the thing actually need this?", and the tidy
doghouse whose door the dog can't fit through, stopped by "how big is the
dog?". A short paragraph adds that shared understanding of the goal, not
tighter instructions, is what prevents both.

Piece 02's ending becomes "When you need more": the capability map is read
when needed rather than by every seat, a world pack is for planning and
routing work, and the world template is pointed to for people setting one up.

The delegating-work skill gains "Hand over intent, not instructions".

Co-authored-by: v-openrig-build <[email protected]>
2026-10-01 02:28:22 -07:00
Rudy Mizrahi Celekli 82dbc01443 fix(daemon): Select the latest inserted same-second restore snapshot (#206)
* fix(daemon): rank same-second restore snapshots by insertion order

Signed-off-by: Rudy Celekli <[email protected]>

* fix(daemon): handle a restore source pruned between selection reads

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 02:20:39 -07:00
Rudy Mizrahi Celekli 5f33183055 fix(config): report filesystem failures when resetting configuration (#284)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 02:11:25 -07:00
Rudy Mizrahi Celekli 22622b8981 fix: Preserve running TUI control sockets during a second launch (#205)
* fix(tui): preserve live control sockets when launching

Signed-off-by: Rudy Celekli <[email protected]>

* test(tui): keep collision sockets inside isolated temporary root

Signed-off-by: Rudy Celekli <[email protected]>

* fix(tui): serialize stale control socket recovery

Signed-off-by: Rudy Celekli <[email protected]>

* fix(tui): keep standalone terminals usable on socket collisions

Signed-off-by: Rudy Celekli <[email protected]>

* test(tui): build child-process entrypoints before package tests

Signed-off-by: Rudy Celekli <[email protected]>

* fix(tui): recover reservations abandoned by killed launchers

Signed-off-by: Rudy Celekli <[email protected]>

* fix(tui): expose the complete selected control endpoint for copying

Signed-off-by: Rudy Celekli <[email protected]>

* test(tui): verify recovered endpoint without assuming inode allocation

Signed-off-by: Rudy Celekli <[email protected]>

* test(tui): size long socket fixture from byte budget

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 01:51:34 -07:00
Mike SchwarzandOpenRig contributors 809c338843 fix(cli): report a mutating handover's timeout as an unknown outcome (#198)
A mutating `rig seat handover` (and `rig handover`, which shares it) waits up to
120 s for the daemon (#260). When the client reaches that bound the daemon may
still be working, so the handover's outcome is unknown, but the CLI surfaced only
a raw transport timeout (reported in korallis/agent-stack#32).

The CLI now prints "handover outcome unknown" in text and JSON, exits 1, and
makes no second request. It tells the user to inspect the seat before any new
handover, and that a result shown there may belong to an earlier attempt.
A completed or refused handover, a connection failure, and dry-run behave as
before.

Co-authored-by: OpenRig contributors <[email protected]>
2026-10-01 01:26:01 -07:00
Rudy Mizrahi Celekli d70c5995a6 fix(bundle): retain recovery exports for inaccessible declared skill paths (#274)
Signed-off-by: Rudy Celekli <[email protected]>
2026-10-01 00:58:15 -07:00
Mike Schwarzandv-openrig-build 792ef42e5f docs: add contributor maps and a developing-openrig skill (#282)
* docs: add contributor maps and a developing-openrig skill

Add ARCHITECTURE.md (packages, request path, counts with the commands that
produce them, where to add a command, route, migration, adapter, skill,
context pack or scenario), docs/as-built/arteries.md (high-impact areas,
their dependents and past regressions) and docs/as-built/test-layers.md
(what to run before a pull request and what each layer proves).

Add a repo-local developing-openrig skill for Claude Code (.claude/skills)
and Codex (.agents/skills) that routes to these maps, with narrow .gitignore
exceptions so only that skill is tracked. Point CONTRIBUTING.md at the maps
and correct its skill-mirror instructions. Replace the private-host paragraph
in the shipped openrig-skills router with a pointer to the repository skill.

* docs: correct six factual points from review

Newest as-built markers; skills may have one or several copies; the host
scenario runner drops rather than refuses TMUX and daemon variables;
scenario-10 declares its own normaliser; the managed Claude launch applies
only with an explicit permission mode; Dockerfile.scenarios is layered by
run-pr-scenarios.sh. Also tighten several wordings the review flagged.

---------

Co-authored-by: v-openrig-build <[email protected]>
2026-10-01 00:49:21 -07:00
Leon.C 94a68a3d77 fix(daemon): render non-image https evidenceRef as a plain link, not an image block (#89)
* fix(daemon): render non-image https evidenceRef as a plain link, not an image block

* fix(daemon): validate Slack evidence rendering boundaries
2026-10-01 00:39:34 -07:00
Shravan Sumanthanan 274e05fdc2 fix(security): prevent SSRF in notifications, drive-by CSRF on API, and pairing queue flooding (#149)
* fix(security): origin guard on /api/*, target URL validation, pairing hygiene, and UTC test fix

- API Origin protection: add apiOriginProtection middleware rejecting unauthorized browser Origin headers on /api/* routes to prevent drive-by web pages posting to the local daemon.
- Notification target URL validation: refuse non-HTTP(S) schemes and embedded URL credentials with clear startup errors and URL credential redaction, set redirect: 'error' on webhook delivery, and preserve self-hosted notifier support on localhost/LAN.
- Pairing hygiene: synchronously reserve capacity before awaited calls to prevent race conditions exceeding MAX_PENDING_PAIRS, prune requests only after a 1-hour grace window past PAIR_TTL_MS so waiting clients receive status 'expired', and sanitize requester strings.
- Timezone test fix: use parseSqliteUtcMs in effective-model-occupant-identity.test.ts to eliminate non-UTC test failure.

* fix(notifications): log warning and disable on invalid target URL, drop redirect error

* fix(notifications): support basic auth credentials in webhook and ntfy URLs

* fix(notifications): safely decode URI components in target URLs falling back on URIError
2026-10-01 00:30:29 -07:00
Mike Schwarzandv-openrig-build 07dab0761d fix(daemon): shipped startup files and resources follow the running install (#261) (#269) (#281)
* fix(daemon): deliver built-in startup files from the running install (#261)

Built-in startup files (CULTURE-default.md, openrig-start.md and the two
onboarding files) are stored per seat as absolute paths inside the install
that created the seat. After an upgrade removes that install (mise and other
versioned managers), every fresh launch rolled back with ENOENT, and restore
blocked on the missing required file. While the old install remained, seats
kept receiving the old version's shipped guidance.

Fresh launch (SeatLifecycleService.readStartupContext) and restore
(pre-validation and replay) now re-anchor a stored file to the running
daemon's assets when it is a recognized built-in: one of the four logical
names, stored at exactly <ownerRoot>/<known relative path>, with ownerRoot a
daemon/assets directory. Custom rig and agent files, including a same-named
CULTURE-default.md, are delivered exactly as stored. Required/appliesOn/
delivery metadata is unchanged, a missing current built-in still fails
honestly, and a same-native resume still replays nothing.

Refs #261

* fix(daemon): project shipped-spec resources from the running install (#261)

Kernel and library agents ship inside the install, so their stored projection
entries (agent guidance, shared runtime settings/MCP fragments) also point
into the install that created the rig. After that install is removed, a fresh
launch still rolled back in projection, before startup-file delivery.

Fresh launch and restore (pre-validation drift checks and replay) now
re-anchor a stored projection entry to the running install's daemon/specs when
both its sourcePath and its resource path lie under the same OpenRig install's
daemon/specs (packaged @openrig/cli/daemon/specs or a packages/daemon/specs
checkout). A resource stored elsewhere, such as the openrig-core plugin under
~/.openrig/plugins, user specs, or a folder merely named daemon/specs, is
projected exactly as stored. Identifiers, category, target, merge behavior and
the existing skill exclusion are unchanged; a resource missing from the
running version keeps the existing failure or warning.

Refs #261

* fix(daemon): own-key built-in lookup; restore-check judges the running paths (#261)

The built-in name lookup used a plain object, so a custom startup file named
constructor, toString or __proto__ matched an inherited key and fresh launch
threw on a valid seat (review finding on be599a64). Use a Map.

RestoreCheckService.checkStartupContext checked and reported the stored
paths directly, so a seat whose old install was removed showed missing
required inputs that replay no longer uses, and named stale paths in its
evidence and remediation. It now applies the same resolvers to the inspected
context and uses the resolved paths in both the predicates and the reported
text. The red/yellow rule, genuinely missing current or custom files, and
missing/malformed/probe-error handling are unchanged. The route's context
reader passes ownerRoot and sourcePath through so the resolvers can apply.

Refs #261

* fix(daemon): shipped-spec startup files follow the running install (#261)

Kernel and library seats also store their rig culture and agent startup files
(culture/CULTURE.md, guidance/role.md, startup/context.md) as absolute paths
inside the install's daemon/specs. With the projection fixed, a kernel created
by a removed install still rolled back on the culture file, and its post-launch
role/context would have followed.

The startup-file resolver now also re-anchors a stored startup file whose
ownerRoot and absolutePath both lie under the same recognized install's
daemon/specs, using the same mapping as projection entries. It applies through
fresh launch (pre- and post-launch delivery), restore replay and
pre-validation, and restore-check. Custom, external and plugin paths, and all
required/applicability/delivery metadata, are unchanged; a file missing from
the running version still fails or warns honestly.

Refs #261

* fix(daemon): re-anchor dev-checkout paths only when missing (#261)

A daemon running from one install remapped every recognized shipped path,
including ones stored from a developer checkout (packages/daemon/...). Those
files can be newer than, or absent from, the running install, so a present
file was remapped to a missing running path and the launch rolled back
(review finding, confirmed by both reviewers).

Packaged installs (@openrig/cli/daemon/...) still re-anchor even while the
stored file exists, so seats never keep an old version's shipped content.
Dev-checkout files re-anchor only when the stored file is missing. Recognition
is limited to those two layouts for built-in assets too. Each consumer passes
its own existence check (restore fsOps, restore-check deps; fresh launch uses
the filesystem).

Refs #261

* fix(daemon): leave legacy persisted startup shapes as stored (#261)

Snapshots and contexts persisted by older versions can lack ownerRoot on
startup files or sourcePath on projection entries. The #261 resolvers called
path.resolve on those fields, which threw, so restore reported restore_error
instead of its real outcome; restore pre-validation walks projection entries
even for an exact resume (CI: restore-honesty-d1-d6 D6a, 4 failures).

Entries without a string root or path are now returned exactly as stored,
which is their pre-#261 behavior.

Refs #261

---------

Co-authored-by: v-openrig-build <[email protected]>
2026-10-01 00:06:13 -07:00
Mike Schwarzanddev-driver d1ba4ee71e fix(cli): honor the recorded daemon endpoint for local commands (#266) (#279)
* fix(cli): use current home daemon endpoint for local clients

* fix(cli): retain recorded endpoint after daemon exit

---------


(cherry picked from commit cdf5fff724)

Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-09-30 23:37:14 -07:00
Mike SchwarzandOpenRig contributors 6963421604 fix: give the managed Claude capability query room before refusing a launch (#278)
A managed Claude launch with an explicit permission mode first runs
`claude --help` to read the supported modes. The query was killed after one
second, so a valid but slow help refused the launch with "capability query
failed". The daemon now allows five seconds; a hung query still ends at that
bound with the same refusal. `rig seat set-permissions` waits up to 10 seconds
and a mutating `rig seat handover` gets the existing 120-second launch window,
so the daemon's answer arrives before the client deadline.

Port of #271 from release/0.6.4 (squash bfa66821). Original author:
dev50-driver; one-line S03 test finisher: dev60-driver.

Refs #260

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 23:32:01 -07:00
Mike SchwarzandOpenRig contributors b24ef4f6f6 fix(daemon): keep binary files byte-identical in rig bundles (#276)
Bundle create read every vendored agent-package file, and the declared
culture, docs and startup files, as UTF-8 text and wrote it back as UTF-8.
Any byte sequence that is not valid UTF-8 became U+FFFD, and the integrity
manifest was hashed from the changed copy, so inspect and install verified
the corrupted bytes as intact.

Copy those files as bytes: the pod assembler reads them with readFileBuffer
and writes the same bytes, and the install-side skills and workflow-spec
routers copy with fs.copyFileSync. Rewriting rig.yaml and agent.yaml import
refs stays a text operation.

Port of #257 from release/0.6.4 (squash 2174b11e). The async test change in
that squash is already on main via #268 and is not repeated here.

Fixes #245

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 23:31:44 -07:00
Mike SchwarzandOpenRig contributors 15763c46bb fix(daemon): relaunch a sole seat after fresh --stop ends its tmux server (#277)
Stopping the server's last session ends tmux's server, so launchFresh's
later probes saw transport_unavailable and refused with tmux_probe_failed
after the occupant was already stopped. After its own successful stop,
launchFresh now restores an empty server with the existing startServer()
(a no-op while the server is up; no session is invented), so the
classified probes get a positive answer. Transport, permission,
collision, claimed-seat and wrong-pane refusals are unchanged.

Port of #267 from release/0.6.4 (squash fa770114), without its RC-only
CHANGELOG notes.

Refs #265

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 23:31:26 -07:00
MelonSmasher 217b5282f8 fix: report identity hook tokenPersisted from stored state (#40) 2026-09-30 23:04:20 -07:00
Mike Schwarzandv-openrig-build 700e6a05be fix(cli): include the repository README in the npm package (#272)
The npm page for @openrig/cli shows "No README data found" because the
published package root has no README.md. The CLI build already copies the
root LICENSE into the package; it now copies the root README.md the same
way, and the generated copy is ignored like LICENSE. The packaging test
now also checks that npm pack carries README.md byte-for-byte.

Co-authored-by: v-openrig-build <[email protected]>
2026-09-30 23:02:03 -07:00
Rudy Mizrahi Celekli 11b14d0150 fix(bundle): warn about unresolved skills in recovery exports (#259)
* fix(bundle): refuse missing declared agent skills

Signed-off-by: Rudy Celekli <[email protected]>

* fix(bundle): preserve recovery exports with unresolved skill warnings

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 22:44:37 -07:00
Rudy Mizrahi Celekli 76ef4d9b13 fix(daemon): compare chat history cutoffs as UTC instants (#208)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 21:37:59 -07:00
Rudy Mizrahi Celekli 02a3ca7bf2 fix(tui): cancel activity streams opened after shutdown (#204)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 21:37:43 -07:00
Rudy Mizrahi Celekli 0ab94672de fix(gate): reject invalid exclusion expiry dates (#199)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 21:37:26 -07:00
Rudy Mizrahi Celekli e1139e2276 fix(daemon): probe bracketed IPv6 service targets (#215)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 21:37:09 -07:00
Rudy Mizrahi Celekli 25ee889495 fix(daemon): reject failed Pi startup RPC responses (#213)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 21:29:43 -07:00
Mike Schwarzanddev-driver 964b904fd2 fix(daemon): prove managed Claude identity through shell wrappers (#264)
* fix(daemon): prove managed Claude identity during restore

* fix(daemon): sample Claude lineage only for shell labels

* test(daemon): distinguish bare shells from unusable Claude panes

* fix(identity): retain unavailable final Claude command observation

---------

Co-authored-by: dev-driver <dev-driver@openrig-build>
2026-09-30 21:28:33 -07:00
Rudy Mizrahi Celekli 662288d791 fix(tmux): preserve delimiters in pane working directories (#216)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 21:19:35 -07:00
Mike SchwarzandOpenRig contributors 030227c5d1 test(daemon): assert async ps samplers stay pending through a microtask drain (#268)
The three anti-sync discriminators in list-processes-async.test.ts raced a
0ms timer against the sampler's result. Timer order is not a valid guarantee
of async execution: in a local probe, a 70ms synchronous stall after spawning
a single-PID `ps eww` let the result settle before the 0ms timer in most
trials. The CI failure of this test is consistent with that race; the CI
timing itself was not measured.

Assert instead that the returned promise is still pending after draining 20
microtask ticks: an async execFile cannot settle without an event-loop turn,
while a synchronous ps inside the call settles within a few ticks. The real
rows and HOME assertions are unchanged.

Refs #245

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 21:08:42 -07:00
Rudy Mizrahi Celekli e0b135ca43 fix(daemon): tolerate unusable snapshots during restore selection (#207)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 20:54:31 -07:00
Rudy Mizrahi Celekli bc8c608506 fix(cli): fail bootstrap and requirements on client errors (#202)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 20:46:15 -07:00
Rudy Mizrahi Celekli b730558413 fix(gate): reject ephemeral and invalid mutex ports (#200)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 20:45:57 -07:00
Rudy Mizrahi Celekli 650b96bb4e fix(daemon): keep truncated ntfy titles ASCII (#217)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 20:45:38 -07:00
Rudy Mizrahi Celekli ed9dc62f5a fix(daemon): require every Compose replica to be healthy (#218)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 20:45:17 -07:00
Rudy Mizrahi Celekli b4a05d279a fix(scope): warn about dependencies left behind by slice moves (#263)
Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 19:49:38 -07:00
Ege 1347d825c3 fix(daemon): create parent directory when packing bundle to missing path (#258) 2026-09-30 19:21:09 -07:00
Rudy Mizrahi Celekli 3691134c5b fix: Render terminal-open HTTP errors before consuming open results (#229)
* fix(cli): render terminal open HTTP errors before results

Signed-off-by: Rudy Celekli <[email protected]>

* fix(cli): select terminal result printer by response shape

Signed-off-by: Rudy Celekli <[email protected]>

---------

Signed-off-by: Rudy Celekli <[email protected]>
2026-09-30 18:38:25 -07:00
Mike SchwarzandOpenRig contributors de9ec4f7ee test(cli): accept a pre-release version in the CLI --version check (#255)
client.test.ts expected only MAJOR.MINOR.PATCH, so the 0.6.4-rc.1 candidate failed
Tests run 36798254083. Allow an optional pre-release suffix and keep the anchors and the
optional (commit[, dirty]) stamp. Test-only.

Co-authored-by: OpenRig contributors <[email protected]>
2026-09-30 18:35:57 -07:00
c1ff76635e test: bind scenario seeds and exercise the library baton in CI (#243)
* test: bind scenario seeds and exercise the library baton in CI

* test: run named scenarios through a remote Docker executor

* test: bound and clean up the testbed image-load check

---------

Co-authored-by: dev-driver <dev-driver@openrig-build>
Co-authored-by: dev-qa <dev-qa@openrig-build>
2026-09-30 17:55:20 -07:00
724 changed files with 45356 additions and 9245 deletions

No files matched your search

@@ -0,0 +1,66 @@
---
name: developing-openrig
description: "Use when changing OpenRig's own source in a clone of the openrig repository: finding which package or file owns a behaviour, choosing which tests to run, testing a change without disturbing the OpenRig daemon your own session runs on, judging whether a diff touches a high-risk area, or preparing a pull request. Not for operating rigs (openrig-skills) or designing rig topologies."
---
# Developing OpenRig
This skill is a map to the other maps. Each one is incomplete and may be stale: where a map and the code disagree, the
code is right, and fixing the map is a welcome contribution.
## First: which OpenRig are you in?
If you are an agent running inside OpenRig, two different OpenRigs are in play:
- **The installed one runs your session.** `rig --version` and `rig daemon status` describe it.
- **The checkout is what you're changing.** `git rev-parse HEAD` describes it.
Restarting the installed daemon does not run your checkout, and stopping it stops the agents running on it, including
you. Test your change with the repo's own harnesses, which start a private daemon and tmux server (see "Test it" below),
not by restarting the daemon you live in.
## Find your way
| You want to know | Read | How much to trust it |
|---|---|---|
| How the packages fit, the request path, where to add a command, route, migration, adapter, skill, context pack or scenario | [`ARCHITECTURE.md`](../../../ARCHITECTURE.md) | Checked against the commit it names; counts come with the command to refresh them |
| Whether your change touches a high-risk area, what depends on it, what broke there before | [`docs/as-built/arteries.md`](../../../docs/as-built/arteries.md) | Incomplete by design: absence from it doesn't make a change safe |
| What to run before you push, and what each layer can and can't prove | [`docs/as-built/test-layers.md`](../../../docs/as-built/test-layers.md) | Checked against the commit it names |
| The exact behaviour of a `rig` command | `rig <command> --help` on the running binary, then `packages/cli/src/commands/` | The binary and the code win over any document |
| A subsystem in depth | [`docs/as-built/`](../../../docs/as-built/README.md) | Most modules predate 0.6; check each one's `last-verified-against-source` marker against `git log` before relying on it |
| Rig spec, agent spec and other user-facing reference | [`docs/reference/`](../../../docs/reference/) | Ships to every user; if you find drift, fix it there |
| How to open a good pull request, and what review looks like | [`CONTRIBUTING.md`](../../../CONTRIBUTING.md) | Current |
If none of these answers your question, search the code before inventing an answer. A missing map is worth an issue.
## Before you change an artery
If your diff touches anything in `arteries.md` (message delivery, launch and resume, the queue, rig identity, skill
projection, process observation, migrations, restore):
1. Read the artery row: what depends on it, and the past regressions listed there.
2. Describe the downstream effect in your pull request, not just the diff.
3. Add or extend a stub-agent scenario, or say precisely why the stub can't exercise it and what you ran instead.
## Test it
The short version is `npm run build`, `npm run lint`, `npm test`. `test-layers.md` has the full ladder, including the
stub-agent scenarios, which run the real CLI, daemon, tmux and SQLite with scripted agents and no model cost.
- The scenario runner starts its own daemon and tmux server and drops your session's `TMUX` and daemon variables, so
it won't touch the daemon your session runs on.
- A stub proves OpenRig's own plumbing, not how Claude Code or Codex behave. Say which you exercised.
## How we judge changes
OpenRig is a coordination layer for a trusted environment: it should help agents and people keep work flowing.
- A change that removes friction, or makes state more truthful, is usually welcome.
- A change that adds a refusal, a prompt or a required step needs the concrete case in CONTRIBUTING.md: who is
harmed, how, and what it costs everyone else.
- One concern per pull request. Keep the diff reviewable in one sitting.
## When the map is wrong
Fix it in the same pull request, or open an issue. Update a document's `last-verified-against-source` marker only when
you have actually checked it against that commit.
@@ -0,0 +1,66 @@
---
name: developing-openrig
description: "Use when changing OpenRig's own source in a clone of the openrig repository: finding which package or file owns a behaviour, choosing which tests to run, testing a change without disturbing the OpenRig daemon your own session runs on, judging whether a diff touches a high-risk area, or preparing a pull request. Not for operating rigs (openrig-skills) or designing rig topologies."
---
# Developing OpenRig
This skill is a map to the other maps. Each one is incomplete and may be stale: where a map and the code disagree, the
code is right, and fixing the map is a welcome contribution.
## First: which OpenRig are you in?
If you are an agent running inside OpenRig, two different OpenRigs are in play:
- **The installed one runs your session.** `rig --version` and `rig daemon status` describe it.
- **The checkout is what you're changing.** `git rev-parse HEAD` describes it.
Restarting the installed daemon does not run your checkout, and stopping it stops the agents running on it, including
you. Test your change with the repo's own harnesses, which start a private daemon and tmux server (see "Test it" below),
not by restarting the daemon you live in.
## Find your way
| You want to know | Read | How much to trust it |
|---|---|---|
| How the packages fit, the request path, where to add a command, route, migration, adapter, skill, context pack or scenario | [`ARCHITECTURE.md`](../../../ARCHITECTURE.md) | Checked against the commit it names; counts come with the command to refresh them |
| Whether your change touches a high-risk area, what depends on it, what broke there before | [`docs/as-built/arteries.md`](../../../docs/as-built/arteries.md) | Incomplete by design: absence from it doesn't make a change safe |
| What to run before you push, and what each layer can and can't prove | [`docs/as-built/test-layers.md`](../../../docs/as-built/test-layers.md) | Checked against the commit it names |
| The exact behaviour of a `rig` command | `rig <command> --help` on the running binary, then `packages/cli/src/commands/` | The binary and the code win over any document |
| A subsystem in depth | [`docs/as-built/`](../../../docs/as-built/README.md) | Most modules predate 0.6; check each one's `last-verified-against-source` marker against `git log` before relying on it |
| Rig spec, agent spec and other user-facing reference | [`docs/reference/`](../../../docs/reference/) | Ships to every user; if you find drift, fix it there |
| How to open a good pull request, and what review looks like | [`CONTRIBUTING.md`](../../../CONTRIBUTING.md) | Current |
If none of these answers your question, search the code before inventing an answer. A missing map is worth an issue.
## Before you change an artery
If your diff touches anything in `arteries.md` (message delivery, launch and resume, the queue, rig identity, skill
projection, process observation, migrations, restore):
1. Read the artery row: what depends on it, and the past regressions listed there.
2. Describe the downstream effect in your pull request, not just the diff.
3. Add or extend a stub-agent scenario, or say precisely why the stub can't exercise it and what you ran instead.
## Test it
The short version is `npm run build`, `npm run lint`, `npm test`. `test-layers.md` has the full ladder, including the
stub-agent scenarios, which run the real CLI, daemon, tmux and SQLite with scripted agents and no model cost.
- The scenario runner starts its own daemon and tmux server and drops your session's `TMUX` and daemon variables, so
it won't touch the daemon your session runs on.
- A stub proves OpenRig's own plumbing, not how Claude Code or Codex behave. Say which you exercised.
## How we judge changes
OpenRig is a coordination layer for a trusted environment: it should help agents and people keep work flowing.
- A change that removes friction, or makes state more truthful, is usually welcome.
- A change that adds a refusal, a prompt or a required step needs the concrete case in CONTRIBUTING.md: who is
harmed, how, and what it costs everyone else.
- One concern per pull request. Keep the diff reviewable in one sitting.
## When the map is wrong
Fix it in the same pull request, or open an issue. Update a document's `last-verified-against-source` marker only when
you have actually checked it against that commit.
+8 -2
View File
@@ -22,8 +22,14 @@ notes/*
*.sqlite-wal
*.sqlite.pre-reboot-backup
.openrig/
.claude
.agents
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/developing-openrig/
.agents/*
!.agents/skills/
.agents/skills/*
!.agents/skills/developing-openrig/
.openrig
.worktrees/
+328
View File
@@ -0,0 +1,328 @@
# OpenRig architecture
> **This map is not the territory.** It is a zoomed-out guide for finding your way around the
> code. It is incomplete by design and it may be stale. Where it disagrees with the code, the code
> is right; please fix the page. Every path, symbol, count and command below was checked against
> commit `1347d825` (`v0.6.3-33-g1347d825c`). Each count shows the command that produced it, so you
> can re-run it.
## What OpenRig is
OpenRig runs a team of coding agents on your machine as one system. A local daemon keeps the team's
state in SQLite and runs each agent as an ordinary Claude Code or Codex session (or a plain
terminal, a Pi runner, or a scripted test stub) inside tmux. People and agents drive it through the
`rig` CLI, a terminal UI (TUI) and an MCP server, all of which talk to the daemon over local HTTP.
The agents themselves are unmodified. What OpenRig adds is the coordination around them: launching
and restoring agents, delivering messages, keeping durable work in a queue, and giving each agent
the right skills and context.
## Packages
| Directory | Package | What it is | How it ships |
|---|---|---|---|
| `packages/daemon` | `@openrig/daemon` (private) | The daemon: HTTP routes, domain services, SQLite migrations, runtime adapters, built-in specs, skills, plugins, static context-pack sources | Copied into `@openrig/cli` |
| `packages/cli` | `@openrig/cli` | The `rig` command, the MCP server, daemon start and stop | The only published package |
| `packages/tui` | `@openrig/tui` (private) | The terminal UI | Copied into `@openrig/cli`; opened by `rig tui`, by bare `rig` in a terminal, or by the `openrig-tui` bin |
| `packages/ui` | `@openrig/ui` (private) | The React web UI, in **maintenance mode**: it still ships and runs, but gets no new feature work. The CLI and TUI are the supported surfaces | Built files copied into `@openrig/cli` and served by the daemon |
| `packages/test-system` | none (not an npm workspace) | The stub-agent scenario library, the CI scenario harness, live-model eval cases | Not shipped |
**What bundles what.** `packages/cli/package.json` publishes `LICENSE`, `dist`, `daemon`, `ui`,
`tui` and `scripts`. `scripts/build-package.sh` (the CLI's `prepublishOnly`) builds the daemon, UI,
TUI and CLI, then assembles them inside `packages/cli/`: the daemon's `dist`, `assets`, `specs` and
`policies`, the generated context packs and a copy of `docs/reference/` go under `daemon/`; the UI
build goes to `ui/dist`; the TUI build goes to `tui/dist`. So `docs/reference/` ships to every
user; `docs/as-built/` and this file do not.
**Sharing code between packages.** The CLI and TUI import daemon code only through the subpaths in
the `exports` field of `packages/daemon/package.json` (18 at this commit), for example
`@openrig/daemon/attention`, which points at `packages/daemon/src/attention-surface.ts`. The CLI
uses 16 of them and the TUI uses 2. In development they resolve through the npm workspace. At
package time `scripts/rewrite-daemon-imports.mjs` rewrites them to the shipped `daemon/dist` copy
and fails the build on a specifier with no `exports` entry. To share new daemon code, add a
`*-surface.ts` file and an `exports` entry.
## The request path
```
rig (CLI) TUI MCP server (rig mcp serve, stdio)
\ | /
+---- HTTP to the daemon (port 7433 by default) --------------+
|
packages/daemon/src/server.ts createApp(): context middleware, /healthz, app.route("/api/...")
|
packages/daemon/src/routes/ one Hono router per area; reads services with c.get(...)
|
packages/daemon/src/domain/ services, repositories, orchestrators, event bus
/ | \
SQLite (db/) tmux (adapters/tmux.ts) runtime adapters (adapters/*-adapter.ts)
claude-code, codex, pi, terminal, stub
```
**Inside the daemon** (`packages/daemon/src/`):
- **Startup.** `index.ts` is the process entry: it resolves where to listen, starts the server and
runs the periodic queue sweeps. It calls `createDaemon()` in `startup.ts`, which opens the
database (`db/connection.ts`: WAL mode, foreign keys on), runs `migrate(db, ALL_MIGRATIONS)`,
constructs every service and adapter, and passes them to `createAppWithWebSocket(deps)` in
`server.ts`.
- **`server.ts`.** In `createApp(deps)`, the first `app.use("*", ...)` middleware puts each
service on the request context (`c.set("<name>" as never, deps.<name>)`), serves `/healthz`, and
mounts each router with `app.route("/api/<area>", ...)`. Unknown `/api/*` paths get a JSON 404;
other GET requests serve the web UI's built files.
- **`routes/`.** Thin handlers. For example, `routes/ps.ts` reads `psProjectionService` from the
context and returns its entries as JSON. `routes/terminal-ws.ts` registers the terminal
WebSocket, and `routes/require-sender-identity.ts` is a shared helper rather than a router.
- **`domain/`.** The behaviour: repositories over SQLite (`rig-repository.ts`,
`queue-repository.ts`, `session-registry.ts`), orchestration (`startup-orchestrator.ts`,
`node-launcher.ts`, `rigspec-instantiator.ts`), message delivery (`session-transport.ts`,
`seat-delivery-guard.ts`) and events (`event-bus.ts`). Larger areas have their own
subdirectories, such as `gateway/`, `provider/`, `policies/`, `scope/`, `context-packs/` and
`mission-control/`.
- **`adapters/`.** The outside world. `tmux.ts` wraps tmux. The five `RuntimeAdapter`
implementations launch each runtime and project skills and files into it. `cmux.ts` and
`compose-services-adapter.ts` cover other integrations.
- **`db/`.** `connection.ts`, `migrate.ts`, `all-migrations.ts` and `migrations/`.
- **`middleware/auth-bearer-token.ts`.** The bearer-token check for operator write routes, plus
the loopback and Tailscale bind detection used at startup.
- **Live updates** go out as server-sent events: `routes/events.ts` uses Hono's `streamSSE`, and
the TUI subscribes to `/api/activity/events`.
- **State** lives in `$OPENRIG_HOME` (default `~/.openrig`). The database is `openrig.sqlite`
there (`daemon-db-path.ts`).
**The clients:**
- **CLI.** `createProgram()` in `packages/cli/src/index.ts` registers every top-level command;
the commands live in `packages/cli/src/commands/`. Most are thin clients: they call a daemon route
through `DaemonClient` (`packages/cli/src/client.ts`) and render the result. A few lifecycle
helpers open the database file directly. Bare `rig` in a terminal opens the TUI
(`packages/cli/src/front-door.ts`).
- **MCP.** `createMcpServer(client)` in `packages/cli/src/mcp-server.ts` registers the tools over
the same `DaemonClient`. `rig mcp serve` (`packages/cli/src/commands/mcp.ts`) runs it on stdio.
- **TUI.** `packages/tui/src/daemon-client.ts` is its only HTTP module. It reads existing daemon
projections, for example `/api/ps`, `/api/rigs/summary` and `/api/queue/list`, and the
`/api/activity/events` stream when the daemon offers it. The daemon URL comes from `--url`,
`OPENRIG_URL`, or `http://127.0.0.1:7433`. Agents can drive a running TUI through its control
socket (`packages/tui/src/socket-server.ts`).
- **Web UI.** Served by the daemon from the bundled `ui/dist`.
**In a development checkout,** `rig daemon start` prefers `packages/daemon/dist` over the bundled
copy (`resolveDaemonPath` in `packages/cli/src/daemon-lifecycle.ts`), and the TUI launcher prefers
`packages/tui/dist` (`packages/cli/src/front-door.ts`). Rebuild the package you changed. A daemon
that is already running keeps running the code it started with.
## Key counts
At commit `1347d825`. Run these from the repository root to refresh them.
| What | Count | Command |
|---|---|---|
| Database migrations | 89 (latest: `089_classification_identity_provenance.ts`) | `git ls-files packages/daemon/src/db/migrations \| wc -l` |
| Files in `routes/` | 67 | `git ls-files packages/daemon/src/routes \| wc -l` |
| ... of which create a Hono router | 65 | `git grep -l 'new Hono' -- packages/daemon/src/routes \| wc -l` |
| `app.route(...)` mounts in `server.ts` | 69 | `grep -c 'app.route(' packages/daemon/src/server.ts` |
| Top-level `rig` commands | 85 | `grep -c 'program.addCommand(' packages/cli/src/index.ts` |
| Runtime adapters | 5 (`claude-code`, `codex`, `pi`, `terminal`, `stub`) | `git grep -l 'implements RuntimeAdapter' packages/daemon/src \| wc -l` |
| MCP tools | 18 | `grep -c 'server.tool(' packages/cli/src/mcp-server.ts` |
| Daemon `exports` subpaths | 18 | `node -p 'Object.keys(require("./packages/daemon/package.json").exports).length'` |
| Library scenarios | 11 | `grep -l '^scenario:' packages/test-system/scenarios/*.yaml \| wc -l` |
## Where to add things
Each recipe names the files to touch. Read a neighbouring example before you start; the code is
the authority.
### A CLI command
1. Create `packages/cli/src/commands/<name>.ts` exporting a function that returns a commander
`Command` and takes optional injected dependencies. `packages/cli/src/commands/unarchive.ts`
is a short example.
2. In the action, get the daemon with `getDaemonStatus(...)` and `daemonStatusGuard(status)`
(`packages/cli/src/daemon-lifecycle.ts`), then call it with
`new DaemonClient(getDaemonUrl(status))` (`packages/cli/src/client.ts`). Offer `--json` for
agents, and set `process.exitCode` on failure.
3. Register it in `createProgram()` in `packages/cli/src/index.ts` with
`program.addCommand(...)`. If it takes injected dependencies, add them to `ProgramDeps` in the
same file.
4. Add `packages/cli/test/<name>.test.ts`. `packages/cli/test/archive.test.ts` shows how tests
inject mocked lifecycle dependencies.
5. If the command needs new daemon behaviour, add a route as well.
### A daemon route
1. Create `packages/daemon/src/routes/<area>.ts` with a Hono router. Both styles exist: a
constant (`export const psRoutes = new Hono()` in `routes/ps.ts`) and a factory
(`whoamiRoutes()` in `routes/whoami.ts`).
2. Read services from the request context with `c.get("<name>" as never)`. Keep the behaviour in
`packages/daemon/src/domain/`.
3. Mount it in `createApp()` in `packages/daemon/src/server.ts` with
`app.route("/api/<area>", ...)`, above the `/api/*` 404 catch-all.
4. A new service needs three more edits: a field on `AppDeps` and a `c.set(...)` line in
`server.ts`, and its construction in `createDaemon()` in `packages/daemon/src/startup.ts`.
5. Test it the way `packages/daemon/test/whoami-routes.test.ts` does: build a small Hono app,
`c.set` the service, mount the router and call `app.request(...)`. `createFullTestDb()` in
`packages/daemon/test/helpers/test-app.ts` gives you an in-memory database migrated with the
test fixture list (`migrationsForFullTestDb`).
### A database migration
1. Create `packages/daemon/src/db/migrations/<NNN>_<name>.ts` exporting a `Migration`
(`{ name: "<NNN>_<name>.sql", sql: "..." }`; the type is in `packages/daemon/src/db/migrate.ts`).
`089_classification_identity_provenance.ts` is a one-line example.
2. Take the next number after the highest on `main` (089 at this commit). Parallel pull requests
often pick the same number, so check again and renumber if `main` moved before you merge.
3. Import it in `packages/daemon/src/db/all-migrations.ts` and append it to `ALL_MIGRATIONS`.
Startup runs exactly that list (`startup-migrations-mirror.test.ts` pins this).
4. Add it to `migrationsForFullTestDb` in `packages/daemon/test/helpers/test-app.ts`, or list it in
`migrationsForFullTestDbExclusions` with a reason. `migration-fixture-parity.test.ts` fails
otherwise.
5. `migrate()` sorts migrations by name and records each applied name in `schema_migrations`, so
an install that already applied a migration never runs it again. Editing a migration that has
shipped changes nothing for those installs; add a new one instead. A long-lived install applies
every new migration on its next start, against real data.
### A runtime adapter
1. Implement `RuntimeAdapter` from `packages/daemon/src/domain/runtime-adapter.ts`: `runtime`,
`listInstalled`, `project`, `deliverStartup`, `launchHarness` and `checkReady`. The smallest
implementation is `packages/daemon/src/adapters/terminal-adapter.ts`; the scripted test double
is `stub-runtime-adapter.ts`.
2. Register it in `packages/daemon/src/startup.ts`. The adapter maps are keyed by runtime name:
`adapters:` (for the pod instantiator) and `runtimeAdapters:`. The context monitor has its own
map for the runtimes it samples.
3. Accept the runtime name in rig spec validation: `SUPPORTED_RUNTIMES` in
`packages/daemon/src/domain/rigspec-preflight.ts`. Other runtime lists, such as `RuntimeHint` in
`domain/discovery-types.ts` and `LEGACY_KNOWN_RUNTIMES` in `domain/rigspec-schema.ts`, may need
it too; search for an existing runtime name such as `"pi"` to find them.
4. Launch, projection and readiness are arteries. Read
[docs/as-built/arteries.md](docs/as-built/arteries.md) first.
### A shipped skill
Skills ship from three copies in this repository (the "edges"), listed in
`scripts/skill-edge-layout.generated.json`:
| Edge | Path | Layout |
|---|---|---|
| `spec` | `packages/daemon/specs/agents/shared/skills/` | by category (`core`, `pm`, `pods`, `process`) |
| `plugin` | `packages/daemon/assets/plugins/openrig-core/skills/` | flat |
| `canonical` | `skills/_canonical/` | public mirror of the `spec` copy |
Each skill in the layout file lists which edges carry it. At this commit, 33 skills are in `spec`
and `canonical`, 17 only in `plugin`, and 2 in all three.
- `npm run mirror-skills:check` (part of `npm test`) hashes every file in each edge and compares the
hashes with `scripts/skill-edge-digests.generated.json`. It also fails on a `SKILL.md` the layout
does not list.
- The full apply, `npm run mirror-skills`, regenerates all three edges from the maintainers' skill
source, which lives outside this repository. It needs `OPENRIG_SKILL_CANON_ROOT` and three
authority-file environment variables, so an outside contributor cannot run it.
- **To fix a skill:** edit the file in every edge that carries it (same bytes), run
`node scripts/regen-edge-digests.mjs` to refresh the hashes from disk, then run
`npm run mirror-skills:check`. Say in the pull request that you edited mirrored skill files: the
next full apply regenerates these copies from the maintainers' source, so the change has to land
there too.
- **To add a skill:** open an issue first. Its layout entry is generated from files that are not
in this repository.
- Every shipped skill also becomes a context pack at package time (next recipe).
### A context pack
- **Static packs** are hand-written: a directory under `packages/daemon/context-packs-src/<name>/`
with a `manifest.yaml` and its files. Keep `version: "0"` in the manifest; the generator stamps
the package version. A pack file may be a symlink to a file under `docs/reference/`, so a
reference document has one source (for example `help/help.md` points to
`docs/reference/help.md`). Any other symlink fails the build. There are 5 static packs at this
commit.
- **Generated packs** go to `packages/daemon/context-packs/`, which is gitignored and never edited
by hand. `scripts/generate-context-packs.mjs` builds it at package time from every shipped skill
and every static pack.
- `npm run generate-context-packs:check` (part of `npm test`) validates every pack through the
daemon's own manifest parser, so build the daemon first. It also runs the leak scan over the
static sources, including the `docs/reference/` files they link to.
- The daemon serves the shipped packs through `rig context get`; `startup.ts` registers the bundled
directory as its built-in root.
### A scenario
Scenarios run the real CLI, daemon, tmux and SQLite with scripted stub agents (`runtime: stub`)
and no model cost.
1. Add `packages/test-system/scenarios/<defect-class>.yaml`, named for the class of bug it
catches. Topology fixtures sit beside it (`*-stub.yaml`).
2. The format is checked by `validateScenario` in
`packages/daemon/test/helpers/scenario-schema.ts`. Step verbs: `up`, `down`, `send`, `restart`,
`restore`, `emit`, `mutate`, `policy`, `seed_regression`, `daemon`, plus `expect`. `expect` may
only read shipped surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`,
`tui_socket`, `policy_provenance`.
3. Today the runner binds `up`, `down`, `send`, `restart` and `daemon`. `restore`, `emit`,
`mutate` and `policy` fail with `UnboundActionError`, and `seed_regression` needs a fault
controller (`packages/daemon/test/helpers/scenario-real-deps.ts`).
4. Write a header comment naming the defect class and the seed: what a seeded regression plants,
and which `expect` must catch it. A scenario counts only as a pair: it passes on healthy code
and fails on the seeded run (`packages/test-system/README.md`).
5. **Run it on your machine** after `npm run build`:
`node --import tsx packages/daemon/scripts/run-scenarios.mjs <file.yaml>`. It uses a private
daemon and a private tmux server, and builds its environment from `HOME`, `PATH` and `TERM`
only, so your session's `TMUX` and daemon variables are dropped and it is safe to run from
inside tmux, including an agent's pane. It supplies no fault controller, so a
`seed_regression` step fails there; the seeded pair runs in the container path.
6. **CI.** The `installed-scenario` job runs `scripts/run-pr-scenarios.sh` in disposable,
network-less containers built from the packed package. It runs two cases, both built around
queue durability (`library` is `queue-baton-survives-restart.yaml`). Adding a case today means
changing three files: the `--case` list in `scripts/run-pr-scenarios.sh`, `CASES` in
`packages/test-system/ci/result.mjs`, and the fault controller in
`packages/test-system/ci/run.mjs`. [docs/as-built/test-layers.md](docs/as-built/test-layers.md)
covers both run modes, running a case on your own Docker host, and what a stub can and cannot
prove.
## Guards you will meet
`npm test` runs `test:repo` (builds the daemon, runs `node --test scripts/*.test.mjs`, the docs
guard, `mirror-skills --check` and `generate-context-packs --check`) and then the daemon, CLI and
TUI suites. `npm run test:ui` runs the web UI suite separately. `npm run lint` typechecks all four
packages.
- **Docs guard** (`scripts/check-docs-guard.mjs`). Under `docs/`, git may track only
`docs/as-built/`, `docs/reference/`, `docs/releases/` and exactly `docs/DESIGN.md`;
`.gitignore` ignores the rest of `docs/`. Remember that `docs/reference/` ships inside the npm
package, and the daemon copies its Markdown files to `$OPENRIG_HOME/reference/` when it
starts. Files in `docs/as-built/` carry a `last-verified-against-source` commit in their
frontmatter.
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`, with rules in
`scripts/internal-tokens.generated.json`). It refuses maintainer-internal names and paths in
content that ships. It runs in `generate-context-packs.mjs` (static pack sources, on every
`npm test`), in the full skill mirror apply (each public skill), in `scripts/build-package.sh`
(the daemon `specs/` tree before it is copied into the package), and in the release-time
substance gate (`scripts/check-substance-gate.mjs`, `npm run gate:substance`) over the packed
CLI. No workflow runs it over a pull request's whole diff.
- **Portability report** (`.github/workflows/portability-report.yml`, running
`scripts/portability-report.mjs`). On each pull request it lists added lines containing
machine-specific values: credentials, home and temporary paths, network addresses, email
addresses. It never fails the job; you read it and decide.
- **Generated files.** Do not hand-edit `scripts/*.generated.json` or anything under
`packages/daemon/context-packs/`. The skill-edge digests are the one generated file a contributor
refreshes, with `node scripts/regen-edge-digests.mjs` (see "A shipped skill").
- **Hosted CI** (`.github/workflows/tests.yml`): `build-and-package`, `typecheck`, `repo-checks`,
`package-tests` (macOS; daemon, CLI, TUI and UI suites with no credentials and no external
network) and `installed-scenario`.
## Pointers
- [CONTRIBUTING.md](CONTRIBUTING.md): setup, what a pull request needs, what to expect from review.
- [docs/reference/developing.md](docs/reference/developing.md): which checks block and which are
advisory. It predates the hosted Tests workflow; `.github/workflows/tests.yml` is current.
- [docs/reference/worktree-builds.md](docs/reference/worktree-builds.md): building in a git
worktree, with its own `npm install` rather than a symlinked `node_modules`.
- [docs/reference/](docs/reference/): user and operator reference; it ships with the package.
- [docs/as-built/](docs/as-built/README.md): module-by-module descriptions of the system. **These
are being re-verified.** Most modules were last verified against `7eaf524c` (2026-05-16, around
v0.3.1); the two pages this change adds, `arteries.md` and `test-layers.md`, are verified against
`1347d825`. The older modules' counts are historical: `architecture/daemon-core.md`
describes 40 migrations, and there are 89 today. Use them for orientation, then check the code.
- [docs/as-built/test-layers.md](docs/as-built/test-layers.md): what each test layer covers and
what it cannot catch.
- [docs/as-built/arteries.md](docs/as-built/arteries.md): the areas where a small change has a
large effect, what depends on them, and what broke there before.
- [packages/test-system/ci/README.md](packages/test-system/ci/README.md): the CI scenario job.
- [docs/releases/](docs/releases/) and [CHANGELOG.md](CHANGELOG.md): release history, written by
the maintainers at release time.
+14 -4
View File
@@ -52,10 +52,13 @@ npm run lint # typecheck every package
```
`npm test` builds the daemon and runs repository checks before the package suites. Read the
specific failure: `npm run mirror-skills` updates skill mirrors, and
`npm run generate-context-packs` updates generated packs. Use them when their source changed and
review the generated diff; neither command fixes every documentation failure. The UI unit-test
suite is advisory and separate: `npm run test:ui`.
specific failure. A shipped skill may exist in more than one copy (see ARCHITECTURE.md): edit every copy, then run
`node scripts/regen-edge-digests.mjs` (the full `npm run mirror-skills` apply needs maintainer-only
inputs; see "A shipped skill" in [ARCHITECTURE.md](ARCHITECTURE.md#a-shipped-skill)).
`npm run generate-context-packs` updates generated packs. Review the generated diff; neither
fixes every documentation failure. The UI unit-test suite is advisory and separate:
`npm run test:ui`. [docs/as-built/test-layers.md](docs/as-built/test-layers.md) lists every test
layer, what CI runs, and what each layer can and cannot prove.
For hands-on development, read the [check requirements](docs/reference/developing.md),
[worktree setup](docs/reference/worktree-builds.md), and
@@ -103,6 +106,13 @@ clarifying question or run the reproduction; a maintainer makes the merge decisi
## Where things live
- **Start here:** [ARCHITECTURE.md](ARCHITECTURE.md) maps the packages, the request path, and where to
add a command, route, migration, adapter, skill, context pack or scenario.
[docs/as-built/arteries.md](docs/as-built/arteries.md) lists the areas where a small change has a
large effect; if your change touches one, read it first.
- **Using a coding agent?** Claude Code and Codex load the repository's `developing-openrig` skill
when they work in this checkout (`.claude/skills/` and `.agents/skills/`). It points at the maps
above and at what to run before you push.
- Repository reference: `docs/reference/`; user documentation: [openrig.dev/docs](https://openrig.dev/docs).
- Skills: `packages/daemon/specs/agents/shared/skills/` and plugin skills under
`packages/daemon/assets/plugins/`; static context-pack sources: `packages/daemon/context-packs-src/`.
+11 -7
View File
@@ -4,7 +4,9 @@
A harness wraps a model. A rig wraps your harnesses. Define your agent team in YAML, boot it with one command. Claude Code and Codex in the same rig, managed as one system.
OpenRig turns AI coding agents from a pile of terminal sessions into a persistent, organized team. Talk to a lead agent about the outcome you want; it can coordinate specialists across teams and bring you results and decisions that need your attention. Start with a repository and one useful change, then keep the team's work and context at the same addresses.
OpenRig is open-source software for building and running your own network of agents. It turns AI coding agents from a pile of terminal sessions into a persistent, organized team. Talk to a lead agent about the outcome you want; it can coordinate specialists across teams and bring you results and decisions that need your attention. Start with a repository and one useful change, then keep the team's work and context at the same addresses.
It's the open-source system behind my AI civilization experiments.
**Guide:** [Getting started](docs/reference/getting-started.md) · **Stuck?** [Help](docs/reference/help.md) · **Questions:** [Q&A](https://github.com/mvschwarz/openrig/discussions/92) · **Updates and demos:** [@_feralmachine on X](https://x.com/_feralmachine)
@@ -90,7 +92,7 @@ using a published package, since repository guidance can be ahead of npm.
| When | What changes and why |
| --- | --- |
| **npm installation** | Installs the CLI, bundled components and dependencies under your npm prefix (with Bun, under Bun's global directory). OpenRig's postinstall checks the Node.js version and that the SQLite module loads; Bun may block this script. It does not run daemon or provider setup. |
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. On macOS it can install cmux and enable its automation socket control in `~/.config/cmux/settings.json`. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. On macOS it can install cmux and enable its automation socket control in `~/.config/cmux/settings.json`; cmux defaults `automation.socketControlMode` to `cmuxOnly`, which only accepts processes started inside cmux and so blocks OpenRig's control, so setup switches it to `automation`. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
| **Daemon startup** | Creates/updates instance state under `OPENRIG_HOME` (normally `~/.openrig`), including its database and managed plugin resources. Seeds the `openrig-skills` discovery skill in `~/.claude/skills` and `~/.agents/skills`, subject to existing version ownership. With `runtime.codex.hooks_enabled` enabled (the default), writes Codex hook configuration and trust records as described below—even before a rig launches. |
| **Rig/seat launch and attachment** | Creates tmux sessions, supplies seat identity and daemon connection environment, and projects selected guidance, skills, plugins and runtime resources into the workspace. Managed startup pre-trusts the workspace. Claude context collection can also be provisioned for attached sessions and refreshed during monitoring. |
| **Explicit permission configuration** | The built-in bootstrap does **not** add `rig` command allow rules. Agent-guided setup recommends Yes and requires your actual answer before the agent [adds rules at your chosen scope](docs/reference/getting-started.md#have-your-agent-configure-permissions). No/no answer preserves settings; existing choices and stricter rules remain relevant. Broader access is separate. |
@@ -98,16 +100,17 @@ using a published package, since repository guidance can be ahead of npm.
The provider files are separate from instance state. Here `~` means the daemon
user's home; changing `OPENRIG_HOME` alone does not isolate provider configuration.
- **Claude Code:** managed startup writes workspace trust and onboarding completion
to `~/.claude.json`. In the workspace, `.claude/settings.local.json` receives
- **Claude Code:** startup writes workspace trust and onboarding completion.
With an explicit permission mode, it uses the launch-selected `HOME/.claude.json`,
or `<CLAUDE_CONFIG_DIR>/.claude.json` when that variable is set. Classic startup
retains the daemon's `~/.claude.json` path. In the workspace, `.claude/settings.local.json` receives
the context collector's `statusLine` command and selected activity hooks;
helper scripts live under `.openrig/`. Selected settings/MCP resources can also
change that settings file and `.mcp.json`. The shared settings resource sets
`permissions.defaultMode` to `acceptEdits` and enables Exa/Context7 MCP entries;
selected MCP resources configure those external services. Built-in bootstrap
no longer writes a command allowlist to `~/.claude/settings.json` or removes
older allowances. The trust writer uses the daemon home, so a custom
`CLAUDE_CONFIG_DIR` is not a general relocation of these writes.
older allowances. `CLAUDE_CONFIG_DIR` does not relocate the project-local writes.
- **Codex:** writes the daemon's `CODEX_HOME/config.toml` (normally
`~/.codex/config.toml`). Startup enables hooks, adds the OpenRig activity relay
commands and pre-writes trust hashes for those commands. Seat startup adds
@@ -221,7 +224,7 @@ Hono HTTP daemon
- **CLI**: Commands for both humans and agents to launch teams, inspect state, send messages, track owned work, and manage context.
- **TUI**: Topology explorer, table and graph views, seat details, Specs, Projects, Terminals, Feed, and System. Navigate with the keyboard, mouse, or command bar.
- **MCP**: Tools so agents can manage their own topology (`rig_up`, `rig_ps`, `rig_send`, `rig_chatroom_send`, etc.)
- **Runtimes**: Native Claude Code and Codex sessions, terminal nodes, and a Pi adapter using an RPC runner inside a terminal pane.
- **Runtimes**: Native Claude Code and Codex sessions, terminal nodes, and Pi and Oh My Pi via RPC runners.
## Terminal UI and Workspaces
@@ -329,6 +332,7 @@ The helper never removes the legacy telemetry or library. Retirement follows
separate stable runtime, writer, reader, and recovery proof. Daemon, database,
seat, plugin, and release lifecycle actions remain agent-owned.
## Requirements
- Node.js 22 or 24 (the supported versions in this release). Node 20 is no
+49
View File
@@ -0,0 +1,49 @@
# Roadmap
These are things we'd welcome help with. Most came from the community, and most are small enough for one pull request.
This isn't a schedule or a promise: we release often, and an item ships when a good change for it lands.
## How to help
1. Pick an item and comment on its issue to say you're working on it. If it has no issue yet, open one first.
2. Read `CONTRIBUTING.md`, and ask your agent to use the `developing-openrig` skill. It's a map of how OpenRig is
built, tested and changed.
3. Open a pull request that says what a user gets and how you verified it.
If more than one pull request arrives for the same item, we take the best one, and the first equivalent one breaks a
tie. Feel free to coordinate on the issue first.
## Testing
- **Behavioural scenarios for more command families.** `docs/as-built/test-layers.md` has a "Help wanted" list of
`rig` command families that have no scenario yet, each with a suggested first check. One family per pull request is
ideal.
## Agents and runtimes
- **Codex seats that can reach the local daemon by default** (#275), with an easy opt-out.
- **Custom Codex providers such as Bedrock** (#194): accept providers that authenticate with an environment key, and
forward their token.
- **Operator recovery for Pi and Oh My Pi seats** (#41).
- **New agent types:** OpenCode (#87, #227, #517), the Cursor CLI (#352), the Grok CLI (#522) and Devin (#44) are
planned on a shared adapter.
Design discussion: #43. Adapter pull requests are welcome once that adapter lands.
## Slack
- **A DM or private channel for single-user setups** (#313).
- **A channel map** (#192): one channel per seat, keyed by channel ID.
## Reliability and everyday use
- **A configurable readiness window for fresh launches** (#182), so seats under load don't time out at a fixed 30
seconds.
- **Draft-aware delivery** (#48): automated sends wait while someone is typing in a seat, or a seat can be set to
inbox-only.
- **A host-aware resource budget for seats** (#80).
- **Predictable `rig up` over a stopped rig with the same name.**
- **A "merged" stage in the project view** for teams that integrate on a branch other than main (#402).
- **Codex seats that leave a repository's tracked `AGENTS.md` alone** (related: #64).
- **Install and run on Node 26.**
Bugs aren't listed here. Please file them as issues; anything labelled `good first issue` is a good place to start.
+1 -1
View File
@@ -2,7 +2,7 @@
"url": "https://context7.com/mvschwarz/openrig",
"public_key": "pk_HcRwWEoFBsmU1mo5VIvWw",
"projectTitle": "OpenRig",
"description": "OpenRig is an open-source multi-agent harness: define a team of Claude Code and Codex agents in YAML, boot it with the rig CLI, and coordinate the agents through tmux sessions, a durable queue and a local daemon.",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"folders": [],
"excludeFolders": [
"docs/releases",
+1
View File
@@ -4,4 +4,5 @@ ARG TESTBED_IMAGE
FROM ${TESTBED_IMAGE}
COPY runner.mjs /opt/openrig-testbed/runner.mjs
COPY scenarios/ /opt/openrig-testbed/scenarios/
COPY library/ /opt/openrig-testbed/library/
CMD ["node", "/opt/openrig-testbed/runner.mjs", "healthy"]
+78 -74
View File
@@ -1,101 +1,105 @@
---
kind: as-built
title: As-Built Docs — Map of Territory + Module Index
title: As-Built Docs — Module Index
status: active
topics: [knowledge-and-context, observability]
domains: [engineering-advisor, operating-advisor, product-advisor]
applies-when: |
Starting any technical task that needs the shipped OpenRig system as it
actually is. Read this first to learn what the as-built tree contains and
which module to open; then go to codemap.md for use-case navigation or
straight to the named module.
siblings: [codemap.md, cli-reference.md, frontmatter-schema.md]
Starting a technical task and choosing the as-built module or source entry
point to read. This index describes the document tree; each module carries
its own source-verification stamp.
siblings: [codemap.md, arteries.md, test-layers.md, cli-reference.md, frontmatter-schema.md]
prerequisite-reads: []
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# OpenRig As-Built Docs
# OpenRig as-built docs
OpenRig is a local control plane for multi-agent coding topologies — a
multi-agent harness that manages your Claude Code and Codex sessions as a
single system, with a daemon (`@openrig/daemon`), a CLI (`@openrig/cli`), a UI
(`@openrig/ui`), and an MCP server all sitting on one SQLite-backed core. This
tree is the **source-verified** description of that system as it actually
ships — every load-bearing claim is grounded to `packages/*/src` at a named
commit, not to memory, chat, or older docs.
These pages describe the code and point to its owning files. This index was checked against
main commit `254122872cf477511514979a4300b695d77cd1f7`. A linked module's
`last-verified-against-source` is its own verification boundary; updating this index does not
revalidate that module or identify a running daemon or published package.
> Verified against source at HEAD `7eaf524c` (`git describe` →
> `v0.3.1-6-g7eaf524c`). Package version is **0.3.1** across all three
> packages; HEAD carries 6 commits of unreleased 0.3.2 work, no `v0.3.2` tag.
For the repository overview and contributor recipes, start with [ARCHITECTURE.md](../../ARCHITECTURE.md).
Use [codemap.md](codemap.md) to locate source, [arteries.md](arteries.md) to identify dependent
paths before a change, and [test-layers.md](test-layers.md) to choose checks.
## How this tree is organized
## Repository surfaces
The as-built corpus was modularized (slice 08, `context-architecture-v1`) from
two monolithic files into a folder of thematic modules, each independently
loadable, each frontmatter-tagged for retrieval, each ≤300 lines (author-mode
modules with no prior prose may run to ≤400 — see the slice-08 ACK).
The npm workspaces are `packages/daemon`, `packages/cli`, `packages/tui`, and `packages/ui`
([root package manifest](../../package.json)). The daemon provides the SQLite-backed HTTP
services; CLI, TUI and MCP clients consume them. Runtime adapters include Claude Code, Codex,
Pi, OMP, terminal and stub implementations under `packages/daemon/src/adapters/`.
```
docs/as-built/
├── README.md ← you are here: map-of-territory + index
├── codemap.md ← navigation index (use-case lookup, source-root pointers)
├── frontmatter-schema.md← the frontmatter convention these docs follow
├── cli-reference.md ← full rig CLI surface (kept whole)
├── architecture/ ← 14 backend/runtime modules
└── ui/ ← 4 operator-surface modules
```
`docs/DESIGN.md` (the canonical visual / brand / design-system spec) **stays
at the repo `docs/` root** by design — it is referenced by many existing
`docs/DESIGN.md` paths and carries no source drift. This tree points at it
(see `ui/library-specs-and-design-system.md`); it is not copied here.
The CLI package contains the assembled daemon, TUI and web UI. User reference documents ship
from `docs/reference/`; this as-built tree is developer documentation. The assembly is in
[scripts/build-package.sh](../../scripts/build-package.sh), with the published file list in
[packages/cli/package.json](../../packages/cli/package.json).
## Module index
### `architecture/` — backend, daemon, runtime
### Daemon and runtime
| Module | What it covers |
| Module | Read for |
|---|---|
| [daemon-core.md](architecture/daemon-core.md) | How the daemon boots, `createDaemon` wiring, the SQLite schema/40-migration set, the route-mount surface. |
| [adapters-and-runtimes.md](architecture/adapters-and-runtimes.md) | The five-method RuntimeAdapter contract, the Claude/Codex/Terminal adapters (launch/resume/fork in tmux), and the resume-honesty layer (honest resumed-vs-fresh assessment). |
| [coordination-primitive.md](architecture/coordination-primitive.md) | PL-004 Phase A stream/queue/inbox/outbox; the hot-potato closure contract; where queue closure is enforced. |
| [workflow-runtime.md](architecture/workflow-runtime.md) | PL-004 Phase D Workflow Runtime — spec cache, instance state, step trails, transactional-scribe projection, watchdog policies. |
| [mission-control.md](architecture/mission-control.md) | PL-005 queue-observability surface — seven views, seven write verbs, action audit, bearer-token middleware. |
| [agent-spec-and-startup.md](architecture/agent-spec-and-startup.md) | AgentSpec/RigSpec types, profile resolution, additive startup layering, StartupOrchestrator, whoami/materialize/bind/adopt identity. |
| [lifecycle-snapshot-restore.md](architecture/lifecycle-snapshot-restore.md) | Snapshot capture, honest restore (resume vs rebuild vs fresh), restore-honesty enforcement, restore-check / restore-packet probes. |
| [transport-and-transcripts.md](architecture/transport-and-transcripts.md) | rig send/capture/broadcast over tmux, pipe-pane transcript capture + search, durable SQLite chat, `rig ask`, MCP-name vs tmux-key distinction. |
| [workspace-primitive.md](architecture/workspace-primitive.md) | The PL-007 typed workspace declaration (root/repos/defaultRepo/knowledgeRoot), migrations 038/039, per-item repo-scope gating, file-backed missions/slices indexing. |
| [content-surfaces.md](architecture/content-surfaces.md) | The operator-allowlisted file browser, atomic conflict-checked writes + JSONL edit audit, PROGRESS.md tree indexer, the one-screen Steering composer. |
| [living-notes-review.md](architecture/living-notes-review.md) | **v0.4.4** — the Living Notes review surface: the ONE `ComposedSliceReview` (intent→plan→delivered) projection, pure composer + gatherer, staged-approval locks, C1-bound `verified`, `/api/review/*` routes, freeze export, ranged media serving, and the SDLC on-disk convention it projects. |
| [plugin-agent-image-context-pack.md](architecture/plugin-agent-image-context-pack.md) | The filesystem-canonical content layer — plugin discovery, agent images, context packs, the Claude auto-compaction policy enforcer. |
| [packaging-bootstrap-bundles.md](architecture/packaging-bootstrap-bundles.md) | Bundle assembly (schema-v2 pod bundles + legacy v1), bundle create/inspect/install + `/api/up`, the staged BootstrapOrchestrator, legacy install seams. |
| [architecture-rules-and-event-system.md](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules, the RigEvent union + SSE delivery, intentional compatibility limits. |
| [daemon-core.md](architecture/daemon-core.md) | Process startup, service wiring, database migrations and HTTP mounts. |
| [adapters-and-runtimes.md](architecture/adapters-and-runtimes.md) | Runtime adapter methods, harness launch/resume and native-session checks. |
| [agent-spec-and-startup.md](architecture/agent-spec-and-startup.md) | Spec resolution, resource projection, startup delivery and identity. |
| [coordination-primitive.md](architecture/coordination-primitive.md) | Stream, queue, inbox/outbox, handoff and closure. |
| [workflow-runtime.md](architecture/workflow-runtime.md) | Workflow specifications, instances, projection, trails and watchdog policies. |
| [mission-control.md](architecture/mission-control.md) | Queue observations, actions and their audit records. |
| [lifecycle-snapshot-restore.md](architecture/lifecycle-snapshot-restore.md) | Snapshot capture, restore decisions and continuity checks. |
| [transport-and-transcripts.md](architecture/transport-and-transcripts.md) | Session messaging, capture, transcripts, chat and ask. |
| [workspace-primitive.md](architecture/workspace-primitive.md) | Workspace and repository declarations, project resolution and scope. |
| [content-surfaces.md](architecture/content-surfaces.md) | File reads/writes, path checks, progress indexing and steering composition. |
| [living-notes-review.md](architecture/living-notes-review.md) | Review gathering/composition, proof records, approvals, export and media. |
| [plugin-agent-image-context-pack.md](architecture/plugin-agent-image-context-pack.md) | Plugin discovery, agent images, context packs and compaction policy. |
| [packaging-bootstrap-bundles.md](architecture/packaging-bootstrap-bundles.md) | Package assembly, reusable topology bundles and bootstrap/install paths. |
| [architecture-rules-and-event-system.md](architecture/architecture-rules-and-event-system.md) | Cross-cutting invariants, events and compatibility notes. |
### `ui/` — operator surfaces
### Navigation, checks and reference
| Module | What it covers |
| Document | Read for |
|---|---|
| [shell-and-routing.md](ui/shell-and-routing.md) | The UI shell (rail / Explorer / center workspace / drawer / preview stack), the actual route tree the shipped UI mounts, the shared detail drawer + event consumption. |
| [topology.md](ui/topology.md) | The topology surface — host hybrid graph, table/terminal views, activity-ring / hot-potato visual language, terminal-preview popovers, navigation/overlay contracts. |
| [project-and-for-you.md](ui/project-and-for-you.md) | The operator destination surfaces — the For-You attention feed (5-card classifier + verb actions), the Project workspace/mission/slice scope pages, the Dashboard landing on the vellum brand system. |
| [library-specs-and-design-system.md](ui/library-specs-and-design-system.md) | The Library (`/specs`) UI — specs/skills/plugins/agent-images surfaces, the spec-review + spec-library + live-identity flows, the design-system pointer. |
| [codemap.md](codemap.md) | Question-to-module navigation and source entry points. |
| [arteries.md](arteries.md) | Code paths where changes affect several subsystems. |
| [test-layers.md](test-layers.md) | Test scope, isolation, scenarios and hosted CI. |
| [cli-reference.md](cli-reference.md) | CLI commands and options; confirm changing details in `packages/cli/src/index.ts` and command help. |
| [frontmatter-schema.md](frontmatter-schema.md) | Metadata fields used by these documents. |
| [architecture.md](architecture.md) | Compatibility pointer from the former monolithic architecture page. |
| [DESIGN.md](../DESIGN.md) | Visual and design-system specification. |
### Root docs
### Web UI reference
| Doc | What it covers |
The following pages remain discoverable at their own historical stamps. Their contents are not
reverified by this index update.
| Document | Topic |
|---|---|
| [codemap.md](codemap.md) | Navigation index — module map, structural relationship diagram, fast-lookup-by-use-case, source-root pointer table. Start here when you know *what* you need but not *which module*. |
| [cli-reference.md](cli-reference.md) | The full `rig` CLI surface — command groups, subcommands, flags, JSON output, cross-host, coordination primitives. Kept as one doc (slice-08 Q4). |
| [frontmatter-schema.md](frontmatter-schema.md) | Which frontmatter convention governs every doc in this tree, plus the one as-built-unique field (`last-verified-against-source`). |
| `../DESIGN.md` | The canonical visual / brand / design-system spec (lives at `docs/` root by design; pointer only — not duplicated here). |
| [ui.md](ui.md) | Compatibility pointer to the UI modules. |
| [shell-and-routing.md](ui/shell-and-routing.md) | Web UI shell and routing. |
| [topology.md](ui/topology.md) | Web topology views. |
| [project-and-for-you.md](ui/project-and-for-you.md) | Project and attention surfaces. |
| [library-specs-and-design-system.md](ui/library-specs-and-design-system.md) | Library/spec views and design-system pointers. |
## Source-grounding contract
For the terminal UI, start at `packages/tui/src/main.ts`, its HTTP client
`packages/tui/src/daemon-client.ts`, and the TUI sections of [test-layers.md](test-layers.md).
Every module declares `last-verified-against-source: <commit-sha>` — the
commit its claims were checked against. Load-bearing corrections are recorded
inline with an auditable annotation (`> Drift-fix Dx — said X; corrected to Y;
slice-00 §z; re-confirmed <file:line> @HEAD`). OPEN items (counts that are
definitional or runtime-only) are carried verbatim, never smoothed. The
schema is defined in [frontmatter-schema.md](frontmatter-schema.md) and the
governing convention is `openrig-work/conventions/frontmatter-for-context/`.
## Derive the document inventory
At the named commit there are **26 Markdown files**: **14** under `architecture/`, **4** under
`ui/`, and **8** at the root of this tree (including both compatibility pointers). Reproduce
these counts from the repository root:
```bash
source_commit=254122872cf477511514979a4300b695d77cd1f7
git ls-tree -r --name-only "$source_commit" -- docs/as-built | grep -E '\.md$' | wc -l
git ls-tree -r --name-only "$source_commit" -- docs/as-built/architecture | grep -E '\.md$' | wc -l
git ls-tree -r --name-only "$source_commit" -- docs/as-built/ui | grep -E '\.md$' | wc -l
git ls-tree -r --name-only "$source_commit" -- docs/as-built | grep -E '^docs/as-built/[^/]+\.md$' | wc -l
```
Read a module's source stamp and prerequisite pointers before relying on it. Source code at the
named commit is the authority when prose disagrees; [frontmatter-schema.md](frontmatter-schema.md)
describes how the stamp is represented.
+33 -42
View File
@@ -1,57 +1,48 @@
---
kind: as-built
title: architecture.md — Reorganized into the Modular As-Built Tree (Redirect Stub)
title: Architecture — Module Navigation
status: superseded
topics: [knowledge-and-context]
domains: [engineering-advisor, operating-advisor, product-advisor]
applies-when: |
You followed an old reference to docs/as-built/architecture.md. The monolith
was reorganized (slice-08, context-architecture-v1) into a folder of thematic
modules. Go to README.md (map of territory) or codemap.md (use-case lookup),
then the named module.
siblings: [README.md, codemap.md, ui.md]
Following an older link to docs/as-built/architecture.md. Use the repository
overview or the current as-built module index linked here.
siblings: [README.md, codemap.md, arteries.md, test-layers.md, ui.md]
prerequisite-reads: []
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# architecture.md was reorganized into the modular as-built tree
# Architecture module navigation
This single-file monolith was split into a folder of independently-loadable,
frontmatter-tagged thematic modules (slice-08, `context-architecture-v1`). The
canonical as-built description of the shipped daemon/runtime now lives in
`architecture/`. This stub is a forwarding pointer so old references still
resolve.
This compatibility page points to the modular documentation. Its links were checked against main
`254122872cf477511514979a4300b695d77cd1f7`; the linked pages retain their own source stamps.
**Start here:**
- [Repository architecture](../../ARCHITECTURE.md): package boundaries, request flow, derived
source counts and contributor recipes.
- [As-built index](README.md): the complete module inventory, including the existing web UI references.
- [Codemap](codemap.md): questions mapped to modules and source entry points.
- [Arteries](arteries.md): dependencies to inspect before changing shared runtime paths.
- [Test layers](test-layers.md): the checks available and what they establish.
- [`./README.md`](./README.md) — the map of territory and full module index.
- [`./codemap.md`](./codemap.md) — navigation index: use-case lookup table and
source-root pointers. Use this when you know *what* you need but not *which*
module.
## Daemon and runtime starting points
## Where the major content went
The architecture content is now the **14 modules under `architecture/`**
(13 from the slice-08 reorg + `living-notes-review.md`, added v0.4.4):
| Module | What moved here |
| Topic | Module |
|---|---|
| [`architecture/daemon-core.md`](architecture/daemon-core.md) | Daemon boot, `createDaemon` wiring, the SQLite schema/migration set, the route-mount surface, system overview, package boundaries. |
| [`architecture/adapters-and-runtimes.md`](architecture/adapters-and-runtimes.md) | The five-method RuntimeAdapter contract; Claude/Codex/Terminal adapters; the **Resume honesty** layer (§ Resume honesty). |
| [`architecture/coordination-primitive.md`](architecture/coordination-primitive.md) | PL-004 Phase A stream/queue/inbox/outbox; the hot-potato closure contract; durable handoff. |
| [`architecture/workflow-runtime.md`](architecture/workflow-runtime.md) | PL-004 Phase D Workflow Runtime — spec cache, instance state, step trails, transactional-scribe, watchdog policies. |
| [`architecture/mission-control.md`](architecture/mission-control.md) | PL-005 Mission Control / Queue Observability — the `/api/mission-control/*` surface, seven views, seven write verbs, action audit, bearer-token middleware. |
| [`architecture/agent-spec-and-startup.md`](architecture/agent-spec-and-startup.md) | AgentSpec/RigSpec types, profile resolution, additive startup layering, StartupOrchestrator, whoami/materialize/bind/adopt. |
| [`architecture/lifecycle-snapshot-restore.md`](architecture/lifecycle-snapshot-restore.md) | Snapshot capture, honest restore (resume vs rebuild vs fresh), the verbatim restore-honesty rules, restore-check / restore-packet probes. |
| [`architecture/transport-and-transcripts.md`](architecture/transport-and-transcripts.md) | rig send/capture/broadcast over tmux, transcript capture + search, durable SQLite chat, `rig ask`, MCP-name vs tmux-key. |
| [`architecture/workspace-primitive.md`](architecture/workspace-primitive.md) | The PL-007 typed workspace declaration, migrations 038/039, per-item repo-scope gating, file-backed missions/slices indexing. |
| [`architecture/content-surfaces.md`](architecture/content-surfaces.md) | The operator-allowlisted file browser, atomic conflict-checked writes + edit audit, the PROGRESS.md tree indexer, the Steering composer. |
| [`architecture/plugin-agent-image-context-pack.md`](architecture/plugin-agent-image-context-pack.md) | Plugin discovery, agent images, context packs, the Claude auto-compaction enforcer. |
| [`architecture/packaging-bootstrap-bundles.md`](architecture/packaging-bootstrap-bundles.md) | Bundle assembly (schema-v2 + legacy v1), bundle create/inspect/install + `/api/up`, the staged BootstrapOrchestrator, legacy install seams. |
| [`architecture/architecture-rules-and-event-system.md`](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules (incl. **rule 15**, restore honesty), the RigEvent union + SSE delivery, intentional compatibility limits. |
| [`architecture/living-notes-review.md`](architecture/living-notes-review.md) | **Added v0.4.4** (not part of the original monolith): the Living Notes review surface — the one intent→plan→delivered projection, staged-approval locks, proof artifacts, freeze export, ranged media serving. |
| Boot, HTTP services and database wiring | [Daemon core](architecture/daemon-core.md) |
| Harness adapters and native session continuity | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| Spec resolution, projection and launch | [Agent spec and startup](architecture/agent-spec-and-startup.md) |
| Durable queue and messaging state | [Coordination](architecture/coordination-primitive.md) |
| Workflow execution and watchdog policies | [Workflow runtime](architecture/workflow-runtime.md) |
| Queue observations and operator actions | [Mission control](architecture/mission-control.md) |
| Snapshot and restore | [Lifecycle](architecture/lifecycle-snapshot-restore.md) |
| Messaging, capture and transcripts | [Transport and transcripts](architecture/transport-and-transcripts.md) |
| Project and repository scope | [Workspace](architecture/workspace-primitive.md) |
| Files, progress and steering | [Content surfaces](architecture/content-surfaces.md) |
| Review, proof and approvals | [Living notes and review](architecture/living-notes-review.md) |
| Plugin discovery, images and context | [Plugins, agent images and context packs](architecture/plugin-agent-image-context-pack.md) |
| Distribution, bundles and bootstrap | [Packaging and bundles](architecture/packaging-bootstrap-bundles.md) |
| Events and cross-cutting invariants | [Architecture rules and events](architecture/architecture-rules-and-event-system.md) |
The UI half of the old `### UI architecture` section is now the **4 modules
under `ui/`** (see [`ui.md`](ui.md), also a redirect stub, and the `ui/` index
in [`./README.md`](./README.md)).
Older web UI links continue through [ui.md](ui.md). For terminal UI source, use the
[package entry points in the codemap](codemap.md#package-entry-points).
@@ -1,228 +1,265 @@
---
kind: as-built
title: Adapters and Runtimes — Claude/Codex/Terminal, tmux/cmux, Resume Honesty
title: Adapters and Runtimes — Claude/Codex/Pi/Stub/Terminal, tmux/cmux, Resume Honesty
status: active
topics: [agent-runtime, runtime-control]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Need the runtime-adapter contract — how OpenRig launches and resumes a
Claude Code, Codex, or terminal harness inside tmux, what the five adapter
methods do, or how the daemon honestly assesses whether a harness actually
resumed vs fresh-launched (the resume-honesty layer).
Claude Code, Codex, Pi, Oh My Pi, stub or terminal harness inside tmux, what
the five required adapter methods do, or how the daemon honestly assesses
whether a harness actually resumed vs fresh-launched (the resume-honesty
layer).
siblings: [daemon-core.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
last-updated: 2026-10-03
---
# Adapters and Runtimes
How OpenRig drives the agent harnesses. The daemon never talks to Claude Code,
Codex, or a shell directly — it talks to a `RuntimeAdapter`. Three adapters
implement one five-method contract; a separate resume-honesty layer answers the
question "did this harness *actually* resume, or did it silently fresh-launch?"
truthfully rather than optimistically.
How OpenRig drives the agent harnesses. Harness launches go through a
`RuntimeAdapter`: five adapter classes implement one contract of five required
methods. A separate resume-honesty layer answers the question "did this harness
*actually* resume, or did it silently fresh-launch?" truthfully rather than
optimistically.
> Verified against source at HEAD `7eaf524c` (`git describe` →
> `v0.3.1-6-g7eaf524c`). All source in this module is **present at `v0.3.0`**
> (`git cat-file -e v0.3.0:<path>` for all 7 files → present) — the adapter
> layer is core reboot-era machinery, **not** a 0.3.x feature; do not
> back-attribute (§10.8 version-attribution proof technique).
> Drift-fix (scope) — `architecture.md` §5 "Runtime adapters" carries **no
> slice-00 numeric drift** (proposed-structure §4.2: "adapter contract is
> current"). The corrections below are *precision* refinements where the source
> is more specific than the prose, not stale-count fixes. Each is annotated
> inline and re-confirmed at HEAD.
> Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. The five-method RuntimeAdapter contract
`RuntimeAdapter` is `packages/daemon/src/domain/runtime-adapter.ts:127`
`RuntimeAdapter` is `packages/daemon/src/domain/runtime-adapter.ts:134`
(`interface RuntimeAdapter`). Every adapter declares a `readonly runtime`
string and implements exactly five methods (`runtime-adapter.ts:128–153`):
string (`runtime-adapter.ts:137`) and implements five required methods
(`runtime-adapter.ts:143–166`; **5** =
`sed -n '/^export interface RuntimeAdapter/,/^}/p' packages/daemon/src/domain/runtime-adapter.ts | grep -c -E '^ [a-zA-Z]+\('`):
| Method | Signature (`runtime-adapter.ts`) | Responsibility |
|---|---|---|
| `listInstalled` | `(binding)` `:131` | List currently installed/projected resources for a node. |
| `project` | `(plan, binding)` `:134` | Project resources from a `ProjectionPlan` to the runtime's target locations. |
| `deliverStartup` | `(files, binding)` `:137` | Deliver resolved startup files to the runtime. |
| `launchHarness` | `(binding, opts)` `:147` | Launch the harness inside the bound tmux session; return a resume token. |
| `checkReady` | `(binding)` `:153` | Probe whether the harness is responsive and ready. |
| `listInstalled` | `(binding)` `:144` | List currently installed/projected resources for a node. |
| `project` | `(plan, binding)` `:147` | Project resources from a `ProjectionPlan` to the runtime's target locations. |
| `deliverStartup` | `(files, binding)` `:150` | Deliver resolved startup files to the runtime. |
| `launchHarness` | `(binding, opts)` `:160` | Launch the harness inside the bound tmux session; return a resume token. |
| `checkReady` | `(binding)` `:166` | Probe whether the harness is responsive and ready. |
The interface also has two optional members: `claudeManagedLaunch` (`:136`)
and `skillTargetPath?()` (`:141`, implemented by `OmpRuntimeAdapter`,
`omp-runtime-adapter.ts:12`).
Startup *action* execution (`slash_command` / `send_text`) is explicitly **not**
part of this contract — the contract docstring (`runtime-adapter.ts:121–125`)
part of this contract — the contract docstring (`runtime-adapter.ts:128–133`)
states actions belong to the `StartupOrchestrator` *after* `checkReady()`. The
orchestrator delivery split is in `agent-spec-and-startup.md`.
### `launchHarness` opts and the fork seam
`launchHarness` opts is `{ name: string; resumeToken?: string; forkSource?:
ForkSource }` (`runtime-adapter.ts:147–150`).
ForkSource }` (`runtime-adapter.ts:162`).
> Drift-fix (precision) — `architecture.md` §4 said `launchHarness(binding,
> opts: { name, resumeToken? })`. Source adds a third, mutually-exclusive
> `forkSource` field. Per the contract docstring (`runtime-adapter.ts:142–146`)
> `resumeToken` and `forkSource` are mutually exclusive — if both are provided
> the adapter **must refuse** with a clear error, not guess; `forkSource`
> triggers a fork and the captured token is the NEW post-fork token, never the
> parent. `ForkSource` is `runtime-adapter.ts:116` (`kind: "native_id" |
> "artifact_path" | "name" | "last"`; v1 MVP accepts `native_id` only — other
> shapes rejected at schema validation, docstring `:106–119`).
Per the contract docstring (`runtime-adapter.ts:152–159`) `resumeToken` and
`forkSource` are mutually exclusive — if both are provided the adapter **must
refuse** with a clear error, not guess; `forkSource` triggers a fork and the
captured token is the NEW post-fork token, never the parent. `ForkSource` is
`runtime-adapter.ts:123` (`kind: "native_id" | "artifact_path" | "name" |
"last"`, `:124`; v1 MVP accepts `native_id` only — other shapes rejected at
schema validation, docstring `:113–122`).
### `HarnessLaunchResult` is a discriminated union with an honest failure arm
> Drift-fix (precision) — `architecture.md` §4 said `HarnessLaunchResult` is
> `{ ok, resumeToken?, resumeType?, error? }` (a single optional-field shape).
> Source is a **discriminated union** (`runtime-adapter.ts:81–86`):
> `| { ok: true; resumeToken?; resumeType? }`
> `| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence? }`.
> The failure arm carries a typed `recovery` hint
> (`HarnessLaunchRecovery = "retry_fresh" | "attention_required"`,
> `:79`) and optional `evidence` (last-N pane lines, flowed through to
> `RestoreNodeResult.attentionEvidence` for `attention_required` outcomes,
> `:83–86`). This is the honest-failure shape, not a smoothed optional `error`.
`HarnessLaunchResult` is a **discriminated union** (`runtime-adapter.ts:88–93`):
`| { ok: true; resumeToken?; resumeType?; appliedLaunch? }`
`| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence? }`.
The failure arm carries a typed `recovery` hint
(`HarnessLaunchRecovery = "retry_fresh" | "attention_required"`, `:86`) and
optional `evidence` (last-N pane lines, flowed through to
`RestoreNodeResult.attentionEvidence` for `attention_required` outcomes,
`:90–93`). This is the honest-failure shape, not a smoothed optional `error`.
## 2. The three adapters
## 2. The runtime adapters
All three live under `packages/daemon/src/adapters/` and implement
`RuntimeAdapter` (Architecture Rule 1: zero Hono in `adapters/`).
**5** classes implement `RuntimeAdapter`
(`git grep -l 'implements RuntimeAdapter' packages/daemon/src | wc -l`):
`ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `PiRuntimeAdapter`,
`StubRuntimeAdapter` and `TerminalAdapter`. All live under
`packages/daemon/src/adapters/` (Architecture Rule 1: **0** files there import
Hono, `git grep -l 'from "hono' -- packages/daemon/src/adapters | wc -l`).
`OmpRuntimeAdapter` (Oh My Pi) extends `PiRuntimeAdapter`, so the daemon wires
**6** runtime keys — `claude-code`, `codex`, `pi`, `omp`, `stub`, `terminal`
(`startup.ts:945`;
`grep 'adapters: {' packages/daemon/src/startup.ts | grep -o -E '"[a-z-]+": ' | wc -l`).
### ClaudeCodeAdapter (`claude-code-adapter.ts:41`)
### ClaudeCodeAdapter (`claude-code-adapter.ts:53`)
- `readonly runtime = "claude-code"` (`:42`).
- **Projects** to `.claude/` targets: `guidance_merge` → `<cwd>/CLAUDE.md`
(`:127`); `skill_install` → `<cwd>/.claude/skills/<name>/` (`:71,133`);
subagents → `.claude/agents`, plugins → `.claude/plugins/<id>`,
runtime resources → `.claude/extensions/<id>`, settings fragments merged
into `.claude/settings.local.json` (`:388–400`).
- **Launches** (`:213–215`): fresh = `claude <permissionMode> --session-id
- `readonly runtime = "claude-code"` (`:54`).
- **Projects** to `.claude/` targets: guidance managed blocks →
`<cwd>/CLAUDE.md` by default (`:187`, `:483`; default at
`managed-blocks.ts:16`, and the rig may select `CLAUDE.local.md` instead,
`managed-blocks.ts:14`); `skill_install` →
`<cwd>/.claude/skills/<name>/` (`:193`, `:564`); subagents → `.claude/agents`,
plugins → `.claude/plugins/<id>`, runtime resources →
`.claude/extensions/<id>` (`:566–568`); settings fragments merged into
`.claude/settings.local.json` (`:576`) and MCP fragments into `<cwd>/.mcp.json`
(`:579`).
- **Launches** (`:311–312`): fresh = `claude <permissionMode> --session-id
<generatedId> --name <name>`; resume = `claude <permissionMode> --resume
<token> --name <name>`; fork = `claude <permissionMode> --resume <parentId>
--fork-session --name <seat>` (`:188`).
> Drift-fix (precision) — `architecture.md` §5 said only "launches via
> `claude --name <name>`, resumes via `claude --resume <token>`". Source
> shows fresh launch uses an explicit `--session-id` (so a deterministic
> resume token exists immediately) and the fork branch exists. Re-confirmed
> `claude-code-adapter.ts:188,213–215` @HEAD.
- **Readiness** (`checkReady`, `:239`): verifies tmux session alive, captures
40 pane lines + pane command, delegates to `assessNativeResumeProbe` (§3);
ready only when probe `status === "resumed"`. The resume-launch verification
loop `verifyResumeLaunch` retries up to **16 attempts** (`:273`), failing
loudly with `recovery: "retry_fresh"` on `no_conversation_found`
(`:284–289`) — no silent fresh fallback.
--fork-session --name <seat>` (`:283`). Each command may carry a
`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` env prefix (`yolo-mode.ts:98`) and a
`--model <model>` argument (`:263`) and `--effort <effort>` (`:265`).
When `binding.permissionMode` is set, the command is built by `claudeManagedLaunch` instead (`:243–249`, `:309`).
- **Readiness** (`checkReady`, `:338`): verifies tmux session alive (`:343`),
captures 40 pane lines + pane command (`:348–349`), and assesses them with
`assessNativeResumeProbe` (§3) through `assessManagedProbe` (`:368`); ready
only when probe `status === "resumed"` (`:352`). The resume-launch
verification loop `verifyResumeLaunch` (`:402`) retries up to **16 attempts**
(`:404`), failing loudly with `recovery: "retry_fresh"` on
`no_conversation_found` (`:411–416`) and with `recovery:
"attention_required"` plus pane evidence on an `attention_required` probe
(`:426–432`) — the adapter never relaunches on its own.
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:37`)
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:61`)
- `readonly runtime = "codex"` (`:38`).
- `readonly runtime = "codex"` (`:62`).
- **Projects** to `.agents/` targets: `guidance_merge` → `<cwd>/AGENTS.md`
(`:139,330`); `skill_install` → `<cwd>/.agents/skills/<name>/` (`:89,145`);
skills resolve under `.agents/skills/<id>` (`:377`).
- **Launches/resumes** (`:205,225–226`): fresh launch then capture a fresh
thread id; resume = `codex<profileArg> resume<queueStateDirArg> <token>`;
fork = `codex<profileArg> fork<queueStateDirArg> <parentId>` (`:205`). On
success returns `{ ok: true, resumeToken: threadId, resumeType: "codex_id" }`
(`:222,245,250`).
> Drift-fix (precision) — `architecture.md` §5 said "launches via `codex`,
> resumes via `codex resume <threadId>`". Source confirms the `codex resume
> <token>` shape (`:226`) and adds the fork branch + the
> `resumeType: "codex_id"` tag. Re-confirmed @HEAD.
(`:290`, `:551`); `skill_install` → `<cwd>/.agents/skills/<name>/` (`:240`,
`:296`); skills resolve under `.agents/skills/<id>` (`:630`).
- **Launches/resumes**: fresh = `codex<posture> -C <cwd> …` (`:415`), then
capture a fresh thread id (`:431`); resume is built by
`buildCodexResumeCore` (`:414`) as `codex<posture> resume
[<queueStateDirArg>] <token>` (`native-resume-probe.ts:81`); fork = `codex<posture> fork<queueStateDirArg>
<parentId>` (`:386`). `<posture>` is `codexPostureArg(...)`
(`yolo-mode.ts:59`): the `-p <profile>` argument, `-s workspace-write`, or
`-s danger-full-access`. Each command may also carry `--no-daemon` (`:366`)
and `-m <model>` (`:337`), plus `-c 'model_reasoning_effort="<effort>"'`
when effort is selected (`codex-runtime-adapter.ts:339`). With a thread id it
returns `{ ok: true,
resumeToken: threadId, resumeType: "codex_id" }` (`:401`, `:428`, `:434`);
a fresh launch whose thread id is not captured returns `{ ok: true }` without
a token (`:437`).
- Refuses `resumeToken` + `forkSource` together with a clear error
(`:180–181`) — honors the mutual-exclusivity contract.
(`:331–332`) — honors the mutual-exclusivity contract.
### TerminalAdapter (`terminal-adapter.ts:19`)
- `readonly runtime = "terminal"` (`:20`).
- **All operations are no-ops** — "the shell IS the harness"
(`terminal-adapter.ts:15`): no-op `project`/`deliverStartup`/`launchHarness`
(`:34`), and `checkReady` returns ready immediately as soon as the tmux
session exists (`:47`). Used for infrastructure nodes — servers, log tails,
build watchers. (A terminal node cannot fork; the runtime-adapter docstring
notes fork-unsupported adapters refuse with a runtime-mismatch error,
`runtime-adapter.ts:113–115`.)
- **All operations are no-ops** — "the shell is immediately interactive"
(`terminal-adapter.ts:15`): no-op `project`/`deliverStartup`, a
`launchHarness` (`:34`) that returns `{ ok: true }`, and a `checkReady` that
returns ready unconditionally (`:47–48`). Used for infrastructure nodes —
servers, log tails, build watchers. A terminal node cannot fork:
`launchHarness` refuses a `forkSource` with a clear error (`:38–42`), as the
runtime-adapter docstring requires of adapters without fork
(`runtime-adapter.ts:120–121`).
These three are constructed by `createDaemon` step 4 (`startup.ts`; see
`daemon-core.md` §4 "Startup sequence").
### Pi, Oh My Pi and stub adapters
- `PiRuntimeAdapter` (`pi-runtime-adapter.ts:60`), `runtime` `"pi"` (`:61`).
- `OmpRuntimeAdapter` (`omp-runtime-adapter.ts:7`) extends it with
`runtime = "omp"` (`:8`).
- `StubRuntimeAdapter` (`stub-runtime-adapter.ts:69`), `runtime` defaulting to
`"stub"` (`:81`).
Pi and stub launch an OpenRig runner inside the seat's tmux pane (header
comments `pi-runtime-adapter.ts:1–10`, `stub-runtime-adapter.ts:1–8`); their
projection, launch and readiness behaviour is not detailed here.
`createDaemon` constructs the adapters (`startup.ts:755`, `:756`, `:759`,
`:760`, `:765`) and creates the terminal adapter inline in the runtime adapter
map (`startup.ts:945`); see `daemon-core.md` §4 "Startup sequence".
## 3. Resume honesty
The daemon does not assume a harness resumed just because the launch command
ran. Three domain files (`packages/daemon/src/domain/`, all present @v0.3.0)
make resume assessment honest:
ran. Three domain files (`packages/daemon/src/domain/`) carry the resume
assessment:
### `native-resume-probe.ts`
`assessNativeResumeProbe(input)` (`native-resume-probe.ts:43`) reads pane
`assessNativeResumeProbe(input)` (`native-resume-probe.ts:84`) reads pane
command + pane content and returns one of four honest statuses
(`NativeResumeProbeStatus`, `:6`):
(`NativeResumeProbeStatus`, `:7`):
- `resumed` — runtime-specific indicators confirm a resumed session.
- `failed` — terminal failure (e.g. Claude printed "No conversation found" →
code `no_conversation_found`, `:51–57`).
code `no_conversation_found`, `:92–95`).
- `inconclusive` — we don't know yet (e.g. Claude trust gate, code
`trust_gate`, `:65–70`).
`trust_gate`, `:106–109`).
- `attention_required` — alive and recoverable but **needs operator action**
(e.g. Claude resume-selection prompt, code `claude_resume_selection_prompt`,
`:58–64`). This is the proxy for "an operator must choose the conversation";
it is *distinct* from `inconclusive` and `failed` (docstring `:3–5`).
`:99–102`). This is the proxy for "an operator must choose the conversation";
it is *distinct* from `inconclusive` and `failed` (comment `:4–6`).
`buildNativeResumeCommand` (`:27`) builds the resume command per runtime:
claude → `claude --resume <token> [--name <name>]` (`:35`); codex → `codex
resume <token>` (`:38`); other runtimes → `null` (`:40`).
`buildNativeResumeCommand` (`:32`) builds the resume command per runtime:
claude → `claude --resume <token> [--name <name>]` (`:41`); codex →
`buildCodexResumeCore` (`:44`), i.e. `codex<posture> resume <token>`
(`:81`); other runtimes → `null` (`:46`).
This is Architecture Rule 15 in code: a failed resume is FAILED loudly; there
is no automatic fresh fallback (the adapter's `verifyResumeLaunch` returns
`ok:false` with a `retry_fresh` recovery hint, never a silent relaunch).
At the adapter level (Architecture Rule 15, §4), the adapter's
`verifyResumeLaunch` returns `ok:false` with a `retry_fresh` recovery hint and
never relaunches itself. Acting on that hint is the caller's choice:
`StartupOrchestrator` retries once fresh on `retry_fresh` unless the caller
passes `allowFreshFallback: false` (`startup-orchestrator.ts:260–268`), and the
restore orchestrator passes `false` when a pod-aware node requested resume
(`restore-orchestrator.ts:1399`).
### `resume-metadata-refresher.ts`
`ResumeMetadataRefresher` (`resume-metadata-refresher.ts:37`). Post-launch
resume-token capture: `refresh(sessions)` (`:62`) skips sessions that already
have a `resumeToken` (`:65`), and for `claude-code` sessions with a token runs
a `probeClaudeResume` returning `"resumable" | "not_resumable" |
"inconclusive"` (`:31,74–75`) — a real launch of the resume command in a
throwaway probe tmux session (`:106–111`), not a metadata guess.
`ResumeMetadataRefresher` (`resume-metadata-refresher.ts:52`). Post-launch
resume-token capture: `refresh(sessions, opts?)` (`:112`) skips `codex`
sessions that already have a `resumeToken` (`:128`, `:144`) and otherwise
captures the thread id (`:147–150`). For `claude-code` sessions with a token
it runs a `probeClaudeResume` returning `"resumable" | "not_resumable" |
"inconclusive"` (`:37`, `:194`) — a real launch of the resume command in a
throwaway probe tmux session (`:251–254`), not a metadata guess. In
`fillNullOnly` mode (`:121`) it skips that probe (`:177`).
### `codex-thread-id.ts`
Codex thread-id extraction (`codex-thread-id.ts`). Reads the Codex thread id
from the Codex *logs* SQLite databases under `~/.codex/`:
`readCodexThreadIdFromCandidateHomes(...)` (`:22`) →
`readCodexThreadIdFromLogs(...)` (`:49`) → `resolveCodexLogDbPaths(homeDir)`
(`:79`) which globs `<homeDir>/.codex/logs_<N>.sqlite` (`:84–89`,
regex `^logs_(\d+)\.sqlite$`) and falls back to `logs_1.sqlite` (`:97`).
Uses `better-sqlite3` (`:5`). Resolves the home dir by the harness PID
(`defaultResolveHomeDirByPid`, `:9`).
`readCodexThreadIdFromCandidateHomes(...)` (`:32`) →
`readCodexThreadIdFromLogs(...)` (`:237`) → `resolveCodexDbPaths(homeDir,
kind)` (`:294`) which globs `<homeDir>/.codex/logs_<N>.sqlite` (regex
`^${kind}_(\d+)\.sqlite$`, `:300`) and falls back to `logs_1.sqlite` (`:312`).
The logged thread ids are then checked against the `threads` table in
`state_<N>.sqlite` (`:262–272`); an id is returned only when exactly one CLI
conversation matches (`:274`). Uses `better-sqlite3` (`:6`). Resolves the home
dir by the harness PID (`defaultResolveHomeDirByPid`, `:16`).
> Precision note — `architecture.md` §5 "Resume honesty" says codex thread IDs
> come from "the Codex SQLite database". Source is more specific: the
> per-version Codex *logs* DBs `~/.codex/logs_N.sqlite`. Stated precisely here;
> re-confirmed `codex-thread-id.ts:79–97` @HEAD.
## 4. Relevant Architecture Rules
## 4. Relevant Architecture Rules (source-verified at HEAD)
The rule texts live in `architecture-rules-and-event-system.md` §1; this
section cites them by number with the adapter-level evidence checked for this
module.
From `architecture.md` §7 (re-confirmed against the source cited inline):
- **Rule 5** — Runtime is member-authoritative in the pod-aware model.
- **Rule 13** — Readiness checking is a retry loop with exponential backoff and
a configurable timeout, using adapter-specific probes (Claude TUI indicator,
Codex ready message, terminal immediate). Re-confirmed: `checkReady`
delegates to `assessNativeResumeProbe`; the retry loop is
`claude-code-adapter.ts:273` (16 attempts).
- **Rule 14** — Resume states are locked: `resumed` / `rebuilt` / `fresh`;
`rebuilt` = new process assembled from artifacts. (The probe layer adds
`inconclusive` / `attention_required` as honest *transient* states, not
outcomes — see §3.)
- **Rule 15** — Restore honesty: failed resume is FAILED loudly; no automatic
fresh fallback; fresh launch is an explicit follow-up only. Enforced in code
by §2/§3 (`verifyResumeLaunch` returns `ok:false`, never relaunches).
- **Rule 1** — §2: **0** files in `adapters/` import Hono.
- **Rule 5** — restore picks a node's adapter by that node's own `runtime`
(`restore-orchestrator.ts:1253`).
- **Rule 13** — the readiness loop is `StartupOrchestrator.waitForReady`
(`startup-orchestrator.ts:488`; 1 s doubling to a 16 s cap, `:494–495`,
`:510`; default timeout 30 s, `:491`), which calls each adapter's
`checkReady` (§2).
- **Rule 14** — the probe's `attention_required` (§3) reaches the restore
result through `HarnessLaunchResult.recovery` and `evidence` (§1), which
become `RestoreNodeResult.status` (`types.ts:458`, `:476`) and
`attentionEvidence` (`types.ts:479`). A startup's `continuityOutcome` is a
separate field that also allows `forked` (`startup-orchestrator.ts:69`).
- **Rule 15** — adapters return `ok:false` and never relaunch (§2, §3);
restore passes `allowFreshFallback: false` for pod-aware resume
(`restore-orchestrator.ts:1399`), and a resume that concludes failed rolls
back to zero sessions as `awaiting-decision` (`restore-orchestrator.ts:1230`,
`:1417`).
## See also
- `daemon-core.md` — where `createDaemon` constructs the three adapters.
- `daemon-core.md` — where `createDaemon` constructs the runtime adapters.
- `agent-spec-and-startup.md` — the `StartupOrchestrator` that calls these
adapters and owns startup-action execution after `checkReady()`.
- `lifecycle-snapshot-restore.md` — how persisted resume tokens flow into
snapshot/restore (resume vs rebuild vs fresh).
- `architecture-rules-and-event-system.md` — the full architecture rule list.
- Source roots: `packages/daemon/src/domain/runtime-adapter.ts`,
`packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,terminal-adapter}.ts`,
`packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,pi-runtime-adapter,omp-runtime-adapter,stub-runtime-adapter,terminal-adapter}.ts`,
`packages/daemon/src/domain/{native-resume-probe,resume-metadata-refresher,codex-thread-id}.ts`.
@@ -11,83 +11,85 @@ applies-when: |
resolve and preserve identity.
siblings: [daemon-core.md, adapters-and-runtimes.md, lifecycle-snapshot-restore.md, packaging-bootstrap-bundles.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
last-updated: 2026-10-03
---
# Agent/Rig Spec, Resolution, Startup, Identity
How the daemon turns authored YAML specs into a resolved, projected, launched,
and identity-addressable topology. The spec-and-startup contract: parse →
resolve → project → deliver → launch → wait → persist replay context.
resolve → project → deliver pre-launch files → persist replay context → launch
→ wait → deliver post-launch files.
> Verified against source at HEAD `7eaf524c` (`git describe` →
> `v0.3.1-6-g7eaf524c`). Package version is **0.3.1** across all three
> packages (slice-00 §1.1). Source feed located by `architecture.md` headings
> (§4 Canonical Reboot Types, §5 Domain Services, §6 Execution Flows, §7
> Architecture Rules) per slice-08 §10.1 — line numbers are advisory only.
> Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. `domain/…` and `routes/…` paths
> are under `packages/daemon/src/`; a bare file name such as
> `rigspec-schema.ts:115` is in `packages/daemon/src/domain/`. Each count sits
> beside the command that produces it; run the command from the repository
> root to refresh it.
## 1. Canonical spec and topology types
(`architecture.md` §4 "Spec and topology")
- **AgentSpec** (`types.ts:938`) — parsed from `agent.yaml`
(`agent-resolver.ts:150`). Owns imports, defaults, startup, resources, and
profiles. Canonical parse/normalize/validate is `domain/agent-manifest.ts`.
- **RigSpec** (`types.ts:1169`) — canonical pod-aware rig topology. Uses
`version: "0.2"` and `pods[]`; owns cross-pod `edges[]`, rig-level startup
overlays, and `cultureFile`, plus the optional `summary`, `permissionPolicy`,
`managedBlocks`, `docs`, `services` and `workspace` fields.
- **AgentSpec** — parsed from `agent.yaml`. Owns imports, defaults, startup,
resources, and profiles. Canonical parse/normalize/validate is
`domain/agent-manifest.ts`.
- **RigSpec** — canonical pod-aware rig topology. Uses `version: "0.2"` and
`pods[]`; owns cross-pod `edges[]`, rig-level startup overlays, and
`cultureFile`.
`version` is the spec-schema version, not the package version. It is not a
code constant: `RigSpecSchema.validate` requires only a non-empty string
(`rigspec-schema.ts:115`) and `RigSpecSchema.normalize` carries the authored
value through (`rigspec-schema.ts:243`). `"0.2"` is the canonical authored
value, and the pod-aware exporter writes it (`rigspec-exporter.ts:223`).
> Drift-fix D-spec-version — `architecture.md` §4 says RigSpec
> "Uses `version: "0.2"`". This is the **spec-schema version, NOT the
> package version** — a different versioning axis from the 0.3.1 package
> version. The value is NOT a code constant: `rigspec-schema.ts:52`
> validates `version` only as a "required non-empty string"
> (`if (!obj["version"] || typeof obj["version"] !== "string")`); `:163`
> carries it through as `raw["version"] as string`. `"0.2"` is the
> canonical authored value, correctly described as a schema axis. Do NOT
> "correct" it toward 0.3.1 (slice-08 §4.6 binding). Re-confirmed
> `rigspec-schema.ts:52,163` @HEAD.
- **RigServicesSpec** — optional `services` block on a pod-aware RigSpec.
Shipped kind is Compose-backed env management with `composeFile`,
`projectName?`, `profiles?`, `downPolicy?`, `waitFor?`, `surfaces?`,
`checkpoints?`.
- **RigSpecPod** — pod-local bounded context with `members[]`, pod-local
`edges[]`, pod startup, optional continuity policy.
- **RigSpecPodMember** — member-level runtime/startup surface: `agentRef`,
`profile`, `runtime`, `model?`, `cwd`, `restorePolicy?`, member startup
overlays.
- **Pod** — persisted DB entity for a pod.
- **ContinuityState** — persisted live continuity row keyed by
`podId + nodeId`.
- **RigServicesSpec** (`types.ts:1216`) — optional `services` block on a
pod-aware RigSpec. Shipped kind is Compose-backed env management
(`kind: "compose"`) with `composeFile`, `projectName?`, `profiles?`,
`downPolicy?`, `waitFor?`, `surfaces?`, `checkpoints?`.
- **RigSpecPod** (`types.ts:1125`) — pod-local bounded context with
`members[]`, pod-local `edges[]`, pod startup, optional continuity policy.
- **RigSpecPodMember** (`types.ts:1069`) — member-level runtime/startup
surface: `agentRef`, `profile`, `runtime`, `model?`, `effort?`, `cwd`, `restorePolicy?`,
member startup overlays (`startup?`), plus `label?`, `codexConfigProfile?`,
`role?`, `permissionPolicy?`, `compactionStrategy?`, `mechanic?`,
`sessionSource?` and `starterRef?`.
- **Pod** (`types.ts:8`) — persisted DB entity for a pod.
- **ContinuityState** (`types.ts:18`) — persisted live continuity row keyed by
`podId + nodeId` (`PRIMARY KEY (pod_id, node_id)`,
`packages/daemon/src/db/migrations/014_agentspec_reboot.ts:24`).
## 2. Execution and projection types
(`architecture.md` §4 "Execution and restore" — restore/snapshot members are
detailed in `lifecycle-snapshot-restore.md`; the spec/projection members live
here)
Restore/snapshot types are detailed in `lifecycle-snapshot-restore.md`; the
spec/projection types live here.
- **ResolvedNodeConfig** — output of profile resolution. Carries effective
runtime/model/cwd, narrowed restore policy, selected resources, layered
startup block, resolved spec identity.
- **ProjectionPlan** — runtime projection plan for a node: runtime, cwd,
projection entries, startup block, diagnostics, conflict/no-op
classifications.
- **RuntimeAdapter** — the five-method contract (adapter detail in
`adapters-and-runtimes.md`): `listInstalled(binding)`
(`runtime-adapter.ts:131`), `project(plan, binding)` (`:134`),
`deliverStartup(files, binding)` (`:137`), `launchHarness(binding, opts)`
(`:147`), `checkReady(binding)` (`:153`). Re-confirmed @HEAD — the contract
is current.
- **HarnessLaunchResult** — `{ ok, resumeToken?, resumeType?, error? }`
returned by `launchHarness`.
- **StartupOrchestrator** — drives the full startup sequence (§4 below).
- **ResolvedNodeConfig** (`profile-resolver.ts:31`) — output of profile
resolution. Carries effective runtime/model/effort/cwd, narrowed restore policy,
selected resources, layered startup block, resolved spec identity.
- **ProjectionPlan** (`projection-planner.ts:41`) — runtime projection plan for
a node: runtime, cwd, projection entries, startup block, diagnostics,
conflict/no-op classifications.
- **RuntimeAdapter** (`runtime-adapter.ts:134`) — the five-method contract
(adapter detail in `adapters-and-runtimes.md`): `listInstalled(binding)`
(`runtime-adapter.ts:144`), `project(plan, binding)` (`:147`),
`deliverStartup(files, binding)` (`:150`), `launchHarness(binding, opts)`
(`:160`), `checkReady(binding)` (`:166`). The interface also declares one
optional method, `skillTargetPath?(...)` (`:141`). Required methods: **5**
(`sed -n '/^export interface RuntimeAdapter /,/^}/p' packages/daemon/src/domain/runtime-adapter.ts | grep -c -E '^ [a-zA-Z]+\('`).
- **HarnessLaunchResult** (`runtime-adapter.ts:88`) — returned by
`launchHarness`: either `{ ok: true, resumeToken?, resumeType?,
appliedLaunch? }` or `{ ok: false, error, recovery?, evidence? }`, where
`recovery` is `"retry_fresh"` or `"attention_required"` (`:86`).
- **StartupOrchestrator** (`startup-orchestrator.ts:107`) — drives the full
startup sequence (§4 below).
## 3. Parsing, validation, resolution pipeline
(`architecture.md` §5 "Parsing and validation" + "Resolution pipeline"; all
under `packages/daemon/src/domain/`, zero Hono imports per Architecture Rule 1)
All of the files below are under `packages/daemon/src/domain/`, and none of
them imports Hono: **0**
(`git grep -l '"hono' -- packages/daemon/src/domain/{agent-manifest,rigspec-schema,rigspec-codec,startup-validation,path-safety,spec-validation-service,spec-review-service,agent-resolver,agent-preflight,profile-resolver,startup-resolver,projection-planner}.ts | wc -l`).
**Parse / validate:**
@@ -110,115 +112,127 @@ under `packages/daemon/src/domain/`, zero Hono imports per Architecture Rule 1)
- `startup-resolver.ts` — additive startup layering.
- `projection-planner.ts` — runtime resource projection planning.
All eleven files re-confirmed present in `packages/daemon/src/domain/` @HEAD.
## 4. Startup orchestration (the spec-startup contract)
(`architecture.md` §4 "StartupOrchestrator" + §5 "Startup, runtime, and
instantiation" + §7 Architecture Rule 6)
`StartupOrchestrator.startNode` (`startup-orchestrator.ts:129`) drives, in
source order: mark pending (`:156`) → project resources (`:167`) → deliver
pre-launch files (`:207`) → persist startup context (`:222`) → launch harness,
recording any resume token (`:237`) → wait for ready (`:322`) → deliver
interactive files (`:403`) → execute `after_files` then `after_ready` actions
(`:426`, `:432`) → mark ready (`:466`). The class doc comment
(`startup-orchestrator.ts:90`–`101`) still lists persistence as step 9, after
the actions; the code persists the startup context before the harness launch.
`StartupOrchestrator` (`domain/startup-orchestrator.ts`) drives:
mark pending → project resources → deliver pre-launch files → launch harness
→ wait for ready → deliver interactive files → execute actions → persist
context → mark ready.
**Pre-launch vs interactive delivery split** — the load-bearing seam,
re-confirmed at source:
**Pre-launch vs interactive delivery split** — the load-bearing seam:
- Pre-launch (filesystem, before harness boot): `guidance_merge`,
`skill_install` (`startup-orchestrator.ts:78,167` — "Deliver pre-launch
files (guidance_merge, skill_install → filesystem)").
`skill_install` (`startup-orchestrator.ts:93` — "Deliver pre-launch files
(guidance_merge, skill_install → filesystem)"; delivered at `:207`).
- Post-launch (TUI, after harness is ready): `send_text`
(`startup-orchestrator.ts:82,160,263` — partition by concrete hint at
`:141`, deliver after readiness at `:263`).
(`startup-orchestrator.ts:97`; partition by concrete hint at `:182`,
`send_text` files held for post-launch at `:200`, delivered after readiness
at `:403`).
The orchestrator persists replay context including the resume token for future
restores (consumed by `lifecycle-snapshot-restore.md`).
The orchestrator persists replay context for future restores (consumed by
`lifecycle-snapshot-restore.md`): the projection entries, resolved startup
files and startup actions go to `node_startup_context`
(`startup-orchestrator.ts:225`), and a resume token returned by the launch is
written to the session row (`updateResumeToken`, `:254`).
**Architecture Rule 6 — startup layering is additive and ordered** (carried
verbatim, `architecture.md` §7): (1) agent base, (2) profile, (3) rig culture
**Startup layering is additive and ordered** (`resolveStartup`,
`startup-resolver.ts:15`–`22`): (1) agent base, (2) profile, (3) rig culture
file, (4) rig startup, (5) pod startup, (6) member startup, (7) operator debug
append. This is the spec-startup contract's invariant; the full 25-rule list
lives in `architecture-rules-and-event-system.md`.
append. Files and actions are concatenated in that order with no
deduplication (`startup-resolver.ts:24`). This is the spec-startup contract's
invariant; the cross-cutting architecture rules are collected in
`architecture-rules-and-event-system.md`.
**Startup action constraints** (`architecture.md` §7 "Current startup action
constraints"): no shell startup actions; action types are `slash_command` and
`send_text` only; non-idempotent actions must not apply on restore; retrying
failed startup is handled as restore.
**Startup action constraints** (`startup-validation.ts`): no shell startup
actions (`:65`); action types are `slash_command`, `send_text` and
`startup_proof` (`:7`); non-idempotent actions must not apply on restore
(`:100`). Retrying a failed startup is handled as a restore, which is why the
orchestrator skips non-idempotent actions on restore
(`startup-orchestrator.ts:556`, "retry-as-restore safety"). The exception is
`PodRigInstantiator.retryFirstStart` (`rigspec-instantiator.ts:1146`), which
re-runs a first start that failed at projection, before any harness launch.
**Remote import constraints** (§7): the reboot supports `local:...` and
`path:/abs/...`. Remote `agent_ref` sources remain unsupported and fail in
preflight.
**Remote import constraints**: `agent_ref` and AgentSpec imports accept
`local:<relative path>` and `path:<absolute path>` only
(`rigspec-schema.ts:41`, `agent-manifest.ts:40`); a terminal member uses the
`builtin:terminal` sentinel (`rigspec-schema.ts:519`). Remote `agent_ref`
sources remain unsupported: RigSpec validation rejects them
(`rigspec-schema.ts:550`), `rigPreflight` runs that validation
(`rigspec-preflight.ts:249`), and the resolver refuses a remote import again
(`agent-resolver.ts:203`).
## 5. Instantiation, preflight, export
(`architecture.md` §5 "Startup, runtime, and instantiation" + §6 "RigSpec
import / validate / preflight / export")
- `runtime-adapter.ts` — adapter contract + bridge types.
- `rigspec-preflight.ts` — dual-stack legacy preflight plus rebooted
`rigPreflight(...)`.
- `rigspec-instantiator.ts` — dual-stack `RigInstantiator` plus
`PodRigInstantiator`.
- `rigspec-preflight.ts` — dual-stack legacy preflight (`RigSpecPreflight`,
`rigspec-preflight.ts:25`) plus rebooted `rigPreflight(...)` (`:241`).
- `rigspec-instantiator.ts` — dual-stack `RigInstantiator`
(`rigspec-instantiator.ts:33`) plus `PodRigInstantiator` (`:474`).
- `rigspec-exporter.ts` — dual-format live rig export to YAML/JSON.
- `pod-repository.ts` — pod CRUD plus live continuity-state CRUD.
`routes/rigspec.ts` is the dual-format seam: validate
`routes/rigspec.ts` is the dual-format seam; a parsed spec with a `pods` array
takes the pod-aware path (`routes/rigspec.ts:83`): validate
(pod-aware → `RigSpecSchema.validate`; legacy → `LegacyRigSpecSchema.validate`),
preflight (`rigPreflight({ rigSpecYaml, rigRoot, fsOps })` vs
preflight (`rigPreflight({ rigSpecYaml, rigRoot, … })` vs
`RigSpecPreflight.check(spec)`), import
(`podInstantiator.instantiate(yaml, rigRoot)` vs
(`podInstantiator.instantiate(yaml, rigRoot, …)` vs
`RigInstantiator.instantiate(spec)`), export (pod-aware exports canonical
`version: "0.2"` RigSpec; legacy exports flat-node v1).
`version: "0.2"` RigSpec; legacy exports flat-node `schemaVersion: 1`,
`rigspec-exporter.ts:105`).
The import validation route delegates to `validateRigSpecImport`
(`routes/rigspec.ts:214–222`, `spec-validation-service.ts:10–19`). The same
helper powers local `rig spec validate` without contacting the daemon
(`packages/cli/src/commands/rig.ts:204–207`); it selects the pod-aware or legacy
validator and distinguishes YAML parse failures from validator exceptions.
## 6. Identity: whoami, materialize, bind, adopt
(`architecture.md` §6 "Whoami and adopted-session parity flow" +
"Materialize / bind / adopt flow")
**Whoami resolution** — the daemon owns the truth surface through
`/api/whoami` (`routes/whoami.ts`); tmux metadata is an adopted-session
anchor, not sovereign truth. `whoami-service.ts:8` declares
`resolvedBy: "node_id" | "session_name"`. The route requires `nodeId` or
`sessionName` (`routes/whoami.ts:10-15`). Resolution order:
explicit `--node-id` → explicit `--session` → env vars → tmux metadata →
raw tmux session-name fallback.
`sessionName` (`routes/whoami.ts:19`–`23`). The CLI resolves identity
(`packages/cli/src/commands/whoami.ts:142`–`148`) in this order: explicit
`--node-id` → explicit `--session` → env vars → tmux metadata → raw tmux
session-name fallback.
- Managed sessions prefer projected `OPENRIG_NODE_ID` /
`OPENRIG_SESSION_NAME`.
- Adopted sessions use tmux-owned metadata written at claim/bind time.
`OPENRIG_SESSION_NAME`, set when the tmux session is created
(`node-launcher.ts:143`–`144`).
- Adopted sessions use tmux-owned metadata written at bind time.
> Drift-nuance D5 (the careful axis — see also `transport-and-transcripts.md`):
> the tmux metadata keys `@rigged_node_id` / `@rigged_session_name` /
> `@rigged_rig_id` / `@rigged_rig_name` / `@rigged_logical_id` in
> `architecture.md` §6 are **CORRECT as-is** and were verified literally at
> source — `claim-service.ts:77-81` writes exactly these `@rigged_*` keys;
> `claim-service.ts` and `rig-lifecycle-service.ts` read them. This is a
> SEPARATE axis from MCP tool names (which are `rig_*`, slice-00 §1.4). Do
> NOT blanket-replace `rigged` → `rig` (banked
> `feedback_release_prep_three_layer_depersonalization`: the rename was
> scoped, not global). The tmux metadata keys were NOT renamed.
> Re-confirmed `claim-service.ts:77-81` @HEAD.
The tmux metadata keys use the `@rigged_` prefix:
`ClaimService.setRiggedMetadata` writes `@rigged_node_id` /
`@rigged_session_name` / `@rigged_rig_id` / `@rigged_rig_name` /
`@rigged_logical_id` (`claim-service.ts:180`–`184`), called from `bind`,
`reconcileSession` and `createAndBindToPod`;
`RigLifecycleService.unclaimSession` clears the same five keys
(`rig-lifecycle-service.ts:209`–`213`); and `rig whoami` reads
`@rigged_node_id` and `@rigged_session_name`
(`packages/cli/src/commands/whoami.ts:173`, `:179`). MCP tool names use a
different prefix, `rig_*` (`packages/cli/src/mcp-server.ts`).
**Materialize / bind / adopt** (`architecture.md` §6): `POST
/api/rigs/import/materialize` creates a pod-aware topology without launching
sessions; `POST /api/discovery/:id/bind` attaches a discovered live session to
an existing logical node; `POST /api/discovery/:id/adopt` is the composite
**Materialize / bind / adopt**: `POST /api/rigs/import/materialize`
(`routes/rigspec.ts:174`) creates a pod-aware topology without launching
sessions and refuses a legacy spec (`routes/rigspec.ts:187`);
`POST /api/discovery/:id/bind` (`routes/discovery.ts:77`) attaches a
discovered live session to an existing logical node (`logicalId`), or creates
a member in a pod (`podNamespace` + `memberName`) and binds it;
`POST /api/discovery/:id/adopt` (`routes/discovery.ts:139`) is the composite
route (bind to existing node, or create a new member in a target pod and bind
immediately). CLI mirrors via `rig bind` / `rig adopt`. Authored pod namespace
is preserved through adoption so logical ids stay
`${podNamespace}.${memberName}` (Architecture Rule 24: adopted-session parity
is tmux-metadata parity, not fake env-var parity).
## OPEN / carried items
- **D-spec-version (resolved-as-correct):** RigSpec `version: "0.2"` is the
spec-schema axis, validated only as a non-empty string in source. Documented,
not "corrected".
- **D5 (careful, resolved-as-correct for this module):** tmux `@rigged_*`
metadata keys are literally still `@rigged_*` at HEAD — architecture.md §6 is
accurate. The MCP-tool-name `rigged_*` drift lands in
`transport-and-transcripts.md`.
immediately). On the CLI, `rig bind` calls the bind route
(`packages/cli/src/commands/bind.ts:56`), and `rig adopt` materializes the
topology and then calls the bind route for each session
(`packages/cli/src/commands/adopt.ts:163`, `:212`). Authored pod namespace is
preserved through adoption so logical ids stay `${podNamespace}.${memberName}`
(`claim-service.ts:661`). Adopted-session parity is tmux-metadata parity, not
fake env-var parity.
## See also
@@ -226,7 +240,8 @@ is tmux-metadata parity, not fake env-var parity).
- `adapters-and-runtimes.md` — the five-method RuntimeAdapter contract detail.
- `lifecycle-snapshot-restore.md` — snapshot/restore consumes persisted replay
context.
- `architecture-rules-and-event-system.md` — the full 25 architecture rules.
- `architecture-rules-and-event-system.md` — the cross-cutting architecture
rules.
- Source roots: `packages/daemon/src/domain/{agent-manifest,rigspec-schema,
profile-resolver,startup-orchestrator,whoami-service,claim-service}.ts`,
`packages/daemon/src/routes/{rigspec,whoami}.ts`.
@@ -11,8 +11,8 @@ applies-when: |
compatibility limits that still describe the shipped system.
siblings: [daemon-core.md, coordination-primitive.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
last-updated: 2026-10-03
---
# Architecture Invariants, Event System, Compatibility Notes
@@ -21,20 +21,25 @@ This module collects the cross-cutting invariants that do not belong to any
single subsystem: the architecture rules the codebase holds itself to, the
event-system shape, and the intentional compatibility limits.
> Verified against source at HEAD `7eaf524c` (`git describe` →
> `v0.3.1-6-g7eaf524c`); package version **0.3.1** across all three packages
> (slice-00 §1.1). HEAD carries 6 commits of unreleased 0.3.2 work; no
> `v0.3.2` tag exists.
> Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. Architecture rules
These are the invariants the codebase is built to preserve (`architecture.md`
§7). Rule 6 (startup layering) and rule 7 (restore-policy narrowing) are the
spec/startup contract — see `agent-spec-and-startup.md` for their flow detail;
they are restated here as system-level invariants.
These are the invariants the codebase is built to preserve. Rule 6 (startup
layering) and rule 7 (restore-policy narrowing) are the spec/startup contract —
see `agent-spec-and-startup.md` for their flow detail; they are restated here as
system-level invariants.
1. Zero Hono in `domain/` and `adapters/`.
2. Routes depend on the domain; the domain never depends on routes.
1. No runtime Hono in `domain/` and `adapters/`. `adapters/` has none; `domain/`
has type-only Hono imports (`import type`) in **3** files
(`git grep -l 'from "hono' -- packages/daemon/src/domain packages/daemon/src/adapters | wc -l`).
2. Routes depend on the domain; the domain does not depend on routes at
runtime. **1** type-only import crosses back:
`domain/project-classifier.ts:1` imports `IdentityProvenance` from
`routes/require-sender-identity.js`
(`git grep -lE 'from "(\.\./)+routes/' -- packages/daemon/src/domain packages/daemon/src/adapters | wc -l`).
3. Shared DB-handle invariants are enforced at construction time.
4. The reboot is engine-first: domain services land before public-surface
rewiring.
@@ -50,31 +55,48 @@ they are restated here as system-level invariants.
in the owning artifact.
10. Restore replay uses classification-free projection intent, not stale
startup-time `no_op` / conflict classifications.
11. Startup status is explicit session state: `pending`, `ready`, `failed`.
11. Startup status is explicit session state: `pending`, `ready`,
`attention_required`, `failed` (`types.ts:100`).
12. Session recency depends on monotonic ULIDs: `session-registry.ts` uses
`monotonicFactory()`; restore selects the newest session by max ULID.
`monotonicFactory()`. Restore does not pick the newest session by max
ULID; it resolves the active occupant recorded in the snapshot
(`resolveActiveSnapshotSession`, `active-occupant.ts:74`, called at
`restore-orchestrator.ts:1171`).
13. Readiness checking is a retry loop with exponential backoff and
configurable timeout, using adapter-specific probes (Claude TUI
indicator, Codex ready message, terminal immediate).
14. Resume states are locked: `resumed` / `rebuilt` / `fresh`. `rebuilt` =
14. Restore outcome states are a fixed set (`RestoreNodeResult.status`,
`types.ts:473`): `resumed`, `rebuilt`, `fresh-primed`,
`awaiting-decision`, `attention_required`, `failed`, plus
`operator_recovered` (set only by later reconciliation) and `fresh`
(retained only for the legacy continuity-restoring skip path). `rebuilt` =
new process assembled from artifacts.
15. Restore honesty: failed resume is FAILED loudly. No automatic fresh
fallback. Fresh launch is explicit follow-up only.
15. Restore honesty: a failed resume stops loudly as `awaiting-decision`, with
the blank session rolled back and no session running
(`restore-orchestrator.ts:1230`). No automatic fresh fallback. Fresh launch
is explicit follow-up only (`rig up --fresh <logicalId>`,
`packages/cli/src/commands/up.ts:85`).
16. Post-command handoff required on `up`, `down`, `restore`,
`snapshot create`: what happened + current state + next action.
17. Session naming: `{pod}-{member}@{rig}` — human-authored,
system-validated. No generation, no slugification.
18. Communication: tmux is transport, not truth. `send/capture/broadcast`
wrap tmux reliably with honest errors.
19. Transcripts: raw capture via pipe-pane, ANSI strip on read. `rg`
preferred, `grep -E` fallback.
19. Transcripts: bounded capture — a periodic `tmux capture-pane` snapshot of
the trailing lines (default 1000, every 2 s) overwrites the transcript
file, replacing pipe-pane (`transcript-rotation.ts:3`, `:27`–`28`; started
at `node-launcher.ts:181`). ANSI strip on read. `rig ask` transcript
search: `rg` preferred, `grep -E` fallback.
20. Config precedence: CLI flag > env var > config file
(`~/.openrig/config.json`, with legacy fallback from
`~/.rigged/config.json`) > default.
(`~/.openrig/config.json`, or `$OPENRIG_HOME/config.json` when
`OPENRIG_HOME` is set; `packages/cli/src/config-store.ts:915`) > default.
21. Semi-deterministic calibration: build what agents use constantly. Agent
handles edge cases from error messages.
22. `rig ask` is context engineering: gathers evidence, does NOT call an
external LLM. The agent IS the LLM.
external LLM. The agent IS the LLM. The exception is the explicit `--wake`
flag (`packages/cli/src/commands/ask.ts:88`), which runs one headless
question against an existing agent session (`codex exec resume` or
`claude -p --resume`, `packages/cli/src/ask-wake.ts:58`–`60`).
23. Spec library truth is YAML on disk; daemon owns the structured
review/index/cache layer.
24. Adopted-session parity is tmux-metadata parity, not fake env-var parity.
@@ -84,133 +106,132 @@ they are restated here as system-level invariants.
### Startup action constraints
- No shell startup actions.
- Action types are `slash_command` and `send_text` only.
- Action types are `slash_command`, `send_text` and `startup_proof` only
(`startup-validation.ts:7`).
- Non-idempotent actions must not apply on restore.
- Retrying failed startup is handled as restore.
### Remote import constraints
The reboot supports `local:...` and `path:/abs/...` agent refs. Remote
`agent_ref` sources remain unsupported and fail in preflight
(`architecture.md` §7 "Remote import constraints"; restated in compat note 1).
`agent_ref` sources remain unsupported and fail in preflight (schema
validation, `rigspec-schema.ts:550`; restated in compat note 1).
## 2. Event system
The daemon's event surface is the single `RigEvent` discriminated union.
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §8
> says "PL-004 Phase A adds 9 coordination events" and (in §3) "Existing 32
> PL-004 events" / "Existing 20 PL-004 events" — internally inconsistent
> contested PL-004 sub-counts. **Do NOT carry the 9 / 32 / 20 figures.** The
> `architecture.md` §8 "Currently emitted in production code" / "Present in
> the union but not yet emitted" lists also predate 0.3.x and are stale. The
> union shape below is re-derived from source at HEAD, not migrated.
`RigEvent` is declared at `packages/daemon/src/domain/types.ts:106`
(`export type RigEvent =`) and runs through `types.ts:311`. It has **98 union
members** declaring **99** `type` literals: one member (`types.ts:107`) carries
both `proof.judged` and `proof.sources_changed`.
`RigEvent` is declared at `packages/daemon/src/domain/types.ts:94`
(`export type RigEvent =`) and runs through `types.ts:218`. It has **73
union members** (slice-00 §1.8, re-confirmed at HEAD:
`grep -cE '^\s*\| \{ type:'` over L94–218 = 73). Every one of the 73
declared `type:` literals is also constructed somewhere in
`packages/daemon/src/{domain,routes}` (re-verified at HEAD: the set of
`type: "<x>"` literals in domain+routes exactly equals the 73 union members
— zero union-only-but-never-referenced types).
- Members:
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "'`
- Type literals:
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"[a-z_]+(\.[a-z_]+)+"' | sort -u | wc -l`
### Per-prefix event families (grep-verified at HEAD)
**96** of the 99 literals appear as a `type: "<x>"` literal somewhere in
`packages/daemon/src` outside `types.ts` (mostly in `domain/` and `routes/`;
`seat.model_divergence` is built in `startup.ts`). **3** are declared but never
constructed: `session.status_changed`, `continuity.sync`, and `continuity.degraded`
(per #490, `qitem.closure_overdue` is produced by `QueueRepository.recordClosureOverdue`
when swept by `queue-stuck-sweep`, and `mission_control.view_refreshed` was retired).
List them with:
Each count below is a fresh, primary-source-grep-verified, explicitly-labeled
per-prefix family count over the union body
(`types.ts:94–218`) — NOT the contested PL-004 sub-count (OPEN-4 ruling:
labeled grep-verified family counts are ground truth; the `9`/`32`/`20`
figures are forbidden).
`for t in $(sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"[a-z_]+(\.[a-z_]+)+"' | tr -d '"'); do git grep -q -F "type: \"$t\"" -- packages/daemon/src ':!packages/daemon/src/domain/types.ts' || echo "$t"; done`
| Prefix | Members | Sample / role |
### Per-prefix event families
Each count below is the number of `type` literals per prefix in the union body
(`types.ts:106`–`311`):
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"[a-z_]+(\.[a-z_]+)+"' | tr -d '"' | cut -d. -f1 | sort | uniq -c | sort -rn`.
| Prefix | Types | Sample / role |
|---|---|---|
| `node.*` | 7 | `node.added` (`types.ts:97`) … `node.startup_failed` (`:139`) — lifecycle/startup |
| `workflow.*` | 6 | PL-004 Phase D workflow runtime (detail in `workflow-runtime.md`) |
| `watchdog.*` | 5 | `watchdog.evaluation_fired` (`:187`) … `watchdog.job_stopped` (`:191`) — PL-004 Phase C |
| `rig.*` | 5 | `rig.created` / `rig.deleted` / `rig.imported` / `rig.stopped` / `rig.expanded` (`:151`) |
| `queue.*` | 5 | PL-004 Phase A queue lifecycle (`:156`–`:159`, `:169`; detail in `coordination-primitive.md`) |
| `node.*` | 15 | `node.added` (`types.ts:124`) … `node.startup_proof_rejected` (`:227`) — lifecycle/startup |
| `workflow.*` | 8 | workflow runtime (detail in `workflow-runtime.md`) |
| `session.*` | 8 | session discovery / status / detach / vanish / stop / clean / resume-token audit |
| `rig.*` | 7 | `rig.created` / `rig.deleted` / `rig.imported` / `rig.stopped` / `rig.archived` / `rig.unarchived` / `rig.expanded` (`:239`) |
| `watchdog.*` | 5 | `watchdog.evaluation_fired` (`:278`) … `watchdog.job_stopped` (`:282`) |
| `seat.*` | 5 | `seat.model_divergence` (`:113`) … `seat.handover_completed` (`:198`) — model divergence, fresh launch, attention clear, handover |
| `queue.*` | 5 | queue lifecycle (`:247`–`:250`, `:260`; detail in `coordination-primitive.md`) |
| `package.*` | 5 | legacy package/install engine events |
| `mission_control.*` | 5 | PL-005 audit/notification (`:212`–`:218`; detail in `mission-control.md`) |
| `mission_control.*` | 4 | audit/notification (`:306`–`:311`; detail in `mission-control.md`) |
| `bootstrap.*` | 5 | legacy bootstrap-run events |
| `session.*` | 4 | session discovery / status / detach / vanish |
| `classifier.*` | 4 | PL-004 Phase B classifier-lease lifecycle |
| `restore.*` | 3 | restore start/complete/reconcile (detail in `lifecycle-snapshot-restore.md`) |
| `qitem.*` | 2 | `qitem.fallback_routed` (`:160`), `qitem.closure_overdue` (`:161`) |
| `pod.*` | 2 | `pod.created` (`:135`), `pod.deleted` (`:136`) |
| `inbox.*` | 2 | `inbox.absorbed` (`:162`), `inbox.denied` (`:163`) |
| `continuity.*` | 2 | `continuity.sync` (`:140`), `continuity.degraded` (`:141`) |
| singletons | 11 | one member each: `workflow_spec.*`, `view.*`, `stream.*` (`:155`), `snapshot.*`, `seat.*`, `project.*`, `kernel.*`, `chat.*` (`:149`), `bundle.*`, `binding.*`, `agent.*` |
| `restore.*` | 4 | restore start/complete/subset-complete/reconcile (detail in `lifecycle-snapshot-restore.md`) |
| `classifier.*` | 4 | classifier-lease lifecycle |
| `qitem.*` | 2 | `qitem.fallback_routed` (`:251`), `qitem.closure_overdue` (`:252`) |
| `proof.*` | 2 | `proof.judged`, `proof.sources_changed` — one union member (`:107`) |
| `pod.*` | 2 | `pod.created` (`:213`), `pod.deleted` (`:214`) |
| `inbox.*` | 2 | `inbox.absorbed` (`:253`), `inbox.denied` (`:254`) |
| `continuity.*` | 2 | `continuity.sync` (`:228`), `continuity.degraded` (`:229`) |
| `agent.*` | 2 | `agent.activity` (`:149`), `agent.session_identity` (`:156`) |
| singletons | 12 | one type each: `workflow_spec.*`, `view.*`, `transport.*` (`:153`), `topology.*` (`:136`), `stream.*` (`:243`), `snapshot.*`, `project.*`, `kernel.*`, `event.*` (`:108`), `chat.*` (`:237`), `bundle.*`, `binding.*` |
Family counts sum to 73 (15 multi-member families totalling 62 + 11
singletons), re-confirmed at HEAD.
Family counts sum to 99 type literals (18 multi-type families totalling 87 +
12 singletons).
### Emission and delivery
Events are constructed and emitted via `eventBus.emit({ type: ... })` 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.
Events are emitted via `eventBus.emit({ type: ... })` (`event-bus.ts:57`) or,
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.
Three SSE delivery surfaces (re-confirmed at HEAD):
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:457`
- `GET /api/events` — global stream of all events (`server.ts:731`
`app.route("/api/events", eventsRoute)`).
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:117`).
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:194`).
- `GET /api/queue/watch` — queue/inbox coordination events
(`routes/queue.ts:357`).
(`routes/queue.ts:994`).
- The chat SSE stream `GET /api/rigs/:rigId/chat/watch` delivers
`chat.message` for one rig (rig-scoped; see compat note 6).
> OPEN-4 (carried verbatim, slice-00): the precise PL-004-only vs PL-005-only
> sub-partition is NOT asserted here. `architecture.md`'s `32`/`20`/`9`
> figures are internally inconsistent and not reconcilable without
> classifying all 73 members by introducing slice — flagged, not smoothed.
> The grep-verified per-prefix family counts above are the substituted ground
> truth.
`chat.message` for one rig (`routes/chat.ts:68`, mounted at `server.ts:783`;
rig-scoped; see compat note 6).
## 3. Remaining compatibility notes
Intentional limits that still describe the shipped system (`architecture.md`
§11), verified as still-current at HEAD:
Intentional limits that still describe the shipped system:
1. Remote `agent_ref` imports remain unsupported (see §1 remote import
constraints).
2. Startup actions remain intentionally constrained (`slash_command`,
`send_text`).
`send_text`, `startup_proof`).
3. Legacy compatibility seams still ship for pre-reboot data and v1
artifacts.
4. `rig ask` gathers context only — does not call an external LLM (rule 22).
5. Transcript search prefers `rg`, falls back to `grep -E`; quality/perf
varies by backend.
4. `rig ask` gathers context only — does not call an external LLM — unless
`--wake` is passed (rule 22).
5. `rig ask` transcript search prefers `rg`, falls back to `grep -E`
(`history-query.ts:252`, `:267`); quality/perf varies by backend.
6. Chat is rig-scoped only — no cross-rig channels or DMs.
7. `--verify` on `rig send` checks pane content for message visibility but
can produce false positives from pre-existing matching content. Known
limitation.
7. `--verify` on `rig send` checks pane content for message visibility, not
agent acknowledgement: it compares occurrences of the message's first 40
characters before and after the send (`session-transport.ts:1413`–`1415`).
8. Terminal node readiness is shell-ready only — no service health probes.
9. `rig env down --volumes` exists in the CLI surface, but the explicit
daemon-side override is not fully plumbed through yet.
10. Managed-app service surfaces are descriptive only — OpenRig does not
auto-inject service URLs/tokens into agent prompts beyond authored
startup/context files.
11. Specialist delegation is conventional, not automatic — addressed by
9. Managed-app service surfaces are descriptive only — OpenRig does not
auto-inject service URLs/tokens into agent prompts beyond authored
startup/context files.
10. Specialist delegation is conventional, not automatic — addressed by
session name or normal communication surfaces.
## 4. Cross-references
`architecture.md` §12 names itself the architecture-level source of truth and
points at `codemap.md` for file-by-file structure. Under the modular
as-built, that role is distributed: this module owns the invariants + event
shape; the source-of-truth pointer is the rewritten `../codemap.md`
navigation index.
Under the modular as-built, this module owns the invariants and event shape;
`../codemap.md` is the navigation index for file-by-file structure.
## See also
- `daemon-core.md` — wiring, DB, migrations, startup; footprint drift-fixes.
- `coordination-primitive.md` — PL-004 Phase A queue/stream/inbox/outbox
events.
- `workflow-runtime.md` — PL-004 Phase D `workflow.*` events.
- `mission-control.md` — PL-005 `mission_control.*` events.
- `daemon-core.md` — wiring, DB, migrations, startup.
- `coordination-primitive.md` — queue/stream/inbox/outbox events.
- `workflow-runtime.md` — `workflow.*` events.
- `mission-control.md` — `mission_control.*` events.
- Source roots: `packages/daemon/src/domain/types.ts` (RigEvent union),
`packages/daemon/src/domain/event-bus.ts` (emit/persist),
`packages/daemon/src/routes/{stream,queue}.ts` (SSE watch),
`packages/daemon/src/server.ts` (`/api/events`).
+125 -281
View File
@@ -1,320 +1,164 @@
---
kind: as-built
title: Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
title: Content Surfaces — Files, Progress, Steering, and Health
status: active
topics: [observability, specification-and-bundles]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Need to know how the operator-allowlisted file browser enforces path
safety, how atomic conflict-checked writes + the JSONL edit audit work,
how the workspace PROGRESS.md tree is indexed, or how the one-screen
Steering surface (priority stack + roadmap rail + lane rails + health
gates) is composed. Author-mode module — no prior architecture.md prose;
every load-bearing claim is sourced to file:line at HEAD.
siblings: [workspace-primitive.md, daemon-core.md, ../ui/project-and-for-you.md]
prerequisite-reads: [../README.md, workspace-primitive.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
Tracing allowlisted file access, conflict-checked writes, progress indexing,
steering composition, or the daemon health-summary endpoints.
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
# Content Surfaces — Files, Progress, Steering, and Health
> **See also (v0.4.4):** the Living Notes review surface — the composed
> intent→plan→delivered projection over this same allowlisted content layer —
> is documented in [`living-notes-review.md`](living-notes-review.md).
Source snapshot: `254122872cf477511514979a4300b695d77cd1f7`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
The daemon exposes filesystem content through the files, progress, and
steering route families. Health summaries read existing daemon state. These
are separate projections with separate configuration; configuring one root
is not a universal filesystem-access policy.
The **content surfaces** are the operator-allowlisted, filesystem-canonical
read/write layer that the Project / Steering UI sits on: a fail-closed file
browser, an atomic conflict-checked write service with a JSONL edit audit,
the recursive PROGRESS.md tree indexer, and the one-screen Steering composer
(+ its compact health-summary gates). None adds SQLite state.
## Configuration and wiring
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists for
> this. Every load-bearing claim carries `> Source: <file:line> @HEAD`;
> ambiguity is declared as an OPEN item, never smoothed. Paths are relative
> to `packages/daemon/src/` unless prefixed `packages/` or `docs/`.
>
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2
> commits; no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
>
> **SPLIT NOTE (§10.6).** This is the content-surfaces half of the
> `workspace-and-content-primitives` SPLIT (sibling `workspace-primitive.md`
> carries the workspace primitive + migrations 038/039 +
> missions/projects/slices). See `workspace-primitive.md` § header SPLIT
> NOTE for the §10.6 rationale and the transparent-noted-deviation
> disclosure.
[`startup.ts`](../../../packages/daemon/src/startup.ts) constructs
`SettingsStore`, decodes `cfg.filesAllowlistRaw` and `cfg.progressScanRootsRaw`,
and injects `FileWriteService`, `ProgressIndexer`, and `SteeringComposer`.
The write service exists only when the decoded file allowlist is nonempty.
Its audit path is explicitly `${OPENRIG_HOME}/file-edit-audit.jsonl`.
## 0. Release attribution (forensic proof — read first)
[`SettingsStore`](../../../packages/daemon/src/domain/user-settings/settings-store.ts)
resolves environment overrides, then settings-file values, then defaults.
`files.allowlist` and `progress.scan_roots` both default to
`workspace:<resolved-workspace-root>`. Their environment overrides are
`OPENRIG_FILES_ALLOWLIST` and `OPENRIG_PROGRESS_SCAN_ROOTS`. Thus, an unset
environment variable does **not** imply an empty allowlist in normal daemon
startup. The standalone `readAllowlistFromEnv` helper has different inputs
and retains its legacy environment fallback.
Per §10.8, re-verified at HEAD via `git cat-file -e <tag>:<path>`:
Roots use comma-separated `name:/absolute/path` pairs. The decoders skip
invalid pairs, canonicalize existing paths, and let the last duplicate name
win. See [`decodeAllowlist`](../../../packages/daemon/src/domain/files/path-safety.ts)
and [`decodeProgressScanRoots`](../../../packages/daemon/src/domain/progress/progress-indexer.ts).
| Subsystem | Release | Forensic proof @HEAD |
|---|---|---|
| `domain/files/{path-safety,file-write-service}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
| `domain/progress/progress-indexer.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
| `domain/steering/{steering-composer,health-summary}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
| `routes/{files,progress,steering,health-summary}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
## Path resolution and reads
> Source: §10.8 proof re-run at HEAD `7eaf524c` — every listed path
> resolves at `v0.3.0` (no failures). All content surfaces are
> **≤0.3.0** features (UI Enhancement Pack v0 + Operator Surface
> Reconciliation v0; slice-00 0.3.0-GT §1.7 "Files / Markdown /
> Progress / Proofs" confirms the cluster shipped 0.3.0). Do **not**
> back-attribute any of these to 0.3.1 — the gap is missing
> architecture.md prose, not a new feature.
[`resolveAllowedPath`](../../../packages/daemon/src/domain/files/path-safety.ts)
requires a configured root, rejects `..` path segments and absolute relative
paths, resolves existing symlinks, and checks containment with a path-separator
boundary. An empty relative path denotes the root. If realpath fails, the
helper falls back to the resolved candidate; file/directory helpers then
perform a stat check. These are the implemented checks, not a claim of
race-free isolation against concurrent filesystem changes.
## 1. Path safety — the fail-closed allowlist (UI Enhancement Pack v0)
[`readAllowedFile`](../../../packages/daemon/src/domain/files/file-read.ts)
is shared by the HTTP reader and the local TUI reader. It returns content,
absolute and root-relative resolved paths, mtime, full-file SHA-256, size,
binary classification, and truncation metadata. The returned text is capped
at **1,048,576 bytes**, but the full file is read and hashed. The cap limits
the response, not the memory required to read the file.
The file browser is gated by a daemon-enforced, **fail-closed** allowlist.
Roots are decoded from `OPENRIG_FILES_ALLOWLIST` (env), comma-separated
`<name>:<absolute-path>` pairs (legacy fallback `RIGGED_FILES_ALLOWLIST`
via `||` so an empty string falls through). Invalid pairs (no colon, empty
name, non-absolute path) are **silently skipped**; duplicate names →
last-wins. Empty/unset → no roots → routes return an empty list with a
structured "configure OPENRIG_FILES_ALLOWLIST" hint. The safe default after
a fresh install is **nothing allowlisted**.
## Writes and audit
> Source: `domain/files/path-safety.ts:50-51` (env vars),
> `:60-93` (`decodeAllowlist` / `readAllowlistFromEnv`; silent-skip
> L67-71, last-wins L78, `||` fallback comment L89-91) @HEAD.
[`FileWriteService.writeAtomic`](../../../packages/daemon/src/domain/files/file-write-service.ts)
reads the existing target and compares **both** the expected mtime and content
hash. A mismatch raises `WriteConflictError` with the current values. It then
writes a temporary file in the same directory, preserves the target's
ordinary permission bits, fsyncs the temporary file, and renames it over the
target. This is optimistic conflict detection; it does not hold a lock over
the read/compare/replace sequence.
`resolveAllowedPath(allowlist, rootName, relativePath)` is the load-bearing
guard. Algorithm: (1) reject unknown root (`root_unknown`); (2) reject
expressed-intent `..` segments **before** filesystem resolution
(`path_escape`) — segment-split so `foo..bar` is not a false positive;
(3) reject an absolute `relativePath` (`path_invalid`); (4) resolve
symlinks via `fs.realpathSync` and reject when the realpath does not start
with `<canonicalRoot><sep>` (`path_escape`) — a `path.sep` boundary so
`/foo/bar` does not match `/foo/bar-other`; (5) `path === ""` resolves to
the root itself (so callers can list the root). Symlinks **inside** the
tree resolve normally; symlinks pointing **outside** are escape attempts.
A non-existent candidate falls back to the unresolved path so the
subsequent stat surfaces the absence with a specific code.
`resolveAllowedFile` / `resolveAllowedDirectory` add a stat assertion
(`not_a_file` / `not_a_directory`).
`createAtomic` handles new files. It creates and fsyncs a temporary file, then
uses an exclusive hard link to establish the target. If the target already
exists, it returns the `target_exists` error instead of overwriting it.
> Source: `domain/files/path-safety.ts:95-171` (`resolveAllowedPath`;
> `..`-before-fs L124-140, absolute-reject L141-147, sep-boundary
> L158-169, empty-path base case in the doc-comment L108-110),
> `:173-225` (file/dir convenience asserts) @HEAD.
Both operations append a JSONL audit record containing actor/provenance,
paths, timestamps, hashes, and byte-count delta. An audit append failure
raises `audit_write_failed` **after the content has landed**; the write is not
rolled back. The service appends records without implementing rotation.
The service's standalone default is under the user's `.openrig` directory;
daemon startup overrides it with the active OpenRig home as described above.
## 2. Atomic write + JSONL audit (UI Enhancement Pack v0 item 4)
## HTTP surface
`FileWriteService.writeAtomic(req)` is the operator-actionable write surface
for `STEERING.md` / `PROGRESS.md` / spec YAML / any allowlisted file.
Sequence: (1) resolve target under allowlist (reuses §1 path-safety);
(2) re-stat + re-hash the target — if `mtime !== expectedMtime` OR
`contentHash !== expectedContentHash`, throw `WriteConflictError` carrying
the **current** mtime + hash for the UI to surface (optimistic-concurrency,
not a lock); (3) write to a temp file in the **same directory** (PID +
random suffix so concurrent writes don't collide); (4) `fsync` the temp fd
for durability before the rename; (5) atomic `renameSync` over the target
(single inode swap on POSIX — readers see old or new, never partial);
(6) recompute mtime + hash + byte delta; (7) append one JSONL audit row.
[`server.ts`](../../../packages/daemon/src/server.ts) mounts these families.
[`filesRoutes`](../../../packages/daemon/src/routes/files.ts) implements:
> Source: `domain/files/file-write-service.ts:9-25` (documented
> atomic-write semantics), `:111-212` (`writeAtomic`; conflict-detect
> L129-135, same-dir temp L137-141, fsync L146, atomic rename L161)
> @HEAD.
| Method and path | Result / important behavior |
|---|---|
| `GET /api/files/roots` | Configured roots; an empty list carries a setup hint. |
| `GET /api/files/list?root=…&path=…` | Directory entries with metadata, including dotfiles. |
| `GET /api/files/read?root=…&path=…` | `readAllowedFile` result. |
| `GET /api/files/asset?root=…&path=…` | Raw asset; single byte-range support (`206`, invalid range `416`), five-minute cache. HTML defaults to plain text; `render=1` opts into HTML rendering. |
| `POST /api/files/write` | Requires root, path, string content, expected mtime and hash. Stale reads return `409 write_conflict`; an absent write service returns `503`. |
The audit file is `~/.openrig/file-edit-audit.jsonl` by default
(`HOME`/`USERPROFILE` → `/tmp` fallback). **Append-only; never rotates at
v0.** A failed audit append throws `FileWriteError("audit_write_failed")`
but does **NOT** undo the landed write — the user's edit succeeded; an
audit-system flake must not silently revert canon. (The route still
surfaces it as a 500 so the failure is honest, not swallowed.)
Writes resolve actor/provenance through `resolveActorWithDeferral`. Missing
route dependencies return `503`; unknown roots and invalid paths return
`400`, and failed file stats return `404`. An audit failure can return `500`
even though the file write succeeded, so an error is not proof of no effect.
> Source: `domain/files/file-write-service.ts:87-91` (default path +
> fallback), `:24-25` (never-rotates-at-v0), `:182-204` (audit append;
> "does NOT undo the write" comment L183-184) @HEAD.
## Progress indexing
## 3. Files routes — browse + read + asset + write
[`ProgressIndexer.scan`](../../../packages/daemon/src/domain/progress/progress-indexer.ts)
walks configured roots on each request, to a default maximum depth of **6**.
It skips dot-prefixed entries and named build/dependency directories, and
collects regular `PROGRESS.md` and `STEERING.md` files. Unreadable directories
and files are skipped. Consequently, an empty scan does not prove all files
were readable.
`filesRoutes()` (mounted `server.ts:507` as `/api/files`). All routes are
**literal** (no `/:param` catchall) so order doesn't matter for shadowing
(Phase A R1 SSE lesson):
The parser skips frontmatter, takes the first H1 as the title, turns
H2–H4 headings into hierarchy rows, and reads checkbox rows as `[x]`/`[X]`
= done, `[~]` = blocked, and `[ ]` = active. Checkbox depth uses two-space
indentation; headings use heading depth. Per-file and aggregate counts cover
checkbox rows. This is a progress-text projection, not proof readiness.
- `GET /roots` — allowlist root list (or `{roots:[], hint}` when none).
- `GET /list?root&path` — directory entries (dirs first, then files,
name-sorted; dotfiles always included — allowlisting a root expresses
inspection intent).
- `GET /read?root&path` — file content + `mtime` + `contentHash` (SHA-256)
+ `size`. **Content is capped at `FILE_READ_TRUNCATION_BYTES` (1 MB)** but
the hash is computed over the **FULL** file so atomic-write conflict
detection stays honest even on a truncated read; response carries
`truncated` / `truncatedAtBytes` / `totalBytes`.
- `GET /asset?root&path` — raw bytes for embedded images/video/pdf (inferred
`Content-Type`, 5-min cache). **v0.4.4 (OPR.0.4.4.20 FR-5):** serves HTTP
**byte ranges** — single-range form, `206` + `Content-Range` +
`Accept-Ranges: bytes` (malformed/unsatisfiable → `416`) — the
iOS-Safari-class media-playback requirement; and `.html` renders as
`text/html` ONLY under the explicit `?render=1` opt-in (text/plain stays
the default). The slice proof-asset route (`/api/slices/:name/proof-asset/*`)
carries the same range semantics (living-notes corrective). See
[`living-notes-review.md`](living-notes-review.md) §5.
- `POST /write` — atomic write (§2); 503 when no write service
(`OPENRIG_FILES_ALLOWLIST` empty); 409 `write_conflict` on stale
mtime/hash with the current values for the UI's refresh prompt.
[`GET /api/progress/tree`](../../../packages/daemon/src/routes/progress.ts)
returns the scan. Missing indexer or empty configured roots returns `503`.
503 `files_routes_unavailable` when the allowlist dep is unwired.
Path-safety errors map to HTTP per code (`root_unknown`/`path_*` → 400,
`stat_failed` → 404).
## Steering composition
> Source: `routes/files.ts:51-242` (handlers; route-order comment
> L17-19, truncation L139-154, write 503/409 L187-191/L225-231,
> path-safety→HTTP `:61-69`); `FILE_READ_TRUNCATION_BYTES = 1_048_576`
> `:49`; mounted `server.ts:507`; CLI: no `rig files` verb — files are
> a UI-only surface @HEAD.
[`steeringOptsFromSettings`](../../../packages/daemon/src/domain/steering/steering-composer.ts)
combines resolved workspace settings with `OPENRIG_STEERING_WORKSPACE`,
`OPENRIG_STEERING_PATH`, `OPENRIG_ROADMAP_PATH`, and
`OPENRIG_DELIVERY_READY_DIR` overrides. `SteeringComposer.compose` reads:
> **§10.7 route-reality (source-verified at `packages/ui/src/routes.tsx`):**
> `/files` is a **REAL route** (`FilesWorkspace`) the Phase-8.1 survey
> MISSED. There is **NO `/markdown` route** — markdown rendering is a
> *component* inside file / drawer surfaces, not a navigable destination.
>
> Source: `packages/ui/src/routes.tsx:192-195` (`path: "/files"` +
> `FilesWorkspace`); grep-confirmed no `path: "/markdown"` declaration
> anywhere in routes.tsx @HEAD.
| Field | Source and selection |
|---|---|
| `priorityStack` | Verbatim `STEERING.md`, mtime, and byte count. |
| `roadmapRail` | `roadmap/PROGRESS.md` checkbox rows; the first unchecked row is marked next. |
| `laneRails` | Progress files under `delivery-ready`, using a depth-3 `ProgressIndexer`; first non-done/non-blocked checkbox is next pull. Default top 3 prefers non-done rows, then fills with done rows. |
## 4. Progress tree indexer (UI Enhancement Pack v0 item 1B)
Missing/unreadable sections become `unavailableSources`. `isReady` means at
least one path resolves; it does not guarantee that every read succeeds.
[`GET /api/steering`](../../../packages/daemon/src/routes/steering.ts)
returns `503` when the composer is absent or no source path resolves.
Queue and health state are fetched through their own endpoints.
`ProgressIndexer` walks operator-allowlisted scan roots
(`OPENRIG_PROGRESS_SCAN_ROOTS`, same `<name>:<abs-path>` shape as the file
allowlist; legacy `RIGGED_PROGRESS_SCAN_ROOTS`), finds **`PROGRESS.md` AND
`STEERING.md`** files (STEERING.md is anchored as the constraint-frame node
per OSR v0 Item 2; the row-machinery is filename-agnostic), and parses each
into a checkbox-hierarchy tree. Recursion is depth-bounded (default 6 —
enough for mission/lane/slice nesting without descending into
`node_modules`/`.git`/`.worktrees`/`dist`/`build`/`.turbo`/`.next` or
dotfiles). Checkbox status: `[x]` → `done`, `[~]` → `blocked`, `[ ]` →
`active`; headings (`##`–`####`) become hierarchy rows; depth from
2-space indent or heading level. Per-file `counts` + a scan-wide
`aggregate`. **In-memory walk per request; no caching at v0** (bounded by
the operator's allowlist scope).
## Health summaries
> Source: `domain/progress/progress-indexer.ts:79-84` (STEERING anchor +
> env vars), `:81` (SKIP_DIRS), `:71` (`DEFAULT_MAX_DEPTH = 6`),
> `:218-235` (checkbox status mapping), `:204-215` (heading rows),
> `:21-23` (no-caching-at-v0) @HEAD.
[`healthSummaryRoutes`](../../../packages/daemon/src/routes/health-summary.ts)
exposes four reads under `/api/health-summary`:
`progressRoutes()` (mounted `server.ts:508` as `/api/progress`):
`GET /tree` — the indexed hierarchy; 503 `progress_indexer_unavailable`
when unwired; 503 `progress_scan_roots_not_configured` + setup hint when
no roots.
| Suffix | Source |
|---|---|
| `/nodes` | `computeNodeHealthSummary`: node inventory aggregated across rigs, grouped by session/lifecycle status, with an attention-required count. |
| `/context` | `computeContextHealthSummary`: `context_usage` rows grouped by urgency and sample freshness. |
| `/version` | `getDaemonVersion`: the running daemon's version, or `unknown` on read failure. |
| `/gateway` | The injected gateway subsystem's `status()`; `503` if unavailable. |
> Source: `routes/progress.ts:18-40` (handler; 503s L29-35); mounted
> `server.ts:508` @HEAD.
The [context aggregation](../../../packages/daemon/src/domain/steering/health-summary.ts)
uses **80%** for critical, **60%** for warning, and **300 seconds** for
freshness. A missing usage value is unknown; a missing timestamp has no
freshness sample. The SQL read catches errors and falls back to an empty
sample set, so zero counts alone are not a database-health guarantee.
> **§10.7 route-reality:** `/progress` is a **`<Navigate to="/project" />`
> redirect-stub** — the progress system lives at the daemon
> `/api/progress` route, but the UI URL folds into Project tabs (Phase 3).
>
> Source: `packages/ui/src/routes.tsx:464-468` ("folds into Project tabs
> (Phase 3) — redirect to /project") @HEAD.
## Related source
## 5. Steering composer (Operator Surface Reconciliation v0 item 1)
`SteeringComposer.compose()` is the **one-screen composed steering surface**.
It is deliberately narrow: it composes only the **filesystem-derived**
pieces; the UI fetches PL-005 queue views (in-motion / loop-state) and
health gates via their own existing endpoints so the composer stays
testable. Three sources, all resolved from a single workspace root
(`OPENRIG_STEERING_WORKSPACE`; per-piece overrides
`OPENRIG_STEERING_PATH` / `OPENRIG_ROADMAP_PATH` /
`OPENRIG_DELIVERY_READY_DIR` trump the root-derived defaults; legacy
`RIGGED_STEERING_WORKSPACE`):
- **priorityStack** — verbatim `STEERING.md` content + mtime + byteCount.
- **roadmapRail** — `roadmap/PROGRESS.md` checkbox rows; detects `PL-XXX`
rail-item codes; marks the **first unchecked** item as `isNextUnchecked`.
- **laneRails** — `delivery-ready/mode-{N}/PROGRESS.md` per-lane: reuses
`ProgressIndexer` (maxDepth 3) for identical checkbox semantics to the
Progress view; "next pull" = first non-done, non-blocked row (Priority
Rail Rule — shelf/queue recency does NOT override); top-N (default 3)
prefers active+blocked, falls back to done only to fill N.
Missing sources are reported in `unavailableSources` (each names the env
var that would resolve it) rather than failing the whole payload.
`isReady()` is true when **at least one** source resolves.
> Source: `domain/steering/steering-composer.ts:1-25` (narrow-by-design
> rationale + 3-source list), `:103-119` (env vars +
> `steeringOptsFromEnv`), `:155-169` (`isReady` / `compose`), `:198-250`
> (roadmap rail; next-unchecked L231-235), `:282-316` (lane next-pull
> L288-289, top-N L293-296), `RAIL_CODE_REGEX` `:346` @HEAD.
`steeringRoutes()` (mounted `server.ts:510` as `/api/steering`):
`GET /` — the composed payload; 503 `steering_composer_unavailable` when
unwired; 503 `steering_workspace_not_configured` + a hint pointing at
`rig config init-workspace` / `workspace.steering_path` /
`OPENRIG_STEERING_PATH` when no source resolves.
> Source: `routes/steering.ts:18-34` (handler; 503s L23-29); mounted
> `server.ts:510` @HEAD.
> **§10.7 route-reality:** `/steering` is a **`<Navigate to="/project" />`
> redirect-stub** — the composer lives at daemon `/api/steering`; the UI
> URL folds into the Project workspace overview tab (Phase 3).
>
> Source: `packages/ui/src/routes.tsx:470-474` ("folds into Project
> workspace overview tab (Phase 3) — redirect to /project") @HEAD.
## 6. Health-summary aggregator (OSR v0 item 1F)
`computeNodeHealthSummary` / `computeContextHealthSummary` are the compact
health gates on the steering surface — **daemon-side aggregation, not a CLI
shell-out** (no per-request subprocess). Node summary: cross-rig roll-up of
`sessionStatus` + `lifecycleState` via `getNodeInventory` per rig, with an
`attentionRequired` tally. Context summary: reads `context_usage` directly
(ContextUsageStore exposes per-node accessors only; listing all rows is a
steering-surface concern) and buckets by urgency
(`usedPercentage` ≥80 critical / ≥60 warning / else low / null unknown) +
freshness (`sampledAt` age vs 300 s → fresh/stale/none). A missing
`context_usage` table (test harness without the migration) yields an empty
summary, not an error.
> Source: `domain/steering/health-summary.ts:44-46` (thresholds:
> `FRESHNESS_THRESHOLD_S=300`, `URGENCY_CRITICAL_PCT=80`,
> `URGENCY_WARNING_PCT=60`), `:48-66` (node roll-up), `:75-117`
> (context roll-up; table-absent → empty L81-84) @HEAD.
`healthSummaryRoutes()` (mounted `server.ts:511` as `/api/health-summary`):
`GET /nodes`, `GET /context`; 503 `health_summary_unavailable` when the
rigRepo dep is unwired.
> Source: `routes/health-summary.ts:23-44` (handlers; 503 L33,L40);
> mounted `server.ts:511` @HEAD.
## 7. Cross-cutting properties
- **Filesystem-canonical, no new SQLite:** all four surfaces read/write the
filesystem + the JSONL audit; health-summary reads existing
`context_usage` only. No content-surface migration
(`progress-indexer.ts:21-23`; `health-summary.ts:81-84`) @HEAD.
- **Fail-closed, honest errors:** path-safety rejects every escape attempt
before fs resolution; write conflicts surface the current mtime/hash for
an explicit refresh; missing config returns 503 + a setup hint naming the
exact env var — never a silent empty success
(`path-safety.ts:124-169`; `file-write-service.ts:129-135`;
`routes/steering.ts:25-29`) @HEAD.
- **Atomic-or-nothing writes:** same-directory temp + fsync + atomic rename;
a rename failure leaves the target untouched (never a partial write)
(`file-write-service.ts:160-172`) @HEAD.
- **Composer narrowness is deliberate:** the steering composer owns only
filesystem-derived pieces; queue/health come from their own endpoints —
keeps it testable (`steering-composer.ts:6-16`) @HEAD.
## OPEN items (carried, not smoothed)
- **OPEN-A** — the JSONL edit audit at `~/.openrig/file-edit-audit.jsonl`
**never rotates at v0** by explicit design
(`file-write-service.ts:24-25`); unbounded growth is a known accepted v0
state (PRD § Item 4 defers rotation), stated here rather than smoothed.
- **OPEN-B** — `OPENRIG_FILES_ALLOWLIST` vs `OPENRIG_PROGRESS_SCAN_ROOTS`
vs `OPENRIG_STEERING_WORKSPACE` are three independent operator-config
surfaces with the same encoding but separate resolution; there is no
single "workspace allowlist" that unifies them at v0. Source-real;
noted, not reconciled.
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
migration-count / route-group / PL-004-event-count claim.
- [Living Notes review](living-notes-review.md) composes proof and scope content.
- [Workspace primitive](workspace-primitive.md) covers workspace identity.
- [File-path tests](../../../packages/daemon/test/files-path-safety.test.ts),
[progress tests](../../../packages/daemon/test/progress-indexer.test.ts), and
[steering tests](../../../packages/daemon/test/steering-composer.test.ts)
exercise these helpers; their presence is not a runtime verification claim.
@@ -1,6 +1,6 @@
---
kind: as-built
title: Coordination Primitive — Stream/Queue/Inbox/Outbox (PL-004 Phase A)
title: Coordination Primitive — Stream/Queue/Inbox/Outbox
status: active
topics: [coordination, observability]
domains: [engineering-advisor, operating-advisor, orchestrator]
@@ -10,57 +10,74 @@ applies-when: |
transactional handoff guarantee, or where queue closure is enforced.
siblings: [workflow-runtime.md, mission-control.md, daemon-core.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
last-updated: 2026-10-03
---
# Coordination Primitive — Stream/Queue/Inbox/Outbox (PL-004 Phase A)
# Coordination Primitive — Stream/Queue/Inbox/Outbox
The PL-004 Phase A coordination primitive is the SQLite-canonical
durable-work layer the daemon exposes through `/api/stream` and `/api/queue`.
It replaces the POC filesystem `rigx queue` / `rigx stream` path for
daemon-backed work; the POC filesystem path remains untouched and the daemon
`rig queue` / `rig stream` write only to SQLite (`architecture.md` §5 L651).
The coordination primitive is the SQLite-canonical durable-work layer the
daemon exposes through `/api/stream` and `/api/queue`. It replaces the POC
filesystem `rigx queue` / `rigx stream` path for daemon-backed work; the POC
filesystem path remains untouched, and the daemon-backed `rig queue` /
`rig stream` commands operate only through the daemon HTTP API, so they write
only to SQLite (`packages/cli/src/commands/queue.ts:19`,
`packages/cli/src/commands/stream.ts:11`).
> Verified against source at HEAD `7eaf524c`.
> Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. The five host-scoped tables
Five host-scoped tables back the primitive (`architecture.md` §3 L416–438;
migrations re-confirmed at HEAD `packages/daemon/src/db/migrations/`):
Five host-scoped tables back the primitive, one per migration `023`–`027` in
`packages/daemon/src/db/migrations/` (**5** tables:
`cat packages/daemon/src/db/migrations/02[3-7]_*.ts | grep -c 'CREATE TABLE'`):
- **`stream_items`** (`023_stream_items.ts`) — L1 append-only intake/audit
root. Columns: `stream_item_id` (ULID PK), `ts_emitted`,
root. Columns: `stream_item_id` (TEXT PK; a ULID unless the caller supplies
one, `packages/daemon/src/domain/stream-store.ts:88`), `ts_emitted`,
`stream_sort_key`, `source_session`, `body`, `format` (default `text`),
`hint_type`, `hint_urgency`, `hint_destination`, `hint_tags` (JSON),
`interrupt`, `archived_at`. Items immutable after emit (only `archived_at`
may be set).
- **`queue_items`** (`024_queue_items.ts`) — L3 owned-work queue. `qitem_id`
is TEXT PK preserving the POC `qitem-YYYYMMDDHHMMSS-<hex>` shape. State
enum (8 values): `pending | in-progress | done | blocked | failed |
denied | canceled | handed-off`. Carries `closure_reason`,
`closure_target`, `closure_required_at`, `chain_of_record` (JSON),
`blocked_on`, `handed_off_to`/`handed_off_from`, nudge/heartbeat fields.
`interrupt`, `archived_at`, and `identity_provenance` (added by
`067_i3_identity_provenance.ts`). Items immutable after emit (only
`archived_at` may be set, `stream-store.ts:217`).
- **`queue_items`** (`024_queue_items.ts`) — L3 owned-work queue.
Unless `--id` is supplied, the CLI generates the create request's `qitem_id` (TEXT PK) before sending as `qitem-<UTC YYYYMMDDHHMMSS>-<16 hex>` (64 random bits) and prints it to stderr as a request identity, not proof of persistence; reuse that ID with `--id` and the unchanged payload after an unknown outcome, while daemon callers without an ID still use `newQitemId()` and its `qitem-<UTC YYYYMMDDHHMMSS>-<8 hex>` form.
State enum (**8** values, `QUEUE_STATES` at `queue-repository.ts:32`;
`sed -n '/^export const QUEUE_STATES/,/] as const/p' packages/daemon/src/domain/queue-repository.ts | grep -c '^ "'`):
`pending | in-progress | done | blocked | failed | denied | canceled |
handed-off`. Carries `closure_reason`, `closure_target`,
`closure_required_at`, `chain_of_record` (JSON), `blocked_on`,
`handed_off_to`/`handed_off_from`, nudge/heartbeat columns.
- **`queue_transitions`** (`025_queue_transitions.ts`) — L3 append-only
transition log; authoritative audit trail for state evolution. No
UPDATE/DELETE from domain code.
transition log; authoritative audit trail for state evolution. Domain code
never updates its rows. The one delete is the retention runner, which moves
every transition of a terminal item whose last transition is older than the
retention window into `queue_transitions_archive`
(`054_queue_transitions_archive.ts`), insert and delete in one transaction
(`packages/daemon/src/domain/queue-retention.ts:203`).
- **`inbox_entries`** (`026_inbox_entries.ts`) — mailbox-style asynchronous
deposit; idempotent on `inbox_id`. State: `pending | absorbed | denied`.
- **`outbox_entries`** (`027_outbox_entries.ts`) — sender-side audit;
symmetric to inbox; idempotent on `outbox_id`. Delivery state:
`pending | delivered | failed`.
deposit; idempotent on `inbox_id`. State: `pending | absorbed | denied`
(`INBOX_STATES`, `inbox-handler.ts:5`).
- **`outbox_entries`** (`027_outbox_entries.ts`) — sender-side record;
symmetric to inbox; idempotent on `outbox_id`. Delivery state (**7** values,
`OUTBOX_DELIVERY_STATES` at `outbox-handler.ts:28`;
`grep '^export const OUTBOX_DELIVERY_STATES' packages/daemon/src/domain/outbox-handler.ts | grep -o '"[a-z]*"' | wc -l`):
`pending | sending | delivered | failed | indeterminate | retained |
retired`.
> Drift-fix D3 — `architecture.md` §3 L243 frames the schema as "27
> migrations (22 existing plus 5 added by PL-004 Phase A)". The 5 PL-004
> Phase A coordination tables (`023`–`027`) are still correct as a *range*,
> but the headline migration count is **40**, not 27 (slice-00 §1.3,
> re-confirmed at HEAD: `startup.ts:206` 40-element `migrate()` array; full
> drift-fix authority and the correction live in `daemon-core.md`).
These five are migrations `023`–`027` of the daemon's **93**
(`git ls-files packages/daemon/src/db/migrations | wc -l`), applied in the
order of `ALL_MIGRATIONS` (`packages/daemon/src/db/all-migrations.ts:102`).
Later migrations add columns to these tables; `daemon-core.md` covers the
migration set.
## 2. The six host-scoped services
Six host-scoped domain services implement the layer (`architecture.md` §5
L649–658; files re-confirmed in `packages/daemon/src/domain/` at HEAD).
Six host-scoped domain services in `packages/daemon/src/domain/` implement the
layer (**6** files:
`ls packages/daemon/src/domain/{stream-store,queue-repository,queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts | wc -l`).
Routes import these; services are Hono-free:
- **`stream-store.ts`** — L1 stream: idempotent emit (on `stream_item_id`),
@@ -68,40 +85,53 @@ Routes import these; services are Hono-free:
and inclusive time-window filters, soft archive. `direction=latest` applies
every filter before taking the newest bounded page, then returns that page
chronologically.
- **`queue-repository.ts`** — L3 queue: create, claim/unclaim, update
(general state mutator with hot-potato strict-rejection on `done`),
transactional handoff (close source as `handed-off` plus create new owned
qitem in a single transaction), pod-fallback rerouting, overdue lookup,
nudge/heartbeat tracking. Cross-rig validation hook exposed as
`validateRig` constructor option.
- **`queue-repository.ts`** — L3 queue: create (`:1391`), claim/unclaim
(`:2165`, `:2247`), update (general state mutator with hot-potato
strict-rejection on `done`, `:2310`), transactional handoff (close source as
`handed-off` plus create new owned qitem in a single transaction, `:1634`;
`handoffAndComplete`, `:1811`, closes the source as `done` instead),
pod-fallback rerouting (`routeToFallback`, `:3580`), overdue lookup
(`findOverdue`, `:3333`), nudge-result tracking (`recordNudgeAttempt`,
`:3565`). Nothing in the daemon writes the `last_heartbeat` column
(`queue-pickup.ts:16`). Cross-rig validation hook exposed as `validateRig`
constructor option (`queue-repository.ts:711`).
- **`queue-transition-log.ts`** — append-only state-transition log; used by
`queue-repository.ts`, exposed read-only on
`queue_repository.transitionLog`.
`queue-repository.ts`, exposed as the read-only property
`QueueRepository.transitionLog` (`queue-repository.ts:660`).
- **`hot-potato-enforcer.ts`** — pure validator for the load-bearing API
contract (see §3).
- **`inbox-handler.ts`** — mailbox handler: authenticated drop (idempotent
on `inbox_id`), absorb (promotes a pending entry to a `queue_item`,
idempotent), deny (records reason). Auth check is a pluggable constructor
hook.
- **`outbox-handler.ts`** — sender-side outbox: idempotent record, mark
delivered/failed, list. Emits no event-bus events (pure audit).
- **`inbox-handler.ts`** — mailbox handler: drop (idempotent on `inbox_id`,
`:99`), absorb (promotes a pending entry to a `queue_item`, idempotent,
`:154`), deny (records reason, `:214`). The handler has no auth hook: the
inbox routes take the sender or receiver from the `X-OpenRig-Session` header
through `requireSenderIdentity` (`routes/queue.ts:1034`), never from the body
(`inbox-handler.ts:71`).
- **`outbox-handler.ts`** — sender-side outbox: idempotent record (`:130`),
delivery-state marks (`markDelivered` `:239`, `markFailed` `:256`,
`markIndeterminate` `:280`), list (`listForSender` `:339`). It also holds the
queue's durable wake intents (ids prefixed `wake-intent-`, `:15`) and the
messages the seat delivery guard retains (`retain` `:181`, `retire` `:217`),
so it is not audit-only. Emits no event-bus events.
The standing detector in `queue-stuck-sweep.ts` creates findings through the
queue repository, with `evidenceRef: rig queue show <source-qitem-id>` pointing
to the underlying durable work row. This satisfies the existing human-route
evidence contract without changing destination resolution: an admitted finding
can still be unroutable. Repeated detections refresh the existing finding;
when the source condition resolves, the sweep closes its own finding.
queue repository, with `evidenceRef: rig queue show <source-qitem-id>`
(`queue-stuck-sweep.ts:523`) pointing to the underlying durable work row. This
satisfies the existing human-route evidence contract without changing
destination resolution: an admitted finding can still be unroutable. Repeated
detections refresh the existing finding; when the source condition resolves,
the sweep closes its own finding (`:549`).
## 3. The hot-potato closure contract (where queue closure is enforced)
`hot-potato-enforcer.ts` is the pure validator for the load-bearing API
contract. Re-confirmed at HEAD (`hot-potato-enforcer.ts:10–24`):
contract. `validateClosure` (`hot-potato-enforcer.ts:59`) checks a transition
against `CLOSURE_REASONS` (`:18`; **7** values:
`sed -n '/^export const CLOSURE_REASONS/,/] as const/p' packages/daemon/src/domain/hot-potato-enforcer.ts | grep -c '^ "'`):
`state=done` requires `closure_reason ∈ {handed_off_to, blocked_on, denied,
canceled, no-follow-on, escalation}`. The reasons
canceled, no-follow-on, escalation, superseded}`. The reasons
`handed_off_to | blocked_on | escalation` additionally require
`closure_target`:
`closure_target` (`:86`):
- `handed_off_to` — work continues with a different seat (`closure_target` =
new owner).
@@ -112,31 +142,43 @@ canceled, no-follow-on, escalation}`. The reasons
- `no-follow-on` — terminal completion, nothing else needed.
- `escalation` — kicked up to a higher tier (`closure_target` = escalation
target).
- `superseded` — the row was replaced by cancel-and-replace
(`closure_target` = the successor qitem). The update path records it on
`state=canceled` and refuses it there without a `closure_target`
(`queue-repository.ts:2566`).
Tier→SLA mapping for `closure_required_at` also lives in
`hot-potato-enforcer.ts`. This validator is invoked by
`QueueRepository.update()` (and `updateWithinTransaction()`), so closure is
enforced at the daemon transaction boundary — the workflow runtime
*projects* on closure but does not gate it (see `workflow-runtime.md`).
`hot-potato-enforcer.ts` (`TIER_SLA_SECONDS`, `:114`). This validator is
invoked by `QueueRepository.update()` (and `updateWithinTransaction()`), both
through the call at `queue-repository.ts:2484`, so closure is enforced at the
daemon transaction boundary — the workflow runtime *projects* on closure but
does not otherwise gate it (see `workflow-runtime.md`). The one
workflow-aware check is in the queue: `update()` refuses a terminal close of a
live workflow frontier packet from a non-workflow verb
(`workflow_frontier_packet`, `queue-repository.ts:2510`), through a predicate
startup injects.
## 3b. Cross-host queue routing (v0.4.6 — OPR.0.4.6.MH3)
## 3b. Cross-host queue routing
The queue's two coordination WRITE verbs — **create + handoff(-and-complete)
only** — are host-aware: a write body may carry an out-of-band `hostId`
envelope (BR-1 — session strings stay `member@rig` everywhere; the 3-part
`agent@rig@host` form is CLI input sugar that never leaves the CLI edge).
The mechanism **generalizes the shipped mission-control forward-then-strip
WRITE template** (`routes/mission-control.ts` § remote action): one shared
route-layer helper (`forwardQueueWrite`, `routes/queue.ts`) resolves the
host registry daemon-side (bearers never reach the caller), rejects
ssh-declared hosts (`unsupported-transport` — the daemon→daemon path is
http-only, because it is what fires the remote nudge), strips `hostId`, and
forwards the WHOLE body via `remoteJsonRequest` under a named write-class
deadline (`QUEUE_FORWARD_TIMEOUT_MS`). The origin's response returns
verbatim; transport failures map to a structured host-named taxonomy
(`remote_queue_write_failed`: unknown-host / unsupported-transport /
unreachable / auth-failed / remote-error). No local row is ever written on
the cross-host path.
Three queue write routes are host-aware — `POST /create`,
`POST /:qitemId/handoff` and `POST /:qitemId/handoff-and-complete` (**3**:
`grep -c '!resolvesToLocalHost(body.hostId, getSelfHostId())' packages/daemon/src/routes/queue.ts`):
a write body may carry an out-of-band `hostId` envelope. The destination
session stays `member@rig`; the 3-part `agent@rig@host` form is CLI input
sugar that `resolveQueueHostDestination`
(`packages/cli/src/commands/queue.ts:364`) splits into the destination and
`hostId` before the request leaves the CLI. The mechanism follows the
forward-then-strip shape of mission-control's remote action
(`routes/mission-control.ts:353`): one shared route-layer helper
(`forwardQueueWrite`, `routes/queue.ts:197`) resolves the host registry
daemon-side (bearers never reach the caller), rejects ssh-declared hosts
(`unsupported-transport` — the daemon→daemon path is http-only), and forwards
the whole body, with `hostId` stripped, via `remoteJsonRequest` (`:231`) under
a named write-class deadline (`QUEUE_FORWARD_TIMEOUT_MS`, `:41`). The origin's
response returns verbatim; failures map to a structured host-named error
(`remote_queue_write_failed`, HTTP 502, `:217`) whose `failureClass` is one of
registry / unknown-host / unsupported-transport / unreachable / auth-failed /
remote-error. No local row is ever written on the cross-host path.
**The model: origin-owns-the-record, at-least-once + idempotent,
message-passing closure (never 2PC).**
@@ -144,99 +186,114 @@ message-passing closure (never 2PC).**
- **Origin-owns-the-record.** The qitem lives in the TARGET host's DB; that
row is THE record. The target daemon's OWN `maybeNudge` fires on ITS local
tmux (the forwarded body includes the `nudge` flag) — the sending daemon
never reaches across a host boundary.
- **Idempotency (arch Q-a).** The forwarding daemon MINTS the `qitemId`
before the first forward, so every retry carries the same id; dedup rides
the existing `qitem_id TEXT PRIMARY KEY`. On PK conflict the origin
returns the stored row when identity fields match (idempotent absorb) and
a structured `qitem_id_reuse` error when they differ
(`QueueRepository.create()` catch-path + `isQitemPrimaryKeyConflict`).
- **Cross-host handoff choreography (arch Q-c).** The local atomic
close+create cannot span two DBs, so the route-layer choreography
(`crossHostHandoff`, `routes/queue.ts`) runs: successor-create on the
target host FIRST (via the one forward helper), local source-close SECOND
(`QueueRepository.closeCrossHostHandoffSource`) — never the reverse. A
crash between the two leaves a live duplicate that the idempotent re-drive
converges; the reverse order would leave a closed source pointing at a
successor that does not exist (a dropped potato — the one forbidden
outcome). The successor id is DERIVED, not minted:
`deriveCrossHostSuccessorId(source, destination, host)` →
`qitem-xh-<sha256[:16]>` — a pure stateless function, so a re-drive
re-derives the same id across daemon restarts and absorbs on the target
PK. *(Named residual, inherent to the at-least-once/no-2PC fence: a
re-drive naming a DIFFERENT destination derives a different id and cannot
absorb the earlier successor — that orphan stays visible via the chain +
provenance tags; the source-close conflict check surfaces the
never reaches across a host boundary. Before forwarding, the forwarding
daemon stamps its own host id, when it has one, onto the source session
(`stampSelfHostSuffix`, `queue-repository.ts:542`; called at
`routes/queue.ts:359` and `:465`), so the target row records the sender as
`member@rig@<forwarding host>`.
- **Idempotency.** On create, the forwarding daemon mints the `qitemId` before
the forward unless the caller supplied one (`routes/queue.ts:457`). The
forward is a single request, so a retry dedups only when it carries the same
id (a caller re-sending `--id`, or a handoff re-drive, whose successor id is
derived). Dedup rides the existing `qitem_id TEXT PRIMARY KEY`. On PK
conflict the origin returns the stored row when destination and source
match (idempotent absorb) and a structured `qitem_id_reuse` error (409) when
they differ (`QueueRepository.create()` catch path,
`queue-repository.ts:1440`, with `isQitemPrimaryKeyConflict`, `:455`).
- **Cross-host handoff choreography.** The local atomic close+create cannot
span two DBs, so the route-layer choreography (`crossHostHandoff`,
`routes/queue.ts:299`) runs: successor-create on the target host FIRST (via
the one forward helper, `:378`), local source-close SECOND
(`QueueRepository.closeCrossHostHandoffSource`, `queue-repository.ts:2000`)
— never the reverse. A crash between the two leaves a live duplicate that
the idempotent re-drive converges; the reverse order would leave a closed
source pointing at a successor that does not exist (a dropped potato — the
one forbidden outcome). The successor id is DERIVED, not minted:
`deriveCrossHostSuccessorId(source, destination, host)`
(`queue-repository.ts:483`) → `qitem-xh-<sha256[:16]>` — a pure stateless
function, so a re-drive re-derives the same id across daemon restarts and
absorbs on the target PK. *(Residual, inherent to at-least-once delivery
without 2PC: a re-drive naming a DIFFERENT destination derives a different
id and cannot absorb the earlier successor — that orphan stays visible via
the chain + provenance tags; the source-close conflict check surfaces the
disagreement.)*
- **Closure across the boundary.** The source closes with
`closure_reason=handed_off_to` and `closure_target=member@rig@<host>` —
the 3-part form is legal THERE because `closure_target` is OPAQUE
audit/display metadata, presence-checked and never parsed for routing
(arch R1; any PR parsing it as a session string is a spec violation).
The exemption spans exactly the `closure_target` column on `queue_items`
and its verbatim mirror on `queue_transitions` — nothing else. In
particular the transition log's free-text `transition_note` is a durable
carrier too: the minted cross-host close note names the 2-part
`toSession` only (rev1-r2 B1). `handed_off_to` and every other
session-string carrier stay 2-part.
`closure_reason=handed_off_to` and
`closure_target=<successor qitem id>@<host>` (`routes/queue.ts:332`); a
source already closed with the older `member@rig@<host>` target keeps it,
and a re-drive matches against it (`:333`). `closure_target` is OPAQUE
audit metadata, never parsed for routing; the standing stuck sweep reads it
only to check successor custody (`queue-stuck-sweep.ts:468`). The
host-qualified key appears only in the `closure_target` column on
`queue_items` and its verbatim mirror on `queue_transitions`: the minted
cross-host close note names the 2-part `toSession` only
(`queue-repository.ts:2056`), and `handed_off_to` and the
`queue.handed_off` event stay 2-part.
Re-drive semantics: already-terminal + MATCHING `closure_target` = absorb;
MISMATCH = structured `cross_host_close_conflict` (409). The successor
carries `chain_of_record = [...source.chain, source.qitemId]` — A-side ids
are opaque lineage identifiers on B (arch R2b; they do not dereference in
B's DB) — plus provenance tags `cross-host` + `from-host:<self-declared
name>` (honest best-effort, not authenticated identity).
- **Boundary discipline.** Claim / update / inbox stay local-by-principle
(after a cross-host handoff the successor lives where its worker lives);
sender-side ops on a forwarded item are a named follow-up. The local
(no-host) paths are byte-identical to pre-MH-3 behavior; the hot-potato
validation contract (§3) is unweakened across the boundary.
MISMATCH = structured `cross_host_close_conflict` (409), checked in the route
before any forward (`routes/queue.ts:343`) and again in the repository. The
successor carries `chain_of_record = [...source.chain, source.qitemId]`
(`routes/queue.ts:365`) — A-side ids are opaque lineage identifiers on B
(they do not dereference in B's DB) — plus provenance tags `cross-host` +
`from-host:<self-declared name>` (the forwarding daemon's OS hostname,
`routes/queue.ts:54`; honest best-effort, not authenticated identity).
- **Boundary discipline.** Claim, update and inbox routes take no `hostId` and
stay local (after a cross-host handoff the successor lives where its worker
lives). Without a `hostId`, with `local`, or with this daemon's own resolved
host id (an exact, case-sensitive match), create and handoff take the
local path; a self-addressed handoff whose deterministic cross-host
successor an earlier self-forward already created runs that cross-host
create and close in process. The
hot-potato validation contract (§3) is unweakened across the boundary: the
cross-host close always records `handed_off_to` with a target.
## 4. Coordination events
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md`
> §3 L394 says "Existing 32 PL-004 events untouched" and L410 says "Existing
> 20 PL-004 events are unchanged" — these two body claims are internally
> inconsistent with each other and predate 0.3.x. **Do NOT carry either
> number.** The current `RigEvent` union (`packages/daemon/src/domain/types.ts:94`)
> has **73 union members** total (slice-00 §1.8, re-confirmed at HEAD:
> `grep -cE '^\s*\| \{ type: ' = 73`). A precise PL-004-only vs PL-005-only
> sub-partition is not reconcilable from static inspection without
> classifying all 73 members by introducing slice; slice-00 OPEN-4 flags
> this and it is carried, not smoothed.
The `RigEvent` union (`packages/daemon/src/domain/types.ts:106`) has **98**
members
(`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "'`);
this module covers only the coordination families below.
Coordination events emitted by these services (re-confirmed in
`domain/types.ts` at HEAD): `stream.emitted` (StreamStore.emit,
`types.ts:155`); `queue.created` / `queue.handed_off` / `queue.claimed` /
`queue.unclaimed` / `qitem.fallback_routed` / `qitem.closure_overdue`
(QueueRepository); `inbox.absorbed` (`types.ts:162`) / `inbox.denied`
(InboxHandler). `architecture.md` §8 L987–990 listed these as "9
coordination events"; the count of the broader `stream|queue|inbox|qitem`
event family at HEAD is 10 (`stream` 1 + `queue` 5 + `inbox` 2 + `qitem` 2)
— stated as a re-derived family count, not as a contested PL-004 sub-count
(OPEN-4).
Coordination events emitted by these services: `stream.emitted`
(`StreamStore.emit`, declared at `types.ts:243`); `queue.created` /
`queue.handed_off` / `queue.claimed` / `queue.unclaimed` / `queue.updated` /
`qitem.fallback_routed` / `qitem.closure_overdue` (QueueRepository); `inbox.absorbed` (`types.ts:253`) /
`inbox.denied` (InboxHandler). `qitem.closure_overdue` (`types.ts:252`) is
emitted by `QueueRepository.recordClosureOverdue` when swept by `queue-stuck-sweep`
and passed through the queue watch filter (**2** files contain its literal:
`git grep -l -F 'type: "qitem.closure_overdue"' -- packages/daemon/src | wc -l`).
The `stream|queue|inbox|qitem` families declare **10** event types (`stream` 1
+ `queue` 5 + `inbox` 2 + `qitem` 2;
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"(stream|queue|inbox|qitem)\.[a-z_]+"' | sort -u | wc -l`).
Two SSE surfaces stream coordination events: `/api/stream/watch`
(aliased `/api/stream/sse`, `routes/stream.ts:117–118`) for new stream
items, and `/api/queue/watch` for queue/inbox events. The event log remains
append-only and SQLite-backed. `rig stream watch` is a thin, single-connection
consumer of `/api/stream/sse`; it does not add a daemon route or reconnect
policy.
Two SSE surfaces stream coordination events (**2**:
`cat packages/daemon/src/routes/stream.ts packages/daemon/src/routes/queue.ts | grep -c 'return streamSSE('`):
`/api/stream/watch` (aliased `/api/stream/sse`, `routes/stream.ts:194`–`195`)
for new stream items, and `/api/queue/watch` (aliased `/api/queue/sse`,
`routes/queue.ts:994`–`995`) for queue/inbox events — every coordination type
except `stream.emitted` and `queue.updated` (filter at
`routes/queue.ts:971`–`978`). The event log remains append-only and
SQLite-backed. `rig stream watch` is a thin, single-connection consumer of
`/api/stream/sse` (`packages/cli/src/commands/stream.ts:191`); it does not add
a daemon route or reconnect policy.
## 5. Route surface
- `/api/stream` (`server.ts:489`) — `POST /emit` (`routes/stream.ts:24`),
`GET /list` (`:56`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, and `until` filters), `GET /watch` + `/sse` SSE (`:117`),
`GET /:streamItemId` (`:121`), `POST /:streamItemId/archive` (`:130`).
- `/api/queue` (`server.ts:490`) — `POST /create` (`routes/queue.ts:99`),
`POST /:qitemId/claim` (`:144`), `POST /:qitemId/unclaim` (`:157`),
`POST /:qitemId/update` (`:170`), plus handoff/list/watch surfaces.
- `/api/stream` (`server.ts:784`) — `POST /emit` (`routes/stream.ts:56`),
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
(`:194`), `GET /:streamItemId` (`:198`), `POST /:streamItemId/archive`
(`:207`).
- `/api/queue` (`server.ts:785`) — `POST /create` (`routes/queue.ts:402`),
`POST /:qitemId/claim` (`:501`), `POST /:qitemId/unclaim` (`:517`),
`POST /:qitemId/update` (`:556`), plus handoff (`:606`, `:682`), list
(`:842`) and watch (`:994`) surfaces.
## See also
- `daemon-core.md` — daemon wiring, the 40-migration set, route surface.
- `workflow-runtime.md` — PL-004 Phase D runtime that projects on closure.
- `mission-control.md` — PL-005 queue observability over `queue_items`.
- `daemon-core.md` — daemon wiring, the migration set, route surface.
- `workflow-runtime.md` — the workflow runtime that projects on closure.
- `mission-control.md` — queue observability over `queue_items`.
- Source roots: `packages/daemon/src/domain/{stream-store,queue-repository,
queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts`,
`packages/daemon/src/routes/{stream,queue}.ts`.
+138 -176
View File
@@ -9,143 +9,106 @@ applies-when: |
graph, the SQLite schema/migration set, or the route-mount surface.
siblings: [coordination-primitive.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
prerequisite-reads: [../README.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
last-updated: 2026-10-03
---
# Daemon Core — Wiring, DB, Migrations, Startup
OpenRig is a local control plane for multi-agent coding topologies. The daemon
(`@openrig/daemon`) is the framework-free SQLite-backed core that the CLI
(`@openrig/cli`), UI (`@openrig/ui`), and MCP server all sit on top of.
(`@openrig/cli`), the terminal UI (`@openrig/tui`), the web UI (`@openrig/ui`)
and the MCP server all sit on top of.
> Verified against source at HEAD `7eaf524c` (`git describe` →
> `v0.3.1-6-g7eaf524c`). Current package version is **0.3.1** across all three
> packages (`package.json` root `"version": "0.3.1"`; slice-00 §1.1). HEAD
> carries 6 commits of unreleased release-0.3.2 work; no `v0.3.2` tag exists.
> Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. System overview
The system has six architectural layers (`architecture.md` §1 L23–30):
For what OpenRig is and how its packages fit together, read `ARCHITECTURE.md` at
the repository root. This module covers the daemon's own wiring.
1. **AgentSpec / pod-aware core** — spec parsing, resolution, precedence,
startup orchestration, snapshot/restore, bundles.
2. **Operator and topology layer** — harness auto-launch, node inventory,
session naming, infrastructure nodes, explorer UI, existing-rig power-on,
auto-snapshot, post-command handoff.
3. **Communication and history layer** — transcript capture (pipe-pane),
communication primitives (send/capture/broadcast), config/preflight,
`rig ask` context packs, durable rig chat.
4. **Authoring and identity layer** — spec review + spec library, `whoami`,
adopted-session tmux-metadata parity, bind/materialize/adopt workflows.
5. **Rig environment layer** — rig-scoped services records, Compose-backed
service orchestration, readiness gates, env snapshot/restore integration.
6. **Agent-managed software layer** — managed-app classification, app-focused
browse/review/runtime UI, and the canonical `secrets-manager` example.
### Source footprint at `b6d37bfadcc189be8fd61e35abfc09676437bf48`
Legacy flat-node/package flows remain for backward compatibility.
The footprint counts use non-test TypeScript files under each package's `src/`
(tests live in separate `packages/*/test/` directories):
`find <dir> -type f \( -name '*.ts' -o -name '*.tsx' \) ! -name '*.test.*' | wc -l`.
### Verified source footprint at HEAD `7eaf524c`
> Drift-fix D1/D6 — `architecture.md` L6,11 said `OpenRig v0.2.0` /
> "Current v0.2.0 release verification" and L7 said `376` source files. Both
> are a frozen pre-v0.2.0-era header (slice-00 §1.9 — last edited `72982bb2`,
> 2026-03-30). Corrected to the values below.
| Metric | Value @ HEAD | Source (independently re-confirmed) |
| Metric | Count | Directory or command |
|---|---|---|
| Package version | **0.3.1** (HEAD +6 unreleased 0.3.2 commits) | slice-00 §1.1; `package.json:version` |
| Total source footprint | **601** files | slice-00 §1.6; `find packages/*/src` non-test = 279+87+235 |
| Daemon footprint | **279** total / **173** domain / **49** route mounts (46 route files) / **11** adapters / **40** migrations | slice-00 §1.5/§1.6; `find packages/daemon/src` non-test |
| CLI footprint | **87** files | slice-00 §1.6; `find packages/cli/src` non-test |
| UI footprint | **235** files | slice-00 §1.6; `find packages/ui/src` `.ts`+`.tsx` non-test |
| All packages | **1158** | `packages/*/src` |
| Daemon | **629** (**409** under `domain/`, **26** under `adapters/`) | `packages/daemon/src`, `…/src/domain`, `…/src/adapters` |
| CLI | **162** | `packages/cli/src` |
| Web UI | **304** | `packages/ui/src` |
| TUI | **63** | `packages/tui/src` |
| Database migrations | **93** (`001_core_schema.ts` … `094_usage_samples_latest_indexes.ts`; no `093`) | `git ls-files packages/daemon/src/db/migrations \| wc -l` |
| Files in `routes/` | **67**, of which **65** create a Hono router | `git ls-files packages/daemon/src/routes \| wc -l`; `git grep -l 'new Hono' -- packages/daemon/src/routes \| wc -l` |
| `app.route(...)` mounts in `server.ts` | **69** | `grep -c 'app.route(' packages/daemon/src/server.ts` |
| Top-level `rig` commands | **85** | `grep -c 'program.addCommand(' packages/cli/src/index.ts` |
| MCP tools | **18**, all named `rig_*` | `grep -c 'server.tool(' packages/cli/src/mcp-server.ts` |
| Runtime adapter classes | **5**: Claude Code, Codex, Pi, Stub, Terminal | `git grep -l 'implements RuntimeAdapter' packages/daemon/src \| wc -l` |
> Footprint counts use the `find packages/*/src -type f \( -name '*.ts' -o
> -name '*.tsx' \) ! -name '*.test.*'` predicate (tests live in separate
> `packages/*/test/` dirs). Counts are reproducible with that predicate; a
> different counting convention shifts absolute numbers (slice-00 OPEN-5).
`OmpRuntimeAdapter` (Oh My Pi) extends `PiRuntimeAdapter`, so the daemon wires
six runtime keys — `claude-code`, `codex`, `pi`, `omp`, `stub`, `terminal`
(`startup.ts:945`).
### The stack
> Drift-fix D2 — `architecture.md` L37,125 said `CLI (53 command groups)`.
> Corrected to **58** at HEAD (slice-00 §1.2: 53 at v0.2.0 tag, 56 at v0.3.0,
> 57 at v0.3.1, **58 at HEAD** — the 58th is `scopeCommand()` added post-0.3.1
> by `0b77cba4`, re-confirmed `index.ts:20,187`).
>
> Drift-fix D4 — `architecture.md` L40 said `31 route groups` and L76 said
> "`createApp()` now mounts 22 route groups". Corrected to **49 `app.route()`
> mounts + 4 dedicated handlers** (slice-00 §1.5; re-confirmed `server.ts`
> 49×`app.route(` L450–513, plus `app.get("/healthz")` :446,
> `handleExportYaml` :461, `handleExportJson` :462, `app.get("*")` :519).
> Carry slice-00 **OPEN-2**: "route-group count is definitional" — there is
> no single canonical "route group" definition in source; reported as mount
> count + dedicated handlers.
>
> Drift-fix D3 — `architecture.md` L54 said `SQLite state (36 migrations)`.
> Corrected to **40** (slice-00 §1.3; re-confirmed below).
```text
CLI (58 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
CLI (85 top-level commands) / TUI / web UI / MCP (18 tools)
|
v
Hono daemon routes (49 app.route() mounts + 4 dedicated health/export/static handlers)
Hono daemon routes (69 app.route() mounts + direct handlers for /healthz,
spec export, unknown /api paths and the static UI)
|
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
+-- rigspec routes (pod-aware and legacy spec formats)
+-- env routes (status / logs / down)
+-- transport routes (send/capture/broadcast)
+-- transcript routes (tail/grep)
+-- ask routes (context evidence packs)
+-- transport routes (send / capture / broadcast)
+-- transcript routes (tail / grep)
+-- ask routes (context evidence)
+-- chat routes (durable rig messaging + SSE)
+-- spec review/library routes (managed-app enrichment + compose preview)
+-- whoami identity + context-usage route
+-- coordination routes (stream / queue / workflow / mission-control)
+-- spec review / spec library routes
+-- whoami identity route
+-- coordination routes (stream / queue / workflow / mission control)
|
v
Framework-free domain services (173 daemon domain files)
Framework-free domain services (409 files under packages/daemon/src/domain)
|
+-- SQLite state (40 migrations)
+-- SQLite state (93 migrations)
+-- tmux / cmux / resume adapters
+-- runtime adapters (Claude Code / Codex / Terminal)
+-- RigEnv substrate (compose adapter, readiness, orchestrator)
+-- runtime adapters (Claude Code / Codex / Pi / Oh My Pi / Stub / Terminal)
+-- rig environment services (compose adapter, service readiness, orchestrator)
+-- transport / transcript / chat / ask layers
+-- whoami identity service
+-- whoami identity service (including context usage)
```
The core product loop: `down (auto-snapshot) → up <rig-name> (auto-restore) →
handoff → inspect/attach → work → repeat`.
> MCP tool count is **17** (`rig_*`) — slice-00 §1.4 confirms the count is
> correct and the names are `rig_*` (NOT `rigged_*`; the `rigged_*` text in
> `architecture.md` §2 is stale — the rename predates v0.2.0). Per-occurrence
> verification of any `rigged_*` reference lands in `transport-and-transcripts.md`
> (D5); the tmux `@rigged_*` metadata keys are a separate axis, not blanket-replaced.
## 2. Database schema
> Drift-fix D3 — `architecture.md` L243 said "27 migrations (22 existing plus
> 5 added by PL-004 Phase A)" and L1002 repeated "27 migrations"; the §1 stack
> diagram L54 said "36 migrations". All corrected to **40** migrations
> (`001`–`040`). Triangulated (slice-00 §1.3, re-confirmed at HEAD): (1)
> filesystem `packages/daemon/src/db/migrations/[0-9][0-9][0-9]_*.ts` → 40
> files, `001_core_schema.ts` … `040_workflow_specs_diagnostic.ts`; (2)
> `startup.ts:206` `migrate(db, [...])` passes a 40-element schema array
> (`coreSchema` … `workflowSpecsDiagnosticSchema`); (3) `migrate.ts:13`
> applies them sorted by name, tracked in `schema_migrations`.
The migrations live in `packages/daemon/src/db/migrations/` (93 files,
`001_core_schema.ts` … `094_usage_samples_latest_indexes.ts`, with no `093`).
`ALL_MIGRATIONS` (`packages/daemon/src/db/all-migrations.ts:102`) lists all 93, and `createDaemon`
applies them with `migrate(db, ALL_MIGRATIONS)` (`startup.ts:283`). `migrate.ts`
sorts them by name (`:29`) and records each applied name in `schema_migrations`
(`:15`).
### Core state tables (`001_core_schema.ts`)
### Core state tables
`rigs` (topology container, `001_core_schema.ts:7`), `nodes` (logical node
identity, `:17`), `edges` (logical topology relationships, `:30`), plus
`bindings` (physical tmux/cmux surface attachment), `sessions` (live execution
state), `events` (append-only event log), `snapshots` (serialized rig state),
`checkpoints` (per-node recovery state). `architecture.md` §3 L247–289.
identity, `:17`) and `edges` (logical topology relationships, `:30`); then
`bindings` (physical tmux/cmux surface attachment) and `sessions` (live
execution state) in `002_bindings_sessions.ts`, `events` (append-only event log)
in `003_events.ts`, `snapshots` (serialized rig state) in `004_snapshots.ts`, and
`checkpoints` (per-node recovery state) in `005_checkpoints.ts`.
### Reboot-era schema
### Pod-aware schema
- `014_agentspec_reboot.ts` — reboot schema shape; adds `pods`,
`continuity_state`, and reboot columns on `nodes`/`sessions`/`checkpoints`
(`pod_id`, `agent_ref`, `resolved_spec_*`, `startup_status`,
`continuity_source`). `architecture.md` §3 L253–289.
- `014_agentspec_reboot.ts` — the pod-aware schema; adds the `pods` and
`continuity_state` tables and pod-aware columns on `nodes`, `sessions` and
`checkpoints`.
- `015_startup_context.ts` — persisted startup replay context for restore.
- `016_chat_messages.ts` — durable rig-scoped chat (SQLite-backed; transcripts
remain filesystem-backed via pipe-pane).
@@ -155,106 +118,105 @@ state), `events` (append-only event log), `snapshots` (serialized rig state),
- `020_rig_services.ts` — rig-scoped environment record for service-backed rigs.
- `021_seat_handover_observability.ts`, `022_node_codex_config_profile.ts`.
### Coordination / PL-004 / PL-005 / workspace migrations
### Coordination, workflow, mission control and workspace migrations
- `023_stream_items.ts` … `027_outbox_entries.ts` — PL-004 Phase A
coordination tables (detail in `coordination-primitive.md`).
- `023_stream_items.ts` … `027_outbox_entries.ts` — the coordination tables
(stream, queue, queue transitions, inbox, outbox; detail in
`coordination-primitive.md`).
- `028_project_classifications.ts`, `029_classifier_leases.ts`,
`030_views_custom.ts` — PL-004 Phase B classifier + view tables. (Note:
`architecture.md` L361 said "028 through 030"; the middle migration is
literally `029_classifier_leases.ts` — re-confirmed at HEAD.)
- `031_watchdog_jobs.ts`, `032_watchdog_history.ts` — PL-004 Phase C watchdog.
`030_views_custom.ts` — classifier and view tables.
- `031_watchdog_jobs.ts`, `032_watchdog_history.ts` — watchdog tables.
- `033_workflow_specs.ts`, `034_workflow_instances.ts`,
`035_workflow_step_trails.ts` — PL-004 Phase D Workflow Runtime tables
(detail in `workflow-runtime.md`). `036_watchdog_policy_enum_extension.ts`
is a documenting no-op.
- `037_mission_control_actions.ts` — PL-005 Phase A audit table
(`037_mission_control_actions.ts:54` `CREATE TABLE … mission_control_actions`;
detail in `mission-control.md`).
- `038_workspace_primitive.ts`, `039_queue_target_repo.ts` — PL-007 typed
workspace primitive (`bab24bf7`).
- `040_workflow_specs_diagnostic.ts` — slice-11 (`f68f453a`); an
`ALTER TABLE ADD COLUMN` adding parser/validator diagnostic columns to
`workflow_specs` (no new table). **Net-new since `architecture.md` was last
edited** — slice-00 §1.3 provenance; carried into `workflow-runtime.md`.
`035_workflow_step_trails.ts` — workflow runtime tables (detail in
`workflow-runtime.md`). `036_watchdog_policy_enum_extension.ts` runs no SQL; it
documents an enum extension.
- `037_mission_control_actions.ts` — the mission control audit table
(`037_mission_control_actions.ts:58`; detail in `mission-control.md`).
- `038_workspace_primitive.ts`, `039_queue_target_repo.ts` — the typed workspace
primitive.
- `040_workflow_specs_diagnostic.ts` — `ALTER TABLE workflow_specs ADD COLUMN`
for parser/validator diagnostics (no new table).
Legacy package/bootstrap/discovery tables (`packages`, `package_installs`,
`install_journal`, `bootstrap_runs`, `bootstrap_actions`,
`runtime_verifications`, `discovered_sessions`) remain active.
Migrations `041`–`092` and `094` continue in the same directory; list them with
`git ls-files packages/daemon/src/db/migrations`.
The package, bootstrap and discovery tables remain: `packages`
(`008_packages.ts`), `package_installs` and `install_journal`
(`009_install_journal.ts`), `bootstrap_runs`, `bootstrap_actions` and
`runtime_verifications` (`011_bootstrap.ts`), and `discovered_sessions`
(`012_discovery.ts`).
## 3. Route-mount surface
`createApp(deps)` (`packages/daemon/src/server.ts:295`) mounts **49**
`app.route()` route-group mounts (`server.ts` L450–513) plus 4 dedicated
non-route handlers: `GET /healthz` (`:446`), `GET /api/rigs/:rigId/spec`
(`handleExportYaml`, `:461`), `GET /api/rigs/:rigId/spec.json`
(`handleExportJson`, `:462`), and the static/deep-link `app.get("*")`
catch-all (`:519`).
`createApp(deps)` (`packages/daemon/src/server.ts:441`) mounts **69**
`app.route()` route groups (`server.ts:724`–`839`) plus direct handlers:
`GET /healthz` (`:665`), `GET /api/rigs/:rigId/spec` (`handleExportYaml`,
`:736`), `GET /api/rigs/:rigId/spec.json` (`handleExportJson`, `:737`), a JSON
`404 not_found` for any other `/api/*` path (`:844`), and the static/deep-link
`app.get("*")` catch-all (`:853`).
> OPEN-2 (carried verbatim, slice-00): the route-group "count" is definitional
> — "49" is the count of `app.route()` mounts in `server.ts`. The older
> "31 route groups" / "createApp now mounts 22 route groups" framing
> (`architecture.md` L40,76) used a different, smaller accounting. Independent
> corroboration: `packages/daemon/src/routes/` has 46 non-test route `.ts`
> files (some groups composed from shared modules; the 49 mounts are the
> authoritative count of mounted groups — slice-00 §1.5).
The 69 is the count of `app.route(` lines in `server.ts`; `packages/daemon/src/routes/`
has 67 files, 65 of which create a Hono router.
Mount families include reboot-era rig/session/spec routes plus the coordination
routes (`/api/stream` `server.ts:489`, `/api/queue` `:490`,
`/api/workflow` `:495`, `missionControlRoutes(...)` `:498`),
`/api/health-summary` (`:511`), `/api/rigs/:rigId/env` (`:512`), and
`/api/restore-check` (`:513`).
Mount families include the rig, session and spec routes plus the coordination
routes (`/api/stream` `server.ts:784`, `/api/queue` `:785`, `/api/workflow`
`:790`, `missionControlRoutes(...)` `:793`), `/api/health-summary` (`:829`),
`/api/rigs/:rigId/env` (`:835`) and `/api/restore-check` (`:836`).
`createAppWithWebSocket(deps)` (`server.ts:893`) sets `enableNodeWebSocket` and
calls `createApp`; the terminal WebSocket is registered only when the web UI is
also enabled (`server.ts:757`).
## 4. Startup sequence (`createDaemon`)
`createDaemon(opts?)` is `packages/daemon/src/startup.ts:203` (async,
returns `DaemonResult`). It returns `{ app, db, deps, contextMonitor }`
(`startup.ts:1204`). The sequence (`architecture.md` §9 L1000–1028,
corrected against source):
`createDaemon(opts?)` is `packages/daemon/src/startup.ts:274` (async, returns
`DaemonResult`). It returns `{ app, db, deps, contextMonitor, eventLoopMonitor,
injectWebSocket }` (`startup.ts:2450`). In source order, it:
1. Open SQLite and run **all 40 migrations** (`startup.ts:206`
`migrate(db, [coreSchema … workflowSpecsDiagnosticSchema])` — a 40-element
array; `architecture.md` L1002 said "27 migrations (22 existing plus the 5
PL-004 Phase A coordination tables)" — corrected to 40, drift-fix D3).
2. Construct core repositories and legacy services.
3. Construct package/bootstrap/discovery services.
4. Construct rebooted startup/runtime services: `StartupOrchestrator`,
`ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `TerminalAdapter`,
`PodRigInstantiator`, `PodBundleSourceResolver`.
5. Construct rig environment services: `ComposeServicesAdapter`,
`ServiceOrchestrator`.
6. Construct operator/transport/history services: `TranscriptStore`,
`SessionTransport`, `ChatRepository`, `AskService` (with `HistoryQuery`),
`ResumeMetadataRefresher`, `ContextUsageStore`, `ContextMonitor`,
`NodeInventory`.
7. Construct authoring/identity/managed-app services: `SpecReviewService`,
`SpecLibraryService`, `WhoamiService`.
8. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
9. Construct PL-004 Phase A coordination services from a shared
`QueueRepository` instance (so `InboxHandler.absorb()` and `/api/queue`
write to the same repo): `StreamStore`, `QueueRepository`, `InboxHandler`,
`OutboxHandler`.
10. Build `AppDeps`, enforce shared-DB invariants, and call
`createApp(deps)` (`startup.ts:1202`) to mount the full route tree.
1. Opens SQLite and applies all 93 migrations (`migrate(db, ALL_MIGRATIONS)`,
`startup.ts:283`).
2. Constructs the coordination stores early: `StreamStore` (`:315`),
`QueueRepository` (`:413`) and `OutboxHandler` (`:428`).
3. Constructs `TranscriptStore` (`:540`) and the rig environment services
`ComposeServicesAdapter` and `ServiceOrchestrator` (`:621`–`622`).
4. Constructs `StartupOrchestrator` (`:753`) and the runtime adapters:
`ClaudeCodeAdapter` (`:755`), `CodexRuntimeAdapter` (`:756`),
`PiRuntimeAdapter` (`:759`), `OmpRuntimeAdapter` (`:760`) and
`StubRuntimeAdapter` (`:765`); the terminal adapter is created inline in the
runtime adapter map (`:945`).
5. Constructs `PodRigInstantiator` (`:941`), `PodBundleSourceResolver` (`:970`)
and `BootstrapOrchestrator` (`:972`).
6. Constructs `ContextUsageStore` (`:1046`), `ResumeMetadataRefresher`
(`:1077`), `SpecReviewService` (`:1102`) and `WhoamiService` (`:1105`).
7. Constructs `SessionTransport` (`:1210`), `ChatRepository` (`:1244`),
`InboxHandler` (`:1248`), `AskService` (`:1297`) and `SpecLibraryService`
(`:1335`). `InboxHandler` receives the same queue repository instance
(`queueRepoInstance`) that serves `/api/queue`, so absorbed inbox items and the
queue route write to one store.
8. Constructs `ContextMonitor` (`:2353`).
9. Builds `AppDeps` and calls `createAppWithWebSocket(deps)` (`startup.ts:2448`)
to mount the full route tree.
The daemon entrypoint `packages/daemon/src/index.ts:36` calls
`createDaemon({ dbPath, bearerToken })`.
Node inventory is a set of functions (`getNodeInventory` and friends in
`domain/node-inventory.ts`), imported where needed rather than constructed at
startup.
## 5. Test and verification state
The daemon entrypoint `packages/daemon/src/index.ts:298` calls
`createDaemon({ dbPath, bearerToken, terminalBearerToken, … })`.
> Drift-fix D7 / OPEN-3 (carried verbatim, slice-00): `architecture.md` L12,13
> and §10 L1036–1046 assert daemon `2561/2561` / CLI `794/794` / total
> `2422/2422` test pass counts and per-package file counts (`127`/`37`/`37`).
> **These are runtime claims and are NOT re-run here** — a read-only static
> trace counted `*.test.ts` files only: daemon **255**, cli **234**
> (slice-00 §1.7; ui test-file count not separately gathered). Pass tallies
> are marked `unverified-runtime-claim`; do not assert them as current. To
> assert pass counts, run `pnpm/npm test` and re-verify.
## 5. Test files
A static count of tracked test files at `b6d37bfadcc189be8fd61e35abfc09676437bf48` (no pass counts are claimed
here; CI runs the suites in `.github/workflows/tests.yml`): daemon **821**,
CLI **216**, web UI **198**, TUI **96**
(`git ls-files 'packages/<package>/**/*.test.ts' 'packages/<package>/**/*.test.tsx' | wc -l`).
## See also
- `coordination-primitive.md` — PL-004 Phase A stream/queue/inbox/outbox.
- `ARCHITECTURE.md` (repository root) — what OpenRig is and where to add things.
- `coordination-primitive.md` — the stream/queue/inbox/outbox coordination layer.
- `agent-spec-and-startup.md` — spec parsing/resolution/startup contract.
- `lifecycle-snapshot-restore.md` — snapshot/restore/continuity.
- Source roots: `packages/daemon/src/{startup.ts,server.ts,index.ts}`,
`packages/daemon/src/db/{all-migrations.ts,migrate.ts}`,
`packages/daemon/src/db/migrations/`, `packages/cli/src/index.ts`.
@@ -6,12 +6,13 @@ topics: [continuity, runtime-control]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Need to know how OpenRig captures a snapshot, restores a rig (resume vs
rebuild vs fresh), enforces restore honesty, consults live continuity state,
or how the daemon-side restore-check / restore-packet readiness probes work.
rebuild vs fresh-primed vs stop-and-ask), enforces restore honesty, consults
live continuity state, or how the daemon-side restore-check readiness probe
and the CLI restore-packet command work.
siblings: [daemon-core.md, agent-spec-and-startup.md, transport-and-transcripts.md]
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# Lifecycle — Snapshot, Restore, Continuity
@@ -21,194 +22,265 @@ The durable-state half of the core product loop:
captures serialized rig state; restore replays it honestly (no silent
fresh-fallback); restore-check is a separate read-only readiness probe.
> Verified against source at HEAD `7eaf524c` (`git describe` →
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
> located by `architecture.md` headings (§4 Execution and restore, §5
> Snapshot/restore/continuity, §6 Snapshot/restore + Auto-snapshot, §7 rules)
> per slice-08 §10.1 — line numbers advisory only. This module is a pure
> split EXCEPT §6 (restore-check/restore-packet) which is the D15
> author-from-source piece — flagged explicitly there.
> Verified against source at main `254122872cf477511514979a4300b695d77cd1f7`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. Snapshot / restore / continuity types
(`architecture.md` §4 "Execution and restore" — spec/projection members live
in `agent-spec-and-startup.md`; the snapshot/restore members live here)
All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
`agent-spec-and-startup.md`; the snapshot/restore types live here.
- **NodeRestoreOutcome** — `"resumed" | "rebuilt" | "fresh" | "failed" |
"n-a"` — the locked restore vocabulary. Re-confirmed at source:
`restore-orchestrator.ts:725-726` sets `baseStatus = "resumed"` on a
resumed harness; `:763` sets `"rebuilt"` when a checkpoint is replayed; the
outcome union appears at `:944` (`{ kind: "resumed" }` … ).
- **SnapshotData** — current serialized snapshot payload. Reboot extensions
are optional for older-snapshot compatibility: `pods?`,
`continuityStates?`, `nodeStartupContext?`.
- **NodeStartupSnapshot** — persisted restore replay input:
- **NodeRestoreOutcome** (`types.ts:501`) — the per-node restore outcome
projected into `rig ps`. **9** values
(`grep '^export type NodeRestoreOutcome' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`):
`resumed`, `rebuilt`, `fresh`, `fresh-primed`, `awaiting-decision`,
`failed`, `attention_required`, `operator_recovered`, `n-a`. The restore
result itself, `RestoreNodeResult.status` (`types.ts:476`), has the same
values without `n-a`: **8**
(`grep -E '^ status: "resumed"' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`).
Where `restore-orchestrator.ts` sets each one:
- `resumed` — `baseStatus = "resumed"` after a legacy resume (`:1212`); the
status is returned only by `finishJoinedResume` (`:1499`, `:1561`) after
it has rebound and verified the pane, otherwise that becomes
`attention_required` (`:1533`).
- `rebuilt` — a checkpoint file was written (`:1272`).
- `fresh-primed` — the default for a deliberate non-resume launch
(`:1194`).
- `fresh` — only the skip path for a pod node whose live continuity state
is already `restoring` (`:992`).
- `awaiting-decision` — the seat could not resume and nothing is running:
before launch (`:1027`), or after launch once the blank session is rolled
back (`:1208`, `:1230`, `:1243`, `:1259`, `:1417`).
- `attention_required` — a live session waiting on a runtime prompt
(`:1221`, `:1443`) or a harness that never started (`:1307`).
- `failed` — occupant ambiguity (`:979`), launch failure (`:1118`), a
checkpoint that cannot be delivered (`:1268`, `:1274`), a missing
required startup file (`:1343`), or a startup error.
- `operator_recovered` — only from later reconciliation,
`reconcileNodeRuntimeTruth` (`:1759`, event at `:1902`).
- **RestoreRigResult** (`types.ts:445`) — the rig-level rollup, **4** values
(`grep '^export type RestoreRigResult' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`):
`fully_restored`, `partially_restored`, `failed`, `not_attempted`
(`rollupRestoreRigResult`, `restore-orchestrator.ts:82`).
- **SnapshotData** (`types.ts:352`) — the serialized snapshot payload. It has
**7** optional fields, kept optional so older snapshots still load
(`sed -n '/^export interface SnapshotData/,/^}/p' packages/daemon/src/domain/types.ts | grep -c '?:'`):
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `topologyRoster?`,
`pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
- **NodeStartupSnapshot** (`types.ts:345`) — persisted restore replay input:
classification-free projection entries, resolved startup files, startup
actions, runtime.
- **PersistedProjectionEntry** — the classification-free restore replay seam:
persists only entry identity + source metadata, NOT stale `classification`,
`conflicts`, or `noOps` (Architecture Rule 10).
- **PersistedProjectionEntry** (`types.ts:333`) — the classification-free
restore replay seam: persists only entry identity + source metadata, NOT
stale `classification`, `conflicts`, or `noOps` (Architecture Rule 10).
Restore rebuilds each entry as `safe_projection` with empty `conflicts` and
`noOps` (`restore-orchestrator.ts:1352`, `:1357`, `:1358`).
## 2. Snapshot, restore, continuity domain services
(`architecture.md` §5 "Snapshot, restore, and continuity")
All under `packages/daemon/src/domain/`:
- `checkpoint-store.ts` — checkpoint persistence with pod/continuity context.
- `snapshot-capture.ts` — captures pods, continuity state, startup replay
context, and latest env receipt. Re-confirmed: `snapshot-capture.ts:66-77`
pulls `pods`, `continuity_state`, and `node_startup_context` rows per rig.
- `snapshot-repository.ts` — snapshot CRUD.
context, the latest env receipt, the active-occupant maps and the intended
topology roster. `captureSnapshot` (`:60`) derives the occupant maps
(`:73–79`), resolves the roster (`:81–90`), then reads `pods` (`:96`),
`continuity_state` (`:100`) and `node_startup_context` (`:106`) rows per rig
and the env receipt (`:116–123`).
- `snapshot-repository.ts` — snapshot CRUD and restore-source selection:
`findLatestRestoreUsable` (`:72`) and `selectRestoreUsable` (`:94`), which
accept only snapshots that pass `isRestoreUsableSnapshotData` (`:283`).
- `restore-orchestrator.ts` — resume, checkpoint delivery, startup replay,
live continuity consultation, topology ordering, and RigEnv boot gating
before agent restore.
live continuity consultation, topology ordering (`computeRestorePlan`,
`:876`, ordering only `delegates_to` and `spawned_by` edges, `:72`), and
service boot gating before agent restore (`serviceOrchestrator.boot`,
`:328`).
- `active-occupant.ts` — the shared rule for which snapshot session row is a
node's occupant (`resolveActiveSnapshotSession`, `:74`).
## 3. Restore flow and restore honesty
(`architecture.md` §6 "Snapshot / restore" + §7 rules 7/14/15/16)
Restore behavior, each point checked in `restore-orchestrator.ts`:
Restore behavior, each point re-confirmed at `restore-orchestrator.ts`:
- Reads the newest session by **monotonic ULID**, not timestamp alone
(`:700` — "Find the NEWEST session for this node. ULIDs are monotonic, so
latest = max id").
- Resolves each node's occupant from the snapshot's recorded active-occupant
maps, not by picking the newest session row
(`resolveActiveSnapshotSession`, `active-occupant.ts:74`, called at
`restore-orchestrator.ts:974` and `:1171`). An ambiguous occupant fails the
node loudly (`activeOccupantAmbiguityError`, `active-occupant.ts:97`).
- Consults live `continuity_state`; preserves state when a node is already
`restoring` (`:611-615` — `SELECT status FROM continuity_state …`; if
`status === "restoring"` the node is skipped with a warning).
`restoring` (`:985–998` — `SELECT status FROM continuity_state …` at
`:987`; if `status === "restoring"` the node is skipped with a warning and
reported as `fresh`, `:990–992`; `degraded` only adds a warning).
- 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** (`:799` — status `"failed"`, error
"Missing required startup files: …").
- Writes a **transcript boundary marker before re-launch** (`:652` — "Write
transcript boundary marker BEFORE launch (before pipe-pane attaches)").
- Refuses to restore over live sessions (`:168` — `rig_not_stopped`: "Rig …
has live sessions. Stop the rig with 'rig down' before restoring").
- Uses `nativeResumeProbe` to honestly assess whether the harness actually
resumed (`:857` continuity-outcome reconciliation).
a required startup file is missing** (`:1343` — status `"failed"`, error
"Missing required startup files: …"). Before any mutation,
`validatePreRestore` (`:585`) already blocks the whole restore when a node
that will consume replay is missing a required startup file
(`required_startup_file_missing`, `:729`; outcome
`pre_restore_validation_failed`, `:290`). A node that resumes its exact
native session replays nothing (`replayContained`, `:1288`).
- Writes a **transcript boundary marker before re-launch**
(`writeBoundaryMarker`, `:1100`, called before `launchNode`).
- Refuses to restore over live sessions (`:258–261` — `rig_not_stopped`: "Rig …
has live sessions. Stop the rig with 'rig down' before restoring"). A tmux
probe that fails also blocks it (`classifyRunningSessions`, `:812`).
- Checks that the harness actually resumed. The resume adapters judge the
pane with `assessNativeResumeProbe` (`domain/native-resume-probe.ts:84`). A
pod-aware resume counts as resumed only when startup reports `resumed`
continuity or the launched session row carries the snapshot's resume type
and token (`launchedSessionMatchesSnapshotResume`,
`restore-orchestrator.ts:1564`); a reported `fresh` continuity without that
proof rolls back to `awaiting-decision` (`:1405–1419`).
**Restore-honesty rules (carried verbatim, `architecture.md` §7):**
**Restore-honesty rules** (texts in `architecture-rules-and-event-system.md`;
cited here by number with the code that carries them):
- Rule 7 — restore-policy narrowing is one-way only:
`resume_if_possible` → `relaunch_fresh` → `checkpoint_only`.
- Rule 14 — resume states are locked: `resumed` / `rebuilt` / `fresh`.
`rebuilt` = new process assembled from artifacts.
- Rule 15 — failed resume is FAILED loudly. No automatic fresh fallback.
Fresh launch is explicit follow-up only.
- Rule 16 — post-command handoff required on `up`, `down`, `restore`,
`snapshot create`: what happened + current state + next action.
- Rule 7 — `RESTORE_POLICY_LEVEL`
(`packages/daemon/src/domain/profile-resolver.ts:103`) orders the three
policies, and `resolveRestorePolicy` (`profile-resolver.ts:488`) rejects a
profile or member value that broadens it.
- Rule 14 — the outcome set is `RestoreNodeResult.status` (`types.ts:476`,
§1); `rebuilt` is set only when a checkpoint is written
(`restore-orchestrator.ts:1272`).
- Rule 15 — in `restore-orchestrator.ts`, a `resume_if_possible` seat with a
session but no token stops as `awaiting-decision` before launch unless
`--fresh` names it (`:1020–1029`); a failed resume kills the blank session
(`rollbackToZeroSession`, `:1140`) and returns `awaiting-decision`
(`:1230`); pod-aware resume passes
`allowFreshFallback: !(isPodAware && resumeRequested)` (`:1399`). Fresh
launch is explicit: `rig up --existing <rig> --fresh <seat...>`
(`packages/cli/src/commands/up.ts:85`).
- Rule 16 — `rig down` prints the snapshot id and a restore command
(`packages/cli/src/commands/down.ts:236–246`); `rig up` on an existing rig
prints per-node statuses and the attach command
(`packages/cli/src/commands/up.ts:540–560`).
(The full 25-rule list lives in `architecture-rules-and-event-system.md`.)
(The full rule list lives in `architecture-rules-and-event-system.md`.)
## 4. Auto-snapshot and existing-rig power-on
(`architecture.md` §6 "Auto-snapshot and existing-rig power-on")
- `rig down <rig>` (name or id, `packages/cli/src/commands/down.ts:104`)
auto-captures an `auto-pre-down` snapshot before teardown when the rig has
live sessions (`packages/daemon/src/domain/rig-teardown.ts:116`; an
already-stopped rig returns at `:89–109` without one).
- `rig up <rig-name>`: a source with no `/` and no
`.yaml`/`.yml`/`.rigbundle`/`.rigtopology` extension is a rig name
(`packages/daemon/src/domain/up-command-router.ts:49–50`). The CLI first
checks the spec library unless `--existing` is given, and refuses a name
that matches both (`packages/cli/src/commands/up.ts:287–302`). The daemon
finds the rig by name (`packages/daemon/src/routes/up.ts:222`) and
restores from `selectRestoreUsable` (`routes/up.ts:105`), which prefers the
newest `auto-pre-down` or `auto-periodic` snapshot, then the newest other
usable one (`snapshot-repository.ts:75`).
- If no usable snapshot exists, or the chosen one names an older occupant
(`snapshotMatchesCurrentOccupants`, `routes/up.ts:109`), `rig up` captures
an `auto-rehydrate` snapshot of current DB state when that state is
eligible (`routes/up.ts:136–145`). Otherwise it errors with code
`no_snapshot` and guidance ("… current DB state is insufficient for
rehydrate. Start fresh with: rig up <spec-path>", `routes/up.ts:117–122`).
- Post-command handoff: `down` output includes the snapshot ID and
`To restore: rig up <name>` (or `rig restore <snapshotId> --rig <rigId>`
when the name is not unique); `up` output includes node statuses and an
`Attach:` command (anchors under Rule 16 above).
- `rig down <rigId>` auto-captures an `auto-pre-down` snapshot before
teardown.
- `rig up <rig-name>` (no file extension) searches for an existing rig by
name and restores from the latest `auto-pre-down` snapshot.
- If no snapshot: error with guidance ("No saved snapshot for rig 'X'. Boot
from a spec or bundle path.").
- Post-command handoff: `down` output includes snapshot ID + restore command;
`up` output includes node statuses + attach command.
## 5. Daemon-side restore-check / restore-packet — AUTHORED FROM SOURCE (D15)
> **D15 author bit (flagged per slice-08 §4.7 binding):** `restore-check` and
> `restore-packet` have NO `architecture.md` narrative. This section is the
> one author-from-source piece in this otherwise pure-split module — authored
> at the slice-00 forensic bar from `routes/restore-check.ts` +
> `domain/restore-check-service.ts` + `commands/restore-packet.ts`, file:line
> cited, version-attribution forensically checked.
>
> **Version attribution (forensic):** restore-check is NOT a 0.3.1 feature.
> `routes/restore-check.ts` first-created `277e279c` ("feat: native rig
> restore-check command", 2026-04-23, pre-0.3.0); present at both `v0.3.0`
> and `v0.3.1` git trees (`git ls-tree` confirmed). `restore-packet`
> first-created `23f2921e` ("Restore-Packet vertical M2a", 2026-05-01),
> likewise present at v0.3.0 and v0.3.1. The gap is missing
> architecture.md *prose*, not a version-attribution drift.
## 5. Daemon-side restore-check and CLI restore-packet
### 5.1 `rig restore-check` — readiness probe
`GET /api/restore-check?rig=<name>&noQueue=<bool>&noHooks=<bool>`
(`routes/restore-check.ts:131`). The route assembles a framework-free
`RestoreCheckDeps` (`:138-168`) over existing daemon projections —
`listRigs` from `rigRepo`, `getNodeInventory` (joining `node_id` by
`logical_id`, `:21-26,143-149`), `getStartupContext` (reads
`GET /api/restore-check?rig=<name>&noQueue=true&noHooks=true&compact=1&ready=1`
(`routes/restore-check.ts:205–206`). The route calls
`createRestoreCheckService` (`:149`), which assembles a framework-free
`RestoreCheckDeps` (`:153–201`) over existing daemon projections —
`listRigs` from `rigRepo` (`:172–175`), `getNodeInventory` (joining
`node_id` by `logical_id`, `:24–29`, `:176–182`), `getStartupContext` (reads
`node_startup_context`, parses `projection_entries_json` /
`resolved_files_json` / `startup_actions_json`, `:44-128`), `hasSnapshot`
/ `getLatestSnapshot` from `snapshotRepo`, and `probeDaemonHealth`
(self-evident: "We're inside the daemon — if this route is responding,
daemon is healthy", `:160-163`). It then runs
`new RestoreCheckService(deps).check(...)` (`:170-171`).
`resolved_files_json` / `startup_actions_json`, `:47–141`), `hasSnapshot`
/ `getLatestSnapshot` from `snapshotRepo` (`:186–192`), `probeQueueStore`
(`:155–167`), `getClaudeActivityHookEvents` (`:168–171`), and
`probeDaemonHealth` (self-evident: "We're inside the daemon — if this route
is responding, daemon is healthy", `:193–196`). It then runs
`service.check(...)` (`:216–217`).
`RestoreCheckService.check()` (`restore-check-service.ts:244`) layers:
`RestoreCheckService.check()` (`restore-check-service.ts:243`) layers:
1. **Host checks** — `checkDaemonReachable` (probe-throw → `verdict:
unknown`, NOT `not_restorable`, `:252-259`), `checkStateDirWritable`
(`:261`), `checkHostInfraDeclaration` (`:262`, impl `:406`).
2. **Rig enumeration** — `listRigs()` throw → `buildUnknown` (`:267-274`);
`--rig` filter; unknown rig → red `rig.<name>.exists` (`:276-284`).
3. **Per-rig checks** — `checkSnapshot` (`:290`, impl `:675`),
`checkSpecPresent` (`:295`).
4. **Per-seat checks** — `checkSeatReadiness` (`:311`), `checkStartupContext`
(`:315`, impl `:763`; `unknownChecks` → `buildUnknown`),
`checkTranscript` (`:325`), `checkResumePath` (`:329`), and unless opted
out: `checkQueueFile` (`:334`, gated by `--no-queue`, impl `:870`) and
`checkHooks` (`:339`, gated by `--no-hooks`, impl `:896`).
5. **Verdict** — `buildResult` aggregates: any red → `not_restorable`; any
yellow → `restorable_with_caveats`; else `restorable`; probe-uninspectable
→ `unknown` (`:1074-1085`, `:1362-1379`). Plus a `RecoveryPlan`
(`buildRecovery`, `:1186`) and a `RepairStep[]` packet
(`buildRepairPacket`, `:1392`; `null` when fully restorable).
unknown`, NOT `not_restorable`, `:256–263`), `checkStateDirWritable`
(`:265`), `checkHostInfraDeclaration` (`:266`, impl `:432`).
2. **Rig enumeration** — `listRigs()` throw → `buildUnknown` (`:271–278`);
`--rig` filter; unknown rig → red `rig.<name>.exists` (`:280–287`).
3. **Per-rig checks** — `checkSnapshot` (`:294`, impl `:701`),
`checkSpecPresent` (`:299`, impl `:1064`).
4. **Per-seat checks** — `checkSeatReadiness` (`:315`), `checkStartupContext`
(`:329`, impl `:789`; `unknownChecks` → `buildUnknown`, `:330–335`),
`checkTranscript` (`:351`), `checkResumePath` (`:355`), and unless opted
out: the daemon SQLite `queue_items` availability probe `checkQueueStore`
(`:360`, gated by `--no-queue`; impl `:910`) and `checkHooks` (`:365`,
gated by `--no-hooks`; impl `:933`). Queue continuity is represented by
the shared daemon store; an empty queue is valid
(`routes/restore-check.ts:159–160`). Claude hook readiness follows the
persisted `claude_activity_hooks` runtime-resource selection
(`restore-check-service.ts:957`) and the adapter's activity-relay
projection in the seat CWD (`restore-check-service.ts:974–975`). A seat
that deliberately omits that resource is not applicable. In compact mode
without `ready=1`, a seat whose readiness check is green gets only the
startup-context check (`restore-check-service.ts:327`, `:347–349`); that
check still counts toward the verdict (`:337–340`).
5. **Verdict** — `buildResult` (`:1106`) aggregates: any red →
`not_restorable`; any yellow → `restorable_with_caveats`; else
`restorable` (`:1121–1128`); probe-uninspectable → `unknown`
(`buildUnknown`, `:1137`). Plus a `RecoveryPlan` (`buildRecovery`,
`:1241`) and a `RepairStep[]` packet (`buildRepairPacket`, `:1475`;
`null` when fully restorable, `:1476`).
The result shape is `RestoreCheckResult` (`restore-check-service.ts:120-130`):
The result shape is `RestoreCheckResult` (`restore-check-service.ts:144–156`):
`verdict`, `readiness`, `continuity`, `rigs[]`, `hostInfra`, `recovery`,
`counts {red,yellow,green}`, `checks[]`, `repairPacket`.
`counts {red,yellow,green}`, `classCounts`, `checks[]`, `repairPacket`. With
`compact=1` the route returns a reduced body (`routes/restore-check.ts:219–245`).
**Honest-error design (slice-00 bar):** a daemon-probe *exception* produces
**Honest-error design:** a daemon-probe *exception* produces
`verdict: unknown` (uninspectable state), distinct from a daemon
definitely-down state which is `red` / `not_restorable`
(`restore-check-service.ts:249-259`). The route's catch-all returns the same
`unknown`-shaped body with HTTP 500 + a `probe.error` red check
(`routes/restore-check.ts:174-222`). `CheckEntry.remediationSafe` defaults to
`false` (conservative — unclassified remediations are NOT
auto-execution-safe, `restore-check-service.ts:18-24`).
(`restore-check-service.ts:253–263`, `checkDaemonReachable` `:393–413`). The
route's catch-all returns the same `unknown`-shaped body with HTTP 500 + a
`probe.error` red check (`routes/restore-check.ts:249–297`).
`CheckEntry.remediationSafe` defaults to `false` (conservative —
unclassified remediations are NOT auto-execution-safe,
`restore-check-service.ts:19–24`, applied at `:1490`).
CLI surface (`cli-reference.md` `### rig restore-check`):
`rig restore-check [--rig <name>] [--no-queue] [--no-hooks] [--json]`. Exit
codes: `0` restorable (or with caveats), `1` not restorable (red), `2`
unknown / probe error.
CLI surface (`../cli-reference.md` `### rig restore-check`; options in
`packages/cli/src/commands/restore-check.ts:222–227`):
`rig restore-check [--rig <name>] [--full] [--ready] [--no-queue] [--no-hooks] [--json]`.
Output is compact unless `--full` (`:313`). Exit codes: `0` restorable (or
with caveats), `1` not restorable (red), `2` unknown / probe error
(`:214–217`).
### 5.2 `rig restore-packet` — cross-runtime restore packet
CLI-side, no daemon route. `commands/restore-packet.ts` (528 lines)
implements three subcommands (`cli-reference.md` `### rig restore-packet`):
`write [options]` (generate a packet directory from a source session or JSONL
file, with `omitted-records` accounting), `read <packet-dir> [--json]`
(render contents; non-mutating), `validate <packet-dir> [--json]` (validate
against the v0 schema; non-mutating). Packet shape is the cross-runtime v0
standard — Claude Code and Codex transcripts both supported via runtime
parsers + redaction.
## OPEN / carried items
- **D15 (author-from-source, completed):** restore-check / restore-packet
authored from source above; flagged as the single author bit in this
module. Version attribution forensically resolved (both pre-0.3.0; the gap
was missing prose, not drift).
- No slice-00 numeric drift applies to this module's split content (the
footprint/migration/route counts land in `daemon-core.md`).
CLI-side, no restore-packet daemon route. `commands/restore-packet.ts`
(**539** lines, `wc -l < packages/cli/src/commands/restore-packet.ts`)
implements **3** subcommands
(`grep -c 'cmd.command(' packages/cli/src/commands/restore-packet.ts`;
`../cli-reference.md` `### rig restore-packet`): `write [options]` (`:216`;
generate a packet directory from a source session or JSONL file, with
`omitted-records` accounting; `--source-session` reads the transcript from the
daemon's `/api/transcripts/<session>/full`, `:156`), `read <packet-dir> [--json]`
(`:371`; render contents; non-mutating), `validate <packet-dir> [--json]`
(`:450`; validate against the v0 schema; non-mutating). Packet shape is the
cross-runtime v0 standard — Claude Code and Codex transcripts both supported
via runtime parsers + redaction (`packages/cli/src/restore-packet/`).
## See also
- `agent-spec-and-startup.md` — StartupOrchestrator persists the replay
context that restore consumes.
- `daemon-core.md` — `/api/restore-check` is one of the 49 route mounts
(`server.ts:513`).
- `daemon-core.md` — `/api/restore-check` is one of the **69** route mounts
in `server.ts` (`grep -c 'app.route(' packages/daemon/src/server.ts`),
mounted at `server.ts:836`.
- `transport-and-transcripts.md` — transcript boundary markers written on
restore.
- Source roots: `packages/daemon/src/domain/{restore-orchestrator,
snapshot-capture,snapshot-repository,checkpoint-store,
restore-check-service}.ts`, `packages/daemon/src/routes/restore-check.ts`,
`packages/cli/src/commands/restore-packet.ts`.
snapshot-capture,snapshot-repository,checkpoint-store,active-occupant,
restore-check-service}.ts`, `packages/daemon/src/routes/{restore-check,up}.ts`,
`packages/cli/src/commands/{restore-packet,restore-check}.ts`.
+146 -176
View File
@@ -1,205 +1,175 @@
---
kind: as-built
title: Living Notes Review Surface — the intent→plan→delivered projection
title: Living Notes — Composed Review and Frozen Exports
status: active
topics: [knowledge-and-context, observability, sdlc]
domains: [engineering-advisor, operating-advisor, product-advisor]
topics: [observability, knowledge-and-context]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Working on or consuming the composed slice/mission review surface — the
/api/review/* routes, the ComposedSliceReview contract, the on-disk SDLC
convention it projects, staged-approval locks, proof artifacts and the C1
header, the freeze export, media serving for review evidence, or the
cross-host FLEET aggregate (/api/review/fleet, the fleet composer's
union/one-count seam, v0.4.6 MH-5).
siblings: [content-surfaces.md, workspace-primitive.md, mission-control.md]
prerequisite-reads: [workspace-primitive.md]
last-verified-against-source: c8341f72
last-updated: 2026-07-08
Tracing slice, mission, rig, or fleet review data back to scope documents,
proof artifacts, judgments, queue state, or frozen exports.
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# Living Notes review surface (v0.4.4)
# Living Notes — Composed Review and Frozen Exports
The review surface is a **pure projection of on-disk markdown into one
reviewable structure per slice**: INTENT → PLAN → DELIVERED. Agents change
files; the daemon re-projects; nothing here writes product state except the
two deliberate approval stamps and the freeze export. Shipped across the
0.4.4 living-notes slices (19 signal layer, 20 composer/surfaces, 22 rig
agents altitude, 23 SDLC operationalization) and the **corrective rebuild**
(2026-07-05/06) that collapsed the surface to the one structure described
here. Everything below is verified at `bb5ad219`.
Source snapshot: `254122872cf477511514979a4300b695d77cd1f7`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
## 1. The ONE contract — `ComposedSliceReview`
Living Notes builds review payloads from existing documents and recorded
state. The composition layer does not make an agent judgment or contact an
agent to obtain one. Its output distinguishes intent, plan, delivered proof,
attention, agents, and source lineage.
`packages/daemon/src/domain/review/types.ts` defines the read contract every
consumer shares (slice Review tab, mission U5 row expansion, For-You
expansion — never a second endpoint per consumer):
## Read and compose boundaries
- **identity + phase**: `slice`, `sliceId`, `title`, `missionId`, `phase`
(five-way, derived read-time: `locked > review > building > spec > intent`,
`derivePhase` in `compose.ts`), `laneLabel` (SS14 vocabulary INTENT / PLAN /
BUILD / REVIEW / LOCKED).
- **`intent`**: `{text, media[], ssotPath, degrade}` — the slice README's
`## Intent` section verbatim (`extractSection`), plus any media it embeds.
- **`plan`**: `{concise: {text, media[]}, lockedArtifacts[], lock, ssotPath}` —
the PRD's pinned `## Mini-requirements` tier + planned mockups; the pinned
artifact set is a **frontmatter READ** (`locked-artifacts:` list on the
slice README — no new write machinery); `lock` is the spec-scope
staged-approval stamp (§3).
- **`delivered`**: `{items[], extraProof[], lock, proofDirPath}` — the
redesigned join (§4): each `## Proof contract` deliverable paired with its
curated proof media and QA's recorded comparison signal. `lock` is the
delivery-scope stamp; `proofDirPath` is the drill-in door to the full
fix-loop history.
- **KEPT orthogonal bands**: `needsYou` (two sources, one queue: agent-routed
items ∪ derived ▲ exceptions with inline evidence), `agents`
(scope-parameterized `slice:<id> | mission:<id> | rig` — one contract, all
scopes), `lineage` (`VerifyLineage`: candidate/merge/tip facts + the four
gate `VerdictCell`s rendering recorded tokens verbatim), `defects`
(out-of-slice media refs surfaced, never silently dropped), `composedAt`.
[`ReviewGatherer`](../../../packages/daemon/src/domain/review/gather.ts)
performs filesystem, database, telemetry, and optional Git reads.
[`compose.ts`](../../../packages/daemon/src/domain/review/compose.ts) turns
those inputs into the contracts defined in
[`types.ts`](../../../packages/daemon/src/domain/review/types.ts), including
`ComposedSliceReview`, `ComposedMissionReview`, and `ComposedRigAgents`.
**Deleted by the corrective (2026-07-05, founder-blocked original):** the
four parallel renderable structures (`sections`, `acceptance`, `compare`,
`join`) and the coequal `green` field. They do not exist in the types, the
composer output, the UI hook, or the fixtures; the daemon route tests assert
the keys are ABSENT on the wire (`test/review-composer.test.ts`,
`test/review-routes.test.ts`). Recorded-verdict green survives in exactly two
places: the per-deliverable `verified` signal (§4) and the mission ledger's
completion fact (§6) — never a slice-review structure.
`gatherSlice` uses `resolveNodeFileVia` to select the scope node document,
reads `IMPLEMENTATION-PRD.md` and `PROOF.md`, and loads the `proof/` artifacts.
It also gathers approval stamps, locked artifacts, tagged queue attention,
agents, workflow exceptions, and `readSliceReadiness`. A current `SPEC.md`
is carried distinctly from a legacy `README.md`; the composer does not
blindly treat every file as an interchangeable source.
## 2. Composer + gatherer split
[`readProofArtifacts`](../../../packages/daemon/src/domain/review/proof-io.ts)
reads sorted top-level `.md` files in `proof/`, parses their headers with
`parseC1Header`, and uses file mtime as `droppedAt`. Missing directories and
unreadable entries yield no artifact for those entries. No claim that all
possible evidence was discovered follows from that read.
- `compose.ts` is **PURE**: `(gathered inputs) → composed doc`; identical
inputs (including caller-supplied `nowIso`/git facts) produce byte-identical
output — idempotence holds by construction and is pinned by tests.
- `gather.ts` (`ReviewGatherer`) is the impure shell: reads slice docs +
`proof/*.md` artifacts from disk, attention/agent rows from SQLite,
approval stamps from frontmatter (cross-checked against the audit log, §3),
git lineage facts from the workspace default repo. Every unreadable source
degrades honestly (nulls / "unknown") — the composer renders the named
degrade, never invented content.
`composeMission` batches slice-index membership reads, gathers each slice,
and composes a mission board, completion ledger, attention union, agents,
and brief spine. `composeRig` reads queue/activity state and reports current
holders, recent holders, overdue work, settled work, and workflow exceptions.
## 3. The two locks = the shipped staged-approval stamps
## Slice source selection and phase
`plan.lock` and `delivered.lock` are projections of the **staged-approval
verb** (`rig scope slice|mission approve --scope spec|delivery` — see
cli-reference "SDLC control plane verbs"): frontmatter stamps
(`approved-spec-by/-at`, `approved-by/-at`) written daemon-side together with
an append-only `mission_control_actions` audit row (no half-stamp). The
gatherer cross-checks each stamp against the pinned scope-approval
`audit_notes_json` shape (`approval_scope` + scope identity); a stamp with no
matching row projects `auditVerified: false` and renders as an UNVERIFIED
stamp — visible, never a block. Approval is freeze/sign-off, **never**
proven-green (BR-6).
In [`composeSliceReview`](../../../packages/daemon/src/domain/review/compose.ts),
intent comes from the node document's `Intent` section. Placeholder-only
intent is treated as absent. Mini-requirements use current `SPEC.md` when
present; legacy selection can fall back from a pristine scaffold PRD section
to authored README material. Proof-contract selection delegates to
`selectProofContractBody` through `extractProofContractSelected`.
## 4. DELIVERED — promised ↔ curated proof ↔ verified
The phase is derived by `derivePhase`, in this precedence order:
`composeDelivered` joins the PRD's `## Proof contract` checkbox items
(`extractProofContract`; an optional markdown image on the checkbox line
becomes the deliverable's `plannedRef` mockup) to the C1-headed proof
artifacts in `<slice>/proof/`:
| Phase | Signal |
|---|---|
| `locked` | Delivery approval stamp. |
| `review` | Recorded verdict or claimed proof pass. |
| `building` | Real proof artifacts, or active queue work paired with an authored/locked spec. |
| `spec` | Authored structure or a spec approval stamp. |
| `intent` | None of the above. |
- An artifact **covers** a deliverable when its C1 `evidences:` list names the
item (exact text or 1-based index).
- `verified` binds to the shipped C1 fields, never mere presence:
**verified** = a covering `qa|adjudication` artifact records the comparison
(`self_check`) AND its recorded verdict is passing; **unverified** = some
covering artifact without a passing recorded QA comparison (QA's
why-kicked-back `note` still surfaces); **missing** = promised, nothing
delivered. All three are render states — fail-open by construction.
- The curated proof set is the covering artifacts' body media refs
(`ProofArtifact.mediaRefs`, populated by `rig proof add --media` or markdown
refs in the artifact body), normalized slice-relative; refs escaping the
slice dir become `defects` findings and are never served or inlined.
Unmapped-artifact media renders bounded as `extraProof`.
- The ▲ insufficient-proof NEEDS-YOU exception fires from the delivered
MISSING count (`deriveExceptions`).
File presence alone is insufficient: a scaffolded `PROOF.md` is not build
evidence, and a tracking queue item alone does not promote an unplanned
slice. Phase and proof readiness are separate output fields.
## 5. Routes, freeze, media serving
## Delivered items and current judgments
- `packages/daemon/src/routes/review.ts`, mounted at `/api/review`
(`server.ts` route mounts): `GET /slice/:name`, `GET /mission/:name`,
`GET /rig` (the OPR.0.4.4.22 rig-agents altitude root), `GET
/agents?scope=slice:<id>|mission:<id>|rig`, `POST /freeze`.
- **Freeze** (`freeze.ts`): the ONE synchronous compose-and-freeze path,
invoked after the delivery stamp + audit row commit. Renders the composed
review to a single self-contained HTML file (`REVIEW-<id>-<date>.html` in
the slice dir): CSS inline, images data-URI-inlined under slice-dir
containment (resolve-prefix + realpath — a traversal/symlink ref renders
the muted outside-slice branch, never inlined), video by link + poster.
Exclusive-create through the allowlist-governed atomic write service;
re-invocation is an idempotent no-op; a failed render never un-approves.
Legacy missions carrying `MISSION_BRIEF.md` may still receive the
section-scoped brief-spine fold; new scaffolds do not create that retired
web-UI-era file.
- **Media serving** for review evidence is ranged on BOTH asset route
families (iOS-Safari-class players require `206`): `GET /api/files/asset`
(`routes/files.ts` — single-range parse, `206`/`416`, `Accept-Ranges:
bytes`; `.html` renders as text/html only under the explicit `?render=1`
opt-in) and `GET /api/slices/:name/proof-asset/*` (`routes/slices.ts` —
same range semantics via `fileAssetResponse`, added by the corrective QA
fixback; immutable-cache posture retained).
`extractProofContract` reads logical checkbox items, retaining authored raw
text for identity. `proofItemIdentity` uses an explicit `proof-item` marker
when supplied, otherwise an ID derived from the item's text. In the legacy
artifact join, `evidences` references match a promised item's exact display
text or its one-based index.
## 6. Mission + rig altitudes
`composeDelivered` pairs each promise with curated media and a verification
state:
`composeMissionReview` consumes `{review, green}` entries per slice: the
board (stage-specific cells re-bound to the collapsed contract — spec-stamp
state, delivered n/m, GREEN·merge pair, stamp), the completion **ledger**
(a query over the slice set, never an authored list; `green` here is the
recorded-verdict fact from `computeRecordedGreen` — regime 1 = four passing
gate verdicts, regime 2 = passing adjudication), `cutComplete` (TRUE only
when every in-cut slice is green AND merged AND has zero open needs-human
items), and the union NEEDS-YOU (distinct identities — one item seen from N
altitudes is one item). `composeRigAgents` serves the standalone rig
altitude (roster ∪ recently-holding, park/health/settled from the queue
transitions log).
- With configured [proof readiness](../../../packages/daemon/src/domain/proof/judgments.ts),
the matching current item must be `accepted`, and readiness must have no
issues, to render `verified`. The note reports the current judgment state
or why it is unavailable.
- Without configured readiness, a winning covering `qa` or `adjudication`
artifact with a non-null `self_check` and a passing verdict supplies legacy
verification. The note explicitly labels it **item revision unbound**.
- Other covering artifacts are `unverified`; no covering evidence/judgment
is `missing`. Unmapped artifact media appears separately as `extraProof`.
## 7. The FLEET altitude — the cross-host aggregate SIBLING (v0.4.6, OPR.0.4.6.MH5)
This distinction matters after a proof contract changes: a legacy PASS
header is not automatically a current revision-bound judgment. Approval
stamps are also not a substitute for the delivered-item join.
`GET /api/review/fleet` aggregates every registered host's composed ▲/● set
into one manage-by-exception glance. It is a **sibling aggregate endpoint**,
never a fourth `AgentsScope` value (arch Q2): the scope grammar stays
exactly `slice:* | mission:* | rig`, and the local composer stays a pure
function over local state. `domain/review/fleet-compose.ts` is the ONE
review-domain module allowed to import hosts transport/registry — a
boundary enforced mechanically by the `review-import-audit` static test
(the zero-I/O `fanout-contract` types module is the sole allowlisted
hosts import elsewhere).
## Approval, attention, and lineage
**Fan-out-the-composed-set (arch Q1):** the ▲ kinds are time-derived on
their own host's clock, so the fleet root fans out each host's
ALREADY-COMPOSED rig root (`GET /api/review/rig` over `remoteJsonRequest`,
concurrent under the named read-class deadline; the local host joins
in-process via the same gatherer — zero self-transport) and only **unions +
host-dimensions + counts**. It never recomputes exception truth (the
composer is clock-free by test pin), so cross-host clock skew can never
distort a ▲. Per-host failures degrade to the closed `PerHostStatus`
honesty contract; a failed host's counts are ABSENT from its
`FleetHostRollup` — absent-not-zero, in the type.
`ReviewGatherer.gatherApproval` reads `approved-spec-by`/`approved-spec-at`
and `approved-by`/`approved-at`, alongside matching `mission_control_actions`
approval audit records. The resulting locks preserve who/when/audit facts.
The approval writer is the separate
[`scope-approve` route](../../../packages/daemon/src/routes/scope-approve.ts).
**One-count across the fleet (arch Q4):** the dedup `Set` lives in the
fleet composer — the one place — keyed `hostId|identity` (rendered verbatim
on the expanded drawer); within-host multi-altitude visibility collapses to
one row with `seenFrom` provenance; an MH-3-forwarded qitem lives only in
its origin host's DB, so no double-count by construction.
`composeNeedsYou` combines recorded queue attention with derived exceptions;
`deriveWorkflowExceptions` adds workflow-specific observations. Mission
composition deduplicates attention identities across slice and mission views.
Media references outside the slice's relative-path contract produce defect
entries instead of disappearing silently.
**Composition pins the seam (arch Q3, the rule of thumb):** sidedness and
caller pick the TRANSPORT seam (MH-4's CLI-direct verbs); when COMPOSITION
exists — the fleet union/one-count is a correctness rule — the seam pins
daemon-side: ONE composer, one endpoint, every consumer (the `/fleet` route
page and the FLEET band today via the shared `useFleet` hook; the TUI/CLI
fleet consumers as named follow-ups) reads the same one. UI cadence is the
bounded named `FLEET_POLL_INTERVAL_MS` with the feed-cadence-class floor,
and the ambient band gates its FETCH on fleet existence (the FS-1 amplifier
discipline; enable-at-scale remains the FS-1 release-validation gate). Read
+ surface only: acting on a remote host's item rides MH-3/MH-4.
Git facts are optional. [`startup.ts`](../../../packages/daemon/src/startup.ts)
passes `OPENRIG_REVIEW_GIT_REPO` to the gatherer. The gatherer reads that
repository's `HEAD`, searches merge messages keyed by scope ID, and computes
ancestry/behind facts when possible. Unavailable facts remain unknown; this
is not a fresh remote GitHub merge-status query.
## 8. The on-disk convention it projects (SDLC control plane)
`composeMissionReview` derives `cutComplete` from a nonempty ledger in which
every slice is green, has a merge SHA, and has no needs-human items. Configured
slice readiness supplies the green predicate; otherwise recorded verdicts
do. That computed field is not itself a publication or higher-level outcome
decision.
The surface projects the markdown-control-plane shapes operationalized by
OPR.0.4.4.23 — conventions SSOT `docs/reference/sdlc-conventions.md`:
`## Intent` (README) / `## Mini-requirements` + `## Proof contract` (PRD) /
`proof/` C1-headed artifacts + `PROOF.md`, scaffolded for every template kind
by `rig scope slice create` and checked advisory-only by `rig scope audit`
(+ the `rig doctor` SDLC row). The UI half of this surface (the slice Review
tab, mission board, For-You approve+chat) is documented in
[`../ui/project-and-for-you.md`](../ui/project-and-for-you.md).
## HTTP routes
[`reviewRoutes`](../../../packages/daemon/src/routes/review.ts), mounted at
`/api/review` by [`server.ts`](../../../packages/daemon/src/server.ts):
| Method and suffix | Payload / behavior |
|---|---|
| `GET /slice/:name` | Composed slice plus proof-source observation. |
| `GET /mission/:name` | Composed mission plus proof-source observation. |
| `GET /agents?scope=…` | Agents band; scope is `slice:<id>`, `mission:<id>`, or `rig`. |
| `GET /rig` | Local rig review root. |
| `GET /fleet` | Local and registered remote host review union. |
| `POST /freeze` | Compose and export an approved slice; body requires `scope: slice` and `name`. |
Missing gatherer returns `503`; invalid agents scope returns `400`; unknown
slice/mission returns `404`. Fleet is a sibling aggregate, not another value
of the agents-scope parameter.
## Freeze and media
[`freezeSliceExport`](../../../packages/daemon/src/domain/review/freeze.ts)
requires a delivery lock and an allowlisted slice directory. It writes
`REVIEW-<scope-id-or-name>-<approval-date>.html` through
`FileWriteService.createAtomic`. An existing target is treated as already
frozen; it is not rewritten. A failed export does not undo approval.
The export embeds contained images as data URIs and links videos with an
optional poster. `containedMediaPath` checks both resolved paths and existing
symlink targets against the slice directory. Unavailable/outside media gets
an explicit rendered message.
After a new freeze, the route may update the generated spine in a conforming
`MISSION_BRIEF.md` through the conflict-checked file writer. A malformed brief
is skipped, and brief-write failures are reported without undoing the export.
The [`slices` routes](../../../packages/daemon/src/routes/slices.ts) separately
serve proof assets; [`files` asset serving](content-surfaces.md) has its own
allowlist and range behavior.
## Fleet composition
[`composeFleet`](../../../packages/daemon/src/domain/review/fleet-compose.ts)
composes local data in-process and reads registered hosts' composed rig
payloads. Its default fan-out concurrency is **4**, with a shared
**4-second** read budget. Synchronous local work cannot be preempted by that
timer; elapsed local work reduces the remaining remote budget.
`unionFleet` keys attention by host plus item identity and retains host
status. Unavailable hosts have absent counts, not synthetic zeros. A missing
registry produces a local-only result; an unreadable existing registry is
reported. Host-derived time/status facts are carried through, not recomputed
as if every host shared one clock.
+169 -112
View File
@@ -1,164 +1,221 @@
---
kind: as-built
title: Mission Control — Queue Observability + 7-Verb Contract (PL-005)
title: Mission Control — Queue Observability + Verb Contract
status: active
topics: [coordination, observability]
domains: [engineering-advisor, operating-advisor, orchestrator, human-operator]
applies-when: |
Need to know how the daemon-backed Mission Control surface works — the
seven views, the seven write verbs, the action audit table, the
bearer-token middleware, or how queue observability maps to PL-004 sources.
seven views, the eight write verbs, the action audit table, the
bearer-token middleware, or how queue observability maps to the
coordination primitive's sources.
siblings: [coordination-primitive.md, workflow-runtime.md, ../ui/project-and-for-you.md]
prerequisite-reads: [../README.md, coordination-primitive.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# Mission Control — Queue Observability + 7-Verb Contract (PL-005)
# Mission Control — Queue Observability + Verb Contract
Mission Control (PL-005) is the daemon-backed queue-observability surface: an
integrated product UI inside the existing shell (a top-level `/mission-control`
route, NOT a new managed app) over the PL-004 Phase A coordination primitive.
Per the PRD acceptance criteria: seven views, seven verbs, first-class human
seats, recent-ships=10, and a daemon-backed action audit table; no
old-dashboard migration/cutover (`architecture.md` §3 L368, L390).
Mission Control is the daemon's queue-observability and operator-action
surface, mounted at `/api/mission-control` over the coordination primitive's
`queue_items` and `stream_items` (see `coordination-primitive.md`). It has
seven views, eight verbs, a recent-ships cap of 10
(`mission-control-read-layer.ts:107`), and a daemon-backed action audit
table. In the web UI the old `/mission-control` page now redirects to
`/for-you`, and the For You feed carries the verb actions (§6).
> Verified against source at HEAD `7eaf524c`. Mission Control is PL-005 (an
> early-0.3.0 graft); plugins and Claude auto-compaction are 0.3.1 features
> and are NOT back-attributed here (slice-00 0.3.0-ground-truth seams a/row-7).
> Verified against source at main `254122872cf477511514979a4300b695d77cd1f7`. A bare file name such as
> `mission-control-read-layer.ts:31` is in
> `packages/daemon/src/domain/mission-control/`; `domain/…`, `routes/…`,
> `middleware/…`, `db/…`, `index.ts`, `server.ts` and `startup.ts` are under
> `packages/daemon/src/`. Each count sits beside the command that produces
> it; run the command from the repository root to refresh it.
## 1. The seven views
`MISSION_CONTROL_VIEWS` is the canonical view list (re-confirmed at HEAD
`packages/daemon/src/domain/mission-control/mission-control-read-layer.ts:32–42`):
`my-queue`, `human-gate`, `fleet`, `active-work`, `recent-ships`,
`recently-active`, `recent-observations`.
`MISSION_CONTROL_VIEWS` is the canonical view list
(`mission-control-read-layer.ts:31–39`): `my-queue`, `human-gate`, `fleet`,
`active-work`, `recent-ships`, `recently-active`, `recent-observations`.
All seven return rows in the load-bearing 9-field phone-friendly content
model (rig/mission name, current phase,
active|idle|attention|blocked|degraded, next-action,
Views: **7** — `sed -n '/^export const MISSION_CONTROL_VIEWS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-read-layer.ts | grep -c '^ "'`
All seven return rows in the 9-field phone-friendly content model,
`CompactStatusRow` (`mission-control-read-layer.ts:60`): rig/mission name,
current phase, active|idle|attention|blocked|degraded, next-action,
pending-human-decision, read-cost, last-update timestamp,
confidence/freshness, evidence link). The model is non-negotiable across all
7 views; UI may render compact, JSON preserves all 9 (`architecture.md` §3
L370).
confidence/freshness, evidence link. The type's doc comment
(`mission-control-read-layer.ts:43–59`) makes the model non-negotiable across
all seven views: UI may render compact, JSON preserves all nine. Four further
optional fields carry the queue item's id and context.
`MissionControlReadLayer` maps each view to its source-of-truth path
(`mission-control-read-layer.ts:4–14`, re-confirmed at HEAD):
Required row fields: **9** — `sed -n '/^export interface CompactStatusRow {/,/^}/p' packages/daemon/src/domain/mission-control/mission-control-read-layer.ts | grep -c -E '^ [a-zA-Z]+:'`
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query PL-004
Phase A `queue_items` via `QueueRepository`.
- `fleet` consumes the per-rig CLI capability cache + queue summary.
- `recently-active` delegates to PL-004 Phase B
`ViewProjector.show("recently-active")`.
- `recent-observations` reads PL-004 Phase A `stream_items` via
`StreamStore`.
`MissionControlReadLayer.readView` (`mission-control-read-layer.ts:143`) maps
each view to its source:
Filesystem fallbacks (`~/.openrig/stream/<date>.jsonl`, raw queue file grep)
are graceful-degradation aids, NOT the primary path (`architecture.md` §3
L378).
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query
`queue_items` via `QueueRepository`.
- `fleet` calls `MissionControlFleetCliCapability.rollupFleet()`
(`mission-control-read-layer.ts:197`;
`mission-control-fleet-cli-capability.ts:109`), which walks the rig
registry (`:110`), summarises each rig's `queue_items` (`:135`), and runs a
per-rig CLI capability probe (`makeLocalCliCapabilityProbe`, `:77`, wired
at `startup.ts:1547`).
- `recently-active` delegates to the view projector's built-in
`ViewProjector.show("recently-active")` (`mission-control-read-layer.ts:246`).
- `recent-observations` reads the latest 50 `stream_items` via `StreamStore`
with `direction: "latest"` (`mission-control-read-layer.ts:265–268`).
> Scope note (slice-00 0.3.0-ground-truth OPEN-3, carried): the exact
> For-You verb subset is an unresolved velocity slice-01 ruling. This module
> describes the **system-level** 7-verb vocabulary (proven, slice-00
> §1.6/seam-c). It does NOT enumerate a For-You-surface subset — that
> surface is described in `../ui/project-and-for-you.md` when authored.
There is no filesystem fallback. When no `StreamStore` is wired,
`recent-observations` returns no rows with
`sourceFallback: "stream-store-not-wired"`
(`mission-control-read-layer.ts:258–262`), and `fleet` always reports
`sourceFallback: "daemon-internal-projection"`
(`mission-control-fleet-cli-capability.ts:156`).
## 2. The seven verbs (the write contract)
> Scope note: this module describes the daemon's verb vocabulary. It does
> NOT enumerate which verbs the For You cards offer; that surface is
> described in `../ui/project-and-for-you.md`.
The seven verbs execute through the load-bearing
`MissionControlWriteContract` (re-confirmed at HEAD
`packages/daemon/src/domain/mission-control/mission-control-write-contract.ts:27–36`):
## 2. The eight verbs (the write contract)
`MISSION_CONTROL_VERBS` (`mission-control-action-log.ts:14–26`) is the verb
list. The verbs execute through `MissionControlWriteContract.act`
(`mission-control-write-contract.ts:131`); the closure mapping is
`verbToClosure` (`:509`).
Verbs: **8** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-action-log.ts | grep -c '^ "'`
| Verb | Effect |
|---|---|
| `approve` | `state="done"`, `closure_reason="no-follow-on"` |
| `deny` | `state="done"`, `closure_reason="denied"` |
| `route` | `state="done"`, `closure_reason="handed_off_to"`, `closure_target`+`handed_off_to`=route target; creates new qitem at the route target (1-hop) |
| `route` | `state="handed-off"`, `closure_reason="handed_off_to"`, `closure_target`+`handed_off_to`=route target; creates a new queue item at the route target (1-hop) |
| `annotate` | no queue mutation; audit record only |
| `hold` | `state="blocked"`, `closure_reason="blocked_on"` |
| `drop` | `state="done"`, `closure_reason="canceled"` |
| `handoff` | the 4-step shape (see below) |
| `handoff` | same closure and new destination item as `route`; the 4-step shape (see below) |
| `resolve` | only for a `blocked` item parked on a human seat: moves it to `in-progress` with the required decision text as the transition note; no closure and no new item (`:369`, `:386`, `:410–411`) |
Each verb is one atomic daemon transaction: queue mutation via
`QueueRepository.updateWithinTransaction()` (which preserves Phase A
hot-potato closure validation — see `coordination-primitive.md` §3) + an
audit row in `mission_control_actions` + a persisted
`mission_control.action_executed` event, all in one `db.transaction`. The
4-step `handoff` shape (source-update + destination-create + opt-in
best-effort notify + audit-record append) is verified atomic; notify failure
does NOT roll back durable mutations (PRD invariant). Failure-injection rolls
back source closure + audit row + new qitem together (`architecture.md` §3
L380; `mission-control-write-contract.ts:5,15`).
Each verb is one atomic daemon transaction: the queue mutation via
`QueueRepository.updateWithinTransaction()` (`domain/queue-repository.ts:2337`,
which keeps the hot-potato closure validation — see
`coordination-primitive.md` §3), an audit row in `mission_control_actions`
(`mission-control-write-contract.ts:207`), and a persisted
`mission_control.action_executed` event (`:225`), all in one
`db.transaction`. For every verb except `annotate`, that transaction is
opened by `EventBus.withNotifyEnvelope` (`:157`; `domain/event-bus.ts:112`,
`:126`); `annotate` opens `db.transaction` itself
(`mission-control-write-contract.ts:308`).
The 4-step `handoff` shape (source-update + destination-create + best-effort
notify + audit-record append) is shared by `route`. The destination item is
created in the same transaction (`createWithinTransaction`, `:173`), along
with the successor's wake intent (`stageWakeIntent`, `:195`, checked by
`assertTerminalClosureHasIntent`, `:242`). The wake itself runs after commit
(`deliverWakeForSuccessor`, `:264`). It is on unless the caller passes
`notify: false` (`domain/queue-repository.ts:1174`), and a notify failure
does NOT roll back durable mutations (`mission-control-write-contract.ts:272–275`).
A failed destination create rolls back the source closure, the audit row and
the new item together
(`packages/daemon/test/mission-control-write-contract.test.ts:220`).
## 3. The action audit table
`mission_control_actions` (`037_mission_control_actions.ts:54` `CREATE TABLE
IF NOT EXISTS mission_control_actions`) is append-only at the API surface:
it records every operator action through Mission Control with before/after
qitem snapshots for forensic reconstruction. Columns include `action_verb`
(TEXT, app-layer enum enforcement, `:56`) and `acted_at` (TEXT NOT NULL ISO
timestamp); indexes `(acted_at DESC, action_verb)`,
`(qitem_id, acted_at DESC)`, `(actor_session, acted_at DESC)`
(`037_mission_control_actions.ts:28–44`). Phase B added NO migrations — this
Phase A table is the only data source (`architecture.md` §3 L364, L392).
`mission_control_actions` (`db/migrations/037_mission_control_actions.ts:58`
`CREATE TABLE IF NOT EXISTS mission_control_actions`) is append-only at the
API surface: `record()` is the only writer `MissionControlActionLog` exposes
(`mission-control-action-log.ts:113`). It records every operator action
through Mission Control with before/after queue item snapshots for forensic
reconstruction. Columns include `action_verb` (TEXT, app-layer enum
enforcement, `037_mission_control_actions.ts:60`) and `acted_at` (TEXT NOT
NULL ISO timestamp, `037_mission_control_actions.ts:63`);
indexes `(acted_at DESC, action_verb)`, `(qitem_id, acted_at DESC)`,
`(actor_session, acted_at DESC)` (`037_mission_control_actions.ts:72–77`).
One later migration adds a nullable `identity_provenance` column
(`db/migrations/065_identity_provenance.ts:18`). The audit browse (§4) reads
only this table.
## 4. PL-005 Phase B — bearer middleware, notifications, audit browse
## 4. Bearer middleware, notifications, audit browse
Phase B extends the daemon Mission Control surface:
- **Bearer-token middleware** —
`packages/daemon/src/middleware/auth-bearer-token.ts` (re-confirmed at
HEAD: header `auth-bearer-token.ts:1–9` "PL-005 Phase B"). Constant-time
bearer comparison via Node `crypto.timingSafeEqual`; the daemon refuses to
start with a non-loopback bind interface AND an empty bearer config
(startup-side check). Bearer enforced on the write verbs:
`app.post("/action", requireAuth)` (`routes/mission-control.ts:294`) and
`app.post("/notifications/test", requireAuth)` (`:295`). v0 is bearer-on-write;
no OAuth/SSO/per-user model.
- **Bearer-token middleware** — `middleware/auth-bearer-token.ts`.
Constant-time comparison via Node `crypto.timingSafeEqual`
(`constantTimeEqual`, `:36`, `:50`). `authBearerTokenMiddleware` (`:93`)
passes every request through when no token is configured (`:98`). The
token comes from `OPENRIG_AUTH_BEARER_TOKEN` (`index.ts:280`). The daemon
refuses to start when the bind host is set explicitly, is neither loopback
nor Tailscale (a hostname is resolved first), and no bearer token is set
(`assertBindAuthInvariant`, `auth-bearer-token.ts:240`, called at
`index.ts:288`). Bearer enforced on the write routes:
`app.post("/action", requireAuth)` (`routes/mission-control.ts:307`) and
`app.post("/notifications/test", requireAuth)` (`:308`). Reads are not
gated. It is one static token; no OAuth/SSO/per-user model
(`auth-bearer-token.ts:11–14`).
- **Notification dispatcher** — two adapters
(`notification-adapter-ntfy.ts` default + `notification-adapter-webhook.ts`
alternate; selected via `OPENRIG_NOTIFICATIONS_MECHANISM` env) plus
`notification-dispatcher.ts` (re-confirmed in
`domain/mission-control/`). ntfy.sh recommended for the operator's phone
via tailnet.
- **Read-only audit-history browse** — `audit-browse.ts` over
`mission_control_actions`, exposed at `GET /api/mission-control/audit`
(`routes/mission-control.ts:346`) with filters and `(limit, before_id)`
pagination cursored on SQLite `rowid` (`architecture.md` §3 L392).
(`notification-adapter-ntfy.ts`, `notification-adapter-webhook.ts`) plus
`notification-dispatcher.ts`. `OPENRIG_NOTIFICATIONS_MECHANISM` selects
`ntfy`, `webhook` or `none`, and the default is `none`
(`startup.ts:1592`). A dispatcher starts only when
`OPENRIG_NOTIFICATIONS_TARGET` is also set (`:1593`, `:1600`) and the
target URL passes validation (`:1611`). The ntfy adapter posts to a topic
URL that the ntfy phone app subscribes to
(`notification-adapter-ntfy.ts:1–6`).
Adapters: **2** — `git grep -l 'implements NotificationAdapter' -- packages/daemon/src | wc -l`
- **Read-only audit-history browse** — `MissionControlAuditBrowse.query`
(`audit-browse.ts:78`) over `mission_control_actions`, exposed at
`GET /api/mission-control/audit` (`routes/mission-control.ts:441`) with
filters and `(limit, before_id)` pagination cursored on SQLite `rowid`
(`audit-browse.ts:134`).
## 5. Mission Control events
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §3
> L394 says "Existing 32 PL-004 events untouched" — internally inconsistent
> with §3 L410's "20" and predates 0.3.x. **Do NOT carry either number.**
> The current `RigEvent` union (`packages/daemon/src/domain/types.ts:94`)
> has **73 members** total (slice-00 §1.8, re-confirmed at HEAD); the
> additive `mission_control.*` events are described below WITHOUT asserting
> a contested PL-004 sub-count.
The `RigEvent` union starts at `domain/types.ts:106`; its four
`mission_control.*` members are at `domain/types.ts:306–311`.
Re-confirmed `domain/types.ts:212–218`: Phase A added
`mission_control.action_executed`, `mission_control.cli_drift_detected`,
`mission_control.view_refreshed`; Phase B added
`mission_control.notification_sent`, `mission_control.notification_failed`
(5 `mission_control.*` members at HEAD).
`RigEvent` members: **98** — `sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "'`
`mission_control.*` members: **4** — `sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "mission_control\.'`
- `mission_control.action_executed` — written by the write contract
(`mission-control-write-contract.ts:225`).
- `mission_control.cli_drift_detected` — emitted by the fleet capability
probe (`mission-control-fleet-cli-capability.ts:171`).
- `mission_control.notification_sent` / `mission_control.notification_failed`
— emitted by the notification dispatcher (`notification-dispatcher.ts:174`,
`:184`).
- *(retired)* `mission_control.view_refreshed` — retired per #490; views are
read-on-demand queries without background refresh caching.
## 6. Route surface
`missionControlRoutes({ bearerToken })` is mounted at `server.ts:498`. Key
routes (`routes/mission-control.ts`): `GET /views` (`:245`),
`GET /cli-capabilities` (`:250`), `POST /action` (`:298`, auth-gated),
`GET /audit` (`:346`), `POST /notifications/test` (auth-gated), plus an SSE
surface. The integrated UI is the `/mission-control` top-level route in
`packages/ui/src/routes.tsx` mounting `MissionControlSurface`
(`architecture.md` §3 L390); UI detail lands in
`../ui/project-and-for-you.md`.
`missionControlRoutes({ bearerToken })` is mounted at `/api/mission-control`
(`server.ts:791–794`). Routes (`routes/mission-control.ts`): `GET /views`
(`:259`), `GET /cli-capabilities` (`:264`), `GET /destinations` (`:276`),
`GET /sse` and its alias `GET /watch` (`:303–304`; they forward
`action_executed` and `cli_drift_detected` events,
`:289–291`), `POST /action` (`:314`, auth-gated), `GET /audit` (`:441`),
`POST /notifications/test` (`:481`, auth-gated), and `GET /views/:view-name`
(`:509`).
In the web UI (`packages/ui/src/routes.tsx`), `/mission-control` is a
redirect to `/for-you` (`:495–500`). `/for-you` renders the For You `Feed`
(`:130–134`), whose cards call `POST /action` through `VerbActions`
(`packages/ui/src/components/for-you/FeedCard.tsx:13`), and `/search` renders
`AuditHistoryView` over `GET /audit` (`routes.tsx:304–308`).
`MissionControlSurface`
(`packages/ui/src/components/mission-control/MissionControlSurface.tsx:9`)
still exists and renders the same `Feed`, but nothing imports it. UI detail
lands in `../ui/project-and-for-you.md`.
## See also
- `coordination-primitive.md` — the PL-004 Phase A `queue_items`/`stream_items`
sources Mission Control reads, and the hot-potato closure contract verbs honor.
- `workflow-runtime.md` — PL-004 Phase D transactional-scribe runtime.
- `../ui/project-and-for-you.md` — the UI surface counterpart (authored phase).
- `coordination-primitive.md` — the `queue_items`/`stream_items` sources
Mission Control reads, and the hot-potato closure contract verbs honor.
- `workflow-runtime.md` — the workflow transactional-scribe runtime.
- `../ui/project-and-for-you.md` — the For You feed that carries the verb
actions in the web UI.
- Source roots: `packages/daemon/src/domain/mission-control/`,
`packages/daemon/src/middleware/auth-bearer-token.ts`,
`packages/daemon/src/routes/mission-control.ts`,
@@ -5,139 +5,183 @@ status: active
topics: [specification-and-bundles, release-and-versioning]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Need to know how rig/pod bundles are assembled (schema-version-2 vs legacy
v1), how bundle create/inspect/install and /api/up route across source kinds,
the staged BootstrapOrchestrator plan/apply flow, or which legacy install-
engine seams still ship for pre-reboot data.
Locate npm package assembly, topology-bundle creation and validation,
source routing, bootstrap plan/apply, or the retained package install engine.
siblings: [agent-spec-and-startup.md, plugin-agent-image-context-pack.md]
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# Packaging, Bootstrap, Bundles, Legacy Install Engine
# Packaging, bootstrap and bundles
How OpenRig packages a topology into a shareable bundle and reconstitutes it
on another host. Fully dual-format: schema-version-2 pod bundles plus legacy
v1 artifacts, routed deterministically by the bootstrap orchestrator.
This module describes source at main commit
`254122872cf477511514979a4300b695d77cd1f7`. Source paths below are repository-relative.
An npm CLI artifact and a `.rigbundle` have different builders and consumers; neither
source verification nor archive integrity establishes that a daemon has adopted an artifact.
> Verified against source at HEAD `7eaf524c` (`git describe` →
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
> located by `architecture.md` headings (§5 Bundles/bootstrap/legacy
> compatibility, §6 Bundle create/inspect/install + /api/up, §11 Compat
> note 3) per slice-08 §10.1 — line numbers advisory only.
## npm CLI assembly
## 1. Bundle, bootstrap, legacy domain services
`scripts/build-package.sh` builds the daemon, web UI, TUI and CLI, then assembles their
outputs under `packages/cli`. The publishable file list and binary declarations live in
`packages/cli/package.json`.
(`architecture.md` §5 "Bundles, bootstrap, and legacy compatibility")
The assembler writes generated `BUILD_INFO` modules into the daemon and CLI outputs with
the package version, Git commit, dirty flag and build time. The dirty check covers tracked
and untracked inputs under `packages/` and `scripts/`; it is not a whole-worktree status
claim.
- `pod-bundle-assembler.ts` — schema-version-2 bundle assembler.
Re-confirmed: emits `schemaVersion: 2` (`pod-bundle-assembler.ts:167`).
- `bundle-types.ts` — v1 and v2 manifest types plus parse/validate/serialize.
Re-confirmed: v2 `PodBundleManifest` type carries `schemaVersion: 2`
(`:23`); `validatePodBundleManifest` rejects unless `schema_version === 2`
(`:38`); `serializePodBundleManifest` writes `schema_version: 2` (`:62`);
`parsePodBundleManifest` (`:87`); legacy path
`validateLegacyBundleManifest` (`:143`) — both formats live in one file.
- `bundle-source-resolver.ts` — `LegacyBundleSourceResolver`
(`bundle-source-resolver.ts:25`) plus `PodBundleSourceResolver` (`:132`).
- `bootstrap-orchestrator.ts` — staged bootstrap flow with direct pod-aware
rig and v2 bundle delegation. `BootstrapMode = "plan" | "apply"`
(`bootstrap-orchestrator.ts:26`).
- `up-command-router.ts` — spec/bundle source classification for `/api/up`.
`SourceKind = "rig_spec" | "rig_bundle" | "rig_name"`
(`up-command-router.ts:6`).
The same script:
All re-confirmed present in `packages/daemon/src/domain/` @HEAD.
- Generates shipped context packs through `scripts/generate-context-packs.mjs`.
- Copies daemon output, assets, specs, context packs, policies and `docs/reference/`.
- Checks the specs staging input with `scripts/check-internal-leak-guard.mjs` and writes
the staged substance-root inventory into the package.
- Copies the web UI and TUI outputs.
- Runs `scripts/rewrite-daemon-imports.mjs` so staged CLI/TUI JavaScript resolves the
shipped daemon output rather than an unpublished workspace package.
## 2. Bundle create / inspect / install
This is the build layout. Native dependency installation and consumer runtime behavior need
their own checks; this document does not infer them from a successful assembly.
(`architecture.md` §6 "Bundle create / inspect / install")
## Topology-bundle creation
`routes/bundles.ts` is fully dual-format:
`packages/daemon/src/routes/bundles.ts` mounts create, inspect, install and history handlers
under `/api/bundles` (the mount is in `packages/daemon/src/server.ts`).
- **create** — detects a pod-aware RigSpec and uses `PodBundleAssembler`
(accepts an optional `rigRoot`); legacy create still uses
`LegacyBundleAssembler`.
- **inspect** — safely extracts the archive, detects `schema_version`; v2
returns `schemaVersion: 2`, `agents[]`, and integrity data; v1 returns the
legacy manifest shape.
- **install** — uses the full bootstrap plan/apply; bootstrap peeks the
manifest and routes deterministically to `pod_bundle` or `rig_bundle`.
Re-confirmed at source: `bootstrap-orchestrator.ts:134-143` — when
`sourceKind === "rig_bundle"` it unpacks to a temp peek dir, reads
`bundle.yaml`, and calls `parsePodBundleManifest(...)` to detect the
schema version before routing.
The create handler validates the source spec before comparing it with recorded live topology.
A divergent spec is refused unless the request explicitly allows drift; an allowed divergence
is recorded in the bundle's provenance notes. The exported spec is not silently rewritten
from live state.
## 3. `/api/up` source routing
| Source shape | Builder and output |
|---|---|
| Pod-aware rig spec | `packages/daemon/src/domain/pod-bundle-assembler.ts`: `PodBundleAssembler.assemble()` produces schema-version-2 metadata, vendors resolved agent trees, and rewrites agent references to local paths. |
| Legacy rig spec | `packages/daemon/src/domain/bundle-assembler.ts`: `LegacyBundleAssembler` (imported as `BundleAssembler` by the route) collects the referenced legacy packages and produces the legacy manifest shape. |
(`architecture.md` §6 "`/api/up`")
The pod builder collects culture, docs and startup material, preserves file bytes and available
mode metadata, and skips vendoring the terminal sentinel as an agent. Required document
collection failures stop assembly; missing declared skills can instead produce warnings.
Inspect the builder's collection helpers when changing these distinctions.
`UpCommandRouter` + `BootstrapOrchestrator` own:
Both route branches can consume a source-root `bundle.yaml` to carry declared skills,
plugins, workflow specs, context packs and agent images into staging before computing
integrity. Manifest types, validation and serialization are in
`packages/daemon/src/domain/bundle-types.ts`. Provenance and compatibility are metadata;
provenance is not an author signature.
- direct pod-aware rig specs,
- legacy rig specs,
- v1 bundle installs,
- v2 pod-bundle installs.
### Archive checks
Plan mode and apply mode both work across those source kinds.
`packages/daemon/src/domain/bundle-archive.ts` owns `pack()` and `unpack()`;
`packages/daemon/src/domain/bundle-integrity.ts` owns the per-file integrity map.
> Definitional note (carried, not a numeric drift) — `architecture.md` §6
> describes bootstrap routing to `pod_bundle` / `rig_bundle`. The
> `UpCommandRouter` *classification* enum is `SourceKind = "rig_spec" |
> "rig_bundle" | "rig_name"` (`up-command-router.ts:6`); the
> `pod_bundle`-vs-`rig_bundle` distinction is resolved one layer deeper in
> `bootstrap-orchestrator.ts` by peeking the manifest schema version
> (`:134-143`). Both statements are accurate at different layers; documented
> here so the layering is explicit rather than appearing contradictory.
Packing sorts entries, uses portable tar metadata and a fixed tar timestamp, and writes a
sibling SHA-256 file. Unpacking checks that digest, rejects archive links and unsafe entry
paths before extraction, requires `bundle.yaml` and its integrity section, and verifies
the extracted file inventory. Hashes establish consistency with the supplied manifest and
digest; they do not authenticate whoever supplied both.
## 4. Legacy install engine (still ships)
The inspect handler unpacks through this same path before reporting the appropriate manifest
shape. A syntactically valid manifest alone is not an inspected archive.
(`architecture.md` §5 "Legacy systems that still ship" + §11 compat note 3)
## Source routing and bootstrap
These pre-reboot seams remain active for backward compatibility:
`packages/daemon/src/domain/up-command-router.ts` classifies a source as
`rig_spec`, `rig_bundle`, `rig_name` or `topology`. It recognizes named rigs,
spec paths, `.rigbundle`, `.rigtopology`, and YAML with a top-level rigs list;
extensionless paths also have content detection.
- package install engine: `package-install-service.ts`,
`package-manifest.ts`, `package-repository.ts`, `install-engine.ts`,
`conflict-detector.ts`, `role-resolver.ts` (all re-confirmed present
@HEAD).
- bootstrap and requirement-probe support.
- discovery and claim services.
- tmux/cmux adapters and resume adapters.
`packages/daemon/src/routes/up.ts` then selects the execution path:
> Drift-check D-pkg — `architecture.md` §5/§6 carries no slice-00 numeric
> drift in this content; the v2 bundle-assembler + dual-format flows were
> verified accurate at HEAD (§1–§3 above). The footprint/migration counts
> that ARE stale land in `daemon-core.md`, not here.
| Input | Consumer |
|---|---|
| Existing rig name | Existing-rig restore path. |
| Single rig spec or bundle | `BootstrapOrchestrator.bootstrap()` in `packages/daemon/src/domain/bootstrap-orchestrator.ts`. |
| Topology manifest | `MultiRigLauncher`, with a single-rig bootstrap or remote-up leaf per entry. |
Compat note 3 (carried verbatim, `architecture.md` §11): "Legacy
compatibility seams still ship for pre-reboot data and v1 artifacts." (The
full §11 list lives in `architecture-rules-and-event-system.md`.)
Topology plan mode is rejected. Placement belongs on the individual topology entries; a
top-level host flag is rejected. The selected topology parser/route also rejects nested
topologies and existing-rig-name entries. This is narrower than the single-rig `up` surface.
> Provenance note (carried, not asserted): `bootstrap-orchestrator.ts:3-5`
> still imports `LegacyRigSpec` / `LegacyRigSpecCodec` / `LegacyRigSpecSchema`
> with in-source `TODO: AS-T08b — migrate to pod-aware RigSpec` markers, and
> `:16` `TODO: AS-T12 — migrate to pod-aware bundle source resolver`. The
> legacy seam is intentional, in-progress migration scaffolding — recorded
> as-is, not smoothed.
Bootstrap distinguishes pod-aware and legacy specs, and inspects a bundle manifest to choose
`PodBundleSourceResolver` or `LegacyBundleSourceResolver` from
`packages/daemon/src/domain/bundle-source-resolver.ts`. Thus the router's
`rig_bundle` kind does not imply a legacy manifest.
## OPEN / carried items
For pod-aware specs, plan mode validates and runs preflight probes; apply delegates to
`PodRigInstantiator`. Plan mode still records a bootstrap run/result: it is not a pure
file read. Apply reports partial completion when nodes fail or require attention, preserving
the created rig identity rather than claiming every member launched.
- **D-pkg (resolved-as-current):** no slice-00 numeric drift in this
module's split content; v2 assembler + dual-format flows verified accurate
at HEAD. Bundle-routing layering documented explicitly to pre-empt an
apparent §6-vs-source contradiction.
- Legacy-migration TODOs carried from source verbatim (AS-T08b / AS-T12).
### Durable install target
## See also
Bundle apply requires an explicit target root at the HTTP boundary. For a pod bundle,
`materializePodBundle()` copies the verified extraction into that durable root before
instantiation, so local agent references and relative working directories survive removal
of the extraction directory. It checks destination conflicts before copying, preserves
identical files, and refuses differing files or incompatible destination types.
- `agent-spec-and-startup.md` — `RigInstantiator` / `PodRigInstantiator` and
the dual-format spec seam that bundles wrap.
- `plugin-agent-image-context-pack.md` — the 0.3.0/0.3.1 reusable
starter-state / content-provenance cluster (authored separately in 8.4b).
- `daemon-core.md` — `/api/up` + `/api/bundles` are among the 49 route
mounts; `BootstrapOrchestrator` is constructed in the createDaemon
sequence.
- Source roots: `packages/daemon/src/domain/{pod-bundle-assembler,
bundle-types,bundle-source-resolver,bootstrap-orchestrator,
up-command-router}.ts`, `packages/daemon/src/routes/{bundles,up}.ts`.
Pod bundles containing service definitions are refused by bootstrap; their service path
requires a stable spec directory. Direct spec bootstrap has separate service prelaunch
handling. See [agent-spec-and-startup.md](agent-spec-and-startup.md) for instantiation.
## Install checks and content routing
The install handler in `packages/daemon/src/routes/bundles.ts` obtains a source-path
lock, reads validated metadata, checks compatibility, and checks a declared rig name
against the repository's rig list before bootstrap. The compatibility override
`skipVersionCheck` and name-conflict override `force` are explicit request fields.
`packages/daemon/src/domain/bundle-conflict-detector.ts` implements the rig-name check.
It is not a complete agent, port or filesystem collision audit; a missing rig name supplies
no name comparison. Target-file conflicts are handled separately by materialization.
Skipping these prechecks does not skip archive validation in bootstrap.
After a completed bundle install, the route separately unpacks and routes declared content:
| Manifest content | Destination selected by the route |
|---|---|
| `skills` | The OpenRig `packages` cache, stripping the legacy `packages/` prefix; this does not import a complete harness skill into the managed skill catalog. |
| `plugins` | The OpenRig `plugins` root, through local plugin references. |
| `workflow_specs` | `workflows/` beneath resolved `workspaceSpecsRoot`; unresolved settings skip this routing. |
| `context_packs` | The configured `context.root`, matching context-library discovery. |
| `agent_images` | The OpenRig `agent-images` root; declarations address image directories containing a manifest. |
The individual implementations are the `bundle-skills-router.ts`,
`bundle-plugins-router.ts`, `bundle-workflow-specs-router.ts`,
`bundle-context-packs-router.ts` and `bundle-agent-images-router.ts` files under
`packages/daemon/src/domain/`. The route treats these post-bootstrap calls as best effort:
their failure does not roll back or turn an already completed rig install into failure.
Inspect their returned results and warnings separately from bootstrap success.
Install audit records are appended best effort to `bundle-audit.jsonl` under the OpenRig
home; the history endpoint reads that audit. Bootstrap run state and action journals are
separate, repository-backed records.
## Legacy package install path
The legacy path remains executable, not just an archive parser.
`packages/daemon/src/routes/packages.ts` is mounted at `/api/packages`, and legacy
bootstrap calls `PackageInstallService` in
`packages/daemon/src/domain/package-install-service.ts`.
The source chain is package resolution/manifest validation, planning and conflict policy,
then apply/verification with repository records:
- `packages/daemon/src/domain/package-resolver.ts` and `package-manifest.ts`.
- `packages/daemon/src/domain/install-planner.ts`, `conflict-detector.ts` and
`install-policy.ts`.
- `packages/daemon/src/domain/install-engine.ts`, `install-verifier.ts`,
`install-repository.ts` and `package-repository.ts`.
Legacy bootstrap also probes requirements and records staged outcomes before rig
instantiation. A failed package installation prevents that path from importing the rig.
This compatibility implementation does not imply that pod-aware bundles use the same
package-install stages.
## Related maps
- [plugin-agent-image-context-pack.md](plugin-agent-image-context-pack.md): content library
consumers and their state boundaries.
- [daemon-core.md](daemon-core.md): bootstrap construction and HTTP wiring.
- [agent-spec-and-startup.md](agent-spec-and-startup.md): spec resolution, projection and launch.
@@ -5,353 +5,212 @@ status: active
topics: [extension-and-user-workspace, continuity, skill-management]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Need to know how OpenRig discovers plugins, captures/forks agent images,
assembles/sends context packs, or how the Claude auto-compaction enforcer
decides to send /compact. Author-mode module — no prior architecture.md
prose; every claim is sourced to file:line at HEAD.
Locate plugin discovery and vendoring, context-pack addressing/composition,
agent-image capture/fork/protection, or Claude guided-compaction behavior.
siblings: [packaging-bootstrap-bundles.md, agent-spec-and-startup.md]
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# Content Layer — Plugins, Agent Images, Context Packs, Compaction Policy
# Content libraries and compaction
Four filesystem-canonical content primitives the daemon discovers and
serves: **plugins**, **agent images**, **context packs**, and the **Claude
auto-compaction policy enforcer**. None adds SQLite state — all are
filesystem-canonical with in-memory daemon caches.
This module describes source at main commit
`254122872cf477511514979a4300b695d77cd1f7`. Source paths below are repository-relative.
Context packs, plugins and agent images have filesystem-backed content and daemon-side
discovery. Their consumers can read database identity, mutate files, deliver messages or
launch sessions; the whole layer is not a read-only catalog.
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists
> for this. Every load-bearing claim carries `> Source: <file:line>
> @HEAD`; ambiguity is declared as an OPEN item, never smoothed. Paths are
> relative to `packages/daemon/src/` unless prefixed `docs/`.
>
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2
> commits; no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
## Context packs
## 0. Release attribution (forensic seam — read first)
`packages/daemon/src/domain/context-packs/context-pack-library-service.ts` implements
`ContextPackLibraryService`. A pack contains `manifest.yaml` and declared members.
Its path-like ref is the lookup identity, independently of the manifest name/version;
the entry ID is `context-pack:<ref>`.
Re-verified at HEAD via `git cat-file -e <tag>:<path>`:
### Discovery and mutation
| Subsystem | Release | Forensic proof @HEAD |
|---|---|---|
| Context packs | **0.3.0** | `v0.3.0:domain/context-packs/context-pack-library-service.ts` → present (slice-00 0.3.0-GT §1.9) |
| Agent images | **0.3.0** | `v0.3.0:domain/agent-images/agent-image-library-service.ts` → present (slice-00 0.3.0-GT §1.9) |
| Plugins | **0.3.1, NOT 0.3.0** | `v0.3.0:domain/plugin-discovery-service.ts` → **ABSENT**; `v0.3.1:` → present (slice-00 0.3.0-GT seam (a); map row 5) |
| Claude auto-compaction | **0.3.1 headline, no 0.3.0 antecedent** | `v0.3.0:domain/claude-compaction-enforcer.ts` → **ABSENT**; `v0.3.1:` → present (slice-00 0.3.0-GT map row 7) |
Startup in `packages/daemon/src/startup.ts` configures the shipped context-pack root,
the resolved `context.root` and its system root, followed by an existing workspace-local
`.openrig/context-packs` root. The library scans recursively, stops descending when it
finds a pack manifest, and does not follow directory symlinks.
> Source: re-run at HEAD `7eaf524c` —
> `git cat-file -e v0.3.0:packages/daemon/src/domain/plugin-discovery-service.ts`
> and `...claude-compaction-enforcer.ts` both fail ("exists on disk, but
> not in 'v0.3.0'"); the two 0.3.0 library services resolve at `v0.3.0`.
> Code present on disk at HEAD does NOT make plugins/compaction a 0.3.0
> feature — ancestry is truth; the same-calendar-day commit date is the
> forensic trap slice-00 0.3.0-GT seam (a) warns about.
The first configured root to encounter a physical pack owns that directory. For distinct
physical packs with the same ref, the later root wins. This distinction prevents overlapping
roots from giving the same physical pack a second address. Scan replaces the in-memory ref
index and reports malformed packs instead of indexing them.
**Do not back-attribute plugins or compaction to 0.3.0.** The CLI
reference already encodes this ("## Plugin Inspection (v0.3.1)").
`composeFromFiles()` creates a durable pack after validating its ref, destination and
sources. `removeByRef()` removes the selected pack directory and rescans, but refuses
shipped builtin packs. Library mutation and lookup share ref validation.
> Source: `docs/as-built/cli-reference.md:943` @HEAD.
`packages/daemon/src/domain/context-packs/manifest-parser.ts` parses manifests.
Its supported file suffixes include Markdown/YAML/text and `.sh`, `.ts`, `.mjs`,
`.py` helper assets. Those assets are served as text; pack assembly does not execute them.
The parser also validates atom metadata and named profiles.
## 1. Context packs (PL-014) — 0.3.0
### Assembly, addresses and profiles
A **context pack** is a directory of `manifest.yaml` + included markdown /
yaml / txt files: operator-authored, library-discoverable, reviewable, and
composable. The `rig context` noun is delivery-free. No SQLite tables;
in-memory cache at daemon scope.
These are separate projections:
> Source: `domain/context-packs/context-pack-types.ts:1-11` @HEAD.
| Operation | Source and behavior |
|---|---|
| Framed pack preview | `packages/daemon/src/domain/context-packs/bundle-assembler.ts`, `assembleBundle()`: pack/file headers, file metadata, token estimates and `missingFiles`. Missing members are reported and skipped; a failed read of a present member raises an error. |
| Plain file composition | The same file's `assemblePlainFiles()`: present files in declared order, separated by two newlines, without framing or trimming their bytes. |
| Addressed read | `packages/daemon/src/routes/context-packs.ts`, `/library/resolve-address`: longest matching pack-ref prefix, a declared file, then optional H2/H3 heading selection through `packages/daemon/src/domain/markdown-address.ts`. |
| Situation profile | `packages/daemon/src/domain/context-packs/profile-composer.ts`, `composeProfile()`: select atoms, close dependencies, order and resolve each piece. |
| Named profile | The same file's `composeNamedProfile()`: declared phases select atoms or supplied work/seat context, preserving phase IDs in the result. |
`ContextPackLibraryService.scan()` walks roots, parses each
`manifest.yaml`, replaces the in-memory index; collisions are
**last-wins** in discovery order (workspace > user_file > builtin).
Startup wires the builtin root (`../context-packs`, first), the configured
user-file root (`context.root`, default `$OPENRIG_HOME/context`), its
`system/` child as the canonical System World root, and the workspace-local
`<workspaceRoot>/.openrig/context-packs` root when present + distinct. A pack is
addressed by its path-like **ref** (e.g. `packs/compaction-restore`), which is
its unique identity; the opaque entry id is `context-pack:<ref>` (a UI routing
key). Colon-id `context-pack:<name>:<version>` addressing and the `/library/:id`
routes were removed in Slice-03 Atom 5. Resolution is by ref only through
`getByRef` and the read/delete/preview/pieces route family. Startup accepts
ordinary files only and rejects context-pack expansion; compose a durable ref
first, then use a dedicated delivery verb when delivery is intended.
Situation composition selects fresh atoms for `fresh`, fresh plus handover atoms for
`handover`, and post-compaction plus handover atoms for `post-compaction`. Runtime
selection is Claude or Codex, with `any` atoms applicable to either. Required atoms join
the selection; missing dependencies or runtime-incompatible dependencies fail composition.
The output is ordered by atom order, then ID. Profile-only atoms are excluded from ordinary
situation selection.
> Source: `domain/context-packs/context-pack-library-service.ts:59-94`
> (scan; last-wins L79-81), `:31-33` (id); `startup.ts`
> (`contextPackLibrary` construction) @HEAD.
The profile route resolves configured tree sources through
`packages/daemon/src/domain/context-packs/profile-source-resolver.ts`.
Seat context requires explicit rig and seat selectors. Mission/slice selectors and named
profile context requirements are validated before reading those trees. The default
project/mission/slice walk is bounded to the conventional single-project workspace layout
and fresh situation; it is not an arbitrary project-catalog resolver.
The parser is pure (no fs; caller passes raw YAML). It rejects malformed
YAML, missing `name`/`version`, non-array `files`, per-file path traversal
(`..`/leading `/`), and unsupported suffixes (`.md .markdown .yaml .yml
.txt`); `version` coerced to string. Per-file token estimate is
`ceil(bytes/4)`.
Each resolved piece names its address/source and reports estimated tokens. A token budget
reports overage and drop candidates without silently truncating the profile. Atom
`regions` are metadata; the profile endpoint has no region-subset selector.
> Source: `domain/context-packs/manifest-parser.ts:11` (suffixes),
> `:13-121` (rejects; traversal `:83-89`); token estimate
> `context-pack-library-service.ts:46-48` @HEAD.
### HTTP and CLI boundaries
`assembleBundle` concatenates files into one paste-ready string framed
`# OpenRig Context Pack: <name> v<version>` + optional purpose + `## File:
<path> (role: <role>)` headers. Missing files are **skipped and surfaced
in `missingFiles`** (operator repair), not a hard fail; a present-but-
unreadable file throws `file_read_failed`.
`packages/daemon/src/server.ts` mounts `contextPacksRoutes()` at
`/api/context-packs`. Its library handlers cover list, sync, compose, read/delete by ref,
preview, pieces, addressed reads and profile composition.
> Source: `domain/context-packs/bundle-assembler.ts:38-39` (prefixes),
> `:73-100` (missing skip L74-76; read-fail throw L81-87) @HEAD.
`packages/cli/src/commands/context.ts` implements `rig context`, including
`get`, `profile`, `compose`, `add`, `rm` and `sync`, plus work-install,
trace, source and recap operations. The library read/compose routes do not themselves send
a pack to a running seat. Delivery and installation consumers have their own behavior;
inspect the selected CLI verb before treating a context command as read-only.
`contextPacksRoutes()` exposes a ref-primary, delivery-free library surface:
`GET /library`, `POST /library/sync`, `POST /library/compose`,
`GET/DELETE /library/by-ref`, `GET /library/by-ref/preview`, and
`GET /library/by-ref/pieces`. Preview returns the assembled review shape;
pieces returns ordered member contents plus the whole plain text and byte size.
There is no pack-owned delivery route. CLI library verbs are
`rig context compose|list|show|preview|sync|add|rm`; delivery belongs to
`rig send --context`, `rig broadcast --context`, `rig walk --through`, and
`rig queue create --body-context`.
## Agent images and forks
> Source: `routes/context-packs.ts` (complete route table);
> `domain/startup-validation.ts` (file-only startup contract);
> `packages/cli/src/commands/context.ts`, `send.ts`, `broadcast.ts`,
> `walk.ts`, and `queue.ts` @HEAD.
`packages/daemon/src/domain/agent-images/agent-image-library-service.ts` implements
`AgentImageLibraryService`. Startup registers the OpenRig `agent-images` root and,
when present, the workspace-local `.openrig/agent-images` root. Entries are keyed by
`agent-image:<name>:<version>`, with later discovery roots replacing identical IDs.
Content is a manifest plus optional files; `stats.json` holds usage data and `.pinned`
marks protection from ordinary deletion/pruning.
## 2. Agent images (PL-016) — 0.3.0
Capture and consumption depend on live identity as well as library files:
An **agent image** is a snapshot bundle of a productive seat's resumable
state: runtime-specific resume token (Claude `resume_token` / Codex
`thread_id`), source-seat lineage, optional cwd-deltas + notes. Consumable
by AgentSpec `session_source: mode: agent_image`. Filesystem-canonical at
`~/.openrig/agent-images/<name>/` + workspace-local; **no SQLite tables**.
- `packages/daemon/src/domain/agent-images/resume-token-discovery.ts`:
`discoverResumeToken()` reads session/node/binding state. It supports Claude Code and
Codex. Claude prefers matching context-usage identity; Codex prefers the persisted resume
token, then an external-CLI binding. Absent native identity remains absent.
- `packages/daemon/src/domain/agent-images/snapshot-capturer.ts`:
`SnapshotCapturer.capture()` refuses unsupported or missing native identity, writes
an image manifest/files and rescans. It records the source working directory when known.
- `packages/daemon/src/routes/agent-images.ts`:
`POST /fork` composes native identity discovery with an add-member operation. The
default path makes no image. The keep-image path captures and pins one before launch,
and reports that retained image even if launch fails.
> Source: `domain/agent-images/agent-image-types.ts:1-13` @HEAD.
Library/snapshot responses redact `sourceResumeToken`; the fork composer builds its
native-ID session source inside the daemon rather than returning that field to the caller.
This describes these response projections, not secrecy of every field in an image manifest.
`AgentImageLibraryService` mirrors `ContextPackLibraryService` (scan / list
/ get / last-wins) with three source-stated differences: `sourceResumeToken`
passes through to consumers (instantiator consumes; operator surfaces
redact); `stats.json` is a separate mutable file updated atomically on
fork-count increment; a `.pinned` sentinel pins from prune. Id
`agent-image:<name>:<version>`.
`recordConsumption()` accepts a separate fork-count flag: consumption intent can update
last-use time without incrementing successful forks. Inspect the instantiation caller as
well as library statistics when assessing whether a launch succeeded.
> Source: `domain/agent-images/agent-image-library-service.ts:5-14`
> (3-difference comment), `:38-41` (id) @HEAD.
`packages/daemon/src/domain/agent-images/evidence-guard.ts` implements
`evaluateProtection()`: pins, parsed spec references and descendants of protected images
contribute protection reasons. The route applies the guard to deletion/pruning unless
`force` is explicit. Prune defaults to dry-run. Its protection view depends on the supplied
spec roots; it is not an inventory of every reference anywhere on the host.
`discoverResumeToken(db, sourceSession)` queries `sessions` then `nodes`
and returns a typed failure (`session_not_found`/`runtime_unsupported`)
rather than fabricating a token; only `claude-code`/`codex` have a native
fork primitive. Claude prefers `context_usage.session_id` over persisted
`sessions.resume_token`; Codex uses `resume_token`, falling back to a
binding's `external_session_name` for `external_cli` seats; returns
`nativeId: null` honestly when none.
The routes mount at `/api/agent-images` and expose list/sync, snapshot/fork,
entry/preview, pin/unpin, delete and prune.
`packages/cli/src/commands/agent-image.ts` owns the image library commands.
> Source: `domain/agent-images/resume-token-discovery.ts:37-85` (honest
> null L84; Claude pref L64-69; external_cli L78-83); honesty comment
> `:9-10` @HEAD.
## Plugins
`SnapshotCapturer.capture()` routes through `discoverResumeToken`, throws
`AgentImageError` on failure or missing native id (no fabricated token, no
auto-fallback to fresh), writes manifest + empty `stats.json` via
`install()`, re-scans. Captured `nodeCwd` → manifest `source_cwd` so the
Use-as-starter snippet emits `cwd:` (fork starts in the parent's directory
where Claude's project-dir-scoped jsonl lives); the daemon does NOT
override cwd at fork dispatch.
### Discovery and inspection
> Source: `domain/agent-images/snapshot-capturer.ts:60-104` (throw
> L65-79; source_cwd L88-93; install+scan L101-102);
> `routes/agent-images.ts:256-265` (no-cwd-override contract) @HEAD.
`packages/daemon/src/domain/plugin-discovery-service.ts` discovers plugins from:
**Evidence guard (CATASTROPHIC-bounce; fails closed).**
`evaluateProtection()` protects an image from deletion if ANY: pinned;
referenced by an active `agent.yaml`; referenced by a rig spec; or a
lineage descendant of a protected image (transitive, fixed-point
iteration). Spec-root scanning parses YAML structure (not string-grep)
and is deliberately conservative — false positives (over-protection)
acceptable; false negatives = catastrophic data loss. `--force` /
`?force=true` overrides.
| Source kind | Directory shape |
|---|---|
| `vendored` | OpenRig plugins root, one plugin directory per entry. |
| `claude-cache` | Claude cache, grouped by marketplace/plugin/version. |
| `codex-cache` | Codex cache, grouped by marketplace/plugin/version. |
| `rig-cwd` | `.claude/plugins` and `.codex/plugins` beneath supplied working-directory roots. |
> Source: `domain/agent-images/evidence-guard.ts:1-16` (bounce +
> fail-closed), `:53-115` (direct + transitive; fixed point L92-112),
> `:117-122` (conservative) @HEAD.
Detection uses the Claude/Codex plugin manifests. `listPlugins()` returns runtime/source
metadata; `getPlugin()` adds manifests, skills, hooks and MCP detail.
`findUsedBy()` scans agent YAML in configured spec-library roots for plugin declarations
and profile references. These references describe authored specs, not proof of a running
harness loading a plugin.
**Resume-token redaction at route boundary (LOAD-BEARING).**
`/api/agent-images` redacts `sourceResumeToken` → `"(redacted)"` on every
operator-facing path; tokens are **never returned over the wire**; only
the in-process rigspec-instantiator consumes the real token.
The service constructor is not read-only: it retires obsolete refocus hook registrations
from the older bundled plugin and rejects duplicate vendored hook registrations across
providers. Cached plugin versions are excluded from that duplicate-provider check.
> Source: `routes/agent-images.ts:15-17` + `:44-46` (`redactResumeToken`),
> applied `:60`,`:67`,`:94`,`:161`; slice-00 0.3.0-GT §1.9 flags this
> LOAD-BEARING @HEAD.
`packages/daemon/src/routes/plugins.ts` exposes GET-only inspection at `/api/plugins`:
list, detail, used-by and file list/read. The file routes use the discovered plugin root as
their path allowlist. `?cwd=` supplies rig-cwd discovery roots.
At this pin, the route's source filter accepts vendored, Claude-cache and Codex-cache only;
`source=rig-cwd` is not applied as a filter, even though the service supports that kind.
`agentImagesRoutes()` (mounted `server.ts:483-485`): `GET /library`,
`POST /library/sync`, `GET /library/:id`, `GET /library/:id/preview`,
`POST /library/:id/pin`, `POST /library/:id/unpin`,
`DELETE /library/:id` (evidence-guarded unless `force=true`),
`POST /snapshot`, `POST /prune` (**dry-run by default**,
`dryRun !== false`); guard `specRoots` injected via
`deps.agentImageSpecRoots`. CLI: `rig agent-image
list|show|preview|create|delete|pin|unpin|prune|sync`.
### Vendoring and projection
> Source: `routes/agent-images.ts:54-250` (table; prune default L112;
> delete-guard L224-239); `server.ts:483-485`;
> `docs/as-built/cli-reference.md:895-912` @HEAD.
`packages/daemon/src/domain/plugin-vendor-service.ts` owns `PluginVendorService`.
`ensureVendored()` seeds absent plugins or advances an older numeric manifest version.
Equal/newer installed versions preserve installed bytes; equal versions can reconcile file
modes on byte-identical files. Missing target version authority is preserved rather than
overwritten; invalid or conflicting manifest versions are errors.
## 3. Plugins (plugin-primitive Phase 3a) — **0.3.1, NOT 0.3.0**
`ensureSkillGlobally()` projects a named plugin skill using a vendor-version marker.
An existing unversioned global skill remains externally owned. Startup wires this service
and its filesystem implementation; inspect that wiring when changing projection roots.
> **Forensic trap (slice-00 0.3.0-GT seam (a)):** the plugin-discovery
> file's first-creating commit is dated the same day as the 0.3.0 release
> commit but is NOT an ancestor of `v0.3.0`. Plugins are **0.3.1**.
> Re-confirmed at HEAD by the §0 tag-presence test.
`ensureLatest()` performs local vendoring before `attemptAutoFetch()`. The latter
requests a release asset with a bounded timeout and logs failures; even its successful
response path does not extract/install the fetched archive at this pin. This does not
establish whether an upstream release asset currently exists.
`PluginDiscoveryService` is a read-only filesystem aggregator — no SQLite,
no mutation routes (SC-29 EXCEPTION #8, declared verbatim in the source
header). It scans four source kinds, unioned with provenance labels:
vendored (`~/.openrig/plugins/<id>/`), Claude cache
(`~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`), Codex cache
(same under `~/.codex`), and rig-cwd (`<cwd>/.claude/plugins/*` +
`<cwd>/.codex/plugins/*`). Detection = presence of
`.claude-plugin/plugin.json` and/or `.codex-plugin/plugin.json` (declares
`runtimes`). Slice 28 added `skillCount` (readdir of `<plugin>/skills/`)
to the list response so the index page avoids an N+1 detail fetch
(SC-29 EXCEPTION #11, verbatim).
`packages/cli/src/commands/plugin.ts` implements `rig plugin
list|show|used-by|validate`. Discovery, vendoring and a runtime's projected plugin tree
are distinct steps; see [agent-spec-and-startup.md](agent-spec-and-startup.md) for launch.
> Source: `domain/plugin-discovery-service.ts:1-29` (SC-29 #8 + scan
> desc), `:39` (4-kind `PluginSourceKind`), `listPlugins` `:205-276`
> (4 scan blocks), `scanCwdBundledPlugins` `:283-307`, `detectPlugin`
> `:393-453` (marker rule L399-404; `skillCount` L433-451 + #11
> L75-80) @HEAD.
## Claude guided compaction
`getPlugin(id)` returns detail (manifests + skills + hooks + MCP servers);
`rig-cwd:` ids are self-resolvable (parses the cwd out of the id prefix
and re-scans so `/api/plugins/:id` does not 404 on an id `?cwd=`
returned). `findUsedBy(id)` walks `agent.yaml` files parsing YAML
structure (not string-grep — comments don't false-positive) to collect
`resources.plugins[].id` + referencing profiles.
`packages/daemon/src/domain/claude-compaction-enforcer.ts` implements
`ClaudeCompactionEnforcer`. Startup shares one instance between `ContextMonitor`
and the manual-compaction route consumers.
`packages/daemon/src/domain/user-settings/settings-store.ts` resolves the
`policies.claude_compaction.*` settings; automatic compaction defaults to disabled.
> Source: `domain/plugin-discovery-service.ts:309-369` (getPlugin;
> rig-cwd self-resolve L310-321), `extractCwdFromRigCwdId` `:466-479`,
> `findUsedBy` `:371-389`, `readResourcesPlugins` `:599-615`,
> `readProfilesUsingPlugin` `:617-633` (structure-not-grep `:20-23`)
> @HEAD.
`maybeAutoCompact()` applies the delivery guard when available. Its implementation
requires Claude Code, observed usage and a valid integer threshold. Above-threshold
automatic work additionally checks enablement, deduplication, cooldown and the
already-triggered latch. It sends prep, then compact, and queues the below-threshold
continuation: turn boundary, restore prompt, then compliance prompt.
`PluginVendorService.ensureLatest()` runs `ensureVendored` (hash-skip
idempotent copy from `packages/daemon/assets/plugins/<name>/` to
`~/.openrig/plugins/<name>/`) then `attemptAutoFetch`
(`github.com/mvschwarz/openrig-plugins`, 5s timeout,
**404/network/timeout tolerated silently** — vendored is ALWAYS the
fallback; upstream repo intentionally empty at v0 so 404 is normal-state;
NO tarball extraction at v0). Wired at startup for `openrig-core`.
The below-threshold continuation checks policy enablement too, except for an explicitly
operator-initiated manual sequence. A known occupant-generation mismatch invalidates the
queued stages; an unknown generation does not prove a match. Failed or retained sends
do not advance the stage. The manual trigger shares this continuation while having its
own initiation checks.
> Source: `domain/plugin-vendor-service.ts:1-24`, `ensureVendored`
> `:80-103` (hash-skip L97-99), `attemptAutoFetch` `:111-131`
> (404-tolerant L116-117; no-extract L123-125), `ensureLatest`
> `:138-141`; `startup.ts:434-472` @HEAD.
Stage, deduplication and cooldown maps are in memory. Startup supplies live generation
resolution and a post-restore callback that records a managed-width receipt, so the wider
operation also has persistent effects. Do not infer durable lifecycle completion from
a library read or one successfully queued message.
`pluginsRoutes()` (mounted `server.ts:478`, read-only): `GET /` (filters
`?runtime=`, `?source=`, `?cwd=`), `GET /:id/used-by`,
`GET /:id/files/list?path=`, `GET /:id/files/read?path=` (slice 28
docs-browser), `GET /:id`. Literal sub-paths are mounted **before** the
bare `/:id` catchall (route-order discipline). The files endpoints reuse
the path-safety machinery with the discovered plugin's absolute path as a
synthetic single-root allowlist (operator need not declare plugin paths
in `OPENRIG_FILES_ALLOWLIST`; plugin folders read-only at v0 so the
content hash is informational). CLI: `rig plugin
list|show|used-by|validate`; no `install` verb at v0 (deferred to 0.3.2).
## Related maps
> Source: `routes/plugins.ts:29-39` + `:107-231` (table; before-catchall
> L128-130/L141-142; `pluginRootAllowlist` :74-92; read-only-hash
> L182-184); `server.ts:478`;
> `docs/as-built/cli-reference.md:943-963` @HEAD.
> **OPEN-A** — `parseSourceFilter` (`routes/plugins.ts:69-72`) accepts
> only `vendored|claude-cache|codex-cache`; it does NOT accept `rig-cwd`
> though `PluginSourceKind` (`domain/plugin-discovery-service.ts:39`) and
> `ListPluginsOpts.sourceFilter` include it. `?source=rig-cwd` is
> silently dropped at the route while `?cwd=` still surfaces rig-cwd
> entries unfiltered. Stated as-is; intent (rig-cwd reachable only via
> `?cwd=`) vs filter-gap is not resolvable from source alone.
## 4. Claude auto-compaction enforcer (slice 27) — **0.3.1 headline**
> **No 0.3.0 antecedent (slice-00 0.3.0-GT map row 7).** The enforcer
> file is absent at `v0.3.0`, present at `v0.3.1` (§0).
`ClaudeCompactionEnforcer.maybeAutoCompact()` decides per seat whether
`ContextMonitor` should send `/compact`, driven by operator
`policies.claude_compaction.*` settings. Decoupled from `ContextMonitor`
scheduling; `ContextMonitor` is constructed with the enforcer at startup.
> Source: `domain/claude-compaction-enforcer.ts:7-12`, `maybeAutoCompact`
> `:205-331`; `startup.ts:1191-1199` (enforcer →
> `new ContextMonitor(db, contextUsageStore, claudeAdapter,
> compactionEnforcer)`) @HEAD.
**Defensive contract** (source classifies compaction lifecycle as
load-bearing per the banked permission-layer foot-gun rule):
- **Opt-in, default-off:** `enabled=false` → never triggers.
- **Runtime filter:** triggers only when `runtime === "claude-code"`.
- **Invalid-policy = disabled:** a hand-edited non-integer / out-of-
`[1,100]` `thresholdPercent` is treated as disabled (safer-failure).
- **Re-arm via threshold crossing:** session must drop below threshold
before another auto-compact fires; window state NOT persisted (restart
resets — safer-failure direction).
- **Send-failure graceful-degrade:** returns `{ triggered: false,
reason: "send_failed" }`, never throws; dedup timestamp set only on
successful send so a transient failure retries next tick.
> Source: `domain/claude-compaction-enforcer.ts:14-44` (risk-class +
> contract), runtime `:206-208`, invalid-policy `:224-232`,
> dedup/threshold `:285-300`, send-fail `:312-314`/`:323-325` @HEAD.
**Active handshake state machine** (Claude hooks can supply context but
do not create a new assistant turn): (1) **pre-compact prep** — first
eligible above-threshold tick sends a normal user-channel prep prompt;
state → `prep_prompt_sent`. (2) **/compact** — next eligible tick sends
`/compact <instruction> + trust-channel bridge note`; sets dedup
timestamp + `triggeredAboveThreshold`; queues post stage
`turn_boundary`. (3) **post-compact, below threshold** — staged sends
`turn_boundary` (ack-only) → `restore_prompt` (points at the pending
marker `<openrigHome>/compaction/restore-pending/<key>.json`, transcript
/ session-id fallbacks) → `compliance_prompt` (read-depth audit); final
stage sets post-restore cooldown + clears the above-threshold latch.
> Source: `domain/claude-compaction-enforcer.ts:71-77` (compact+bridge),
> `:79-98` (prep), `:108-145` (restore + marker path), `:147-163`
> (compliance), `:165-171` (turn boundary), state machine `:233-330`
> (below-threshold L233-283; above-threshold L302-330) @HEAD.
Policy fields via `SettingsStore.resolveClaudeCompactionPolicy()`:
`enabled`, `thresholdPercent`, `preCompactInstruction`,
`compactInstruction`, `messageInline`, `messageFilePath`,
`postRestoreAuditInstruction` — each backed by a
`policies.claude_compaction.*` key + `OPENRIG_POLICIES_CLAUDE_COMPACTION_*`
env override. Defaults: dedup `60_000` ms; post-compact restore cooldown
`10 * 60_000` ms.
> Source: `domain/user-settings/settings-store.ts:553-565` (resolver),
> keys `:97-103`, env `:145-151`;
> `domain/claude-compaction-enforcer.ts:45-46` (default windows) @HEAD.
## 5. Cross-cutting properties
- **Filesystem-canonical, no new SQLite** across all four; daemon caches
in-memory; compaction window state intentionally not persisted
(context-pack-types.ts:8-11; agent-image-types.ts:12-13;
routes/plugins.ts:6; claude-compaction-enforcer.ts:26-29) @HEAD.
- **Honest failure, no fabrication** — discovery returns null; capturer
throws; enforcer degrades + never throws; evidence guard fails closed
(resume-token-discovery.ts:9-10; snapshot-capturer.ts:11-12;
claude-compaction-enforcer.ts:30-33; evidence-guard.ts:16) @HEAD.
- **503-when-unprovisioned** is the uniform route pattern when the
backing service is absent from context (routes/context-packs.ts:31;
routes/agent-images.ts:59; routes/plugins.ts:113) @HEAD.
## OPEN items (carried, not smoothed)
- **OPEN-A** — plugin `?source=` filter omits `rig-cwd` (§3); intent vs
filter-gap unresolvable from source.
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
migration / route-group / PL-004-event counts. Slice-00 0.3.0-GT
OPEN-3 (For-You verb subset) is out of scope (UI surface, not
content-layer).
- [packaging-bootstrap-bundles.md](packaging-bootstrap-bundles.md): bundle export and
routing into content libraries.
- [agent-spec-and-startup.md](agent-spec-and-startup.md): context/resource projection
and image consumption during launch.
- [lifecycle-snapshot-restore.md](lifecycle-snapshot-restore.md): broader continuity flows.
@@ -5,238 +5,265 @@ status: active
topics: [coordination, observability]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Need to know how rig send/capture/broadcast works, how pipe-pane transcript
capture and rg/grep search behave, how durable rig chat (SQLite + SSE) is
modeled, what rig ask gathers, or the exact MCP-tool-name vs tmux-metadata-key
naming distinction.
Need to know how rig send/capture/broadcast works, how transcript capture
(periodic tmux capture-pane) and rg/grep search behave, how durable rig chat
(SQLite + SSE) is modeled, what rig ask gathers, or the exact MCP-tool-name
vs tmux-metadata-key naming distinction.
siblings: [daemon-core.md, lifecycle-snapshot-restore.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# Transport, Transcripts, Chat, Ask
The communication-and-history layer: tmux is transport, not truth. Send/
capture/broadcast wrap tmux with honest errors; transcripts are raw pipe-pane
captures; chat is daemon-backed SQLite; `rig ask` gathers evidence but never
calls an LLM.
capture/broadcast wrap tmux with honest errors; transcripts are bounded
`tmux capture-pane` snapshots written to files; chat is daemon-backed SQLite;
the daemon's `rig ask` service gathers evidence and never calls an LLM.
> Verified against source at HEAD `7eaf524c` (`git describe` →
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
> located by `architecture.md` headings (§5 Transport and communication, §6
> Communication/Transcript/Chat flows, §11 Compat notes) per slice-08 §10.1
> — line numbers advisory only.
> Verified against source at main `254122872cf477511514979a4300b695d77cd1f7`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. The naming axes (D5 — read this first)
## 1. The naming axes (read this first)
This module carries the D5 drift, which has **two independent axes** that
must NOT be conflated or blanket-replaced (banked
`feedback_release_prep_three_layer_depersonalization`: the rename was scoped,
not global). Per-occurrence source verification:
Two independent naming axes meet in this layer. They must not be conflated or
blanket-replaced: `rigged` → `rig` is a per-occurrence question, not a global
substitution.
**Axis 1 — MCP tool names: `rig_*` (architecture.md is STALE → corrected).**
slice-00 §1.4 confirms all 17 MCP tools are `rig_*`. The `rigged_*`
references in `architecture.md` are stale text from a rename that predates
v0.2.0:
**Axis 1 — MCP tool names are `rig_*`.** `packages/cli/src/mcp-server.ts`
registers **18** tools, all named `rig_*`
(`grep -c 'server.tool(' packages/cli/src/mcp-server.ts`), including
`rig_rig_nodes` (`mcp-server.ts:288`), `rig_send` (`:305`),
`rig_capture` (`:339`), `rig_chatroom_send` (`:364`) and `rig_chatroom_watch`
(`:400`). No `rigged_*` name other than the tmux keys below remains in product
source: **0** hits
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
> Drift-fix D5 (MCP-tool-name axis) — `architecture.md` §5 `node-inventory.ts`
> description says MCP `rigged_rig_nodes`; §6 chat flow says MCP
> `rigged_chatroom_send` + `rigged_chatroom_watch`. Corrected to **`rig_*`**.
> Re-confirmed at source: `mcp-server.ts:288` registers `"rig_rig_nodes"`,
> `:305` `"rig_send"`, `:339` `"rig_capture"`, `:364` `"rig_chatroom_send"`,
> `"rig_chatroom_watch"` (tool #17). Zero `rigged_chatroom_send` /
> `rigged_send` / `rigged_rig_nodes` in non-test product source (grep clean
> @HEAD). slice-00 §1.4; the rename landed in `b183c50c` pre-v0.2.0.
**Axis 2 — tmux metadata keys: `@rigged_*` (architecture.md is CORRECT → do
NOT change).** The tmux metadata keys written at claim/bind time are a
SEPARATE thing from MCP tool names and were NOT renamed:
> Drift-nuance D5 (tmux-metadata-key axis) — `architecture.md` §6 "Whoami
> and adopted-session parity flow" lists `@rigged_node_id`,
> `@rigged_session_name`, `@rigged_rig_id`, `@rigged_rig_name`,
> `@rigged_logical_id`. These are **CORRECT as-is** — verified literally at
> source: `claim-service.ts:77-81` writes exactly these five `@rigged_*`
> keys. Do NOT blanket-sed `rigged` → `rig`; this is a per-occurrence
> verify, not a global substitution. (The metadata-key axis is detailed in
> `agent-spec-and-startup.md` §6.) Re-confirmed `claim-service.ts:77-81`
> @HEAD.
**Axis 2 — tmux metadata keys are `@rigged_*`, and are correct as-is.** The
tmux metadata keys written at claim/bind time are a separate thing from MCP
tool names and were not renamed. `setRiggedMetadata` in
`packages/daemon/src/domain/claim-service.ts:180`–`184` writes exactly **5**
keys (`grep -c '@rigged_' packages/daemon/src/domain/claim-service.ts`):
`@rigged_node_id`, `@rigged_session_name`, `@rigged_rig_id`,
`@rigged_rig_name`, `@rigged_logical_id`. Do not blanket-sed `rigged` → `rig`.
The metadata-key axis is detailed in `agent-spec-and-startup.md` (identity:
whoami, bind, adopt).
## 2. Transport and communication domain services
(`architecture.md` §5 "Transport and communication")
All under `packages/daemon/src/domain/`:
- `session-transport.ts` — communication primitives: send/capture/broadcast
with session resolution (canonical + legacy names), mid-work detection,
honest error reporting, pod/rig/global targeting.
- `transcript-store.ts` — pipe-pane transcript management: ANSI stripping on
read, boundary markers, readTail, grep. Filesystem-backed, NOT SQLite.
- `history-query.ts` — transcript + chat search. Prefers `rg` when
available, falls back to `grep -E`, surfaces which backend was used.
Re-confirmed: `history-query.ts:7` `backend: "rg" | "grep" | "none"`;
`:103` execs `rg -i --no-filename -e <pattern>`; `:108` returns
`backend: "rg"`.
with session resolution, send-readiness classification (interactive-prompt
and mid-work detection), honest error reporting, and list/pod/rig/global
targeting.
- `transcript-store.ts` — transcript file management: path convention, ANSI
stripping on read, boundary markers, `readTail`, `readFull`, `grep`.
Filesystem-backed, NOT SQLite. The capture that writes the files is
`transcript-rotation.ts` (§4).
- `history-query.ts` — transcript + chat search. Prefers `rg`, falls back to
`grep -E`, surfaces which backend was used: `history-query.ts:8`
`backend: "rg" | "grep" | "none"`; `:252` runs
`rg -i --no-filename -e <pattern>` over the rig's transcript directory;
`:267` falls back to `grep -E -i -h -e <pattern>`.
- `ask-service.ts` — context-engineering evidence pack: gathers rig summary
plus transcript excerpts, chat excerpts, insufficiency state, guidance.
Does NOT call an external LLM.
- `chat-repository.ts` — durable rig-scoped chat: CRUD for `chat_messages`
table, SSE-compatible event emission.
- `chat-repository.ts` — durable rig-scoped chat over the `chat_messages`
table (`send`, `sendTopic`, `history`, `latest`, `searchChat`, `clear`). The
repository emits no events: `routes/chat.ts` emits `chat.message` on the
event bus after each send or topic, and `/watch` streams those as SSE.
Routes: `routes/{transport,transcripts,ask,chat,whoami}.ts` — all confirmed
present @HEAD.
Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
— all present.
## 3. Communication flow
(`architecture.md` §6 "Communication flow")
`rig send <session> "message"` → CLI → `POST /api/transport/send` →
`SessionTransport`:
`SessionTransport.send()`:
1. Resolve session name (canonical or legacy; by session/rig/pod/global).
2. Check mid-work state (unless `--force`) — re-confirmed
`session-transport.ts:136-141` (`findPatternEvidence(recentLines,
MID_WORK_PATTERNS)`); legacy mid-work check at `:666`.
3. Two-step tmux send: a unique file/buffer pasted with `paste-buffer -d -r -p` at every payload size → ~200ms delay → separate `C-m`. Bracketed paste preserves multiline input in supporting TUIs; the payload never enters a shell argument. A successful paste proves transport execution, not runtime consumption.
(`session-transport.ts:717` submits `C-m`).
4. Optional `--verify`: capture post-send pane, check message visibility
(`session-transport.ts:305` `verify?`; `:694` `if (opts?.verify)`).
1. Resolve the session name (`resolveBySessionName`,
`session-transport.ts:729`): not found → 404; the same name in more than
one rig → 409. Pod, rig, global and `--to` list targets go through
`POST /api/transport/broadcast`, which resolves them with
`resolveSessions`.
2. Classify send readiness. Only a positive interactive-prompt reading
(`needs_input`) refuses, with `target_needs_input`
(`session-transport.ts:1272`), unless the caller passes
`--dangerously-interact --reason`. A mid-work reading (`running`, from a
fresh runtime hook or the pane's mid-work patterns,
`findPatternEvidence(recentLines, MID_WORK_PATTERNS)`, `:225`) or an
`unknown` one proceeds with an advisory `warning` (`:1321`). `--force` has
no effect on this path.
3. Two-step tmux send: a unique file/buffer pasted with `paste-buffer -d -r -p`
at every payload size (`packages/daemon/src/adapters/tmux.ts:604`) →
~200ms delay (`session-transport.ts:1378`) → separate named `Enter` (`:1385`).
Bracketed paste preserves multiline input in supporting TUIs; the payload
never enters a shell argument. A successful paste proves transport
execution, not runtime consumption.
4. Optional `--verify`: capture the last 30 pane lines before and after the
send and count the message's first 40 characters in each. A higher count
after the send reports `outcome: "delivered"`; otherwise the outcome is
`rendered-unconfirmed` — text and Enter landed but the capture could not
re-confirm the render, which is not a failure (`session-transport.ts:472`
`verify?`; `:1403` `if (opts?.verify)`; `:1415`
`verified = postCount > preCount`).
5. Honest result with reason on failure.
Architecture Rule 18 (carried, `architecture.md` §7): tmux is transport, not
truth — `send/capture/broadcast` wrap tmux reliably with honest errors.
Architecture rule 18 (`architecture-rules-and-event-system.md`): tmux is
transport, not truth — `send/capture/broadcast` wrap tmux reliably with honest
errors.
### 3b. Cross-host coordination verbs (v0.4.6 — OPR.0.4.6.MH4)
### 3b. Cross-host coordination verbs
`rig send/capture/transcript/broadcast --host <id>` (and the `agent@rig@host`
target sugar on the session-target verbs) cross the host boundary with **zero
daemon-side changes** — the remote daemon's existing local routes do all the
work; the only net-new is the CLI transport branch.
target sugar on the session-target verbs) cross the host boundary through the
remote daemon's ordinary local routes; the cross-host logic is in the CLI.
- **The seam principle (arch-ruled): SIDEDNESS + CALLER pick the seam.**
ONE-SIDED remote ops from a BEARER-CAPABLE caller (the CLI resolves the
registry bearer locally) go **CLI-DIRECT** via the shipped
`runRemoteHttpOp` — one hop, no local-daemon involvement (the
`ps --all-hosts` precedent). TWO-SIDED ops (MH-3's queue handoff closes the
local source and creates the remote successor — the local daemon owns half
the transaction) or BEARER-INCAPABLE callers (the browser: MC-action,
MH-2's read-through) use the **daemon-side forward-then-strip**. MH-4's
four verbs are pure one-sided remote ops from the CLI.
- **Transport is dictated by the host entry (ssh XOR http), never a
per-call choice:** ssh hosts keep the shipped shell-out byte-verbatim for
send/capture; http hosts (the `pair` front door's kind) take the
CLI-direct branch to `POST /api/transport/send|capture|broadcast` /
`GET /api/transcripts/*` with the SAME bodies/paths the local CLI builds
(one route, two callers = wrap parity by construction). transcript and
broadcast are http-only (no ssh path exists for them); a wrong-transport
verb dies with the structured requirement error — never a fallback.
- **Terminal-bearer posture (named limitation, v0 — `/api/transport/*`
ONLY):** the remote's transport routes (send/capture/broadcast) gate on
ITS TERMINAL bearer class (`OPENRIG_TERMINAL_BEARER_TOKEN`, default null →
pass-through; the tailnet is the auth boundary by design), while the CLI
presents the REGISTRY bearer from `hosts.yaml` when one is configured;
for a URL-only anonymous host the `Authorization` header is omitted.
A remote enforcing a
DIFFERENT terminal bearer surfaces as the structured `permission-gate`
step (never a hang, never silent). Remedy: set the remote terminal bearer
equal to the paired registry bearer, or rely on the tailnet boundary.
**The transcript read is DELIBERATELY outside this class (arch n2):**
`/api/transcripts/*` mounts UNGATED (`server.ts` — the shipped open-route
posture; daemon-local trust boundary with route-level credential
redaction as the protective primitive), so a wrong terminal bearer that
permission-gates `send --host` does NOT gate `transcript --host` — the
read keeps succeeding, and the proof matrix must not treat the auth-fail
class as uniform across the four verbs. A coherent transcript-read auth
policy across tail/grep/full is a named future slice per
`routes/transcripts.ts`'s own comment (orch approved-option-a).
Bearer-class unification is likewise a NAMED follow-up, not v0 (no
un-asked auth machinery).
- **BR-1 holds:** the 3-part form is CLI-edge sugar only (suffix must match
a REGISTERED host id, else passthrough + a loud host hint); every session
string that reaches any daemon stays `member@rig`; the host travels
out-of-band. Durable cross-host coordination stays MH-3's queue — MH-4
adds no queue surface.
- **Sidedness and caller pick the seam.** One-sided remote operations from a
caller that can resolve the registry bearer locally (the CLI) go
**CLI-direct** through `runRemoteHttpOp`
(`packages/cli/src/remote-host-ops.ts:25`) — one hop, no local-daemon
involvement, as `rig ps --all-hosts` does. Two-sided operations (a
cross-host queue handoff closes the local source and creates the remote
successor, so the local daemon owns half the transaction) and callers
without the bearer (the browser: Mission Control actions and the `?host=`
read-through) go through the local daemon, which forwards to the remote.
These four verbs are one-sided remote operations from the CLI.
- **The host entry dictates the transport (ssh or http), never a per-call
choice.** ssh hosts shell out over ssh for send/capture
(`packages/cli/src/cross-host-executor.ts`); http hosts (the kind
`rig host pair` registers) go CLI-direct to
`POST /api/transport/send|capture|broadcast` / `GET /api/transcripts/*` with
the same bodies and paths the local CLI uses. transcript and broadcast are
http-only (no ssh path exists for them); on an ssh host they fail with a
structured error naming the transport requirement
(`remote-host-ops.ts:47`), never a fallback.
- **Terminal-bearer posture (`/api/transport/*` only).** The remote's
transport routes (send/capture/broadcast) gate on its terminal bearer
(`routes/transport.ts:26`; `OPENRIG_TERMINAL_BEARER_TOKEN`,
`packages/daemon/src/index.ts:283`). With no token configured the
middleware passes every request through
(`packages/daemon/src/middleware/auth-bearer-token.ts:99`) — the tailnet is
the auth boundary by design — except that a daemon bound to an explicit
address that is neither local nor tailnet falls back to
`OPENRIG_AUTH_BEARER_TOKEN`
(`index.ts:289`). The CLI presents the registry bearer from `hosts.yaml`
when one is configured; for a URL-only anonymous host the `Authorization`
header is omitted. A remote enforcing a different terminal bearer answers
401, which surfaces as the structured `permission-gate` step
(`packages/cli/src/host-registry.ts:53`) — never a hang, never silent.
Remedy: set the remote terminal bearer equal to the paired registry bearer,
or rely on the tailnet boundary. **The transcript read is deliberately
outside this class:** `/api/transcripts/*` mounts with no bearer middleware
(`packages/daemon/src/server.ts:749`, beside the gated `/api/transport` at
`:750`), and credential-shaped text is redacted on the `/full` route only
(`routes/transcripts.ts:292`). So a wrong terminal bearer that
permission-gates `send --host` does NOT gate `transcript --host` — the read
keeps succeeding, and the auth-failure class is not uniform across the four
verbs. The route's own comment (`routes/transcripts.ts:250`) leaves a
coherent transcript-read auth policy across tail/grep/full for later.
- **The 3-part form is CLI-edge sugar only.** `resolveCrossHostTarget`
(`packages/cli/src/cross-host-target.ts:58`) strips the suffix only when it
resolves to a registered host, or equals this host's own id, which routes
home (`:95`); otherwise the string passes through unchanged with a loud
host hint. Every session string that reaches any daemon stays
`member@rig`; the host travels out-of-band. Durable cross-host
coordination stays the queue's cross-host routing
(`coordination-primitive.md`); these four verbs add no queue surface.
## 4. Transcript flow
(`architecture.md` §6 "Transcript flow")
1. `NodeLauncher` starts `pipe-pane` immediately after tmux session creation
(before harness boot).
2. Raw terminal output streams to
`~/.openrig/transcripts/{rig-name}/{session-name}.log`.
1. `NodeLauncher` starts transcript rotation right after tmux session
creation, before the harness boots
(`packages/daemon/src/domain/node-launcher.ts:181`).
2. Each rotation tick runs `tmux capture-pane -p -S -<lines>`
(`adapters/tmux.ts:1020`) and atomically overwrites (temp file + rename,
`transcript-rotation.ts:146`)
`~/.openrig/transcripts/{rig-name}/{session-name}.log`. Defaults: 1000
trailing lines every 2 s (`transcript-rotation.ts:27`–`28`), tunable with
`OPENRIG_TRANSCRIPTS_LINES` and `OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS`.
This bounded capture replaced the earlier `tmux pipe-pane` stream
(`transcript-rotation.ts:3`), so the file holds a trailing window, not the
whole session.
3. `TranscriptStore` owns path convention, ANSI stripping on read, boundary
markers, `readTail`, `grep`.
markers, `readTail`, `readFull`, `grep`.
4. `rig transcript <session> --tail N / --grep "pattern"` provides
agent-facing access.
5. On restore: a boundary marker is written before re-launch; pipe-pane
reconnects to the same file (append). (Restore-side detail in
`lifecycle-snapshot-restore.md` §3.)
5. On restore: a `--- SESSION BOUNDARY: … ---` marker is written before
re-launch (`restore-orchestrator.ts:1100`); each rotation tick keeps every
boundary line as a header above the fresh capture
(`transcript-rotation.ts:122`). (Restore-side detail in
`lifecycle-snapshot-restore.md`.)
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts,
insufficiency state, and guidance.
Architecture Rule 19 (carried): transcripts are raw capture via pipe-pane,
ANSI strip on read; `rg` preferred, `grep -E` fallback. Rule 22: `rig ask`
is context engineering — gathers evidence, does NOT call an external LLM;
the agent IS the LLM.
Architecture rule 19, as the code implements it: transcripts are a bounded
`capture-pane` snapshot (not pipe-pane), ANSI-stripped on read; `rg`
preferred, `grep -E` fallback. Rule 22: the daemon's `rig ask` is context
engineering — it gathers evidence and does NOT call an external LLM; the
agent IS the LLM. The one exception is the explicit CLI flag `rig ask --wake`
(`packages/cli/src/commands/ask.ts:88`), which runs a headless resume of a
Claude or Codex session (`claude -p --resume` / `codex exec resume`,
`packages/cli/src/ask-wake.ts:58`–`60`) to answer one question.
## 5. Chat flow
(`architecture.md` §6 "Chat flow")
1. `rig chatroom send <rig> "message"` → `POST
/api/rigs/:rigId/chat/send` → `ChatRepository.addMessage()`.
/api/rigs/:rigId/chat/send` → `ChatRepository.send()`
(`routes/chat.ts:35`); the route then emits `chat.message` (`:37`).
2. SSE: `GET /api/rigs/:rigId/chat/watch` delivers real-time messages.
3. History: `GET /api/rigs/:rigId/chat/history` returns full channel
history; `POST /api/rigs/:rigId/chat/topic` persists topic markers.
4. UI: chat-room tab in the rig drawer.
5. MCP: **`rig_chatroom_send` + `rig_chatroom_watch`** (D5 axis-1 correction
— `architecture.md` §6 said `rigged_*`; source `mcp-server.ts:364` +
tool #17).
3. History: `GET /api/rigs/:rigId/chat/history` returns channel history in id
order, 100 messages unless `limit` says otherwise
(`chat-repository.ts:68`), filterable by `topic`, `after`, `since` and
`sender`; `POST /api/rigs/:rigId/chat/topic` persists topic markers.
4. UI: a `RigChatPanel` component exists
(`packages/ui/src/components/RigChatPanel.tsx`), but no UI view mounts it:
**1** file under `packages/ui/src` names it, its own
(`git grep -l 'RigChatPanel' -- packages/ui/src | wc -l`).
5. MCP: **`rig_chatroom_send` + `rig_chatroom_watch`** (`mcp-server.ts:364`,
`:400`).
6. Source of truth: daemon-backed SQLite (`chat_messages` table), NOT tmux
scrollback.
**ChatMessage** type (`architecture.md` §4): durable rig-scoped message —
**ChatMessage** type (`chat-repository.ts:6`): durable rig-scoped message —
`id`, `rigId`, `sender`, `kind`, `body`, `topic`, `createdAt`.
## 6. Compatibility notes (carried verbatim, `architecture.md` §11)
## 6. Compatibility notes
The intentional limits in this layer:
The intentional limits in this layer (numbered as in
`architecture-rules-and-event-system.md`):
- Note 4 — `rig ask` gathers context only; does not call an external LLM.
The agent reasons about the gathered evidence.
- Note 4 — the daemon's `rig ask` gathers context only; it does not call an
external LLM. The agent reasons about the gathered evidence. Only the
explicit `--wake` flag executes a runtime (§4).
- Note 5 — transcript search prefers `rg` but falls back to `grep -E`;
search quality/performance varies by backend.
- Note 6 — chat is rig-scoped only: no cross-rig channels or DMs.
- Note 7 — `--verify` on `rig send` checks pane content for message
visibility but can produce false positives from pre-existing matching
content. Known limitation.
- Note 7 — `--verify` on `rig send` checks the pane for the message by
comparing snippet counts before and after the send (§3 step 4); when the
capture cannot re-confirm the render it reports `rendered-unconfirmed`,
not a failure.
(The full §11 compatibility-notes list lives in
(The full compatibility-notes list lives in
`architecture-rules-and-event-system.md`.)
## OPEN / carried items
- **D5 (the careful one — resolved per-occurrence, NOT blanket-replaced):**
Axis 1 (MCP tool names) corrected `rigged_*` → `rig_*` (3 sites: §5
node-inventory, §6 chat flow ×2). Axis 2 (tmux `@rigged_*` metadata keys)
verified literally correct at `claim-service.ts:77-81` and left unchanged.
No slice-00 numeric drift applies to this module's split content.
## See also
- `daemon-core.md` — transport/transcript/chat/ask are among the 49 route
mounts; MCP tool count (17, `rig_*`) is anchored there.
- `agent-spec-and-startup.md` §6 — the tmux `@rigged_*` metadata-key axis
detail (whoami/adopt).
- `lifecycle-snapshot-restore.md` §3 — restore-side transcript boundary
- `daemon-core.md` — the route-mount surface these routes sit in, and the MCP
tool count.
- `agent-spec-and-startup.md` — the tmux `@rigged_*` metadata-key axis detail
(whoami/adopt).
- `lifecycle-snapshot-restore.md` — restore-side transcript boundary
markers.
- `coordination-primitive.md` §3b — MH-3's cross-host QUEUE routing (the
two-sided daemon-forward twin of §3b's one-sided CLI-direct verbs).
- `cli-reference.md` § Cross-host execution — the per-verb transport
capability table, parse rules, and http failure taxonomy (MH-3 + MH-4).
- Source roots: `packages/daemon/src/domain/{session-transport,
transcript-store,history-query,ask-service,chat-repository}.ts`,
- `coordination-primitive.md` — cross-host queue routing (the two-sided
daemon-forward twin of §3b's one-sided CLI-direct verbs).
- `../cli-reference.md` § Cross-host execution (`--host <id>`) — the per-verb
transport table, the `agent@rig@host` parse rules, and the structured
failure modes.
- Source roots: `packages/daemon/src/domain/{session-transport,transcript-store,transcript-rotation,history-query,ask-service,chat-repository}.ts`,
`packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`,
`packages/cli/src/mcp-server.ts`,
`packages/cli/src/{remote-host-ops,cross-host-target,cross-host-executor}.ts` (v0.4.6 MH-4).
`packages/cli/src/{remote-host-ops,cross-host-target,cross-host-executor,ask-wake}.ts`.
+173 -463
View File
@@ -1,509 +1,219 @@
---
kind: as-built
title: Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
title: Workflow Runtime — Specs, Packets, and Lifecycle Graphs
status: active
topics: [coordination, runtime-control]
topics: [orchestration, coordination]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Need to know how the daemon-native Workflow Runtime works — workflow specs
cache, instance state, step trails, the transactional-scribe projection
contract, or the watchdog policy set including workflow-keepalive.
siblings: [coordination-primitive.md, mission-control.md]
prerequisite-reads: [../README.md, coordination-primitive.md]
last-verified-against-source: slice/opr-0.4.6.wf2-spec-language tip (base d18907ed — WF-1 merged)
last-updated: 2026-07-06
Following workflow validation, instantiation, projection, routing, failure
recovery, lifecycle graph revisions, or the corresponding CLI/API.
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
# Workflow Runtime — Specs, Packets, and Lifecycle Graphs
The daemon-native Workflow Runtime (PL-004 Phase D) turns an intended
sequence of work into durable SQLite state: declarative workflow specs, live
instance state, append-only step trails, and the load-bearing
transactional-scribe contract. The PRD §L4 operating model is
owner-as-author semantically plus workflow-as-transactional-scribe
mechanically (`architecture.md` §3 L398).
Source snapshot: `254122872cf477511514979a4300b695d77cd1f7`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
> Verified against source at HEAD `7eaf524c`.
A workflow binds a specification to durable queue packets. The daemon
records the instance, its current frontier, and the step trail; agents supply
step outcomes and evidence. Serial workflows and dependency graphs share
this runtime. The engine checks declared fields and recorded state; it does
not judge the substance of a referenced proof document.
## 1. The three Phase D tables (+ the diagnostic addition)
## Source map
Re-confirmed at HEAD in `packages/daemon/src/db/migrations/`:
All paths in this table are under `packages/daemon/src/`.
- **`workflow_specs`** (`033_workflow_specs.ts:33` `CREATE TABLE … workflow_specs`)
— a read-through cache of human-authored markdown/YAML spec files. Sources
are workspace-surface; the daemon caches by `(name, version)` with a
content `source_hash` so valid operator edits to the spec file win at next
read (workspace-surface reconciliation). Spec authoring stays
markdown-authoritative; the cache exists for fast lookup and runtime
resolution (`architecture.md` §3 L400).
- **`workflow_instances`** (`034_workflow_instances.ts:45` `CREATE TABLE …
workflow_instances`) — live state per running workflow: `status`
(`active|waiting|completed|failed`), `current_frontier_json` (active qitem
ids), `hop_count` (loop-guard counter), `last_continuation_decision_json`.
Instances survive daemon restart from SQLite — no filesystem
reconciliation (`architecture.md` §3 L402).
- **`workflow_step_trails`** (`035_workflow_step_trails.ts:32` `CREATE TABLE …
workflow_step_trails`) — append-only history of meaningful step
transitions. Every closure produces one trail row pairing the prior qitem
with the next qitem (null on terminal). `WorkflowStepTrailLog.record()` is
the only writer (`architecture.md` §3 L404).
- **`040_workflow_specs_diagnostic.ts`** — slice-11 (`f68f453a`,
slice-00 §1.3 provenance). An `ALTER TABLE ADD COLUMN` adding
parser/validator diagnostic columns to `workflow_specs` (**no new table,
no constraint changes beyond a DEFAULT**; the cache carries the
parse/validation diagnostic for the UI to render — `040_..._diagnostic.ts:5–25`).
**This migration is net-new since `architecture.md` was last edited** and
is not described in any prior as-built doc (slice-00 §1.3 — `f68f453a`
postdates the §3 body) — authored here from source per the slice-08
drift-to-fix register.
| File / symbol | Responsibility |
|---|---|
| [`domain/workflow-types.ts`](../../../packages/daemon/src/domain/workflow-types.ts) | Spec, instance, trail, frontier, and failure-occurrence contracts. |
| [`WorkflowSpecCache`](../../../packages/daemon/src/domain/workflow-spec-cache.ts) | YAML parsing, source-hash cache, stored specs, diagnostics and retention. |
| [`WorkflowValidator`](../../../packages/daemon/src/domain/workflow-validator.ts) | Roles, exits, routing/dependency edges, pins, cycles, and advisories. |
| [`WorkflowRuntime`](../../../packages/daemon/src/domain/workflow-runtime.ts) | Instantiation, inspection, routing, resume, abort, and lifecycle facade. |
| [`WorkflowProjector`](../../../packages/daemon/src/domain/workflow-projector.ts) | Close the current step and update its successor/frontier state. |
| [`WorkflowInstanceStore`](../../../packages/daemon/src/domain/workflow-instance-store.ts) | Instances, guarded updates, frontier bindings, and failure occurrences. |
| [`WorkflowStepTrailLog`](../../../packages/daemon/src/domain/workflow-step-trail-log.ts) | Append/read step history. |
| [`workflow-reconciliation.ts`](../../../packages/daemon/src/domain/workflow-reconciliation.ts) | Inspect graph changes, apply explicit revisions, recover operation receipts. |
| [`workflow-keepalive.ts`](../../../packages/daemon/src/domain/policies/workflow-keepalive.ts) | Watchdog evaluation and reminders. |
`036_watchdog_policy_enum_extension.ts` is a documenting no-op that records
the Phase D watchdog enum extension (Phase C uses application-layer
enforcement via the `PHASE_D_POLICIES` array, so no DDL is needed —
`architecture.md` §3 L363).
## Specification and cache
## 2. The transactional-scribe contract
`parseWorkflowSpec` parses the `workflow` document and checks known key sets.
`WorkflowValidator.validate` then checks relationships between roles, steps,
exits, and targets. The exits are `handoff`, `waiting`, `done`, and `failed`.
An exit-specific `next_hop.on` mapping takes precedence over structural
routing; without a mapping, `resolveNextStep` considers `forbid`, suggested
roles, `require`, and then the next array element.
The load-bearing Phase D guarantee is implemented in
`WorkflowProjector.project()` (`packages/daemon/src/domain/workflow-projector.ts`).
Re-confirmed at HEAD: the projector header (`workflow-projector.ts:1–20`)
declares "transactional-scribe contract" and `project()` runs a single
`db.transaction` (`workflow-projector.ts:184` `const txn = this.db.transaction(...)`).
Inside that one transaction:
The language includes `harness`, `host`, singular `gate`, `depends_on`, typed
`acceptance`, and waiting re-presentation settings. At this pin,
`WORKFLOW_AGENT_HARNESSES` contains only `claude-code` and `codex`; support for
another runtime elsewhere in OpenRig does not add it to this pin's workflow
harness grammar. Nonlocal host pins can validate as registered names but
`instantiate` still rejects execution with `host_pin_remote_unsupported`.
This is a workflow-step restriction, not a claim that all queue traffic is
local-only.
1. Close the current packet (state mutation on `queue_items`).
2. Create the next-step packet (`QueueRepository.createWithinTransaction()`,
`workflow-projector.ts:230`).
3. Record the trail entry.
4. Update the instance frontier + status.
5. Persist workflow events.
`allowed_exits` and `max_hops` affect validation/projection. In contrast,
`continuation_required`, `preserve_lineage`, `closure_required`, closure
messages, `skill_refs`, and `spawn_budget` produce
`declared_not_enforced_v1` advisories. Legacy `gates: []` and
`next_hop.mode: prefer` are rejected by the parser rather than silently
accepted. Prerequisite cycles are rejected; a routing cycle requires a
`max_hops` declaration and is distinct from a prerequisite cycle.
Either everything commits or everything rolls back; lost handoffs are
impossible by design. Post-commit, subscribers are notified and the next
owner is nudged (`architecture.md` §3 L406).
`WorkflowSpecCache.readThrough` parses the source file, hashes it, and
updates the `(name, version)` cache row. Full `spec_json` preserves fields
that the older scalar columns cannot represent. A same-name/same-version
source edit can update that cache row; the version label alone is not an
immutable content pin. `getByNameVersion` is the runtime lookup.
Diagnostic-only rows, moved sources, and versions retained for unfinished
work have separate paths in `readThrough`, `writeDiagnostic`, and
`isPinnedByUnfinishedWork`.
`WorkflowRuntime` (`packages/daemon/src/domain/workflow-runtime.ts:61`
`export class WorkflowRuntime`) is the orchestration class above the
projector.
## Durable state
**Phase D scope boundary** (`architecture.md` §3 L412): excludes multi-hop
chaining, gate-return-sweep, and the closure-enforcement path. The daemon's
transactional state remains the closure authority via Phase A's hot-potato
strict-rejection (see `coordination-primitive.md` §3); the workflow runtime
**projects on closure, it does not gate closure**.
The migration files record the storage evolution; they are not a count of
all database migrations:
## 3. The workflow-keepalive watchdog policy
| Migration | Workflow state introduced |
|---|---|
| [`033`](../../../packages/daemon/src/db/migrations/033_workflow_specs.ts), [`034`](../../../packages/daemon/src/db/migrations/034_workflow_instances.ts), [`035`](../../../packages/daemon/src/db/migrations/035_workflow_step_trails.ts) | Spec cache, instances/frontier/current step, and append-only trail API. |
| [`040`](../../../packages/daemon/src/db/migrations/040_workflow_specs_diagnostic.ts), [`050`](../../../packages/daemon/src/db/migrations/050_workflow_spec_json.ts) | Cache diagnostics and complete parsed spec JSON. |
| [`049`](../../../packages/daemon/src/db/migrations/049_workflow_instance_version.ts), [`051`](../../../packages/daemon/src/db/migrations/051_workflow_resume.ts), [`052`](../../../packages/daemon/src/db/migrations/052_workflow_instance_bound_rig.ts) | Optimistic version guard, resume/hop baseline, and bound rig. |
| [`079`](../../../packages/daemon/src/db/migrations/079_workflow_lifecycle_parallel.ts) | Lifecycle operation key/digest/binding, packet-to-step frontier bindings, and failure occurrences. |
`workflow-keepalive` is the Phase C-deferred watchdog policy, a TypeScript
port of the POC `lib/policies/workflow-keepalive.mjs` adapted to read SQLite
(`packages/daemon/src/domain/policies/workflow-keepalive.ts:1–5`).
Re-confirmed at HEAD (`workflow-keepalive.ts:5–16`):
Current instance states include `active`, `waiting`, `completed`, `failed`,
and `aborted`. A multi-packet frontier needs packet-to-step bindings; a
single `current_step_id` cannot describe all parallel work.
- **LOAD-BEARING:** it MUST read `workflow_instances` directly via SQLite —
never the markdown source.
- Eligibility: `status === "active" || status === "waiting"`. Else
`action=terminal, reason="workflow_not_active"`.
- Frontier empty + no fallback target: skip with `reason="empty_frontier"`.
- Resolves frontier qitem owners by querying `queue_items`; combines with
explicit observer/created-by targets; sends to the first resolved target.
## Instantiation and owner selection
The watchdog supervision tree itself (PL-004 Phase C, `031_watchdog_jobs.ts` /
`032_watchdog_history.ts`) records only meaningful evaluations; quiet skip
reasons (`not_due`, `no_actionable_artifacts`, `active_wake_not_due`) are
NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are
not woken about scheduler polls (`architecture.md` §3 L362). The Phase D
policy enum extends Phase C's three values with `workflow-keepalive`.
`WorkflowRuntime.instantiate` resolves a cached spec name or explicit path,
validates it, resolves the rig and entry owners, then creates the instance
and entry packets through the transactional event/queue path. An explicit
unknown `targetRig` fails; an unknown spec-default rig produces an advisory
and falls back to unbound operation. A bound rig without a required declared
role is a different error.
## 4. Workflow events
`resolveDefaultOwner` uses declared `preferred_targets` first, applying a
harness match when pinned. With no preferred targets and a bound rig, it uses
[`selectRoleSeat`](../../../packages/daemon/src/domain/workflow-role-resolver.ts):
a running managed agent with the declared role and required runtime, ordered
by pending-only backlog and then canonical coordinate. Infrastructure and
unresolvable/adopted coordinates do not become implicit replacement agents.
Explicit owner overrides are reconciled with harness pins. A structured gate
resolves either a human destination or a handler role.
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §3
> L410 says "Existing 20 PL-004 events are unchanged" — internally
> inconsistent with L394's "32 PL-004 events untouched". **Do NOT carry
> either number.** The current `RigEvent` union
> (`packages/daemon/src/domain/types.ts:94`) has **73 members** total
> (slice-00 §1.8, re-confirmed at HEAD). The additive Phase D `workflow.*`
> events are described below WITHOUT asserting a contested PL-004 sub-count.
Instantiation also emits advisories for missing members in otherwise
registered target rigs. That member check is advisory and does not imply
all preferred targets received a live-readiness test.
Phase D extends `RigEvent` with the additive `workflow.*` events
(re-confirmed `domain/types.ts:196–201`): `workflow.instantiated`,
`workflow.step_closed`, `workflow.next_qitem_projected`,
`workflow.completed`, `workflow.failed`, `workflow.routing_table_changed`
(6 members; a separate `workflow_spec` event also exists in the union).
## Projection, waiting, and concurrency
## 5. Route surface
`WorkflowProjector.project` checks active/waiting status, frontier membership,
packet-to-step identity, allowed exits, and applicable evidence requirements.
For a successful advance, queue closure, successor creation, trail entries,
frontier/version updates, durable events, and wake intent staging share the
transactional notification envelope. Terminal delivery happens after the
commit. A committed packet or staged wake is not evidence that its recipient
consumed the message.
`/api/workflow` (`server.ts:495`) — `POST /validate`
(`routes/workflow.ts:82`), `POST /instantiate` (`:93`,
`getRuntime(c).instantiate(...)`), `POST /project` (`:118`,
`getRuntime(c).project(...)` — the transactional-scribe entry),
`GET /:instance_id/trace` (instance + trail), `POST /:instance_id/continue`
(idempotent inspect). Surface enumerated `routes/workflow.ts:21–28`.
Cross-ref: the `rig workflow` CLI surface — see `../cli-reference.md`.
The serial path absorbs an identical waiting replay by matching the stored
closure intent; changed wait data is a new decision. A consumed terminal
packet is no longer on the frontier and is refused. `max_hops` constrains
routing relative to the current drive's baseline; resume establishes a new
bounded drive rather than erasing history.
## 6. The WF-1 failure envelope (OPR.0.4.6.WF1)
Presence of `depends_on` selects `projectDependencyGraph`. It uses durable
packet bindings to advance eligible successors and record per-occurrence
failures without treating an unrelated live branch as completed. Inspection
reports unknown bindings rather than inventing a step from trail order.
Added on top of the kept Phase D core (nothing above was re-specced;
FR-1 regression tests pin it):
The injected [`createWorkflowFrontierPredicate`](../../../packages/daemon/src/domain/workflow-frontier-guard.ts)
lets the queue refuse ordinary terminal closure of live workflow packets.
Workflow mutations use the explicit workflow path; the queue does not need
to import the workflow domain to evaluate that predicate.
- **Step deadlines (FR-2, derived — never stored):**
`workflow-deadline.ts` classifies every active|waiting instance's
frontier packet by anchor — claimed w/ `closure_required_at` ·
claimed w/ NULL deadline (`claimed_at` + threshold; workflow packets
ship tier `mode2`, which has no SLA entry) · never-claimed
(`created_at` + threshold) · unclaimed-after-claim (`created_at`;
unclaim NULLs `claimed_at`). `WORKFLOW_STEP_STUCK_THRESHOLD_SECONDS`
(4h, = the routine-tier SLA) is THE single threshold home; WF-5
binds to it. Stuck self-clears on normal re-projection.
- **Keepalive auto-arm (FR-3):** instantiate + every handoff ensure
ONE per-instance `workflow-keepalive` watchdog job INSIDE the scribe
transaction; terminal exits disarm it. Auto-armed jobs carry
`context.deadline_gated: true` — quiet while healthy, and their
overdue send targets the stuck packet's owner with re-project
steering. Operator-registered jobs keep exact POC always-send parity.
- **Boot sweep (FR-4):** `workflow-boot-sweep.ts` at daemon startup —
re-arms missing keepalives, reissues LOST post-commit nudges
(pending frontier packet with `last_nudge_attempt` NULL = the
commit-then-crash window, detected from the nudge ledger), surfaces
stuck instances; one summary log line.
- **Real idempotency (FR-5):** waiting-replay ABSORPTION under the
full closure-intent identity (exit/packet/step/actor/resultNote/
effective-blocker/evidence deep-equal) — exact replay = zero writes;
any mismatch = a new decision via the normal path. Migration 049
adds `workflow_instances.version`: every guarded advance bumps it
`WHERE version = ?`; a stale writer gets structured
`instance_version_conflict` and its whole transaction rolls back.
- **max_hops enforced (FR-6):** compared at projection via
`exceedsMaxHops(hopCount, baseline, maxHops)` (v1 baseline = 0; the
baseline is WF-5's resume seam). Exceeding converts the handoff to
an honest structured failure (packet closed, instance failed, guard
evidence in trail + `workflow.failed`). Migration 050 adds
`workflow_specs.spec_json` — before it, `loop_guards`/`invariants`/
`closure`/`entry` were silently DROPPED at projection-time
rehydration (column-only rebuild); legacy rows self-heal on
readThrough and degrade VISIBLY (named once-per-spec advisory).
- **Validation (FR-7):** `parseWorkflowSpec` rejects unknown keys loud
at every level against EXPORTED closed keysets (WF-2 extends them);
the validator walks reachability/cycles over the projector's own
exported `resolveNextStep` — unreachable steps fail; a cycle without
`max_hops` fails naming the fix; with it, sanctioned.
- **`continue` honesty (FR-8):** relabeled to its real read-only
inspector semantics everywhere (CLI description/outcome, route
comment); `project` remains the sole advance write path.
- **The v2 dispositions (FR-9):** every declared-but-unenforced key —
`invariants.{continuation_required,preserve_lineage,closure_required}`,
`closure.*`, step `gates[]`, role `skill_refs`, `next_hop.mode:
prefer`, `loop_guards.spawn_budget` — produces the fail-open
`declared_not_enforced_v1` validator advisory (warning; never
blocks). `spawn_budget`'s advisory names its WF-2/WF-6
parallel-frontier acceptance pointer (arch ruling 2026-07-06).
`fallbackSynthesis` (instance column, never written) is dispositioned
in the `workflow-types.ts` JSDoc.
## Recovery operations
## 7. The WF-2 spec language (OPR.0.4.6.WF2)
`WorkflowRuntime` distinguishes these operations:
WF-2 grows the language the ratified WF-1 engine speaks. ONE named
engine extension (branch execution); everything else is language +
compilation onto shipped seams.
| Operation | Meaning |
|---|---|
| `route` | Replace a current packet's owner while retaining the step. This is not step advancement. Multiple frontier packets require an explicit packet selection. |
| `resume` | Redrive failed work with a recorded decision and newly resolved owner. Dependency failures use occurrence identity; multiple unresolved failures require selection. Identical occurrence/decision replay returns the recorded redrive, while changed decision bytes conflict. |
| `abort` | Explicitly stop unfinished work with an actor and reason. |
| `continue` | Read-only inspection of instance, trail, guidance, reconciliation, frontier, failures, unknowns, and boundary obligations. It does not mechanically advance a step. |
**Conditional-on-outcome branching (FR-1).** A step may declare
`next_hop.on: {<exit>: <step-id>}` — branch keys are the recorded exit
enum ONLY (`handoff|waiting|done|failed`; closed set, enforced at
parse — `spec_branch_key_invalid`). A MAPPED exit routes to its target
INSIDE the same scribe transaction: next qitem created in-txn, instance
stays ACTIVE bound to the target, hop count + version guard bumped
identically to a linear advance, and the taken branch recorded
ADDITIVELY (`lastContinuationDecision.branchTaken` + the trail row's
`closure_evidence.branch_taken`) — never in `closure_reason` (closed
Phase-A enum). An UNMAPPED `failed`/`done` stays terminal exactly as
before; unmapped `waiting` stays a park. The `max_hops` guard fires on
ANY route (branch routes create the canonical remediation cycles);
cycle detection at validation runs over the structural ∪ branch edge
union and requires a declared `max_hops` to sanction any cycle.
Routing seam: `resolveNextStep(spec, step, recordedExit?)` — one
exported function, structural default when no exit supplied (the
validator's path).
## Deadlines and exceptions
**Per-step `harness:` pin (FR-2).** `claude-code | codex` (agent
harnesses only — `terminal` rejected at parse with a teaching error; Pi
joins in 0.4.7). Owner resolution picks the first `preferred_target`
whose node `runtime` column matches (`nodeRuntimeOf`: latest session →
node join); no match = structured `harness_pin_unsatisfied` naming the
pin + every candidate's runtime. Explicit owner overrides are
reconciled too — an override can never silently defeat a pin. Static
check at instantiate for every pinned step; re-checked at each route.
[`evaluateStepDeadline`](../../../packages/daemon/src/domain/workflow-deadline.ts)
derives overdue state from frontier queue rows. Claimed work uses its closure
deadline, otherwise its claim time plus **4 hours** (creation time if the
claim time is absent). Pending work uses creation time plus **4 hours**.
Blocked waits are not overdue under this evaluator. This is a diagnostic,
not a substitute for packet existence or binding checks.
**Per-step `host:` pin (FR-3).** `local`/absent = full execution today.
A registry id validates against `~/.openrig/hosts.yaml` (daemon
read-only twin; unknown id = `host_not_registered` naming registered
ids) but a remote pin fails loud at INSTANTIATE with
`host_pin_remote_unsupported` naming the MH-3 boundary + workaround —
the queue is local-only until MH-3; no qitem is ever minted into a
queue that cannot route it, and there is no silent local fallback.
[`resolveExceptionRoute`](../../../packages/daemon/src/domain/workflow-exception-router.ts)
selects per-class configuration, then workflow default, host default, and
finally orchestrator-first behavior. An unresolved orchestrator route falls
back to registered-human selection. A human gate is intrinsically human
routed. Agent-routed exceptions use the ordinary workflow tier; human-routed
ones use `human-gate`.
**Structured step-level `gate:` (FR-5 — the socket; WF-5 owns
semantics).** Singular per step, closed keyset `{target, summary,
evidence_ref}`. HUMAN target (the shipped human-seat predicate) →
compiles to a human-routed item (tier `human-gate` + summary +
evidence_ref — the shipped 0.4.4 write path), resolved by the shipped
`resolve` verb; HANDLER-ROLE target → an ordinary agent item to the
role's resolved seat. Routing INTO a gated step creates the gate item
as the frontier packet and parks the instance `waiting`; resolve/close
continues the flow from that step (the WF-1 unpark — no restart). A
gated ENTRY step parks from birth.
[`workflow-human-destination.ts`](../../../packages/daemon/src/domain/workflow-human-destination.ts)
handles human selection. A valid agent route need not have a human fallback
configured. If fallback is needed, missing/ambiguous selection is an explicit
error, not an invented destination. Failure admission is transactional;
overdue detection is handled by boot/keepalive paths. Runtime reconciliation
closes recovered overdue occurrences against their own packets, preserving
unresolved siblings and unknown provenance.
**Dispositions (FR-4) — the inert third state is dead.** The legacy
step `gates: [...]` string list is REMOVED at parse
(`spec_gates_removed`, what/why/fix naming the new `gate:` object);
`next_hop.mode: prefer` is REMOVED at parse
(`spec_prefer_mode_removed` — it never had distinct behavior).
`skill_refs` / `closure.*` / `invariants.{continuation_required,
preserve_lineage,closure_required}` keep their WF-1 FR-9 explicitly-v2
advisories; `spawn_budget` stays explicitly-v2 (WF-6/parallel-frontier
acceptance pointer). Every key is consumed, removed, or
machine-readably advisory — zero silently-inert keys.
## Project lifecycle graphs
**Versioning honesty (FR-6).** New strictness lands at
`parseWorkflowSpec` (the only seam that sees raw keys; the WF-1
exported closed keysets extended with `harness`/`host`/`gate` +
`next_hop.on`) and applies at validate/instantiate/re-parse. A LIVE
instance pinned to a pre-WF-2 spec version keeps executing un-failed
(project() has no validation gate, by design; stored `spec_json` blobs
missing the new optional fields read fine); the same spec FILE
re-validated fails under the new rules.
`compileLifecycle` delegates to
[`compileProjectLifecycle`](../../../packages/daemon/src/domain/project-lifecycle-compiler.ts).
`instantiateLifecycle` requires an operation key and compiled input digest.
An exact replay retrieves the recorded instance/entry packet; changed input
under the same key conflicts. Compilation and an eligible result do not by
themselves launch work.
**Hand-authorability (FR-6).** Three shipped example shapes at
`packages/daemon/src/builtins/workflow-specs/`: `linear-build.yaml`
(zero WF-2 features — the zero-regression reference),
`gated-release.yaml` (human gate + harness pins),
`branched-remediation.yaml` (bounded failed-path remediation loop).
`inspectGraph`, `reviseGraph`, and `recoverGraphOperation` in
[`workflow-reconciliation.ts`](../../../packages/daemon/src/domain/workflow-reconciliation.ts)
separate inspection from explicit graph revision and operation recovery.
`guidance` reads context and current packet information without interpreting
an agent's substantive judgment.
**The composed RSI example (OPR.0.4.6.FAC2).** `factory-rsi.yaml` is the
single-rig recursive-self-improvement factory MVP: it composes the branch +
gate + guard primitives into the inner loop — `plan → implement → qa_check →
review → release_prep` — where `qa_check`/`review` branch `failed` back to
`implement` (bounded remediation) and `release_prep` (the release-manager
prepares artifacts, un-gated) hands off to a human-gated `release_signoff`.
Dogfood is decoupled from this gated loop: the dogfood seat runs out-of-band
against the shipped product and feeds its findings into the next plan (the RSI
edge, ungated). The remediation loops are sanctioned only by the enforceable
`loop_guards.max_hops`; a trip is a WF-5 exception (orchestrator-first via
`exception_routing`). It targets the shipped `factory-rsi` launch starter
(`specs/rigs/launch/factory-rsi/`) whose seats pin 1:1 to its roles via
`preferred_targets` — the v0 hardcode seam, no binding layer.
For lifecycle steps that require a receipt, a `done`/`handoff` projection
requires an `evidence_ref`. Typed acceptance gates additionally compare the
submitted candidate, allowed verdict, and evidence reference against the
step declaration. These are mechanical identity/presence checks; the
projector does not read the referenced prose to decide whether the work is
good or ready to publish.
## The CLI surface (OPR.0.4.6.WF3)
## CLI and HTTP surface
WF-3 made the CLI the primary human/agent driving surface. Render-side
by rule (BR-2): `run`/`watch` consume the shipped SSE endpoints
(snapshot-first-then-stream, priorQitemId dedup, reconnect → announced
poll fallback, outcome-as-exit-code: 0 completed / 3 failed);
`trace`/`list`/`show` human modes are formatted (argo-shape tree, ps-
mechanics columns, ATTN markers) while `--json` stays byte-stable;
`status` composes the needs-attention rollup CLI-SIDE from the
API-carried `instance.deadline` classification (one threshold home —
the CLI never recomputes a class). The two daemon additions:
The [CLI inventory](../cli-reference.md#workflow) lists every registered
`rig workflow` subcommand and option. The implementation is
[`packages/cli/src/commands/workflow.ts`](../../../packages/cli/src/commands/workflow.ts).
The [`workflow` routes](../../../packages/daemon/src/routes/workflow.ts)
are mounted under `/api/workflow`:
- **`route`** (`POST /api/workflow/:id/route`, runtime `route()`):
close+recreate+rebind in ONE scribe transaction — honest
`handed_off_to` closure with provenance, successor recreate (same
step; `current_step_id` unchanged; no hop bump — route is not an
advance), frontier rebind under the version guard, keepalive
re-target in-txn. Advance-authority revocation is STRUCTURAL: the
old packet leaves the frontier in the transaction, so a zombie
owner's stale `project` hits the shipped `packet_not_on_frontier`
409.
- **The frontier close-path guard** (`workflow-frontier-guard.ts` →
INJECTED into `QueueRepository` at startup; the queue never imports
the workflow domain): terminal closure of a live frontier packet
from non-workflow verbs rejects `workflow_frontier_packet` with a
what/why/fix naming `rig workflow project` / `route`. Workflow
writers pass `viaWorkflowVerb`; non-workflow qitems see zero new
behavior.
| Method | Suffixes |
|---|---|
| `POST` | `/validate`, `/compile`, `/instantiate`, `/instantiate-lifecycle`, `/project` |
| `GET` | `/list`, `/specs`, `/operations/:key`, `/:instance_id`, `/:instance_id/trace`, `/:instance_id/guidance`, `/:instance_id/revision` |
| `POST` | `/:instance_id/route`, `/:instance_id/resume`, `/:instance_id/abort`, `/:instance_id/continue`, `/:instance_id/revision` |
| `GET` | `/sse`, `/watch` (same live event stream handler) |
## The exception + human-gate model (OPR.0.4.6.WF5)
The stream filters the named workflow events; it is not a general queue
stream or a stored-history replay. Route error mapping distinguishes missing
records, authoring errors, state/version conflicts, and internal failures.
See the source mapping before assigning a meaning to a nonzero CLI result.
The deterministic engine's exception layer: the happy path stays
orchestrator-free and PROVEN so; every exception becomes exactly one
durable attention item the moment it exists; the responder resolves it
and the flow resumes from where it stopped.
- **The taxonomy** (`workflow-exception.ts`): three closed classes as
pure predicates over recorded state — `unmapped_failed` (recorded
`status=failed`; a WF-2-mapped `failed` routes to remediation and is
NOT an exception), `stuck_overdue` (the WF-1 deadline evaluator's
verdict consumed verbatim — the single threshold home), and
`human_gate_trip` (WF-2 HUMAN gates; the compiled park IS the item).
Handler-role gate-trips are deterministic handoffs, not exceptions —
classes (a)/(b) backstop the handler's own step. The occurrence key
is the recorded packet id of the episode: re-detections dedupe,
resolve+resume closes, a fresh packet is a NEW occurrence.
- **The maturity dial** (`workflow-exception-router.ts` + the
`exception_routing` spec grammar + the `workflow.exception_routing`
settings key): target resolution = spec per-class → spec default →
host dynamic key → ORCHESTRATOR-FIRST (the declared orchestrator
role via the same `preferred_targets` pick step owners use) →
registered-human selection (`workflow-human-destination.ts`). THE TIER SPLIT: `human-gate` rides
ONLY human-routed positions — an orchestrator-routed item carries
the ordinary tier so the shipped attention union (which matches on
tier regardless of destination) never leaks it into NEEDS-YOU. The
shipped attention predicate is untouched.
- **Class (a) born-in-txn**: serial failure and unhandled dependency-branch
failure share exception admission. The failed packet, dependency occurrence,
owning item and staged wake commit together, even while an unrelated frontier
keeps the instance active. Mapped remediation remains ordinary workflow work;
an unmapped or max-hop failure requires an owner. An agent destination rejected
as an unknown rig tries registered-human selection. Selection, admission,
or storage failure rolls back the close; it never commits a phantom
human alert. Other errors retain their original diagnosis. **Class (b) at detection**: the boot sweep and the keepalive
evaluation call the injected ensurer (`workflow-exception-
escalation.ts`) — occurrence-deduped against OPEN items by tag
query, scoped to the exact workflow, instance and packet; the crash-surviving
sweep re-creates a missed item. Normal projection, keepalive (including
healthy/terminal returns), and boot (including completed instances) reconcile
each overdue item's own packet. A resolved wait, completion or obsolete
frontier packet closes only that occurrence with a retained transition;
an overdue sibling stays open. Unknown provenance is retained. A later
overdue episode can create a fresh item even when it reuses the same packet.
- **Human selection and failure**: the existing `workspace.operator_seat_name`
setting selects a registered human; when unset, exactly one registered human
is required. No `human@host` alias is invented and no arbitrary choice is made
among several humans. The setting and registry are read at fallback time,
so a valid configured agent route needs no human registry. Missing, ambiguous,
invalid or unavailable selection produces `workflow_human_destination_unavailable`
(HTTP 409 for failed projection). Fix the registration/selection and retry.
Detection-time admission failures are logged by boot and included in the
keepalive's existing owner nudge and evaluation notes; later detection retries.
A registered destination still uses the gateway's ordinary delivery rules and
receipt ledger. Admission is not proof of posting or readership.
A capability inventory or detector instance-binding read failure propagates
instead of becoming a no-match:
failed projection returns HTTP 500 and rolls back its packet, instance and
history writes; overdue detection surfaces the read error without an exception
item. An evidenced no-match still uses registered-human selection.
- **`resume`** (`POST /api/workflow/:id/resume`, runtime `resume()`,
`rig workflow resume`): redrive semantics in ONE scribe transaction —
failed→active REBOUND to the recorded failed step; the owner is
RE-RESOLVED through the projection resolver (never copied from the
stale destination — resume is the one sanctioned re-resolution
point); `--decision` lands durably in the redrive packet; the
occurrence's open items close with provenance; the trail is
preserved, never rewritten. For a dependency graph, `--occurrence <failed-qitem-id>`
selects the episode (required with several unresolved failures). Its resolution,
redrive packet/wake and exact instance/occurrence exception closure share one
transaction; siblings remain intact. An identical occurrence/decision retry
returns the existing redrive; changed decision bytes conflict without mutation.
THE LIVELOCK RAIL (migration 051):
`hops_baseline` re-anchors the max_hops guard at resume so each
redrive gets exactly one bounded window; `resume_count` is the
recorded redrive fact; re-exceeding raises an honest NEW occurrence.
- **The workflow-aware ▲ band** (`review/compose.ts
deriveWorkflowExceptions` + the gatherer source): the missing-item
backstop row, the stuck row with evaluator evidence, the
frontier-non-open ANOMALY row (detection behind WF-3 FR-6's
prevention), and THE AWARENESS ROW — an orchestrator-routed
exception renders holder + age in the human band (one identity, two
projections, count = 1); human-routed items render nothing there
(the ● item is the row). Recomposition clears on state-exit.
## The workflow ↔ rig binding layer (OPR.0.4.6.FAC1)
Run any workflow on any rig that can support it, without editing the
spec — the self-driving factory's binding substrate. Three seams:
- **A1 — bind at instantiation** (migration 052,
`workflow_instances.bound_rig`): the effective binding =
`--rig`/`targetRig` override `?? spec.target.rig ?? null`; the spec
field stays a DEFAULT (no routing path reads it — display only). The
rig NAME persists (the durable operator-space coordinate); name→id
re-resolves fresh at each resolution site — a vanished rig fails loud
(`bound_rig_not_found`), never silently. NULL = unbound =
byte-identical pre-FAC-1 behavior. **Unknown-rig validation SPLITS by
PROVENANCE** (arch ruling 2026-07-07, target-rig zero-regression):
an explicit operator `--rig`/`targetRig` is AUTHORITATIVE — unknown →
`bound_rig_unknown` HARD-FAIL before any mutation; a spec-default
`target.rig` is ADVISORY (authored under the pre-FAC-1 regime where the
field was ignored at runtime) — unknown → DEGRADE to unbound + a LOUD
`advisories` notice on the instantiate result (surfaced by route + CLI
stderr), never a hard-fail. This preserves AC-1 zero-regression for
shipped/example specs that declare a descriptive `target.rig` AND route
via `preferred_targets` (e.g. `conveyor`): they instantiate exactly as
pre-FAC-1. A spec that genuinely needs a bound rig still fails loudly,
per-step (entry at instantiate, later role-only steps at projection) —
the degrade is to honest per-step failure with a heads-up, never to
silence.
- **A2 — role→seat capability resolution**
(`workflow-role-resolver.ts` pure policy +
`workflow-role-context.ts` lazy sync snapshot): TIER 3 inside
`resolveDefaultOwner`, activating ONLY when a role declares zero
`preferred_targets` AND the instance is bound. Tier order is sacred:
explicit owner → gate compile → declared `preferred_targets`
(byte-identical, never inventory-filtered) → capability → loud null.
The CLOSED fact set: role (`nodes.role`, declared per pod member —
seat-side, opt-in) · nodeKind · lifecycleState (`running` only) ·
runtime (harness-pin aware) · sync `pendingWorkCount` (pending-only
backlog) · the derived canonical coordinate. Async
`attachAgentActivity`/tmux probes are structurally absent from the
transaction. Managed seats only: adopted seats are excluded LOUDLY
(`adopted_seat_not_role_resolvable_v1`). Selection = least backlog,
plain-codepoint coordinate tiebreak (`driver10@rig < driver2@rig`).
Resolution runs ONCE at step-close (after the frontier + absorption
guards — replays perform zero inventory reads) and records as the
packet destination; the WF-5 resume is the one re-resolution point,
now capability-aware. All SIX owner-resolution sites carry the
context: projector next-step, human-gate parked-packet owner,
handler-role gate destination (no-targets + bound → capability),
runtime entry (live + recorded), the eager instantiate loop
(STRUCTURAL zero-role-coverage check only — `bound_rig_role_uncovered`;
no live resolution of future steps; a warming rig instantiates), and
resume. The WF-5 exception dial's orchestrator-role position resolves
capability-aware on the bound rig at both homes (in-txn class-(a) +
detection-time class-(b)), then uses registered-human selection if no
agent resolves. Failures are loud-with-candidates: structured
per-candidate disqualifiers + a named zero-candidate message; never a
spawn, never auto-`add_member`, never a dead-seat route. Additive
`owner_resolution` trail evidence records `{mode, role, boundRig?,
seat}` per routing decision.
- **A3 — roles bind to SEATS, never occupants**: the ONE string rule —
the derived canonical coordinate `{pod}-{member}@{rig}` is both the
tiebreak key and the recorded destination, so an agent handover
behind the seat never strands the workflow (raw occupant-era session
names are never recorded as role-resolution destinations).
## The member-exists instantiate advisory (OPR.0.4.6.FAC3 — engine bit)
The queue transport validates the RIG of a destination, never the member
(`topologyValidateRig` is rig-exists-only by design — hardening it would
gate every queue write and break legitimate non-managed destinations),
so a declared `preferred_target` naming a registered rig but a typo'd or
stale MEMBER would mint an orphan packet, visible only later as a WF-5
stuck exception. FAC-3 catches it at the earliest knowable moment: at
instantiate, for every step-referenced role (each step's `actor_role`
plus a handler gate's target role — the same reference set the
structural coverage check walks), each declared `preferred_target` that
parses CANONICAL and names a rig registered on this daemon is probed
for member existence (`rigMemberExists` beside `rigDeclaresRole` —
sync SQL, the derived canonical coordinate per the FAC-1 Q5 one-string
rule, existence at ANY lifecycle state and node kind; liveness stays
projection's business). An unknown member yields ONE aggregated
advisory per unique target — naming every declaring step/role pair, the
consequence (the work will not be claimed; it will surface as a stuck
exception), and the fix hint — pushed into the shipped
`InstantiateResult.advisories` list (the FAC-1 `target.rig`-degrade
surface: one list, now two producers; rendered by the route body + CLI
stderr, zero new surface). ADVISORY-NEVER-DENY: instantiate always
proceeds. Skips (in order): human-seat refs (classified BEFORE parse,
the queue-gate archetype), non-canonical raw/adopted destinations (the
inventory cannot vouch for them), unregistered rigs (the transport
already rejects those loudly at queue-write — no double advisory).
## See also
- `coordination-primitive.md` — PL-004 Phase A; the closure authority the
runtime projects against.
- `mission-control.md` — PL-005 queue observability + 7-verb contract.
- Source roots: `packages/daemon/src/domain/{workflow-projector,
workflow-runtime,workflow-instance-store,workflow-spec-cache,
workflow-step-trail-log,workflow-validator}.ts`,
`packages/daemon/src/domain/policies/workflow-keepalive.ts`,
`packages/daemon/src/domain/{workflow-exception,workflow-exception-router,
workflow-exception-escalation}.ts`, `packages/daemon/src/routes/workflow.ts`.
For authoring examples and project-level boundaries, use
[product journey SDLC](../../reference/product-journey-sdlc.md) and
[release boundaries](../../reference/release-boundary.md).
+384 -285
View File
@@ -9,92 +9,72 @@ applies-when: |
defaultRepo / knowledgeRoot), how that block is persisted and resolved into
whoami / node-inventory, how per-item target_repo scope is validated, or how
the file-backed missions/slices tree is indexed and projected into the
Project UI. Author-mode module — no prior architecture.md prose; every
load-bearing claim is sourced to file:line at HEAD.
Project UI.
siblings: [content-surfaces.md, daemon-core.md, ../ui/project-and-for-you.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
last-updated: 2026-10-03
---
# Workspace Primitive — RigSpec.workspace, Migrations 038/039, Missions/Projects/Slices
The **workspace primitive (PL-007)** is the typed declaration that lets a rig
name *where its work lives* — a workspace root, a set of named repos with
kinds, an optional default repo, and an optional knowledge root — and have
that surface through `whoami` / node-inventory and gate per-item repo scope.
Alongside it, a **file-backed missions/slices tree** is indexed read-only and
projected into the Project UI's mission / slice surfaces.
The **workspace primitive** is the typed declaration that lets a rig name
*where its work lives* — a workspace root, a set of named repos with kinds, an
optional default repo, and an optional knowledge root — and have that surface
through `whoami` / node-inventory and gate per-item repo scope. Alongside it, a
**file-backed missions/slices tree** is indexed read-only and projected into
the Project UI's mission / slice surfaces.
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists for
> this. Every load-bearing claim carries `> Source: <file:line> @HEAD`;
> ambiguity is declared as an OPEN item, never smoothed. Paths are relative to
> `packages/daemon/src/` unless prefixed `packages/` or `docs/`.
>
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2 commits;
> no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
>
> **SPLIT NOTE (§10.6).** The honest source-grounded scope of
> `workspace-and-content-primitives` (proposed-structure.md §4.9 / D13)
> exceeds ~400 lines across two distinct subsystem clusters with distinct
> source roots. Per the §10.6 SPLIT DIRECTIVE it is split by subsystem
> cluster into this module (workspace primitive + migrations 038/039 +
> missions/projects/slices) and the sibling `content-surfaces.md`
> (files/markdown/progress/steering). This is a transparent noted deviation
> from ratified Q7 "18", a faithful application of the founder's own
> ratified Q3 principle ("distinct primitive / distinct source root →
> split"); surfaced at the slice-08 review gate, not a mid-execution
> interrupt.
> Paths are relative to `packages/daemon/src/` unless prefixed `packages/` or
> `docs/`. Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. Each count sits beside
> the command that produces it; run the commands from the repository root.
> The files / markdown / progress / steering surfaces are in the sibling
> `content-surfaces.md`.
## 0. Release attribution (forensic proof — read first)
## 0. Release attribution
Per §10.8, re-verified at HEAD via `git cat-file -e <tag>:<path>`:
Checked with `git cat-file -e <tag>:packages/daemon/src/<path>`. The table
records when each file first appeared; most have changed since.
| Subsystem | Release | Forensic proof @HEAD |
| Subsystem | First release | Check |
|---|---|---|
| `domain/workspace/{workspace-resolver,frontmatter-validator,default-workspace-scaffold}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
| migrations `038_workspace_primitive.ts`, `039_queue_target_repo.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
| `domain/slices/{slice-indexer,slice-detail-projector}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
| `routes/{workspace,slices,projects}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
| `domain/workspace/getting-started-narrative.ts` | **0.3.1** | `v0.3.0:` → **ABSENT**; `v0.3.1:` → present |
| `routes/missions.ts` | **0.3.1** | `v0.3.0:` → **ABSENT**; `v0.3.1:` → present |
| `domain/workspace/{workspace-resolver,frontmatter-validator,default-workspace-scaffold}.ts` | **≤0.3.0** | present at `v0.3.0` |
| migrations `038_workspace_primitive.ts`, `039_queue_target_repo.ts` | **≤0.3.0** | present at `v0.3.0` |
| `domain/slices/{slice-indexer,slice-detail-projector}.ts` | **≤0.3.0** | present at `v0.3.0` |
| `routes/{workspace,slices,projects}.ts` | **≤0.3.0** | present at `v0.3.0` |
| `domain/workspace/getting-started-narrative.ts` | **0.3.1** | absent at `v0.3.0`; present at `v0.3.1` |
| `routes/missions.ts` | **0.3.1** | absent at `v0.3.0`; present at `v0.3.1` |
| `domain/workspace/workspace-doctor.ts` | **0.3.2** | absent at `v0.3.1`; present at `v0.3.2` |
> Source: §10.8 proof re-run at HEAD `7eaf524c` —
> `git cat-file -e v0.3.0:packages/daemon/src/domain/workspace/getting-started-narrative.ts`
> and `...routes/missions.ts` both fail ("exists on disk, but not in
> 'v0.3.0'"); all other listed paths resolve at `v0.3.0`. The workspace
> primitive itself is a **0.3.0** feature (slice-00 0.3.0-GT §1.4, PL-007,
> migrations 038/039) — do not back-attribute it to 0.3.1. The
> `missions` route + the getting-started narrative scaffold are the
> **0.3.1** additions layered on top (slice 12 mission scope; slice 21
> onboarding-conveyor).
The workspace primitive itself (declaration, resolver, validator, migrations
038/039) is a **0.3.0** feature — do not back-attribute it to 0.3.1. The
`missions` route and the getting-started narrative are **0.3.1** additions;
the workspace doctor is **0.3.2**.
slice-00 0.3.0-GT §1.4 independently confirms the primitive shipped 0.3.0:
workspace-primitive merge `2ce54abc` (PL-007) with migrations
`038_workspace_primitive` + `039_queue_target_repo`, "fresh databases apply
migrations through `039_queue_target_repo`".
`getting-started-narrative.ts` is still in the tree, but nothing imports it:
**0** files reference it (`git grep -l 'workspace/getting-started-narrative' -- packages | wc -l`).
Workspace initialization no longer seeds a getting-started mission (§3).
## 1. The typed workspace declaration (PL-007) — 0.3.0
## 1. The typed workspace declaration — 0.3.0
`RigSpec.workspace` is an **optional** typed block. Rigs without it stay valid;
`whoami` / node-inventory return a null workspace block in that case.
> Source: `domain/types.ts:762-770` (`WorkspaceSpec`), `:751-758`
> (`WorkspaceRepoSpec`), `:780` (`RigSpec.workspace?`) @HEAD.
> Source: `domain/types.ts:1160–1167` (`WorkspaceSpec`), `:1150–1156`
> (`WorkspaceRepoSpec`), `:1183` (`RigSpec.workspace?`).
| Field | Shape | Notes |
|---|---|---|
| `workspaceRoot` | string | Verbatim from spec |
| `repos[]` | `{ name, path, kind }[]` | `path` resolved to absolute at parse time; authors may declare relative to `workspaceRoot` in YAML |
| `repos[]` | `{ name, path, kind }[]` | `path` resolved to absolute at parse time (`domain/rigspec-schema.ts:350–352`); authors may declare it relative to `workspaceRoot` in YAML |
| `defaultRepo?` | string | active repo when no env override / cwd match |
| `knowledgeRoot?` | string | treated as `kind=knowledge` when surfaced |
`WorkspaceKind` is a closed 5-member union: `user`, `project`, `knowledge`,
`lab`, `delivery`.
`WorkspaceKind` is a closed union of **5** members
(`grep '^export const WORKSPACE_KINDS' packages/daemon/src/domain/types.ts | grep -o '"[a-z]*"' | wc -l`):
`user`, `project`, `knowledge`, `lab`, `delivery`.
> Source: `domain/types.ts:748-749` (`WORKSPACE_KINDS` /
> `WorkspaceKind`) @HEAD.
> Source: `domain/types.ts:1146–1147` (`WORKSPACE_KINDS` / `WorkspaceKind`).
### 1.1 Persistence — migration 038 + RigRepository
@@ -102,9 +82,9 @@ Migration **038** adds `workspace_json TEXT` to the `rigs` table. It holds the
typed `RigSpec.workspace` block as JSON when a rig declares one; NULL for rigs
without a workspace block.
> Source: `db/migrations/038_workspace_primitive.ts:16-21`
> (`ALTER TABLE rigs ADD COLUMN workspace_json TEXT`); doc-comment
> `:3-14` @HEAD.
> Source: `db/migrations/038_workspace_primitive.ts:16–21`
> (`ALTER TABLE rigs ADD COLUMN workspace_json TEXT` at `:19`); doc comment
> `:3–15`.
`RigRepository.setRigWorkspace(rigId, workspace)` persists it (UPDATE
`workspace_json` + `updated_at`); `getRigWorkspace(rigId)` reads it back,
@@ -113,302 +93,421 @@ are **defensive no-ops** when the column is absent (`hasRigColumn` probe) —
older test fixtures that bypass the canonical migration list don't have the
column; the setter's contract is "best-effort persistence".
> Source: `domain/rig-repository.ts:98-105` (setter, column probe
> L99), `:106-117` (getter; parse-fail → null L114-116) @HEAD.
> Source: `domain/rig-repository.ts:174–179` (setter; column probe `:175`),
> `:182–192` (getter; probe `:183`; parse failure → null `:189–191`);
> `hasRigColumn` `:324`.
The instantiator persists the block at rig-create time **only when declared**:
The block is written only when declared. **3** call sites write it
(`git grep -n 'setRigWorkspace(' -- packages/daemon/src | grep -v 'rig-repository.ts' | wc -l`):
the instantiator's two rig-persist paths, `materializeValidatedSpec` and
`instantiateOnce`, each guarded by `if (rigSpec.workspace)`; and the
workspace-only import route `POST /api/rigs/import/workspace`
(`rig import --workspace-only --target-rig <rigId>`), which validates and
normalizes the block and writes it only when it differs from the stored one.
> Source: `domain/rigspec-instantiator.ts:653-655` (`if
> (rigSpec.workspace) … setRigWorkspace(rigId, rigSpec.workspace)`) @HEAD.
> Source: `domain/rigspec-instantiator.ts:618` (`materializeValidatedSpec`),
> `:654–656`; `:1259` (`instantiateOnce`), `:1346–1348`;
> `routes/rigspec.ts:133–171` (validation `:156`; compare-and-write
> `:166–168`), mounted `server.ts:735`; CLI
> `packages/cli/src/commands/import.ts:82`.
### 1.2 Runtime resolution — workspace-resolver
`resolveWorkspaceContext({ spec, cwd, envOverride })` (consumed by
`whoami-service`) returns the `WhoamiWorkspaceBlock` or `null` when no spec.
`activeRepo` resolution: an `envOverride` (non-empty, trimmed) wins
**verbatim** — even an unknown repo name is honored, because operators set
`OPENRIG_TARGET_REPO` consciously (PL-007 PRD § Item 3); otherwise
`defaultRepo` is used **only if it names a declared repo**. `knowledgeKind`
is `"knowledge"` when `knowledgeRoot` is declared, else `null`.
`activeRepo` resolution: a non-blank `envOverride` wins **verbatim** — even an
unknown repo name is honored, because operators set `OPENRIG_TARGET_REPO`
consciously (the comment at `workspace-resolver.ts:35–36`); otherwise `defaultRepo` is used **only
if it names a declared repo**. `knowledgeKind` is `"knowledge"` when
`knowledgeRoot` is declared, else `null`.
> Source: `domain/workspace/workspace-resolver.ts:26-52` (resolver;
> env-override-verbatim L34-43, including the "honored verbatim"
> comment L35-36) @HEAD.
> Source: `domain/workspace/workspace-resolver.ts:26–52` (resolver; null
> without a spec `:32`; env override `:39–40`; declared `defaultRepo`
> `:41–42`; `knowledgeKind` `:50`).
`whoami-service` reads the persisted spec and resolves with the query's
`targetRepoOverride`, falling back to `process.env["OPENRIG_TARGET_REPO"]`:
`targetRepoOverride`, falling back to the daemon's own
`process.env["OPENRIG_TARGET_REPO"]`. `rig whoami` forwards the caller's
`OPENRIG_TARGET_REPO` as `?targetRepo=`, which the route passes on as
`targetRepoOverride`.
> Source: `domain/whoami-service.ts:319-324` (`getRigWorkspace` +
> `resolveWorkspaceContext`, env fallback L323); `whoami` returns
> `workspace` in its payload `:335` @HEAD.
> Source: `domain/whoami-service.ts:333–338` (`getRigWorkspace` +
> `resolveWorkspaceContext`, env fallback `:337`); `whoami` returns
> `workspace` in its payload `:350`; `routes/whoami.ts:29`;
> `packages/cli/src/commands/whoami.ts:261–262`.
`resolveNodeWorkspace({ spec, cwd })` (consumed by `node-inventory`) derives a
per-node `NodeWorkspaceInfo` by walking the node's `cwd` up the directory
tree for the **longest-prefix repo path** that contains it; falls back to
`knowledge` when cwd is under `knowledgeRoot`, then to the rig's
`defaultRepo` when cwd doesn't resolve. Containment uses a `path.relative`
boundary check (not a string `startsWith`) so `/foo/bar` does not match
`/foo/bar-other`.
per-node `NodeWorkspaceInfo` by picking the **longest declared repo path that
contains** the node's `cwd`. When no repo contains it, `kind` falls back to
`knowledge` if cwd is under `knowledgeRoot`; `activeRepo` then falls back to
the rig's `defaultRepo` (taking that repo's kind if `kind` is still unset).
Containment uses a `path.relative` boundary check (not a string `startsWith`)
so `/foo/bar` does not match `/foo/bar-other`. Node-inventory reads
`workspace_json` with its own queries rather than through `RigRepository`.
> Source: `domain/workspace/workspace-resolver.ts:57-95`
> (longest-prefix L67-73; knowledge fallback L77-79; default-repo
> fallback L82-88), `isInside` `:97-103`;
> `domain/node-inventory.ts:397` (`workspace: resolveNodeWorkspace(...)`),
> `:434-437` (`NodeWorkspaceInfo`) @HEAD.
> Source: `domain/workspace/workspace-resolver.ts:57–95` (longest match
> `:67–73`; knowledge fallback `:77–79`; default-repo fallback `:82–88`),
> `isInside` `:97–103`; `domain/node-inventory.ts:626`
> (`workspace: resolveNodeWorkspace(...)`), readers `:711–720`
> (`readRigWorkspaceJson`) and `:637–647` (`readAllRigWorkspaceJson`,
> batched); `NodeWorkspaceInfo` `domain/types.ts:792–796`.
### 1.3 Per-item repo scope — migration 039 + queue validation
Migration **039** adds `target_repo TEXT` to `queue_items` plus
`idx_queue_items_target_repo`. It carries the per-item typed repo scope when
an operator passes `--target-repo <name>`; NULL when the qitem is
unambiguous against the rig's `default_repo` or no workspace is declared.
Mission Control views surface the field for cross-rig handoff clarity.
`idx_queue_items_target_repo`. Per the migration's comment it carries the
per-item typed repo scope when an operator passes `--target-repo <name>`; it
is NULL when the item is unambiguous against the rig's `default_repo` or no
workspace is declared, and Mission Control views surface the field for
cross-rig handoff clarity.
> Source: `db/migrations/039_queue_target_repo.ts:16-22`
> (`ALTER TABLE queue_items ADD COLUMN target_repo TEXT` +
> `CREATE INDEX … idx_queue_items_target_repo`); doc-comment
> `:3-14` @HEAD.
> Source: `db/migrations/039_queue_target_repo.ts:16–22`
> (`ALTER TABLE queue_items ADD COLUMN target_repo TEXT` at `:19`;
> `CREATE INDEX … idx_queue_items_target_repo` at `039_queue_target_repo.ts:20`); doc comment
> `:3–15`; CLI flag `packages/cli/src/commands/queue.ts:431`.
The queue route validates `target_repo` at the route layer against the
**source rig's** `RigSpec.workspace.repos[]`: it parses the rig name out of
`source_session` (`<member>@<rig>`), looks up the rig, reads
`getRigWorkspace`, and rejects an unknown repo with `unknown_target_repo` +
the known-repo list. It **fails open** (`{ ok: true }`) when there is no
rigRepo, no parseable rig, the rig is unknown, or no workspace is declared —
validation only bites when a workspace actively declares repos.
`validateTargetRepo` checks an explicit `targetRepo` against the **source
rig's** `RigSpec.workspace.repos[]`: it parses the source session with
`parseSessionName`, looks the rig up by name, reads `getRigWorkspace`, and
rejects an unknown repo with 400 `unknown_target_repo` plus the `knownRepos`
list. It **fails open** (`{ ok: true }`) when there is no rigRepo, the session
is not a canonical `<member>@<rig>` name, the rig is unknown, or no workspace
is declared — validation only bites when a workspace actively declares repos.
> Source: `routes/queue.ts:49-57` (fail-open guards), `:55`
> (`getRigWorkspace`), `:58-66` (`unknown_target_repo` +
> `knownRepos`) @HEAD.
It runs on **3** routes (`grep -c 'validateTargetRepo(c,' packages/daemon/src/routes/queue.ts`):
`POST /create`, `POST /:qitemId/handoff` and
`POST /:qitemId/handoff-and-complete`, each before any cross-host forwarding,
because the source rig's workspace lives on the source host. A handoff that
inherits the source row's `targetRepo` without an override is not
re-validated.
## 2. Workspace HTTP route — frontmatter validator (0.3.0)
> Source: `routes/queue.ts:96–123` (fail-open guards `:102`, `:106`, `:109`,
> `:112`; `getRigWorkspace` `:111`; `unknown_target_repo` + `knownRepos`
> `:114–120`); calls `:452`, `:635`, `:708` in handlers `:402`, `:606`,
> `:682`; ordering note `:444–450`; inherited value `:632–633`; mounted
> `server.ts:785`.
`workspaceRoutes()` (mounted `server.ts:491` as `/api/workspace`) exposes a
**single read-only endpoint** at v0:
## 2. Workspace HTTP route — frontmatter validator and doctor
`POST /api/workspace/validate` — body `{ root, workspaceKind?, recursive?,
requireFrontmatter?, maxFiles? }`; returns a `FrontmatterValidationReport`.
`root` is required (400 `root_required`); an out-of-vocabulary
`workspaceKind` is rejected 400 `invalid_workspace_kind`; validator throws
→ 500 `validate_failed`. **No filesystem mutation.**
`workspaceRoutes()` (mounted `server.ts:786` as `/api/workspace`) exposes **2**
endpoints (`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/workspace.ts`).
**Neither mutates the filesystem.**
> Source: `routes/workspace.ts:23-65` (handler; root-required L35-37;
> kind-enum L39-47; 500 L59-62); mounted `server.ts:491` @HEAD.
- `POST /api/workspace/validate` — body `{ root, workspaceKind?, recursive?,
requireFrontmatter?, maxFiles? }`; returns a `FrontmatterValidationReport`.
`root` is required (400 `root_required`); an out-of-vocabulary
`workspaceKind` is rejected 400 `invalid_workspace_kind`; validator throws →
500 `validate_failed`.
- `POST /api/workspace/doctor` — runs `runWorkspaceDoctor` against the
daemon's resolved `workspace.root`, or a `workspaceRoot` given in the body,
and returns its report. 503 `settings_unavailable` without a settings
store; 500 `doctor_failed` when it throws.
`validateWorkspaceFrontmatter()` walks a root, parses each `.md` file's
YAML frontmatter (delimited `---` on the first line), and emits a structured
gap report — **advisory only, never modifies files**. Gap kinds:
> Source: `routes/workspace.ts:16` (no mutation), `:37–74` (validate;
> root-required `:46–48`; kind check `:50–58`; 500 `:72`), `:85–160`
> (doctor; 503 `:87`; `runWorkspaceDoctor` call `:145`; 500 `:158`);
> `domain/workspace/workspace-doctor.ts:643`; mounted `server.ts:786`.
`validateWorkspaceFrontmatter()` walks a root, parses each `.md` file's YAML
frontmatter (delimited `---` on the first line), and emits a structured gap
report — **advisory only, never modifies files**. There are **4** gap kinds
(`sed -n '/^export type FrontmatterGapKind/,/;$/p' packages/daemon/src/domain/workspace/frontmatter-validator.ts | grep -c '^ | '`):
`missing-required-field`, `unrecognized-status-value`, `parse-error`,
`missing-frontmatter`. Per-kind required fields: `user`/`project` →
`["doc"]`; `knowledge`/`lab`/`delivery` → `["doc","status","created","owner"]`.
Valid `status` enum: `active|draft|archived|superseded`. Default behavior
skips files without `---` silently (informal notes) unless
`requireFrontmatter`; recurses by default; skips `node_modules` / `.git` /
`.worktrees` / `dist` / `build`; hard cap `maxFiles` default 10000.
`missing-frontmatter`. Per-kind required fields (checked only when a kind is
given): `user`/`project` → `["doc"]`; `knowledge`/`lab`/`delivery` →
`["doc","status","created","owner"]`. The `status` enum has **4** values
(`grep '^const VALID_STATUS_VALUES' packages/daemon/src/domain/workspace/frontmatter-validator.ts | grep -o '"[a-z]*"' | wc -l`):
`active|draft|archived|superseded`. Default behavior skips files without
`---` silently (informal notes) unless `requireFrontmatter`; recurses by
default; skips `node_modules` / `.git` / `.worktrees` / `dist` / `build`;
hard cap `maxFiles` default 10000.
> Source: `domain/workspace/frontmatter-validator.ts:53` (status enum),
> `:56-62` (per-kind required), `:23-27` (gap kinds), `:90-122` (walk;
> skip-dirs L105-111), `:160-172` (missing-frontmatter behavior),
> `:201-226` (required + status check) @HEAD.
> Source: `domain/workspace/frontmatter-validator.ts:12` (advisory), `:23–27`
> (gap kinds), `:53` (status enum), `:56–62` (per-kind required), `:81–83`
> (defaults), `:90–121` (walk; skip-dirs `:105–111`), `:160–172`
> (missing-frontmatter behavior), `:201–226` (required + status check), `:239`
> (opening delimiter).
CLI surface: `rig workspace validate [root]` — v0 is intentionally narrow
(`validate` only); future versions add typed-kind authoring on the same
walker.
CLI surface: `rig workspace validate [root]` and `rig workspace doctor`.
> Source: `docs/as-built/cli-reference.md:931-941` @HEAD.
> Source: `packages/cli/src/commands/workspace.ts:86` (`validate`), `:146`
> (`doctor`); `docs/as-built/cli-reference.md:593–594`.
## 3. Default project-workspace scaffold
`workspaceScaffoldDirs()` / `workspaceScaffoldFiles()` produce the
repo-ready default workspace (`~/.openrig/workspace/` or `--root`). The same
scaffold is used **idempotently by daemon startup**. It emits exactly two
directories (`missions/`, `exhaust/`) and four files (`SPEC.md`,
`project.yaml`, `workspace.yaml`, `.gitignore`). The catalog points its
single default project at `.`; `project.yaml` owns project intent and mission
discovery and exposes empty `install.context` / `install.skills` selectors.
The ignore file excludes `exhaust/` and local `.openrig/` projection state
while keeping authored project context versionable.
`workspaceScaffoldDirs()` / `workspaceScaffoldFiles()` produce the repo-ready
default workspace (`~/.openrig/workspace/`, the default `workspace.root`, or
`--root`). It emits exactly **2** directories
(`grep '^const WORKSPACE_DIRS' packages/daemon/src/domain/workspace/default-workspace-scaffold.ts | grep -o '"[a-z]*"' | wc -l`):
`missions/`, `exhaust/`; and **4** files
(`sed -n '/^export function workspaceScaffoldFiles/,/^}/p' packages/daemon/src/domain/workspace/default-workspace-scaffold.ts | grep -c 'relPath: "'`):
`SPEC.md`, `project.yaml`, `workspace.yaml`, `.gitignore`. The catalog
(`workspace.yaml`) points its single `default` project at `.`; `project.yaml`
names `SPEC.md` as project intent, roots mission discovery at `missions`, and
exposes empty `install.context` / `install.skills` selectors. The ignore file
excludes `exhaust/` and local `.openrig/` projection state while keeping
authored project context versionable.
The workspace owner is called by the shared instance initializer used by CLI
daemon start and direct daemon first start. The initializer is additive. It
never deletes or overwrites an existing file;
the retained `--force` spelling is a compatibility no-op for overwrite
behavior. CLI and daemon scaffolds are byte-parity pinned. Mission and slice
content is created explicitly through `rig scope`, not seeded by workspace
initialization. Instance context, System World, skill source, runtime state,
and the retired `artifacts/`, `evidence/`, `progress/`, `field-notes/`,
`dogfood-evidence/`, `README.md`, and `STEERING.md` entries are not emitted.
`ensureDefaultWorkspace()` applies the scaffold additively: it creates only
missing paths and never deletes or overwrites an existing file. It checks
every managed path first; if one exists with the wrong kind (a file where a
directory belongs, or the reverse) it writes nothing and returns `ok: false`
with the conflicts. Its callers are the shared instance initializer
`ensureOpenRigInstance()`, which both CLI daemon start and direct daemon start
run (so the scaffold is reconciled on every start); `rig config
init-workspace`; and `POST /api/config/init-workspace` (409
`init_workspace_conflict`). The retained `--force` spelling is a
compatibility no-op. The CLI command re-exports the daemon's owner from
`@openrig/daemon/instance-initialization` rather than keeping its own copy,
and a parity test pins the CLI and daemon layouts as byte-identical. Mission
and slice content is created explicitly through `rig scope`, not seeded by
workspace initialization. Instance context, System World, skill source,
runtime state, and the retired `artifacts/`, `evidence/`, `progress/`,
`field-notes/`, `dogfood-evidence/`, `README.md`, and `STEERING.md` entries
are not emitted.
> Source: `domain/workspace/default-workspace-scaffold.ts`
> (`workspaceScaffoldDirs`, `workspaceScaffoldFiles`); CLI
> `domain/instance-initialization.ts`; daemon `index.ts`; CLI
> `packages/cli/src/daemon-lifecycle.ts` and
> `packages/cli/src/commands/config-init-workspace.ts` (`runInitWorkspace`);
> parity: `packages/daemon/test/getting-started-narrative-parity.test.ts`
> @HEAD.
> Source: `domain/workspace/default-workspace-scaffold.ts:7` (dirs),
> `:20–30` (`project.yaml`), `:32–36` (`workspace.yaml`), `:38–41`
> (`.gitignore`), `:43–54` (`workspaceScaffoldDirs`,
> `workspaceScaffoldFiles`), `:100–153` (`ensureDefaultWorkspace`; conflicts
> checked before writes `:117–135`; write-if-missing `:140`);
> `domain/instance-initialization.ts:39` (`ensureOpenRigInstance`; default
> workspace root `:44`; dry-run plan `:83`; apply `:103`); default
> `workspace.root` `domain/user-settings/settings-store.ts:27–30`; daemon
> `index.ts:242`; CLI `packages/cli/src/daemon-lifecycle.ts:658`;
> `packages/cli/src/commands/config-init-workspace.ts:10` (import), `:13`
> (re-export), `:28` (`--force`), `:56–67` (`runInitWorkspace`);
> `routes/config.ts:56–65`, mounted `server.ts:773`; `rig scope`
> `packages/cli/src/commands/scope.ts:2071`; parity
> `packages/daemon/test/getting-started-narrative-parity.test.ts:15–25`,
> retired entries `:43–57`.
## 4. File-backed missions / slices tree
### 4.1 SliceIndexer (Slice Story View v0) — 0.3.0
### 4.1 SliceIndexer — 0.3.0
`SliceIndexer` reads slice folders from a configured filesystem root. The
**default workspace contract** is
`workspace/missions/<mission>/slices/<slice>`; explicitly configured flat
roots (`workspace/slices/<slice>`) remain supported for compatibility.
`SliceIndexer` reads slice folders from configured filesystem roots. The
**default workspace contract** is `workspace/missions/<mission>/slices/<slice>`;
flat roots (`workspace/slices/<slice>`) remain supported for compatibility.
A nested `slices/` child folder makes the parent a mission
(`missionId = <mission-folder>`); a bare folder is a flat slice
(`missionId = null`). NO new SQLite migration, NO new event type: read-only
projection over existing tables (`queue_items`, `queue_transitions`,
`mission_control_actions`) + dogfood-evidence directories. Time-bounded
listing + detail caches (`invalidate()` drops both). `isReady()` is true
when any configured slice root exists on disk.
(`missionId = null`). Startup builds the indexer on `workspace.slices_root`
(default `<workspace.root>/missions`) and adds `<workspace.root>/missions` and
`<workspace.root>/slices` as extra roots. NO new SQLite migration, NO new event
type: read-only projection over existing tables (`queue_items`,
`queue_transitions`, `mission_control_actions`) + dogfood-evidence
directories. Listing, detail and mission-status caches are time-bounded (60 s
default); `invalidate()` drops all three plus the queue-membership index.
`isReady()` is true when any configured slice root exists on disk.
> Source: `domain/slices/slice-indexer.ts:1-14` (contract + no-new-state),
> `:174-176` (`isReady`), `:179-182` (`invalidate`), `:212-256`
> (`readSliceLocations`; nested-vs-flat L233-253), `:62-89`
> (`SliceListEntry` shape: `missionId` / `slicePath` / `qitemIds` /
> `proofPacket`) @HEAD.
> Source: `domain/slices/slice-indexer.ts:2–15` (contract + no new state),
> `:238` (TTL), `:297–299` (`isReady`), `:331–336` (`invalidate`), `:476–521`
> (`readSliceLocations`; nested-vs-flat `:495–517`), `:61–88` (`SliceRecord`:
> `missionId` `:65`, `slicePath` `:67`, `qitemIds` `:77`, `proofPacket`
> `:81`), `:90–111` (`SliceListEntry`, which carries `qitemCount` /
> `hasProofPacket` instead); startup `startup.ts:1668`, `:1675–1680`,
> `:1693–1698`; default `domain/user-settings/settings-store.ts:479`.
### 4.2 SliceDetailProjector (Slice Story View v0 + v1) — 0.3.0
### 4.2 SliceDetailProjector — 0.3.0
Given a `SliceRecord`, the projector assembles the full per-slice payload
across six tabs (Story, Acceptance, Decisions, Docs, Tests/Verification,
Topology). Read-only; composes already-shipped tables +
Topology) — **6** tab keys in `SliceDetailPayload`
(`sed -n '/^export interface SliceDetailPayload/,/^}/p' packages/daemon/src/domain/slices/slice-detail-projector.ts | grep -c -E '^ (story|acceptance|decisions|docs|tests|topology):'`),
plus a `readiness` field. Read-only; composes already-shipped tables +
`workflow_specs`/`workflow_instances`/`workflow_step_trails` + slice docs on
disk + dogfood-evidence. **v1 removed the v0 hardcoded legacy phase enum**
disk + dogfood-evidence. **v1 removed the v0 hardcoded legacy phase taxonomy**
(`discovery`/`product-lab`/`delivery`/…): `StoryEvent.phase` is now an
open-ended string-or-null — the spec-defined `step.id` when bound to a
`workflow_instance`, else `null` (UI groups under "Untagged"). When no
workflow runtime is constructed the projector silently degrades to v0
behavior (`workflowBinding=null`, `specGraph=null`).
behavior (`workflowBinding`, `specGraph`, `phaseDefinitions` and
`currentStep` all `null`).
> Source: `domain/slices/slice-detail-projector.ts:1-21` (six-tab
> contract + v1 enrichment), `:18-21` (v0 phase enum REMOVED at v1);
> startup degrade-path `startup.ts:1063-1076` (projector built with
> `workflowRuntime?.specCache`; comment L1064-1071) @HEAD.
> Source: `domain/slices/slice-detail-projector.ts:1–20` (six-tab contract +
> v1 enrichment), `:16–20` (v0 phase taxonomy removed), `:64`
> (`StoryEvent.phase`), `:177–203` (`SliceDetailPayload`), `:205–213`
> (optional `workflowSpecCache`); startup degrade path
> `startup.ts:1699–1710` (comment `:1699–1705`;
> `workflowSpecCache: workflowRuntime?.specCache` `:1709`).
### 4.3 Slices routes — 0.3.0
`slicesRoutes()` (mounted `server.ts:501` as `/api/slices`):
`GET /` (filter `all|active|done|blocked`, default `all`; `?refresh=1`
invalidates; optional `?boundToWorkflow=<name>:<version>` lens narrows to
slices bound to a workflow instance), `POST /refresh` (drops both indexer
caches — no daemon restart), `GET /:name/proof-asset/*` (path-traversal
guarded; immutable 1-day cache), `GET /:name/doc/*` (markdown for the Docs
tab; traversal guarded), `GET /:name` (full per-tab payload). 503
`slices_indexer_unavailable` when unwired; 503 `slices_root_not_configured`
+ setup hint when not ready. **Route-order discipline:** literal `/` /
`/refresh` / `/:name/proof-asset/*` / `/:name/doc/*` are registered BEFORE
the dynamic `/:name` so they are not shadowed.
`slicesRoutes()` (mounted `server.ts:804` as `/api/slices`) registers **5**
handlers (`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/slices.ts`):
> Source: `routes/slices.ts:31-177` (handlers; route-order L34-35,
> L101-102, L109-113, L166-167; 503s L38-44; `boundToWorkflow`
> L60-86); mounted `server.ts:501` @HEAD.
- `GET /` — filter `all|active|done|blocked` (default `all`; anything else is
400 `filter_invalid`); `?refresh=1` (or `true`) invalidates first; optional
`?boundToWorkflow=<name>:<version>` lens narrows to slices bound to a
workflow instance (400 `boundToWorkflow_invalid` without the colon). Each
slice row carries `readiness`, and a `missions` sidecar carries authored
mission status and readiness. Rows sort by last activity, newest first.
- `POST /refresh` — drops the indexer caches, no daemon restart.
- `GET /:name/proof-asset/*` — serves a proof asset from the slice's matched
dogfood-evidence directory; rejects `..` (400); `Cache-Control: public,
max-age=86400`; byte ranges (206 / 416).
- `GET /:name/doc/*` — markdown for the Docs tab; rejects `..`.
- `GET /:name` — full per-tab payload. With `?project=<id>` it also requires
`?mission=<id>` (400 `exact_mission_and_slice_required`) and reads through
an indexer scoped to that project's missions root.
### 4.4 Missions route — **0.3.1** (slice 12 + slice 13 + slice 18)
503 `slices_indexer_unavailable` when unwired; on `GET /`, 503
`slices_root_not_configured` + setup hint when not ready. **Route-order
discipline:** literal `/` / `/refresh` / `/:name/proof-asset/*` /
`/:name/doc/*` are registered BEFORE the dynamic `/:name` so they are not
shadowed.
`missionsRoutes()` (mounted `server.ts:505` as `/api/missions`) is the
mission scope data layer. `GET /:missionId` returns
`{ missionId, missionPath, slices, workflow_spec, topology, status }` —
slices filtered from the SliceIndexer by `missionId`; `missionPath` derived
by going up two levels from any slice's `slicePath`; `workflow_spec` parsed
lazily from `<missionPath>/README.md` frontmatter (same `parseWorkflowSpecRef`
helper the slice-indexer uses); `topology.specGraph` projected via
`projectSpecGraph(spec, null)` when the spec is cached, `{ specGraph: null }`
when declared-but-not-cached, `null` when nothing declared; `status` read
from README frontmatter. `POST /:missionId/complete` writes
`status: complete` to the mission README frontmatter (idempotent; preserves
unrelated fields) — the daemon is the audit-trail surface, the UI keeps an
optimistic localStorage mirror. 404 `mission_not_found` when no slices match.
> Source: `routes/slices.ts:33–198` (handlers; route-order comments
> `:36–37`, `:118`, `:126–127`, `:175`; 503s `:40–46`; filters `:31`,
> `:47–53`; refresh `:54–57`; `boundToWorkflow` `:67–93`; readiness +
> `missions` sidecar `:99–110`; `/refresh` `:119–124`; proof asset
> `:131–154`, cache header `:235`, ranges `:237–266`; doc `:158–173`;
> project scope `:180–190`); mounted `server.ts:804`.
> Source: `routes/missions.ts:39-118` (handlers), `:124-142`
> (`writeMissionStatusComplete`), `:148-150` (`computeMissionPath`
> up-two-levels), `:171-177` (`readMissionWorkflowSpec`), `:205-215`
> (`computeMissionTopology`); mounted `server.ts:505`; §0 proof:
> `routes/missions.ts` [email protected] ⇒ **0.3.1** @HEAD.
### 4.4 Missions route — 0.3.1
### 4.5 Projects route — Coordination L2 classifier (PL-004 Phase B) — 0.3.0
`missionsRoutes()` (mounted `server.ts:815` as `/api/missions`) is the
mission scope data layer, with **2** handlers
(`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/missions.ts`).
`projectsRoutes()` (mounted `server.ts:492` as `/api/projects`) backs the
`rig project` CLI verb — this is the **PL-004 Phase B project *classifier***
(lease lifecycle + idempotent classify + operator-verb reclaim + SSE), NOT
the Project *workspace UI* (that is the slices/missions surface above; the
naming overlap is a documented seam). Endpoints: `POST /lease/acquire`
(optional `evaluateDeadnessFirst` clears stale/dead leases first),
`POST /lease/heartbeat`, `POST /reclaim-classifier`, `POST /project`
(idempotent on `stream_item_id`), `GET /lease`, `GET /list`,
`GET /sse` + `GET /watch` (SSE), `GET /:projectId`. **Route-order
discipline:** literal `/lease`, `/list`, `/sse`, `/watch` are registered
BEFORE the bare `/:projectId` catchall (Phase A R1 SSE lesson).
`GET /:missionId` returns
`{ missionId, missionPath, readiness, slices, workflow_spec, topology, status }`:
> Source: `routes/projects.ts:18-194` (handlers; route-order note
> L15-17, L140-141, L158-159, L185); error-code → HTTP mapping
> `:31-52`; mounted `server.ts:492` @HEAD.
- `slices` — filtered from the SliceIndexer by `missionId`, each with its
`readiness`;
- `missionPath` — two levels up from any slice's `slicePath`;
- `workflow_spec` — parsed from the frontmatter of the mission's node file
(`SPEC.md`, else legacy `README.md`) with the same `parseWorkflowSpecRef`
helper the slice-indexer exports;
- `topology.specGraph` — projected via `projectSpecGraph(spec, null)` when the
spec is cached; `{ specGraph: null }` when declared but not cached (or no
cache is wired); `null` when nothing is declared;
- `readiness` — the mission's proof readiness;
- `status` — `metadata.status` from `mission.yaml` when present, else the
node file's `status` frontmatter.
## 5. UI route-reality (§10.7 — source-verified at routes.tsx@HEAD)
`POST /:missionId/complete` sets the mission status to `complete`:
`metadata.status` in `mission.yaml` when the manifest exists (other fields and
comments kept), otherwise the `status` line in the node file's frontmatter
(creating `SPEC.md` when the mission has no node file). It is idempotent.
After a successful write it drops the indexer's mission-status cache so the
next `/api/slices` read sees the new status. The daemon is the audit-trail surface; the UI keeps an
optimistic localStorage mirror. 404 `mission_not_found` when no slices match;
500 `mission_complete_write_failed` on a write error.
`packages/ui/src/routes.tsx` is **542 lines** and uses TanStack Router
(`createRoute({ path, component })` objects), NOT JSX `<Route>`. The
Phase-8.1 survey's grep-derived missions/projects expectations are
corrected here against routes.tsx reality:
> Source: `routes/missions.ts:45–88` (GET; 503s `:46–64`; 404 `:68–70`;
> readiness + status `:77–78`; response `:79–87`), `:95–125` (complete; 500
> `:110–117`; cache drop `:123`), `:132–163` (`writeMissionStatusComplete`;
> manifest `:133–141`; node file `:145–162`), `:169–171`
> (`computeMissionPath` up two levels), `:179–186` (`readMissionStatus`),
> `:192–198` (`readMissionWorkflowSpec`), `:226–236`
> (`computeMissionTopology`); node-file precedence
> `domain/scope/node-file.ts:17`; readiness
> `domain/proof/judgments.ts:288–295`; UI mirror
> `packages/ui/src/hooks/useCompletedMissions.ts:15`; mounted `server.ts:815`;
> §0: `routes/missions.ts` absent at `v0.3.0` ⇒ **0.3.1**.
| Survey expectation | routes.tsx@HEAD reality | routes.tsx:NN |
### 4.5 Projects route — stream classifier — 0.3.0
`projectsRoutes()` (mounted `server.ts:787` as `/api/projects`) backs the
`rig project` CLI verb — the **stream-item classifier** (lease lifecycle +
idempotent classify + operator-verb reclaim + attempt ledger + SSE), NOT the
Project *workspace UI* (that is the slices/missions surface above; the naming
overlap is a documented seam). Endpoints:
- `GET /worker-sources` — requires `?project=<id>`, a workspace catalog
project;
- `GET /shadow`, `POST /shadow/drain`, `POST /shadow/stop`;
- `POST /lease/acquire` (optional `evaluateDeadnessFirst` clears stale/dead
leases first), `POST /lease/heartbeat`, `POST /reclaim-classifier`;
- `POST /project` (idempotent on `stream_item_id`);
- `POST /attempts/begin`, `POST /attempts/:attemptId/abstain`,
`POST /attempts/:attemptId/fail`, `GET /eligible`;
- `GET /lease`, `GET /list`, `GET /sse` + `GET /watch` (SSE);
- `GET /:projectId`.
**Route-order discipline:** every literal path is registered BEFORE the bare
`/:projectId` catchall, which comes last.
> Source: `routes/projects.ts:27–373` (handlers; route-order notes `:25`,
> `:238`, `:307`, `:320`, `:337–338`, `:364`); worker sources `:98–109`;
> shadow `:111–125`; lease / reclaim / project `:135–235`
> (`evaluateDeadnessFirst` `:140–142`); attempts and eligible `:242–304`;
> lease and list `:308–334`; SSE `:339–362`; `/:projectId` `:365–370`;
> error-code → HTTP mapping `:58–95`; CLI
> `packages/cli/src/commands/project.ts:46`; mounted `server.ts:787`.
## 5. UI route reality — source-verified at routes.tsx
`packages/ui/src/routes.tsx` is **605** lines (`wc -l < packages/ui/src/routes.tsx`)
and uses TanStack Router (`createRoute({ path, component })` objects), NOT JSX
`<Route>` (**0** matches: `grep -c '<Route' packages/ui/src/routes.tsx`). The
mission / project / slice destinations as they exist:
| Path | routes.tsx reality | routes.tsx:NN |
|---|---|---|
| `/project` (workspace) | **REAL route** — `WorkspaceScopePage` | `:128-131` |
| `/project/mission/$missionId` | **REAL route** — `MissionScopePage` | `:134-137` |
| `/project/slice/$sliceId` | **REAL route** — `SliceScopePage` | `:140-143` |
| `/files` | **REAL route** — `FilesWorkspace` (see content-surfaces.md) | `:192-195` |
| `/mission-control` | **`<Navigate to="/for-you" />`** redirect-stub (DELETED per SC-18; the Mission Control *system* lives at daemon/PL-005, not a UI destination) | `:440-444` |
| `/slices` | **`<Navigate to="/project" />`** redirect-stub (DELETED per project-tree.md) | `:447-451` |
| `/slices/$name` | **`<Navigate to="/project/slice/$sliceId" />`** redirect-stub | `:453-460` |
| `/progress` | **`<Navigate to="/project" />`** redirect-stub (folds into Project tabs, Phase 3) | `:464-468` |
| `/steering` | **`<Navigate to="/project" />`** redirect-stub (folds into Project workspace overview tab, Phase 3) | `:470-474` |
| `/missions` (top-level) | **NO route** — missions are reached only via `/project/mission/$missionId` | (absent) |
| `/markdown` | **NO route** — markdown is a component in file/drawer surfaces, not a destination (see content-surfaces.md §4) | (absent) |
| `/project` (workspace) | **REAL route** — `WorkspaceScopePage` | `:136–140` |
| `/project/mission/$missionId` | **REAL route** — `MissionScopePage` | `:142–146` |
| `/project/slice/$sliceId` | **REAL route** — `SliceScopePage` | `:148–152` |
| `/files` | **REAL route** — `FilesWorkspace` (see content-surfaces.md) | `:248–252` |
| `/mission-control` | **`<Navigate to="/for-you" />`** redirect-stub (the Mission Control *system* lives in the daemon at `/api/mission-control`, not as a UI destination) | `:496–500` |
| `/slices` | **`<Navigate to="/project" />`** redirect-stub | `:503–507` |
| `/slices/$name` | **`<Navigate to="/project/slice/$sliceId" />`** redirect-stub | `:509–516` |
| `/progress` | **`<Navigate to="/project" />`** redirect-stub (folds into Project tabs) | `:519–523` |
| `/steering` | **`<Navigate to="/project" />`** redirect-stub (folds into the Project workspace overview tab) | `:526–530` |
| `/missions` (top-level) | **NO route** — missions are reached only via `/project/mission/$missionId` | absent: **0** (`grep -c 'path: "/missions"' packages/ui/src/routes.tsx`) |
| `/markdown` | **NO route** — markdown is a component in file/drawer surfaces, not a destination (see content-surfaces.md) | absent: **0** (`grep -c 'path: "/markdown"' packages/ui/src/routes.tsx`) |
> Source: `packages/ui/src/routes.tsx:90-195` (real routes),
> `:435-474` (redirect-stub block; each `<Navigate>` + DELETED/folds
> comment), `:476-490` (route tree) @HEAD. Grep-confirmed absent:
> no `path: "/missions"` / `path: "/markdown"` declaration anywhere
> in routes.tsx @HEAD.
> Source: `packages/ui/src/routes.tsx:136–152` (project routes), `:248–252`
> (`/files`), `:484–530` (redirect block, which also redirects `/context` to
> `/topology`), `:539–597` (route tree); `/api/mission-control` mounted
> `packages/daemon/src/server.ts:791–794`.
Net §10.7 ruling: the daemon `/api/missions` + `/api/slices` +
`/api/projects` routes are real and load-bearing; the **operator-facing UI**
consumes them through `/project*` destinations only. `/mission-control`,
`/slices`, `/progress`, `/steering` are redirect-stubs (the systems exist at
the daemon layer, the URLs were collapsed into `/project` / `/for-you`).
Net: the daemon `/api/missions` + `/api/slices` + `/api/projects` routes are
real. The **operator-facing UI** consumes the missions and slices routes
through the `/project*` destinations; it does not call `/api/projects`
(**0** files: `git grep -l '/api/projects' -- packages/ui/src | wc -l`).
`/mission-control`, `/slices`, `/progress`, `/steering` are redirect-stubs
(the systems exist at the daemon layer; the URLs were collapsed into
`/project` / `/for-you`).
> Source: `packages/ui/src/hooks/useMission.ts:51` (`/api/missions`),
> `packages/ui/src/hooks/useSlices.ts:84` (`/api/slices`).
## 6. Cross-cutting properties
- **Optional + valid-without:** rigs without a `workspace` block stay valid;
whoami / node-inventory / queue-target-repo all return null / fail-open
(`workspace-resolver.ts:32`; `rig-repository.ts:99,107`;
`routes/queue.ts:55`) @HEAD.
- **Defensive column probes:** every `workspace_json` / `target_repo` access
guards on column presence so partial test fixtures don't crash
(`rig-repository.ts:99,107`; migrations 038/039 ship separately so a
fixture can apply only the half it needs — `038.ts:11-14`) @HEAD.
whoami / node-inventory return null and queue target-repo validation fails
open (`workspace-resolver.ts:32`, `:62`; `rig-repository.ts:175`, `:183`;
`routes/queue.ts:112`).
- **Defensive column access:** `RigRepository` probes for `workspace_json`
(`rig-repository.ts:175`, `:183`), node-inventory's own reads fall back to
null on error (`node-inventory.ts:645`, `:717–719`), and the queue
repository detects `target_repo` (`queue-repository.ts:752`), so partial
test fixtures don't crash. Migrations 038/039 ship separately so a fixture
can apply only the half it needs (`038_workspace_primitive.ts:12–14`).
- **Read-only projection, no new state for the tree:** SliceIndexer /
SliceDetailProjector add NO migration / event type; only the workspace
declaration (038) + per-item scope (039) touch SQLite
(`slice-indexer.ts:12-14`) @HEAD.
- **Advisory, never mutates:** the frontmatter validator and the
init-workspace scaffold never delete operator content
(`frontmatter-validator.ts:12-13`; `config-init-workspace.ts:12-16`) @HEAD.
(`slice-indexer.ts:14–15`).
- **Advisory, never deletes:** the frontmatter validator never modifies files
(`frontmatter-validator.ts:12`), and the workspace scaffold never deletes or
overwrites operator content (`default-workspace-scaffold.ts:140`;
`packages/cli/src/commands/config-init-workspace.ts:18`).
## OPEN items (carried, not smoothed)
## Open items
- **OPEN-A** — `resolveWorkspaceContext` honors an `envOverride`
**verbatim even when it names no declared repo** (`workspace-resolver.ts:34-43`,
by explicit PL-007 PRD § Item 3 design), whereas the queue route
**rejects** an unknown `target_repo` against the same `repos[]`
(`routes/queue.ts:58-66`). These two surfaces apply opposite policies to
an unknown repo name (whoami trusts the operator; queue validates). Stated
as-is — the asymmetry is source-real and intentional per the cited
comments, but the divergence is not reconciled in source.
- **OPEN-B** — the `/api/projects` *classifier* (PL-004 Phase B) and the
`/project*` *workspace UI* (slices/missions) share the word "project" but
are unrelated subsystems. Documented as a naming seam; no source defect.
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
migration-count / route-group / PL-004-event-count claim (those land in
daemon-core / coordination-primitive / architecture-rules).
- `resolveWorkspaceContext` honors an `envOverride` **verbatim even when it
names no declared repo** (`workspace-resolver.ts:39–40`), whereas the queue
route **rejects** an unknown `target_repo` against the same `repos[]`
(`routes/queue.ts:114–120`). These two surfaces apply opposite policies to
an unknown repo name (whoami trusts the operator; queue validates). The
resolver's comment at `workspace-resolver.ts:35–36` states the verbatim behavior is intended, but
the comment line before it (`workspace-resolver.ts:34`) and `whoami-service.ts:331–332` describe
a repo-name check that the code does not perform. Stated as-is; the
divergence is not reconciled in source.
- The `/api/projects` *classifier* and the `/project*` *workspace UI*
(slices/missions) share the word "project" but are different subsystems.
Both accept a workspace catalog project as `?project=<id>`
(`routes/projects.ts:105`; `routes/slices.ts:181`). Documented as a naming
seam; no source defect.
+103
View File
@@ -0,0 +1,103 @@
---
kind: as-built
title: Arteries — where a small change has a large effect
status: active
applies-when: |
You are about to change, or review a change to, an area that many other paths depend on:
message delivery, launch and resume, the queue, rig identity, skill projection, native
process observation, migrations, or restore. Read this to see what depends on the area and
what broke there before, then go and read the code.
siblings: [README.md, test-layers.md]
prerequisite-reads: [../../ARCHITECTURE.md]
last-verified-against-source: 1347d825
last-updated: 2026-10-01
---
# Arteries: where a small change has a large effect
## Read this first: this map is not the territory
- **This page is a zoomed-out view, not a description of OpenRig.** It names a few areas where
small changes caused large regressions before. OpenRig is much larger than this page. Code,
behaviour and risk that are not listed here exist, and some of it is riskier than anything below.
- **It is incomplete by construction.** A pull request is not safe because its files are missing
from this list.
- **It may be stale.** It was checked against commit `1347d825`. Files and dependencies move. If
this page and the code disagree, the code is right; fix the page.
- **Use it to change altitude, then go to the ground:** read the code, run the commands, check the
behaviour. When you are lost, go and look rather than leaning on this page.
## The arteries
Paths are relative to `packages/`. The key files are entry points, not the full extent of each
area.
| Artery | Key files | What depends on it | Past regressions and lessons |
|---|---|---|---|
| **Message delivery to seats** | `daemon/src/domain/session-transport.ts`, `daemon/src/domain/seat-delivery-guard.ts`, `daemon/src/adapters/tmux.ts` | Every `rig send`, queue wake, nudge, watchdog wake and handoff | #150 (the fix for #142) stopped typing into a bare shell. It then refused managed Codex seats (fixed by #171 before release) and every Claude seat launched with an explicit permission mode, which shipped in 0.6.2 as #197 (fixed by #220). The delivery guard's rule that a name must resolve to exactly one target, added in #109 (0.6.0), was involved in #141 (fixed by #151), the delivery half of #174 (fixed by #181) and #188 (fixed by #189). |
| **Launch, relaunch, resume** | `daemon/src/adapters/claude-code-adapter.ts`, `daemon/src/domain/claude-managed-launch.ts`, `daemon/src/adapters/codex-runtime-adapter.ts`, `daemon/src/domain/codex-daemon-support.ts`, `daemon/src/domain/native-resume-probe.ts`, `daemon/src/domain/startup-orchestrator.ts` | Every seat start, restore and permission mode | Always setting `CLAUDE_CONFIG_DIR` gave explicit-mode Claude seats a blank config (#154; #225 fixes its config-selection part). Codex seats launch with `--no-daemon` only when `codex --help` shows support (#69, #77); when support is unknown, OpenRig refuses to launch, so a failing or slow `codex --help` blocks the seat. The fixed 30-second readiness window times out under load (#182, open). A git worktree's `.git` file broke the Codex sandbox (#121; see #126). |
| **Queue claim, pickup, wakes** | `daemon/src/domain/queue-repository.ts`, `daemon/src/domain/queue-pickup.ts`, `daemon/src/domain/queue-wake-ladder.ts`, `daemon/src/routes/require-sender-identity.ts`, `cli/src/client.ts` | All durable work between seats | After a slow `/healthz`, the CLI host-qualified a local sender and claims failed (#131, fixed by #135). Done-and-closed items read as stalled forever (#164, fixed by #176). |
| **Rig identity: same names, archived and deleted rigs** | `daemon/src/domain/rig-repository.ts`, `daemon/src/domain/rig-lifecycle-service.ts`, `daemon/src/domain/rig-teardown.ts`, `daemon/src/domain/seat-handover-service.ts`, `daemon/src/domain/running-name-guard.ts` | Seat resolution, `rig remove`, restore, importing a rig from YAML | Archived rigs still matched seat references, and `rig remove` killed another rig's live seat with the same name (#174, fixed by #181). Replacing stopped same-name rig generations on YAML import followed in #196. |
| **Projection of skills and files** | `daemon/src/domain/rigspec-instantiator.ts`, `daemon/src/domain/projection-planner.ts`, and each runtime adapter's `project()` | What every agent is given at start | Codex seats skipped skills that a Claude sibling had already projected (#159, fixed by #162). File-shaped entries failed with ENOTDIR (fixed by #185). |
| **Native process observation** | `daemon/src/domain/native-process-lineage.ts` (parses `ps -Ao pid,ppid,pgid,tpgid,ucomm,lstart,command`) | Liveness, identity verdicts, delivery checks | Processes whose command name contains a space were dropped (#134, fixed by #82). Non-English locales broke the date parsing (fixed by #239, which runs `ps` with `LC_ALL=C`). #220 also changed this file to recognise managed Claude seats behind launch wrappers. |
| **Schema and migrations** | `daemon/src/db/migrations/`, `daemon/src/db/all-migrations.ts`, `daemon/src/db/migrate.ts` | Every install, and every upgrade of a long-lived install | Parallel pull requests pick the same next number: open #112 adds `086_rig_kernel_variant` while `main` already has `086_classification_fields_and_attempts`, and open #155 and #195 take 090 and 091. A long-lived install upgrading across several releases applies many migrations in one start, against real data. |
| **Restore and recovery** | `daemon/src/domain/reconciler.ts`, `daemon/src/domain/restore-check-service.ts`, `daemon/src/domain/startup-orchestrator.ts`, `daemon/src/adapters/tmux.ts` | Reboots, power loss, daemon restarts | Detached seats kept stale tmux pane ids after a reboot (#141, fixed by #151 in `tmux.ts`). `restore-check` ignores the configured shared-docs root (#130, open). After a fresh relaunch, a stale Codex restore attention flag never clears (#116, open). |
## How reviews use this page
- **Find where the effect lands.** A reviewer notes which artery a pull request touches, if any,
and what depends on it.
- **At an artery, review the effect, not only the diff.** Whatever the diff size, the reviewer
states the downstream effects and checks behaviour with a scratch reproduction or a test.
- **Away from the arteries,** small pull requests keep a normal review. Scrutiny goes where the
effect is.
## The scenario rule
**A pull request that touches an artery adds or extends a stub-agent scenario, or says in its
description why it cannot.** Reviewers check for one or the other. Scenarios live in
`packages/test-system/scenarios/`; [ARCHITECTURE.md](../../ARCHITECTURE.md#a-scenario) says how to
write and run one, and [test-layers.md](test-layers.md) says what each test layer can and cannot
catch.
The goal is that every artery has at least one scenario whose seeded regression fails, so a small
change to an artery meets a real end-to-end check before it merges, not only a reviewer's reading.
### Scenario coverage today
At commit `1347d825`. For why the stub cannot reach some of these yet, see "What a stub can and
cannot prove" in [test-layers.md](test-layers.md).
- **Queue durability across a daemon restart: covered.** CI runs
`queue-baton-survives-restart.yaml` healthy, then with a seeded `baton-drop` fault that must
fail, then healthy again (#243). It runs `rig queue claim` and checks the destination after the
restart, but pickup, wakes and claim-identity edge cases (such as the #131 sender case) have no
scenario yet.
- **Delivery (the #197 class): not covered.** The stub runtime has no input behaviour, so it cannot
exercise delivery to a managed wrapper; `send-verify-means-rendered.yaml` is not admitted to CI
for that reason (`packages/test-system/ci/README.md`).
- **Restore (the #141 class): not covered.** The scenario runner does not bind the `restore` verb
yet; it fails with `UnboundActionError`.
- **Rig identity (the #174 class): not covered.** No library scenario sets up a same-name archived
or removed rig.
- **Projection (the #159 class): not coverable with `runtime: stub`,** which projects skills into its
own `.openrig/stub/skills/` directory. Use adapter-level unit tests.
- **Native process observation: unit tests only** (for example
`packages/daemon/test/codex-process-observation.test.ts`, `native-ps-locale.test.ts` and
`send-runtime-not-running.test.ts`).
- **Migrations: unit guards only** (`migration-fixture-parity.test.ts`,
`startup-migrations-mirror.test.ts`). Every scenario starts from an empty database, so upgrading
an existing database is not covered.
- **Launch:** every scenario's `up` launches stub seats through the real launch path. Native Claude
Code and Codex launches are not exercised; the CI containers have no provider CLIs or
credentials.
## Regression log
When a regression reaches a release, add one line: date, issue, artery, and which layer missed it
(the layers are named in [test-layers.md](test-layers.md)). Each line should end up as a scenario.
- 2026-09-30, #197 (0.6.2), delivery: review saw a small diff in isolation; CI had no managed
explicit-mode Claude seat; the project's own day-to-day seats use the default launch path.
- 2026-09-29/30, #141, #174, #188 (after 0.6.0), delivery and rig identity: reviews of #109 did not
see the downstream effect of the exactly-one-target rule.
File diff suppressed because it is too large. Load diff
+73 -178
View File
@@ -1,197 +1,92 @@
---
kind: as-built
title: OpenRig Codemap — Navigation Index / Map of Territory
title: OpenRig Codemap — Modules and Source Entry Points
status: active
topics: [knowledge-and-context, observability]
domains: [engineering-advisor, operating-advisor]
applies-when: |
You know what you need to learn about the shipped OpenRig system but not
which as-built module holds it. Use the module map, the use-case lookup,
or the source-root table to route to the right doc — or jump straight to
the source root the codemap points at.
siblings: [README.md, cli-reference.md]
You know the behavior you need to inspect but not its owning source files.
Follow the module and source entry points below, then read the implementation.
siblings: [README.md, arteries.md, test-layers.md, cli-reference.md]
prerequisite-reads: [README.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# OpenRig Codemap — Navigation Index
# OpenRig codemap
This is a **map of territory**, not a content dump. It tells you which
as-built module answers a question and which `packages/*/src` root that
module is grounded in. The deep content lives in the modules; the code lives
in the source roots; this file points at both.
The paths and named entry points below were checked at main
`254122872cf477511514979a4300b695d77cd1f7`. This is a navigation map, not a replacement for
reading the implementation. Linked documents retain their individual verification stamps.
[README.md](README.md) lists the complete document tree and commands to derive its inventory.
> The old codemap was a 425-line file-by-file `Exports:`/`Related:` flat dump
> that drifted fastest (its own header carried a stale v0.2.0 footprint and an
> internally-contradictory command-group count) and never fit a context
> window. That anti-pattern is retired (slice-08 Q5, founder-ratified). The
> genuinely-useful part — the source-root index — is preserved below as the
> source-root pointer table; the per-file transcription is not.
## Package entry points
## (a) Product frame
Paths in backticks are relative to the repository root.
OpenRig is a local control plane for multi-agent coding topologies — a
multi-agent harness that manages your Claude Code and Codex sessions as a
single system. The daemon (`@openrig/daemon`) is the framework-free
SQLite-backed core; the CLI (`@openrig/cli`), UI (`@openrig/ui`), and MCP
server all sit on top of it.
> Source-verified at HEAD `7eaf524c` (`v0.3.1-6-g7eaf524c`); package version
> **0.3.1**, 6 unreleased 0.3.2 commits on HEAD, no `v0.3.2` tag. Frame
> sentence triangulated against `architecture/daemon-core.md` §1 +
> `openrig-internal/product/positioning.md` ("a multi-agent harness, a local
> control plane that manages your Claude Code and Codex sessions as a single
> system").
## (b) Module map
One row per module. "Reach for this when…" mirrors each module's actual
`applies-when` frontmatter.
### architecture/
| Module | One-sentence summary | Reach for this when… |
|---|---|---|
| `daemon-core.md` | The framework-free SQLite-backed core the CLI/UI/MCP all sit on. | You need to know how the daemon boots, how `createDaemon` wires the dependency graph, the SQLite schema/migration set, or the route-mount surface. |
| `adapters-and-runtimes.md` | The five-method runtime-adapter contract + the resume-honesty layer. | You need the runtime-adapter contract, how OpenRig launches/resumes a Claude Code, Codex, or terminal harness inside tmux, or how the daemon honestly assesses whether a harness actually resumed vs fresh-launched. |
| `coordination-primitive.md` | PL-004 Phase A: the SQLite-canonical durable-work layer (`/api/stream`, `/api/queue`). | You need the stream/queue/inbox/outbox tables, the hot-potato closure contract, the transactional handoff guarantee, or where queue closure is enforced. |
| `workflow-runtime.md` | PL-004 Phase D: turns an intended sequence of work into durable SQLite state. | You need workflow specs cache, instance state, step trails, the transactional-scribe projection contract, or the watchdog policy set including workflow-keepalive. |
| `mission-control.md` | PL-005: the daemon-backed queue-observability surface inside the existing shell. | You need the seven views, the seven write verbs, the action audit table, the bearer-token middleware, or how queue observability maps to PL-004 sources. |
| `agent-spec-and-startup.md` | How authored YAML specs become a resolved, launched, identity-addressable topology. | You need the AgentSpec/RigSpec/pod-aware reboot types, profile resolution + additive startup layering, the StartupOrchestrator delivery split, or how whoami/materialize/bind/adopt resolve identity. |
| `lifecycle-snapshot-restore.md` | The durable-state half of `down → up → handoff`. | You need how OpenRig captures a snapshot, restores a rig (resume vs rebuild vs fresh), enforces restore honesty, consults live continuity, or how restore-check / restore-packet readiness probes work. |
| `transport-and-transcripts.md` | The communication-and-history layer; tmux is transport, not truth. | You need how rig send/capture/broadcast works, pipe-pane transcript capture + search, durable SQLite chat, what `rig ask` gathers, or the MCP-tool-name vs tmux-metadata-key distinction. |
| `workspace-primitive.md` | PL-007: the typed declaration of where a rig's work lives. | You need how a rig declares a typed workspace (root/repos/defaultRepo/knowledgeRoot), how it persists/resolves into whoami / node-inventory, how per-item `target_repo` scope is validated, or how the file-backed missions/slices tree is indexed and projected into Project. |
| `content-surfaces.md` | The operator-allowlisted, filesystem-canonical read/write layer Project/Steering sits on. | You need how the file browser enforces path safety, how atomic conflict-checked writes + the JSONL edit audit work, how the PROGRESS.md tree is indexed, or how the one-screen Steering surface is composed. |
| `plugin-agent-image-context-pack.md` | Four filesystem-canonical content primitives the daemon discovers and serves. | You need how OpenRig discovers plugins, captures/forks agent images, assembles/sends context packs, or how the Claude auto-compaction enforcer decides to send `/compact`. |
| `packaging-bootstrap-bundles.md` | How a topology is packaged into a shareable bundle and reconstituted elsewhere. | You need how rig/pod bundles are assembled (schema-v2 vs legacy v1), how bundle create/inspect/install + `/api/up` route across source kinds, the staged BootstrapOrchestrator flow, or which legacy install-engine seams still ship. |
| `architecture-rules-and-event-system.md` | The cross-cutting invariants that belong to no single subsystem. | You need the 25 architecture rules + startup/import constraints, the shape of the RigEvent union and its SSE delivery, or the intentional compatibility limits that still describe the shipped system. |
| `living-notes-review.md` | **v0.4.4** — the one intent→plan→delivered review projection over on-disk SDLC markdown. | You need the `ComposedSliceReview` contract, the `/api/review/*` routes, staged-approval locks (`--scope spec|delivery`), the `## Proof contract` ↔ proof-artifact join and C1-bound `verified`, the freeze export, or ranged media serving for review evidence. |
### ui/
| Module | One-sentence summary | Reach for this when… |
|---|---|---|
| `shell-and-routing.md` | The shell-first, route-first, primitive-driven operator surface. | You need how the UI shell (rail / Explorer / center workspace / drawer / preview stack) is assembled, the actual route tree the shipped UI mounts, or how the shared detail drawer and event consumption work. |
| `topology.md` | The operator's live picture of host → rig → pod → seat. | You need how the topology surface is built — the host hybrid graph, the table/terminal views, the activity-ring / hot-potato visual language, terminal-preview popovers, and the navigation/overlay contracts. |
| `project-and-for-you.md` | The three operator-facing destination surfaces (For You / Project / Dashboard). | You need how the For-You attention feed (5-card classifier + verb actions), the Project workspace/mission/slice scope pages, and the Dashboard landing on the vellum brand system are built. |
| `library-specs-and-design-system.md` | The Library (`/specs`) destination + the design-system pointer. | You need how the Library UI is assembled — the spec/skills/plugins surfaces, the spec-review + spec-library + live-identity flows that feed it — or where the canonical visual/design-system spec lives. |
### root
| Doc | One-sentence summary | Reach for this when… |
|---|---|---|
| `cli-reference.md` | The full `rig` CLI surface, kept as one doc. | You need the exact rig CLI surface — command groups, subcommands, flags, JSON output, cross-host, coordination primitives. |
| `frontmatter-schema.md` | The frontmatter convention these docs follow + the as-built-unique field. | You are authoring or updating a doc under `docs/as-built/`. |
| `../DESIGN.md` | The canonical visual / brand / design-system spec (repo `docs/` root). | You need the visual system, brand identity, or design-system tokens. (Stays at root by design; pointer only.) |
## (c) Structural relationship diagram
Built from each module's `siblings` / `prerequisite-reads` frontmatter.
`daemon-core.md` is the spine (prerequisite for almost every architecture
module); `shell-and-routing.md` is the UI spine.
```
README.md (entry — prerequisite for all)
│
┌───────────────┴───────────────┐
▼ ▼
architecture/ ui/
│ │
daemon-core.md ◀── (prerequisite spine for the column below)
│ │ │ │
│ │ │ └──▶ adapters-and-runtimes.md
│ │ │ (five-method contract + resume honesty;
│ │ │ consumed by agent-spec startup orchestration)
│ │ └────────────────────────────┐
│ └──────────────┐ │
▼ ▼ ▼
coordination- agent-spec-and- transport-and-
primitive.md startup.md transcripts.md
│ │ │ │
▼ ▼ │ ▼
workflow- mission- │ lifecycle-snapshot-restore.md
runtime.md control.md │ ▲ (consumes persisted replay context)
│ │ │
└────┬─────┘ ├──▶ packaging-bootstrap-bundles.md
▼ │ ▲
architecture-rules- │ │ (0.3.x reusable starter-state cluster)
and-event-system.md │ ▼
(consumes all └──▶ plugin-agent-image-context-pack.md
PL-004/005 events)
workspace-primitive.md ──▶ content-surfaces.md
(PL-007 declaration) (filesystem read/write layer on top)
│ │
└──────────┬───────────────┘
▼ (UI counterpart)
ui/project-and-for-you.md
shell-and-routing.md ──▶ topology.md
│ └──▶ project-and-for-you.md ◀──▶ architecture/mission-control.md
└──────────────▶ library-specs-and-design-system.md ──▶ ../DESIGN.md (pointer)
```
Reading-order rule: open a module's `prerequisite-reads` first
(`README.md`, then `daemon-core.md` for most architecture modules;
`README.md` then `shell-and-routing.md` for most ui modules).
## (d) Fast lookup by use-case
| I need to know… | → see |
| Surface | Start here |
|---|---|
| How the daemon boots / `createDaemon` wiring / migration set / route mounts | `architecture/daemon-core.md` |
| The runtime-adapter contract / how a harness is launched or resumed in tmux | `architecture/adapters-and-runtimes.md` |
| Where Claude resume honesty lives / resumed-vs-fresh assessment | `architecture/adapters-and-runtimes.md` |
| Where queue closure is enforced / hot-potato contract / durable handoff | `architecture/coordination-primitive.md` |
| How workflow specs project on closure / transactional-scribe / watchdog policies | `architecture/workflow-runtime.md` |
| The Mission Control views/verbs / queue-observability / action audit | `architecture/mission-control.md` |
| AgentSpec/RigSpec types, profile resolution, startup layering, whoami/bind/adopt | `architecture/agent-spec-and-startup.md` |
| Snapshot/restore, restore honesty, restore-check / restore-packet probes | `architecture/lifecycle-snapshot-restore.md` |
| rig send/capture/broadcast, transcripts, durable chat, `rig ask`, MCP-name vs tmux-key | `architecture/transport-and-transcripts.md` |
| The workspace primitive (root/repos), `target_repo` scope gating, missions/slices indexing | `architecture/workspace-primitive.md` |
| The file browser path safety, atomic writes + edit audit, PROGRESS tree, Steering composer | `architecture/content-surfaces.md` |
| The composed slice/mission review (`/api/review/*`), staged-approval locks, proof artifacts + `verified`, freeze export, ranged media | `architecture/living-notes-review.md` |
| Plugin discovery / agent images / context packs / Claude auto-compaction enforcer | `architecture/plugin-agent-image-context-pack.md` |
| Bundle assembly, bundle install / `/api/up`, BootstrapOrchestrator, legacy install seams | `architecture/packaging-bootstrap-bundles.md` |
| The 25 architecture rules / RigEvent union / SSE delivery / compatibility limits | `architecture/architecture-rules-and-event-system.md` |
| The UI shell, the real route tree, the shared detail drawer | `ui/shell-and-routing.md` |
| The topology graph/table/terminal views, activity-ring / hot-potato visuals | `ui/topology.md` |
| The For-You attention feed, Project scope pages, the Dashboard landing | `ui/project-and-for-you.md` |
| The vellum brand system on the destination surfaces (0.3.1 brand identity) | `ui/project-and-for-you.md` (vellum brand = **0.3.1** per slice-00 0.3.0-GT seam (b); slice-00 §2 row 1 = vellum-primitives → brand-identity, row 2 = destination-model → polished-destinations) |
| The Library `/specs` UI + spec-review/spec-library/live-identity flows | `ui/library-specs-and-design-system.md` |
| The full `rig` CLI surface | `cli-reference.md` |
| The visual / brand / design-system spec | `../DESIGN.md` (repo `docs/` root) |
| Which frontmatter these docs use + the as-built-unique field | `frontmatter-schema.md` |
| Daemon process | `packages/daemon/src/index.ts`; `createDaemon()` in `packages/daemon/src/startup.ts`. |
| HTTP application | `createApp()` in `packages/daemon/src/server.ts`; route modules under `packages/daemon/src/routes/`. |
| State and migrations | `packages/daemon/src/db/connection.ts`, `packages/daemon/src/db/migrate.ts`, `packages/daemon/src/db/all-migrations.ts`. |
| CLI | `createProgram()` in `packages/cli/src/index.ts`; commands under `packages/cli/src/commands/`, HTTP in `packages/cli/src/client.ts`. |
| MCP | `createMcpServer()` in `packages/cli/src/mcp-server.ts`; stdio command in `packages/cli/src/commands/mcp.ts`. |
| Terminal UI | `packages/tui/src/main.ts`, `packages/tui/src/state.ts`, `packages/tui/src/render.ts`; HTTP in `packages/tui/src/daemon-client.ts`. |
| Web UI | `packages/ui/src/main.tsx`, `packages/ui/src/routes.tsx`. |
| npm assembly | `scripts/build-package.sh`, `scripts/rewrite-daemon-imports.mjs`, `packages/cli/package.json`. |
## (e) Source-root pointer table
The daemon's route handlers obtain domain services from request context. `startup.ts` constructs
those services, repositories and runtime adapters; `server.ts` wires them into the application.
CLI and TUI clients use the daemon's HTTP projections. Package boundaries and the development
versus bundled paths are described in [ARCHITECTURE.md](../../ARCHITECTURE.md).
Module → primary `packages/*/src/...` roots. This replaces the old per-file
`Exports:`/`Related:` dump: it points at the code, it does not transcribe it.
Source-grounding for each claim lives inside the module with file:line cites.
## Runtime and state modules
| Module | Primary source roots |
Each row names an existing module and a small set of source starting points. Directories group
related code; they are not an exhaustive dependency list.
| Question | Module | Source entry points |
|---|---|---|
| How are the daemon, database and routes assembled? | [Daemon core](architecture/daemon-core.md) | `packages/daemon/src/startup.ts`, `packages/daemon/src/server.ts`, `packages/daemon/src/db/`. |
| How do runtime launch, projection and resume work? | [Adapters and runtimes](architecture/adapters-and-runtimes.md) | `packages/daemon/src/domain/runtime-adapter.ts`, `packages/daemon/src/adapters/`, `packages/daemon/src/domain/native-resume-probe.ts`. |
| How do authored specs become launched agents with identity? | [Agent spec and startup](architecture/agent-spec-and-startup.md) | `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/startup-orchestrator.ts`, `packages/daemon/src/domain/whoami-service.ts`. |
| Where are durable work, delivery and handoff handled? | [Coordination](architecture/coordination-primitive.md) | `packages/daemon/src/domain/queue-repository.ts`, `packages/daemon/src/domain/stream-store.ts`, `packages/daemon/src/domain/inbox-handler.ts`, `packages/daemon/src/domain/outbox-handler.ts`. |
| How does a workflow retain progress and resume? | [Workflow runtime](architecture/workflow-runtime.md) | `packages/daemon/src/domain/workflow-runtime.ts`, `packages/daemon/src/domain/workflow-projector.ts`, `packages/daemon/src/domain/policies/`, `packages/daemon/src/routes/workflow.ts`. |
| How are queue observations and actions exposed? | [Mission control](architecture/mission-control.md) | `packages/daemon/src/domain/mission-control/`, `packages/daemon/src/routes/mission-control.ts`. |
| What is captured and checked during restore? | [Lifecycle, snapshot and restore](architecture/lifecycle-snapshot-restore.md) | `packages/daemon/src/domain/snapshot-capture.ts`, `packages/daemon/src/domain/restore-orchestrator.ts`, `packages/daemon/src/routes/restore-check.ts`. |
| How do messages and transcript reads reach sessions? | [Transport and transcripts](architecture/transport-and-transcripts.md) | `packages/daemon/src/domain/session-transport.ts`, `packages/daemon/src/domain/transcript-store.ts`, `packages/daemon/src/routes/transport.ts`. |
| Where do workspaces, projects and repository scope resolve? | [Workspace](architecture/workspace-primitive.md) | `packages/daemon/src/domain/workspace/`, `packages/daemon/src/domain/current-work.ts`, `packages/daemon/src/routes/projects.ts`. |
| How are files, progress and steering read or written? | [Content surfaces](architecture/content-surfaces.md) | `packages/daemon/src/domain/files/`, `packages/daemon/src/domain/progress/`, `packages/daemon/src/domain/steering/`. |
| How are review inputs, proof and approvals composed? | [Living notes and review](architecture/living-notes-review.md) | `packages/daemon/src/domain/review/`, `packages/daemon/src/domain/scope/`, `packages/daemon/src/routes/review.ts`. |
| Where are plugins, agent images and context packs discovered? | [Plugins, images and context packs](architecture/plugin-agent-image-context-pack.md) | `packages/daemon/src/domain/plugin-discovery-service.ts`, `packages/daemon/src/domain/agent-images/`, `packages/daemon/src/domain/context-packs/`. |
| How are reusable bundles assembled and installed? | [Packaging, bootstrap and bundles](architecture/packaging-bootstrap-bundles.md) | `packages/daemon/src/domain/pod-bundle-assembler.ts`, `packages/daemon/src/domain/bootstrap-orchestrator.ts`, `packages/daemon/src/routes/bundles.ts`, `packages/daemon/src/routes/up.ts`. |
| Where are event types, publication and SSE handled? | [Architecture rules and events](architecture/architecture-rules-and-event-system.md) | `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/event-bus.ts`, `packages/daemon/src/routes/events.ts`. |
For an execution projection, also read `packages/daemon/src/domain/execution-view.ts` and
`packages/daemon/src/domain/view-projector.ts`. The terminal-side consumer starts in
`packages/tui/src/execution/`. These sit alongside the queue and workflow sources above.
## Change and verification guides
| Need | Read |
|---|---|
| `architecture/daemon-core.md` | `packages/daemon/src/{startup.ts,server.ts,index.ts}`, `packages/daemon/src/db/migrations/`, `packages/cli/src/index.ts` |
| `architecture/adapters-and-runtimes.md` | `packages/daemon/src/domain/runtime-adapter.ts`, `packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,terminal-adapter}.ts`, `packages/daemon/src/domain/{native-resume-probe,resume-metadata-refresher,codex-thread-id}.ts` |
| `architecture/coordination-primitive.md` | `packages/daemon/src/domain/{stream-store,queue-repository,queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts`, `packages/daemon/src/routes/{stream,queue}.ts` |
| `architecture/workflow-runtime.md` | `packages/daemon/src/domain/{workflow-projector,workflow-runtime,workflow-instance-store,workflow-spec-cache,workflow-step-trail-log,workflow-validator}.ts`, `packages/daemon/src/domain/policies/workflow-keepalive.ts`, `packages/daemon/src/routes/workflow.ts` |
| `architecture/mission-control.md` | `packages/daemon/src/domain/mission-control/`, `packages/daemon/src/middleware/auth-bearer-token.ts`, `packages/daemon/src/routes/mission-control.ts`, `packages/daemon/src/db/migrations/037_mission_control_actions.ts` |
| `architecture/agent-spec-and-startup.md` | `packages/daemon/src/domain/{agent-manifest,rigspec-schema,profile-resolver,startup-orchestrator,whoami-service,claim-service}.ts`, `packages/daemon/src/routes/{rigspec,whoami}.ts` |
| `architecture/lifecycle-snapshot-restore.md` | `packages/daemon/src/domain/{restore-orchestrator,snapshot-capture,snapshot-repository,checkpoint-store}.ts`, `packages/daemon/src/routes/restore-check.ts`, `packages/cli/src/commands/restore-packet.ts` |
| `architecture/transport-and-transcripts.md` | `packages/daemon/src/domain/{session-transport,transcript-store,history-query,ask-service,chat-repository}.ts`, `packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`, `packages/cli/src/mcp-server.ts` |
| `architecture/workspace-primitive.md` | `packages/daemon/src/domain/workspace/`, `packages/cli/src/commands/config-init-workspace.ts`, `packages/daemon/src/db/migrations/{038,039}*`, `packages/ui/src/routes.tsx` |
| `architecture/content-surfaces.md` | `packages/daemon/src/domain/{files,progress,steering}*`, `packages/daemon/src/routes/{files,progress,steering}.ts`, `packages/ui/src/routes.tsx` |
| `architecture/living-notes-review.md` | `packages/daemon/src/domain/review/{types,compose,gather,freeze,brief-spine}.ts`, `packages/daemon/src/routes/{review,files,slices}.ts`, `packages/cli/src/commands/{scope,proof}.ts`, `packages/ui/src/components/review/` |
| `architecture/plugin-agent-image-context-pack.md` | `packages/daemon/src/domain/plugin-discovery-service.ts`, `packages/daemon/src/domain/{agent-images,context-packs}/`, `packages/daemon/src/domain/claude-compaction-enforcer.ts` |
| `architecture/packaging-bootstrap-bundles.md` | `packages/daemon/src/domain/{pod-bundle-assembler,bootstrap-orchestrator,bundle-*,package-*,install-*}.ts`, `packages/daemon/src/routes/{bundles,up}.ts` |
| `architecture/architecture-rules-and-event-system.md` | `packages/daemon/src/domain/types.ts` (RigEvent union), `packages/daemon/src/routes/{stream,queue}.ts` (SSE watch), `packages/daemon/src/server.ts` (`/api/events`) |
| `ui/shell-and-routing.md` | `packages/ui/src/routes.tsx`, `packages/ui/src/components/AppShell.tsx` |
| `ui/topology.md` | `packages/ui/src/components/topology/`, `packages/ui/src/lib/{graph,hybrid,multi-rig}-layout.ts` |
| `ui/project-and-for-you.md` | `packages/ui/src/routes.tsx`, `packages/ui/src/components/dashboard/vellum/` |
| `ui/library-specs-and-design-system.md` | `packages/ui/src/components/specs/`, `packages/daemon/src/domain/{spec-review-service,spec-library-service}.ts`, `../DESIGN.md` (pointer) |
| `cli-reference.md` | `packages/cli/src/index.ts`, `packages/cli/src/commands/*` |
| Contributor setup and a small change recipe | [CONTRIBUTING.md](../../CONTRIBUTING.md), [ARCHITECTURE.md](../../ARCHITECTURE.md). |
| What depends on a sensitive source path | [arteries.md](arteries.md). |
| Unit, route, scenario, package and CI coverage | [test-layers.md](test-layers.md), `packages/test-system/README.md`, `.github/workflows/tests.yml`. |
| Exact CLI syntax | [cli-reference.md](cli-reference.md), then `packages/cli/src/index.ts`, its command module and current command help. |
| As-built metadata | [frontmatter-schema.md](frontmatter-schema.md). |
| An older architecture link | [architecture.md](architecture.md), which redirects into this module tree. |
| Visual specification | [DESIGN.md](../DESIGN.md). |
> No per-file flat dump. File-level detail belongs in the code; this codemap
> points at the source root and the module that explains it. The per-file
> index anti-pattern (old codemap) is retired, not relocated.
## Existing web UI references
These historical documents are still indexed; this pass checks their locations, not their
current implementation claims. Consult each page's stamp.
| Reference | Source area |
|---|---|
| [UI compatibility pointer](ui.md) | `packages/ui/src/`. |
| [Shell and routing](ui/shell-and-routing.md) | `packages/ui/src/routes.tsx`, `packages/ui/src/components/AppShell.tsx`. |
| [Topology](ui/topology.md) | `packages/ui/src/components/topology/`. |
| [Project and For You](ui/project-and-for-you.md) | `packages/ui/src/routes.tsx`. |
| [Library/specs and design system](ui/library-specs-and-design-system.md) | `packages/ui/src/components/specs/`. |
+52 -44
View File
@@ -1,65 +1,73 @@
---
kind: as-built
title: As-Built Frontmatter — Pointer + As-Built-Unique Field
title: As-Built Frontmatter
status: active
topics: [knowledge-and-context, frontmatter]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Authoring or updating a doc under docs/as-built/. Tells you which
frontmatter convention governs these docs and the one field that is
unique to as-built docs.
siblings: [README.md]
prerequisite-reads: [README.md]
last-verified-against-source: 7eaf524c
last-updated: 2026-05-16
Writing or refreshing a source-grounded document under docs/as-built/.
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
---
# As-Built Frontmatter — Pointer + As-Built-Unique Field
# As-Built Frontmatter
Every doc under `docs/as-built/` carries YAML frontmatter. The shape is
**not** defined here. It is the substrate-wide context-frontmatter
convention; this doc only points to it and documents the one field that
is unique to as-built docs.
Source snapshot: `254122872cf477511514979a4300b695d77cd1f7`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
## Canonical convention (do not duplicate — read it)
Frontmatter makes an as-built page discoverable and records the source it
was checked against. This page describes the convention visible in this
repository. It does not require an unpublished schema or an internal
workspace to interpret the fields.
`openrig-work/conventions/frontmatter-for-context/README.md` is the
authoritative schema: required floor (`kind` + `title` + `status` +
`applies-when`), recommended fields (`topics`, `domains`, `siblings`,
`prerequisite-reads`), and the controlled vocabularies for `kind:`,
`topics:`, and `domains:`. Author against that convention. This doc adds
nothing to it except the field below.
## Fields
## `kind: as-built`
The corpus uses these fields; see the [module index](README.md) and
[CLI reference](cli-reference.md) for examples.
These docs use `kind: as-built`. That value is in the convention's
controlled `kind:` vocabulary (first row of its "Controlled vocabulary —
`kind:`" table, source `openrig/docs/as-built/*`). Using it is conformant,
not an extension.
| Field | Meaning |
|---|---|
| `kind: as-built` | Identifies a description of implemented behavior. |
| `title` | Human-readable subject of the page. |
| `status` | Document status, such as `active`; not a daemon or feature status. |
| `applies-when` | Short retrieval cue explaining when to open the page. |
| `topics`, `domains` | Lists of indexing labels used in this corpus. They do not select permissions or runtime behavior. |
| `siblings`, `prerequisite-reads` | Related pages and reading order, when useful. Paths are relative to the containing page. |
| `last-verified-against-source` | A resolvable Git commit whose source was used to check the page. |
| `last-updated` | Date of the document update, in `YYYY-MM-DD` form. |
## `last-verified-against-source: <sha>` (as-built-unique)
## Verification stamp
As-built docs describe the running system. They drift when the source
drifts. The as-built-unique field records the exact commit the doc was
last verified against:
Use a commit ID, preferably full length, rather than a branch name or a
moving `HEAD` label. A source stamp identifies what was read; it is not proof
that an installed package or a running system uses that source. Keep runtime
observations separately qualified.
```yaml
last-verified-against-source: 7eaf524c
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
last-updated: 2026-10-03
```
- **Value:** the short SHA of the source HEAD the doc was verified
against at its last edit. Pair with `last-updated: <iso-date>`.
- **Why it is here and not in the convention:** the convention is
substrate-wide; most context docs do not track a source SHA. This
field is meaningful only for docs that mirror code, i.e. as-built
docs. It is the per-module drift-detection anchor the
context-architecture-v1 mission dogfoods on its own production.
- **Discipline:** when re-verifying a module against a newer HEAD,
bump this field (and `last-updated`) and carry an inline
`re-confirmed <file:line> @HEAD` annotation on each load-bearing
claim that was re-checked.
Check that the commit resolves with `git cat-file -t <commit>` in a checkout
containing that history. Verify the paths, symbols, and numeric claims cited
by the page at that commit before changing its stamp. A newer date alone
does not refresh the content. Missing history in a shallow checkout and an
invalid commit ID are different conditions.
## See also
Use repository links and named symbols for source references. For numeric
inventories, state what is counted and how it was obtained; for example, the
CLI reference distinguishes registered command objects from aliases and
Commander-generated help.
- `openrig-work/conventions/frontmatter-for-context/README.md` — the canonical schema
- `README.md` — the as-built corpus map of territory
## What the repository guard checks
[`scripts/check-docs-guard.mjs`](../../scripts/check-docs-guard.mjs)
checks allowed tracked documentation paths. Its `findBlockedDocsPaths`
function does **not** validate this frontmatter or prove the prose matches
source. Passing that guard should not be reported as a schema or factual
accuracy check.
[As-built pages](README.md) and [operator reference pages](../reference/help.md)
serve different purposes. `docs/reference/` is staged by
[`scripts/build-package.sh`](../../scripts/build-package.sh); placing content
there changes what is distributed in the package.
+499
View File
@@ -0,0 +1,499 @@
---
kind: as-built
title: Test layers — what to run before you push, and what each layer proves
status: active
applies-when: |
You changed OpenRig and want to know which checks to run before opening a pull
request, what each check actually proves, and what CI will run for you. Also
read it before claiming a stub-agent scenario covers a behaviour.
siblings: [arteries.md, README.md, codemap.md]
last-verified-against-source: 1347d825
last-updated: 2026-10-02
---
# Test layers: what to run before you push, and what each layer proves
> **This page is a map, not the territory.** It was checked against source at `1347d825`
> and it will drift. It is also incomplete on purpose. Before you rely on a command or a
> claim here, read the script it names or run it. If a behaviour is missing from this page
> (or from [arteries.md](arteries.md)), that tells you nothing about whether a change to it
> is safe.
## Short version
For every change, run what the pull-request template asks for:
```sh
npm run build
npm run lint # typecheck only; there is no formatter or style linter
npm test # = npm run test:repo && npm run test:workspaces
```
Then add the layers your change calls for, using the ladder below. CI also runs a
network-denied macOS test pass and a containerised installed-package scenario on every PR
to `main`. In the PR description, say which layers you ran and which you could not run.
## The ladder
| Layer | Command | What it proves | What it cannot prove | When to run it |
|---|---|---|---|---|
| Typecheck | `npm run lint` | Builds the daemon, then `tsc --noEmit` passes for daemon, ui, cli and tui | Any runtime behaviour. Formatting. | Every change |
| Build | `npm run build` | All four workspaces compile (the UI also runs `vite build`) | That the *published* package is complete (see Packaging) | Every change |
| Repo checks | `npm run test:repo` | Builds the daemon, then runs `node --test --test-concurrency=1 scripts/*.test.mjs`, `scripts/check-docs-guard.mjs`, `mirror-skills --check` and `generate-context-packs --check` | Package behaviour | Every change. It matters most when you touch `scripts/`, `docs/` or skills. |
| Package suites | `npm run test:workspaces`. One package: `npm run test -w packages/cli`. One file, from the repo root: `npx vitest run packages/cli/test/<file>.test.ts` | Vitest unit and integration tests for daemon, cli and tui. Some of them start real scenario daemons, tmux sessions and stub runners. | The installed package. Real Claude Code or Codex. CI's sandbox conditions (see CI). | Every change (`npm test` covers it) |
| Web UI suite | `npm run test:ui` | jsdom unit tests for `packages/ui` | Real browser rendering | When you touch `packages/ui`, or a daemon API the UI reads. The web UI is in maintenance mode, so this suite is advisory locally and is not part of `npm test`. |
| Generated files | `npm run mirror-skills:check`, `npm run generate-context-packs:check` (both part of `npm test`) | Every copy of each shipped skill matches its recorded hash, and every static context pack passes the daemon's own manifest parser and the leak scan | Whether the skill content is any good | After you edit a shipped skill: edit the same file in every copy that carries it, run `node scripts/regen-edge-digests.mjs`, then the check. The full `npm run mirror-skills` apply needs maintainer-only inputs. See "A shipped skill" in [ARCHITECTURE.md](../../ARCHITECTURE.md#a-shipped-skill). |
| Local gate | `npm run gate` | Lint plus `npm test` on a clean tree, tied to `HEAD`, one run per machine at a time, with a JSON receipt | Anything that lint and `npm test` don't already prove | Optional. Useful when several agents share one machine, or when you want a receipt tied to a commit. |
| Packaging | `npm run build:package`, `npm run test:tui-package`, `bash scripts/smoke-fresh-install.sh` | The publishable `@openrig/cli` assembles, the packed TUI launches, and a fresh install starts a daemon | Behaviour beyond startup | When you touch dependencies, bin entries, build scripts, or files a package ships |
| Stub scenarios, host mode | `node --import tsx packages/daemon/scripts/run-scenarios.mjs [scenario.yaml …]` | A scenario's steps and assertions pass against a scratch daemon, real tmux and `runtime: stub` seats | The installed package. Seeded-regression pairing. Everything under [What a stub can and cannot prove](#what-a-stub-can-and-cannot-prove). | While you write or debug a scenario |
| Stub scenarios, installed package in a container | CI job `installed-scenario`. Before pushing: `DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh --remote …` | A clean Linux install starts. A claimed queue item survives a daemon restart. A planted fault is caught at the intended assertion. Two stub seats' scripted output reads back through `rig transcript` and `rig capture` (passing form only). | Native runtimes. Most command families (see [Help wanted](#help-wanted-command-families-without-a-behavioural-scenario)). | Changes that can affect daemon startup, packaging, queue durability or the scenario harness |
| Live-model evals | `npm run eval -w packages/daemon -- --provider rig …` | Whether a real Claude Code seat runs the expected `rig context get <entry>` for natural prompts | Determinism. Codex. Anything without a case. | Changes to shipped skills or context entries meant to steer agents. **Uses provider credits.** |
| Testbed runbooks | `docker/testbed/runbooks/` (manual) | The testbed image itself works as a test environment | Product behaviour | Only when you change the testbed image or the container plumbing |
### Gotchas that cost people time
- **Stale vendored daemon.** `npm run build:package` leaves an assembled copy of the daemon at
`packages/cli/daemon/` (gitignored). If you change daemon source afterwards,
`scripts/check-cli-daemon-freshness.test.mjs` inside `test:repo` can fail on the stale copy.
Re-run `npm run build:package` or delete `packages/cli/daemon/`. `npm run gate` deletes it for you.
- **Local passes, CI fails.** CI runs the package suites with a scratch `HOME`, no
credentials, and outbound network blocked except to localhost (see below). A test that reads
your real home directory, a running daemon, the network or a credential can pass on your
machine and fail in CI.
- **Docs location.** `check-docs-guard` fails if a tracked file under `docs/` sits outside
`docs/as-built/`, `docs/reference/` or `docs/releases/`. `docs/DESIGN.md` is the one allowed
exception.
## What CI runs on your pull request
`.github/workflows/tests.yml` runs on pull requests to `main` and on manual dispatch, all on
Node 22. Each suite is its own job, so one failure doesn't hide the others. No provider
credentials are supplied.
| Job | Runner | What it does |
|---|---|---|
| `build-and-package` | ubuntu | `npm ci` and `npm run build`. Then `npm pack --dry-run` for cli, daemon and tui, with each file list uploaded as the `pack-lists` artifact. The job only records the lists; reviewers read them. |
| `typecheck` | ubuntu | `npm run lint` |
| `repo-checks` | ubuntu | `npm run test:repo` |
| `package-tests` | macOS, matrix `daemon`, `cli`, `tui`, `ui` (fail-fast off) | Installs tmux, runs `npm ci` and `npm run build`. Then runs `npm run test -w packages/<pkg>` under `env -i` with a scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR` and a `sandbox-exec` profile that blocks outbound network except localhost and the job's own socket directory. `scripts/ci-macos-process-probe.mjs` runs first. |
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (eight container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
A second workflow, `.github/workflows/portability-report.yml` (job `portability-report`),
lists lines your PR adds that contain machine-specific values: credentials, home or temp
paths, network addresses, email addresses. It never fails because of a finding. To see the
matched text locally (credentials show only their first characters), run
`node scripts/portability-report.mjs`. By default it compares the
merge-base with `origin/main` against `HEAD`; pass `--staged` to check staged changes instead.
The UI suite is a CI matrix leg even though it is advisory locally. Check your PR's status to
see whether a red UI leg is treated as blocking. Branch-protection settings are not in the
repository.
## The local gate: `npm run gate`
`scripts/gate-lane.mjs` runs two legs: `npm run lint` and `npm run test`. It does not run
`test:ui`, the scenarios or the packaging checks. On top of those legs it:
- holds a machine-wide lock on localhost port 40404 (override with `OPENRIG_GATE_LANE_PORT`).
A second gate exits 2 straight away instead of waiting.
- refuses a dirty worktree. Refuses a `node_modules` that is a symlink, or `@openrig/*`
workspace links that resolve outside this worktree. Both are common with shared git worktrees.
- deletes the stale vendored daemon bundle at `packages/cli/daemon/` before the legs run.
- writes `gate-lane-verdict.json` at the repo root (or wherever `OPENRIG_GATE_VERDICT`
points). The file holds the `HEAD` SHA, per-leg results and durations, and a note of other
`node`/`vitest`/`tsc` processes running at the time. It fails if `HEAD` changes during the
run. The file is not gitignored, so don't commit it.
Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
`OPENRIG_GATE_LANE_SMOKE=1` skips the legs to test the wiring only, and the verdict records
`smoke: true`.
## Stub-agent scenarios
### What's in the box
| Piece | Where |
|---|---|
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `emit`, `mutate`, `policy` parse but are unbound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario library | `packages/test-system/scenarios/`: 15 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
| In-container entry and result check | `packages/test-system/ci/run.mjs`, `packages/test-system/ci/result.mjs` |
| PR orchestrator | `scripts/run-pr-scenarios.sh` |
| Image | `docker/testbed/Dockerfile`, built by `scripts/build-testbed-image.sh`; `docker/testbed/Dockerfile.scenarios` is layered on top by `scripts/run-pr-scenarios.sh` |
### Host mode: while you're writing a scenario
```sh
npm run build # the runner drives packages/cli/dist/bin-wrapper.js
node --import tsx packages/daemon/scripts/run-scenarios.mjs # the fixture scenario-*.yaml files
node --import tsx packages/daemon/scripts/run-scenarios.mjs packages/daemon/test/fixtures/scenarios/scenario-02-baton.yaml
```
- The runner creates a scratch `HOME`/`OPENRIG_HOME`, its own tmux server and its own
daemon. It builds the scenario environment from `HOME`, `PATH` and `TERM` only, so your
shell's daemon-target variables (`OPENRIG_URL`, `OPENRIG_PORT` and similar), `TMUX` and
`OPENRIG_TEST_CLOCK_NOW` are dropped rather than inherited. It does not touch the daemon or
tmux server your own session uses.
- It accepts YAML paths only. Any flag, including `--container`, is refused before anything runs.
- It supplies no fault controller, so any scenario with a `seed_regression` step fails loudly
at that step. That includes the library's `queue-baton-survives-restart`. Use the container
path to run the seeded pair.
- `scenario-10-one-view-state` declares its own normaliser for its cross-surface `equals`
check. Whether it passes in host mode has not been verified.
### Container mode: the scenario pack CI runs
In CI the `installed-scenario` job runs `scripts/run-pr-scenarios.sh` with no arguments:
both baton cases (`fixture` = `scenario-02-baton.yaml`, `library` = `queue-baton-survives-restart.yaml`),
each run three times as healthy, then lost-baton, then healthy. Then the two stub-script
cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
`capture-returns-addressed-seat.yaml`) run once each, healthy only. That is eight fresh
containers.
To run a case before you push, point `DOCKER_HOST` at a Docker engine you control over SSH:
```sh
npm ci
DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh \
--remote --case library --mode healthy --out dist/scenario-check
```
- Options: `--case fixture|library|transcript|capture` (default: all four), `--mode
healthy|lost-baton` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to `fixture` and `library`; `transcript` and `capture`
always run once, healthy, and `--mode lost-baton` leaves them out. Use a fresh `--out`
directory for each run you want to keep.
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
`DOCKER_CONTEXT` is set. The engine must be Linux on amd64 or arm64. The script reads the
*server's* platform, so a macOS/arm64 laptop driving a Linux/amd64 engine works.
- Without `--remote`, the script refuses to run anywhere except GitHub Actions on Linux.
- The source build and pack run on your machine. The image build and the containers run on
the engine. Building the image needs network access for the pinned base image, Node and
dependencies. The scenario containers have no network.
- The image is built from your working tree but tagged with `git rev-parse HEAD`. Commit
first, so the tag names what you actually tested.
- It never pushes images. It removes only the containers it named. If the SSH connection
drops, it prints the name of any container it could not remove.
What happens, in order:
1. `scripts/build-testbed-image.sh` packs `@openrig/cli` from your tree. It installs the
package into a digest-pinned Debian image with Node, checks `rig --version`, and checks
that the installed daemon loads. The result is tagged `openrig-testbed:<sha>`.
2. `docker/testbed/Dockerfile.scenarios` adds a bundled copy of
`packages/test-system/ci/run.mjs` plus the fixture and library scenarios.
3. Each run uses a fresh container: `--network none`, read-only root plus a 512 MB `/tmp`
tmpfs, `--cap-drop ALL`, `no-new-privileges`, non-root, 2 CPUs, 2 GiB, 256 PIDs and a
300-second deadline.
4. In lost-baton mode, the runner stops the daemon, changes exactly one queue row in the
scratch database from `in-progress` to `pending`, and restarts the daemon.
`packages/test-system/ci/result.mjs` then accepts only one outcome: a failure at the
post-restart queue assertion, with `baton-1` observed as `pending` at the expected
destination. A startup error, a timeout or any other failure does not count as a caught
regression. Healthy runs must pass with no fault armed.
5. The `transcript` and `capture` runs are passing form only: `result.mjs` accepts only a
clean PASS of the named scenario, with no seed or fault. Their planted failures are
product-code mutations, so they stay per-PR evidence and are not CI runs. Stub scripts
work here because the runner executes the pipeline in host mode inside the image.
A single healthy run proves that one leg only. The paired fault run is what shows the
assertion can catch the failure.
Adding a new case to this pack today means changing three files:
`scripts/run-pr-scenarios.sh` (the `--case` list), `CASES` in
`packages/test-system/ci/result.mjs`, and the fault controller in
`packages/test-system/ci/run.mjs`, which is currently specific to the baton row. A
passing-only case needs no fault controller: add it to `PASSING_CASES` and the `--case` list
in `scripts/run-pr-scenarios.sh`, and to `PASSING_CASES` in `result.mjs`.
### What a green stub scenario proves
- The packaged CLI installs into a clean Linux container and starts a daemon.
- `rig up` and `rig down` of a stub topology go through the real daemon, tmux, SQLite and CLI.
- Queue state, read through the shipped `rig queue list` path, survives a daemon restart.
- With the paired seeded run: the assertion fails when that state is broken.
### What a stub can and cannot prove
The stub does not fabricate product outputs. It runs the real daemon, tmux and CLI. But it is
not Claude Code or Codex, and it differs from them in ways that decide what a stub green
means:
| Area | What the stub does at `1347d825` | What that means for your test |
|---|---|---|
| Consuming a message | `stub-runner.ts` runs its launch script once and then idles. It has no stdin reader, socket or other input channel. The default script prints `[stub] scripted reply: acknowledged` at boot, before anything has been sent. | A pane showing a reply, or your echoed text, does not prove the message was consumed. No stub scenario can currently prove "delivered and answered". Separately, `rig send --verify` means "appeared in the pane", not acknowledgement (see `rig send --help`), and the scenario `send` step doesn't pass `--verify` at all. |
| Launch path | `StubRuntimeAdapter` types `node <stub-runner> …` into the pane (`tmux.sendText`, then Enter). Claude Code with an explicit permission mode launches through a managed launch (`ClaudeManagedLaunch.prepare`, `tmux.sendShellCommand`); without one it uses `sendText`. The shell-foreground check in `session-transport.ts` (`unverifiedShellForeground`) runs for every runtime except `terminal`, but its native-process proof is limited to `codex` and `claude-code`. | A stub seat doesn't exercise wrapper, managed-launch or native-process-identity behaviour (the class behind #197). Wrapping the stub in a shell wouldn't change that. |
| Permissions | `validateNativePermissionSelection` accepts only `codex` and `claude-code`. The stub models only the `floor` / `full_bypass` launch posture. | Testing Claude permission modes or per-seat permission selection needs a real runtime. |
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
| Step-time restore, compaction, mutation, policy | `restore`, `emit`, `mutate` and `policy` parse, but the runner throws `UnboundActionError` when it reaches them. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects into `<cwd>/.claude/skills/`. | A stub green says nothing about provider-specific projection (the class behind #159). |
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
The most useful next pieces of harness work, roughly in dependency order:
1. A stub that consumes input and answers a fresh nonce.
2. A model-free test entry point shaped like a provider, that goes through the real native
adapter and managed launch.
3. Lifecycle and identity steps (tmux reset, archive/remove).
4. Then pickup, wake and restore scenarios.
### Library status
Three scenarios run in CI: `queue-baton-survives-restart` (since #243) with its seeded pair,
and `transcript-reads-addressed-seat` and `capture-returns-addressed-seat` in passing form
only. Two more, `down-stops-every-seat` and `send-renders-in-addressed-pane`, run with the
local runner but not in CI. Those four later scenarios are not in this table; see "Status
today" under [Help wanted](#help-wanted-command-families-without-a-behavioural-scenario). The
other ten scenarios are authored but not yet runnable as meaningful checks:
| Scenario | Why it doesn't run as a check yet | Smallest useful next step |
|---|---|---|
| `queue-baton-survives-restart` | Runs in CI: healthy / seeded `baton-drop` / healthy | Extend it to handoff, block, resolve and unclaim |
| `clean-lifecycle-no-residue` | The post-`down` "nothing left over" assertion is commented out, because the format has no way to say "empty" | Add a real absence assertion |
| `ps-scope-honesty` | The second `up` reuses the first topology, and the expected match tolerates the other rig | Make `up` honour the per-step topology, then assert the exclusion |
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
| `compaction-restore-resumes-role` | Step-time `emit` is unbound | Input-triggered hooks plus evidence specific to one seat |
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
| `policy-posture-survives-structural-edit` | `policy` and `mutate` are unbound | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | `policy` and `restore` are unbound | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`), and `mutate` is unbound | Declare the comparable fields and real mutations |
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
In most of these files, `seed_regression` steps that come *after* the assertions are
historical markers, not executable fault injections.
## If your change touches an artery, add or extend a scenario (or say why not)
[arteries.md](arteries.md) lists the behaviours that everything else depends on. If your
diff can affect one, your PR description should do one of these:
- link the scenario that covers it, and say which layer you ran it in;
- add or extend a scenario. For a regression scenario, show that the fixed code passes and
that the planted old defect fails *at the intended assertion*. A setup crash or a timeout
doesn't count;
- or say precisely why the stub can't exercise it. Use the table above, which covers
message consumption, native launch and permissions, tmux reset and so on. Then say what
you ran instead, for example a manual run with the real runtime.
This is part of normal review, not an extra approval step. It also isn't a claim that
changes outside the arteries map are safe.
## Live-model evals
`packages/test-system/evals/` is a separate check from scenarios. A real seat receives a
natural prompt and decides what to do. A case passes when the seat's transcript shows the
expected `rig context get <entry>` command, in the expected order. Cases live in
`cases/selection.yaml` (10) and `cases/loading.yaml` (4).
```sh
# Harness check: a fake provider fed a JSON map of prompt -> transcript.
# Any prompt without a transcript counts as an error, never as a pass.
npm run eval -w packages/daemon -- --transcripts <transcripts.json> --out <result.json>
# Live run: drives a real Claude Code seat
npm run eval -w packages/daemon -- --provider rig --seat <session> --out <result.json>
npm run eval -w packages/daemon -- --provider rig --seat-spec "$PWD/<one-seat-rig.yaml>" --out <result.json>
```
- **Live runs use provider credits, and the results vary from run to run.** CI never runs them.
- `--provider rig` needs `OPENRIG_HOME` set, and it supports Claude Code seats only. It
refuses a Codex seat.
- `--seat-spec` runs `rig up` of a scratch rig against **your current daemon**, then
`rig down` at the end. `--seat` attaches to an existing seat and leaves it running.
- `evals/rig-seat/example-scratch-rig.yaml` sets `permission_policy: builtin:yolo` and
`cwd: "."`. It also references `local:agents/blank`, and no such agent directory ships
beside it. Read the file and expect to adapt it before you use it.
- Before any run, the runner builds the production context package in a temp directory and
refuses if any case's entry doesn't resolve in it.
- `npm test` covers the harness without a model: the case schema, the grader, the fixtures
and production entry resolution (`packages/daemon/test/eval-*.test.ts`).
## Docker testbed runbooks
`docker/testbed/runbooks/` holds manual procedures, L0 to L6, that you run on a machine with
Docker. They check the testbed image itself:
- L0: resolve the pinned base image and the stub asset list
- L1: PTY allocation
- L2: tmux server lifecycle
- L3: the daemon inside a container, settling a stub rig
- L4: the scenario environment still refuses a foreign daemon target inside a container
- L5: several containers acting as named hosts on one Docker network
- L6: the scenario runner driving a scenario through a container
Use them when you change `docker/testbed/`, `scripts/build-testbed-image.sh` or the
container staging helpers. Ordinary PRs don't need them. They were written before the CI
scenario job and contain internal planning references, so treat them as procedures to adapt,
not as current status.
## Help wanted: command families without a behavioural scenario
> This list may be stale. It was taken from the `rig --help` tree of 0.6.3: 85 visible
> top-level families. At `1347d825`, `packages/cli/src/index.ts` registers 85 top-level
> commands. Before picking one up, check `packages/test-system/scenarios/`, the cases in
> `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.
> They exist, but this list is about behavioural scenarios.
Status today: `queue` has partial coverage, namely create, claim and list, plus the claim
surviving a restart. `down` has partial coverage in
`packages/test-system/scenarios/down-stops-every-seat.yaml`: after `rig down`, `rig ps` reports
every seat stopped and the rig still listed. `send` has partial coverage in
`send-renders-in-addressed-pane.yaml` beside it: the envelope and the message body render in
the addressed seat's pane. `transcript` has partial coverage in
`transcript-reads-addressed-seat.yaml`: each of two seats' scripted output is readable through
that seat's `rig transcript --tail 200 --json`. `capture` has partial coverage in
`capture-returns-addressed-seat.yaml`: in a rig whose member names overlap by prefix (`impl`,
`impl2`; `dev-impl`, `dev-impl2` with the pod), `rig capture <seat>` returns each seat's own
pane. The scenario catches a capture that resolves the seat by a prefix match on its member
name (so `dev-impl` also matches `dev-impl2`) and takes the first hit, or any resolver that
returns the same pane for both seats; it does not exercise a prefix match on the full seat
name. All four run with the local runner (`run-scenarios.mjs`). The transcript and capture
scenarios also run in the CI job, in passing form only; the down and send scenarios don't.
They do not prove that panes are gone or that a seat consumed a message. The transcript
case does not establish ordering, uniqueness, sibling-token absence, tail limits or restart
persistence. `up` and `daemon` are used by the existing scenarios but have no scenario of their
own. Everything else has none.
Before you start, two honest constraints:
- Today's step verbs can't run arbitrary `rig` commands. Many rows below need a runner
binding first. One proposal (not implemented) is a single structured-argv step that runs
the installed CLI through the existing `rigBin`, instead of one new verb per command.
- Rows that need a seat to *answer* (anything with "nonce") depend on the input-consuming
stub described above.
- `expect` has no negative, absence or count form. `match` is a structural subset and
`contains` is a substring, so "the sibling received nothing", "the panes are gone" or "no
duplicates" can't be asserted yet. An exact field value, such as `runningCount: 0` on the
`ps` surface, is the closest available form.
- The `restart` step runs `rig launch <rig> <node>` with no flags, and the node is the logical
ID (for example `dev.qa`). A stub seat has no resume token, so relaunching it after `down`
stops at `awaiting-decision` and asks for `--fresh`. The `launch` row needs that flag passed
through first.
| Group | Family | Proposed first check (observable result) |
|---|---|---|
| lifecycle | `start` | Restore two stopped stub seats. Both answer distinct fresh nonces, and no second daemon starts. |
| lifecycle | `daemon` | start/status/stop/restart. Port, process and persisted queue identity agree through public commands. |
| lifecycle | `bootstrap` | Bootstrap a minimal stub spec. Seats come up ready and the selected spec is the one used. |
| lifecycle | `up` | Launch two stub seats. Each consumes its own send. Relaunching doesn't duplicate identities. |
| lifecycle | `down` | (partial) Next: its panes are gone, a sibling rig still answers, retained data follows the docs. |
| lifecycle | `create` | Create a one-seat rig through the public path. It appears and answers without a hand-written topology. |
| lifecycle | `launch` | Fresh, relaunch, resume and fork on provider-shaped fixtures. Correct restored token, one live process. |
| 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 | `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. |
| continuity | `agent-image` | create/show/pin/unpin/delete for a provider-shaped fixture. References and identity match. |
| continuity | `fork` | Fork at a known history token. The child keeps the prefix with its own identity, and the parent still answers. |
| continuity | `restore-check` | One restorable and one missing transcript. Readiness tells them apart without relaunching. |
| continuity | `restore-packet` | write/read/validate round trip. A malformed packet fails clearly. |
| continuity | `compact-plan` | Scripted history and usage fixtures. Candidates and exclusions match thresholds, and nothing is compacted. |
| continuity | `compact` | Input-driven compact and restore keep the role and token. Messages before and after reach the same seat. |
| continuity | `handover` | Scripted predecessor and successor. Context is delivered before the rebind, and the successor answers. |
| recovery | `crash-cart` | Stop only the scratch daemon. The output names paths and state, and the command changes nothing. |
| recovery | `preflight` | Ready and missing-dependency fixtures produce named checks without launching seats. |
| recovery | `doctor` | Healthy and damaged scratch installs. Exact diagnosis, no automatic repair. |
| recovery | `destroy` | In a disposable scenario HOME only. Removes the intended state and keeps explicitly retained markers. |
| registry | `gateway` | Add, show, update and remove a synthetic human. Reads match exact fields, and removal sticks. |
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
| coordination | `workflow` | validate/compile/instantiate a tiny flow. It transitions and resumes after a restart with no duplicate queue work. |
| coordination | `chatroom` | send/history/topic/clear in a scratch room. A bounded watcher sees exactly the ordered messages. |
| coordination | `heartbeat` | Known execution-proof and missing-closure fixtures report the right status. Empty output isn't completion. |
| authoring | `export` | Export a stub rig, then validate and re-import it. Membership and configuration are equivalent. |
| authoring | `import` | Import a minimal spec, and the export matches it. Malformed input leaves state unchanged. |
| authoring | `package` | validate/plan/install/rollback in scratch. Installed files and prior state match. |
| authoring | `requirements` | Complete and missing capability fixtures produce the right named requirements. |
| authoring | `bundle` | create/inspect/install a local bundle and round-trip it. A missing asset gives a named failure. |
| authoring | `agent` | Validate a good and a malformed stub AgentSpec. The diagnostic names the bad field. |
| authoring | `spec` | validate/audit/preflight/show a minimal spec, with predictable diagnostics for a malformed field. |
| authoring | `specs` | add/show/preview/rename/remove a library fixture. A missing reference fails explicitly. |
| authoring | `workspace` | validate/doctor known roots plus one missing-frontmatter case. Reports the exact affected path. |
| authoring | `config` | set/get/reset a scratch key and init a workspace. Readback and files match the requested root. |
| presentation | `ui` | Open the URL through a fake browser executable and read a local UI route. Rendering is out of scope. |
| presentation | `tui` | Drive the TUI command bar and socket over two stub rigs. select, mutate and back show exact identities. |
| presentation | `terminal` | open/status/views against a fake terminal-app endpoint. Correct target and layout arguments. |
| adoption | `discover` | One unmanaged and one owned scratch tmux session. Only the intended candidate is discovered. |
| adoption | `attach` | Attach to the intended seat. `whoami` reports it without duplicating the process. |
| adoption | `bind` | Bind one discovered pane. A different, already-occupied identity is refused. |
| adoption | `adopt` | Adopt two scratch sessions. PIDs and history are preserved, and the identities become addressable. |
| adoption | `reconcile-session` | Rebind a hand-resumed provider-shaped fixture without starting or typing into another process. |
| adoption | `unclaim` | Release an adopted seat. The tmux process and its history stay alive. |
| adoption | `release` | Release one rig's claimed sessions. Processes survive, and a sibling rig is unchanged. |
| identity | `archive` | Archive an old same-name generation. The current one stays routable, and the old data is kept. |
| identity | `unarchive` | Unarchive a retained generation without taking over another live seat. |
| identity | `remove` | Remove an archived generation or one member. Only that identity goes, and a reused name still routes. |
| identity | `whoami` | Inside each stub seat, it returns that seat, not a sibling or the caller. |
| identity | `seat` | Status, held-message guard, stop/launch and resume token keep the right process and permissions. |
| integration | `mcp` | Serve against a scratch daemon. One listed tool's value matches CLI state. |
| integration | `slack` | Manifest and config round trip, plus deterministic inbound and outbound fixture requests. No real Slack account. |
| integration | `ask` | Search fixture transcripts via a fake model endpoint. Citations resolve to the stored lines. |
| integration | `file` | Copy a fixture payload through isolated transport endpoints. Verify bytes and paths. |
| integration | `env` | A fixture service-backed rig. status/logs/down reflect that service and stop only it. |
| delivery | `transcript` (partial) | Own scripted output from two addressed seats is covered by `transcript-reads-addressed-seat.yaml`. Remaining: ordering, uniqueness, no other seat's token, tail limits and restart persistence; absence/count assertions need a runner binding. |
| delivery | `send` | (partial) Next: send a fresh nonce and require an answer derived from it. Boot text or echo can't pass. |
| delivery | `capture` | (partial) Each seat's own token, under prefix-overlapping member names. Next: the not-found, tail (`--lines`), sibling-absence and `--rig`/`--pod` cases, which need an error form, a tail option, an absence form and a multi-target surface in the runner. |
| delivery | `broadcast` | Broadcast a nonce to a rig or pod. Each intended seat answers once, and an excluded seat gets nothing. |
| delivery | `walk` | Walk two pieces through an input-consuming stub. Order is kept, and a bad piece isn't reported as delivered. |
| context | `context` | add/get/compose/trace/profile over a fixture project. Output bytes, order and roots match. |
| context | `plugin` | list/show/used-by/validate a fixture plugin. A missing resource is not the same as a valid empty selection. |
| context | `skill` | Resolve and audit a fixture skill, then inspect the projection the consumer actually gets. |
| context | `mode` | set/effective/cite/unset at nested scopes. Precedence holds, and the inherited value returns after unset. |
| context | `startup-proof` | Submit a proof from a real stub identity. Check the stored attribution and the duplicate behaviour. |
| permissions | `policy` | Apply and read an explicit permission selection using a provider-shaped fixture. It survives a restore, and the default is unchanged. |
| accounts | `auth` | Synthetic auth files in scratch: save, list, validate, switch. No provider contact, no real credentials. |
| accounts | `provider` | Synthetic account, binding and signal state. The precheck refuses where it should, and a switch keeps seat history. |
| topology | `expand` | Add a pod to a live stub rig. Old seats survive, and new members answer nonces. |
| topology | `add` | Add one seat. Exact binding, siblings preserved, and the duplicate-name behaviour is explicit. |
| topology | `grow` | Grow two seats. Identities are unique, config is inherited, and existing history is kept. |
| topology | `shrink` | Remove a pod. Unrelated seats still answer, and the removed membership is gone. |
| onboarding | `setup` | Scripted newcomer answers against synthetic providers. No install or login for an unused provider. |
| work | `scope` | Create a mission and a slice. move/progress/notes/graph readback keep identity and paths. |
| work | `proof` | add/judge/show evidence in a fixture scope. Readiness updates without overwriting earlier proof. |
How to make a case useful:
- Assert actual state or content, not just exit code 0. Where ordinary use includes bad
input, add an invalid or missing input case too.
- Reuse the existing runner and the installed CLI. Don't add a second scenario engine.
- If a scenario exposes an existing product bug, keep the failing case and open an issue.
Include the exact source revision, the shortest failing sequence, and the intended versus
observed result. Don't weaken the assertion to make it pass. Keep "the harness setup
failed" separate from "the product misbehaved".
- A check that only covers the success path is still useful. Just don't call it a
regression detector until you have seen its negative case fail.
## Existing docs in this area that are out of date
Read these with care. This page notes them and proposes no changes.
- `packages/test-system/README.md`: says the scenarios "DO NOT run" until a runner lands.
The runner exists, and three library scenarios run in CI. Its layout lists `fixtures/*.yaml`,
a directory that no longer exists (the topologies live in `scenarios/`). It also says the
emit behaviours don't exist yet. Launch-script `emit` now exists, but step-time `emit` is
still unbound. `packages/test-system/ci/README.md` is the current description of the CI
scenario job.
- `packages/test-system/scripts/README.md` and `packages/test-system/ROUTING.md` are
dependency-routing notes from when the scenarios were first written. Several of the items
they list have since landed. Treat them as history.
- `packages/test-system/evals/README.md`: says cases are `cases/*.ts`. They are
`cases/selection.yaml` and `cases/loading.yaml`. Its "build in progress" status line is old.
- `docs/reference/developing.md`: says "There is no external CI at this tip". In fact
`.github/workflows/tests.yml` runs on every PR to `main`, and its `package-tests` matrix
includes `ui`. Its description of `test:repo` leaves out the `generate-context-packs --check` step.
- `docker/testbed/runbooks/`: see above. They are manual procedures that predate the CI job.
+36 -1
View File
@@ -269,7 +269,7 @@ Each array contains resource IDs. These can be:
- **Unqualified** (`vault-user`) — resolves against the spec's own `resources` first, then imported specs
- **Qualified** (`shared:openrig-user`) — resolves against a specific imported spec's resources
The `uses` categories are: `skills`, `guidance`, `subagents`, `hooks`, `runtime_resources`.
The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `hooks`, `runtime_resources`.
---
@@ -299,6 +299,40 @@ resources:
Resources are the available pool. They are NOT automatically delivered to agents — profiles select them via `uses`. The only resources delivered are those that the active profile's `uses` block references.
### Plugin paths
A local plugin's `source.path` supports these forms:
| Form | Resolution |
|------|------------|
| `/absolute/plugin` | The exact absolute path. |
| `~/plugins/example` or `~` | The operating-system user home. |
| `openrig-home:plugins/openrig-core` | Relative to the daemon's configured OpenRig home, including a non-default `OPENRIG_HOME`. |
| `plugins/example` or `./plugins/example` | Relative to the agent spec directory. |
For a plugin seeded by OpenRig, use the home-relative form so the spec selects
that daemon's copy:
```yaml
resources:
plugins:
- id: openrig-core
source:
kind: local
path: "openrig-home:plugins/openrig-core"
profiles:
default:
uses:
plugins: [openrig-core]
```
`openrig-home:` is resolved by the daemon from its configured state root. It is
not shell interpolation and does not consult a seat's shell startup files. Its
suffix must be relative. Existing absolute, tilde and spec-relative paths keep
their meaning; `~user` is still a literal relative segment, not a user lookup.
This form requires a daemon version that supports it; keep bundles using it
pinned to such a version.
### Resource Categories
| Category | Fields | Description |
@@ -306,6 +340,7 @@ Resources are the available pool. They are NOT automatically delivered to agents
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Delivered via `skill_install`. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files. Delivered via `guidance_merge` into CLAUDE.md/AGENTS.md. |
| `subagents` | `id`, `path` | Subagent definitions. |
| `plugins` | `id`, `source` | Local plugin directories; selected with `uses.plugins`. |
| `hooks` | `id`, `path`, `runtimes`* | Hook scripts. Optional `runtimes` array restricts to specific runtimes. |
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. |
+81 -2
View File
@@ -250,11 +250,14 @@ OpenRig performs best-effort deterministic runtime configuration for managed ses
installations may retain one; existing user settings are not removed.
- Claude global state: `~/.claude.json`
Purpose: pre-trust managed workspaces and mark onboarding complete for fresh managed sessions.
Explicit permission modes use the launch-selected `HOME/.claude.json`, or
`<CLAUDE_CONFIG_DIR>/.claude.json` when that variable is set. Classic startup
retains the daemon-home path.
- Claude project-local config: `.claude/settings.local.json`
Purpose: apply context collector/activity hooks and selected `claude_settings_fragment` resources inside the project without committing them to git.
- Claude project-local MCP config: `.mcp.json`
Purpose: apply selected `claude_mcp_fragment` resources for Claude in that project.
- Codex global config: `~/.codex/config.toml`
- Codex global config: `$CODEX_HOME/config.toml`, or `~/.codex/config.toml` when unset
Purpose: pre-trust managed workspaces and apply selected `codex_config_fragment` resources. Codex currently has no equivalent project-local MCP config path for global profile settings.
Two important caveats:
@@ -266,6 +269,39 @@ can affect native configuration, but OpenRig's launch flags can override those
values. Recording a config-surface `permission_policy` is not proof that its
rules were translated or applied. See [permission precedence and limits](getting-started.md#custom-settings-and-precedence).
### Separate Codex homes for separate installations
Set an **absolute `CODEX_HOME` in the environment that starts the daemon** when
you want a separate Codex configuration and state root. OpenRig uses that root
for its startup hook setup/removal and trust records, feature flag, workspace
trust and selected config fragments. Startup can write those files even when
the daemon has no seats.
The explicit selection also applies to future managed Codex fresh, resume and
fork launches, their capability/profile/configuration probes, native thread and
context reads, and plugin-cache discovery. Launch commands reassert it after
pane shell startup, so an rc file cannot silently switch that launch to another
Codex home. Executable and PATH selection stay the same. Existing running
sessions are not moved or restarted; a thread in another home is not searched
as a fallback for an explicitly selected home.
**With `CODEX_HOME` unset, a second install still shares `~/.codex`.** Setting only
`OPENRIG_HOME` separates OpenRig state, not provider configuration. The default
launch behavior is unchanged, including existing pane-shell overrides.
OpenRig does not copy or migrate authentication, history, rules, trust or
settings into the selected home. A separate file-backed home needs its intended
authentication provisioned if absent; OS credential stores and other config
layers can behave differently, so a new login is not universally required.
Explicit Codex auth commands resolve their **caller's** `CODEX_HOME`, otherwise
`$HOME/.codex`; contacting a daemon with `OPENRIG_URL` does not retarget auth
save/switch/registry writes to that daemon's selection. Set the intended root
on those commands as well.
This separates provider files, not OS users or accounts. Project-local resources
still use the selected project directory; shared project and system settings
remain shared.
### Runtime Differences
**Claude Code:**
@@ -283,6 +319,14 @@ rules were translated or applied. See [permission precedence and limits](getting
- Approval policy and sandbox access are separate controls. OpenRig's default
`-s workspace-write` selects the sandbox; it does not force `-a`. A member's
`codex_config_profile` selects native `-p`, distinct from the AgentSpec `profile`.
- On that plain `-s workspace-write` launch (fresh, resume, fork or restore), a
short-lived `codex app-server` first reads Codex's own configuration with the
seat's executable, home and working directory. OpenRig adds
`-c sandbox_workspace_write.network_access=true` only when no configuration
layer sets network access and no managed requirement could restrict it. A
timeout, error or unrecognized answer adds nothing. Named profiles and full
bypass are not read. The read may write Codex's own state files in
`CODEX_HOME`, read its login and fetch managed policy, as a Codex start does.
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
When authoring startup content, note which instructions are runtime-specific. For example, an orchestrator that needs a monitoring loop should include instructions like: "If running Claude Code, use `/loop 3m` to periodically check rig health. If running Codex, check rig health at the start of each task cycle instead."
@@ -301,6 +345,41 @@ profile → uses.skills: [openrig-user]
```
This is the minimum effective startup. The agent knows who it is, how the team works, and how to use the rig.
For seats sharing a working directory, keep each role in its own AgentSpec startup entry:
```yaml
startup:
files:
- path: guidance/role.md
orientation: role
delivery_hint: send_text
required: true
```
`rig queue whoami --json` reports only the calling seat's explicitly marked role
bindings, independently of its current work. The `role.state` is `no-record`,
`not-declared`, `missing`, `present`, or `unknown` when identity, storage or the
observation is unavailable. Files retain their original `path`, `absolutePath`
and `ownerRoot` plus the currently resolved `resolvedPath` and
`resolvedOwnerRoot`. Built-in packaged paths follow the running installation;
existing development-checkout paths stay where they were recorded.
`recordedAt` is the startup-context record write/attempt, which happens before
launch succeeds. Exact resume can retain an older record. `present` means the
resolved file currently exists, not that its bytes still match the spec or that
the agent read it. Unmarked startup files and startup actions are not exposed.
Refocus includes the role path and a re-read reminder with topology or both trees,
even if the trace fails. Work-only mode omits it; disabling refocus still disables
the entire hook. Custom refocus prose keeps its existing precedence. The hook
reuses its work lookup, or makes one bounded lookup when the work node is explicit;
a failed or skipped lookup reports `unknown`.
The shipped kernel seats no longer project their role into shared guidance.
Existing user files and legacy role blocks are left intact; this is not a cleanup
of already-installed shared blocks. General user-authored guidance projection is
unchanged.
**Separate project context from role**
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
@@ -356,7 +435,7 @@ If your rig REQUIRES a hook to function and the hook installation fails silently
When creating a new agent's startup experience:
- [ ] Write a `guidance/role.md` — who is this agent?
- [ ] Reference it in the agent spec's `resources.guidance` AND `startup.files`
- [ ] Reference it in `startup.files` with `orientation: role` and `delivery_hint: send_text`. Avoid projecting distinct seat roles into one shared `AGENTS.md` or `CLAUDE.md`.
- [ ] Write a rig `CULTURE.md` if the rig doesn't have one
- [ ] Choose skills from the shared pool via profile `uses`
- [ ] Write a `startup/context.md` if the agent needs environment grounding
+48
View File
@@ -0,0 +1,48 @@
## Browser access and allowed addresses
The OpenRig daemon answers its API only when a request is addressed to this machine and, if it
comes from a web page, only when that page is the OpenRig UI or a page you allowed.
### What works with no setup
- The CLI, the TUI and agents on this machine, addressing the daemon by `localhost` or an IP
address.
- Other OpenRig hosts and clients that reach this daemon by its IP address, its own hostname, or
its own Tailscale name (for example `my-machine.your-tailnet.ts.net`, or the short `my-machine`).
The daemon learns its own Tailscale name automatically from Tailscale on this machine.
- The web UI, when it is turned on (`rig config set ui.enabled true`, then restart the daemon) and
opened at the daemon's own address and port. To open it at a non-loopback name or address, such
as its Tailscale name, also list that page's exact origin in `OPENRIG_ALLOWED_ORIGINS` (for
example `http://my-machine.your-tailnet.ts.net:7433`), then restart the daemon. Without it, the
UI's terminal is refused there, and at a non-loopback IPv6 address its API requests are too.
### When you see a refusal
The daemon answers `403` with a sentence that says what to do. There are two kinds:
- **The address is not recognized** (`untrusted_host`). You reached the daemon by a name it does not
know as its own, such as a custom DNS name, an alias in `/etc/hosts`, or a reverse proxy domain.
Use `localhost`, an IP address or the machine's own name instead, or list the name you use:
`OPENRIG_ALLOWED_HOSTS=rig.example.com` in the daemon's environment, then restart the daemon.
A client that presents the daemon's configured bearer token is not refused for its address
(it still needs whatever access each route requires).
- **The web page is not allowed** (`browser_origin_refused`). A browser page that is not the
OpenRig UI at the daemon's own address called the API. This includes another local app or
development server on a different port. To allow a page, list its exact origin:
`OPENRIG_ALLOWED_ORIGINS=http://localhost:5173` in the daemon's environment, then restart the
daemon. A bearer token does not override this.
`OPENRIG_ALLOWED_HOSTS` lists names you use to reach the daemon. `OPENRIG_ALLOWED_ORIGINS` lists
web pages allowed to call it. They are separate: a UI served through your own domain usually needs
both. Pages on other origins cannot read the refusal; the message is for you, the CLI and agents.
### Limits
- If you rename the machine in Tailscale, the daemon keeps accepting its previous name until it
restarts or until it next looks its name up after seeing an unrecognized one.
- If the daemon cannot reach Tailscale's resolver, its Tailscale name is refused until a later
lookup succeeds. `localhost` and IP addresses keep working.
- This protects against unknown web pages and unknown names. It is not complete browser isolation:
a page on another site can still cause a simple read request to an address the daemon accepts,
although it cannot read the answer. Keep the daemon on loopback or your tailnet.
+17 -9
View File
@@ -1,8 +1,13 @@
# Developing OpenRig — gates and lanes
This is the contributor-facing statement of which checks BLOCK a change and which are
advisory. There is no external CI at this tip: the root `package.json` script chain IS
the gate, and the release checklists invoke it.
advisory. Every pull request to `main` runs `.github/workflows/tests.yml` on a clean GitHub
runner: build and packaging, typecheck, the repository scripts (`npm run test:repo`), each
workspace's test suite (daemon, cli, tui and ui) on macOS without ambient credentials or
external network, and an installed-package scenario (`scripts/run-pr-scenarios.sh`). The root
`package.json` scripts below run the same build, typecheck, repository and test checks locally,
with one difference: `npm test` does not include the UI suite, which CI runs as
`package-tests (ui)`. Run `npm run test:ui` for it.
## Blocking gates (must pass before a candidate moves)
@@ -10,23 +15,26 @@ the gate, and the release checklists invoke it.
|---|---|---|
| Typecheck | `npm run lint` | daemon + **ui** + cli + tui tsconfigs — UI typecheck STAYS blocking |
| Build | `npm run build` | all workspaces — the UI dist ships in the package, so its build STAYS blocking |
| Repo scripts | `npm run test:repo` | script self-tests, docs guard, skill mirror check |
| Repo scripts | `npm run test:repo` | daemon build, script self-tests, docs guard, skill mirror check, context-pack generation check |
| Unit tests | `npm run test:workspaces` | `packages/daemon` + `packages/cli` + `packages/tui` |
| UI unit tests | `npm run test:ui` | `packages/ui` vitest. NOT part of `npm test`, but every pull request runs it as `package-tests (ui)` (the `package-tests` matrix in `.github/workflows/tests.yml`) |
`npm test` runs `test:repo` and `test:workspaces` — the blocking set is readable in the
script itself.
`npm test` runs `test:repo` and `test:workspaces`; `npm run test:ui` is the separate UI
suite. Both sets are readable in the root `package.json` scripts.
## Advisory lane
| Lane | Command | Meaning |
|---|---|---|
| UI unit tests | `npm run test:ui` | runs `packages/ui` vitest at will; NOT part of `npm test` |
| Portability report | `node scripts/portability-report.mjs` | lists added lines with machine-specific values; findings never fail it (`.github/workflows/portability-report.yml`) |
## The norm (web-UI freeze at 0.5.0)
Daemon API changes no longer require UI sync or UI verification; the contract mirrors
under `packages/ui/src/hooks/` are no longer proactively maintained; a new `test:ui`
failure signals a moved API contract, not a broken gate.
Daemon API changes no longer require browser or interaction verification of the UI, and
the contract mirrors under `packages/ui/src/hooks/` are no longer proactively maintained. A
new `test:ui` failure usually signals a moved API contract. Because pull-request CI runs the
UI suite as `package-tests (ui)`, the change that moves the contract has to update the affected
UI test or mirror to pass that check.
Browser/interaction testing of the web UI is not a contributor gate. (The packaged
starter-rig agent skills that exercise the UI are product content for user rigs, not
+7 -2
View File
@@ -15,8 +15,13 @@ supported; Node 26 and other versions are untested.
Codex's `-s workspace-write`, with approval policy from your native configuration,
or Claude Code's `acceptEdits`, which still leaves commands subject to native
rules and prompts. Codex's sandbox normally blocks network access, including the
local OpenRig daemon. A command allowance does not change general sandbox/network
settings. The starter's `profile: default` selects OpenRig resources, not a native
local OpenRig daemon, so before that plain launch OpenRig asks Codex for its own
configuration and adds network access inside the sandbox unless a configuration
layer sets `sandbox_workspace_write.network_access` or a managed requirement could
restrict it. To keep it off, set `network_access = false` under
`[sandbox_workspace_write]` in your Codex configuration. If Codex does not answer
within a few seconds, the seat starts without it. A command allowance does not
change general sandbox/network settings. The starter's `profile: default` selects OpenRig resources, not a native
permission profile.
Agent-guided setup [asks once](#have-your-agent-configure-permissions): “Allow
your agents to run OpenRig commands without repeated permission prompts?”
+10 -3
View File
@@ -132,9 +132,13 @@ these boundaries. One occurrence and the existing owner cooldown apply to each
episode; a disposition stops repeated requests.
This bounded source covers explicitly linked project or mission work: at most 2,000 touched
qitems, 200 roots, 1,000 members / 10,000 transitions per family, 200 workflow
qitems, 1,000 members / 10,000 transitions per family, 200 workflow
receipts, 200 declared slices, and 100 proof files per selected slice. Overflow
refuses visibly. Context files are bounded to 64 KiB. A lineage beginning before
refuses visibly. When more than 200 families (roots) were touched, it evaluates the
200 with the most transitions in the window, then by lineage ID, and reports the
result as partial coverage with the total and omitted counts. Omitted families
were not evaluated, so having no finding for them is not a healthy verdict.
Context files are bounded to 64 KiB. A lineage beginning before
the retained window remains indeterminate, with the missing interval named.
Project planning does not require a successor mission: an explicit project identity
resolves its current context, with mission and phase absent unless evidenced.
@@ -370,7 +374,10 @@ A consumer such as a later Herder plugin reads `GET /api/health` and
`GET /api/health/:findingId`, or the identical `rig health --json` and
`rig health explain <finding-id> --json` records. The record schema is
`openrig.health/v0alpha1`; list metadata is `openrig.health-list/v0alpha1`.
Preserve ID, status, policy version, time window, freshness, evidence and
For a source that bounds its input, list metadata also carries `coverage`
(`source`, `total`, `evaluated`, `omitted`, `limit`, `order`, `partial`), and
`rig health policy` reports the scheduled evaluation's coverage under
`engine.lastEvaluation.coverage`. Preserve ID, status, policy version, time window, freshness, evidence and
threshold together. There is no health score or implicit remediation authority.
The default list excludes cleared records; explicit cleared queries and exact
ID reads retain them while the source can still project them. A recurrence after
+10 -4
View File
@@ -58,6 +58,7 @@ Read [Have your agent configure permissions](getting-started.md#have-your-agent-
(`rig context get reference/getting-started.md#have-your-agent-configure-permissions`). Identify the
specific prompt or sandbox restriction. Work within the user's chosen permissions; don't switch the whole environment
to unrestricted access to clear one prompt.
For a `403` naming `untrusted_host` or `browser_origin_refused`, see [Browser access and allowed addresses](browser-access.md).
### A seat shows attention, waiting or a failed restore
@@ -77,12 +78,17 @@ involved before proposing changes.
Compare the harness version and what you actually observe before attributing a failure to one of these. Check the
issue's current status; a similar symptom alone is not a diagnosis.
- Claude seats reported as needing attention after a restore: [#86](https://github.com/mvschwarz/openrig/issues/86).
- Claude usage limits are not detected: [#98](https://github.com/mvschwarz/openrig/issues/98),
[#99](https://github.com/mvschwarz/openrig/issues/99).
- Codex prompt variants not recognised as ready: [#79](https://github.com/mvschwarz/openrig/issues/79).
- Claude seats reported as needing attention after a restore: [#86](https://github.com/mvschwarz/openrig/issues/86),
[#273](https://github.com/mvschwarz/openrig/issues/273).
- Claude usage limits may not be detected: [#99](https://github.com/mvschwarz/openrig/issues/99).
- Codex prompt variants not recognised as idle: [#79](https://github.com/mvschwarz/openrig/issues/79).
- Codex asks about hook trust at first launch: [#17](https://github.com/mvschwarz/openrig/issues/17), not reproduced
by the team.
- A Codex seat on the default `workspace-write` sandbox starts without network access, so it can't reach the local
daemon, when its Codex configuration sets network access off, a managed requirement could restrict it, or Codex
doesn't answer OpenRig's configuration read in time: [#275](https://github.com/mvschwarz/openrig/issues/275).
- Stopping the daemon can report a timeout while closing connections:
[#166](https://github.com/mvschwarz/openrig/issues/166).
For everything else, search [open issues](https://github.com/mvschwarz/openrig/issues).
+60
View File
@@ -0,0 +1,60 @@
# Host load and transcript capture
Use `rig ps --resources` to inspect the serving host, or add `--host <id>`
for one registered HTTP/SSH host. `--json` returns the same measurements for
operators and monitoring tools. This view is host-scoped; rig/node filters and
multi-host fan-out do not apply. Ordinary `rig ps` output is unchanged.
The view reports available CPU count, host load averages (1/5/15 minutes),
load divided by CPU count, running seats, and transcript capture attempts,
failures, UTF8 bytes and elapsed capture time for currently rotating seats.
Load is not CPU utilization; capture elapsed time includes waiting for tmux,
and is not daemon CPU time. Load averages are unavailable on Windows, rather
than reported as zero. Counters reset when a rotation is restarted/stopped or
the daemon restarts, so they are observations rather than a durable billing log.
When no seat is capturing, the active interval, idle ceiling and line count are
unavailable (`null` in JSON), not zero.
## Reduce idle capture cost
Transcript rotation still captures the bounded trailing buffer. When successive
captures are unchanged and tmux exposes an unchanged current-window activity
hint, capture backs off exponentially to a derived ceiling. With the default
2-second active interval and 1-second settings timer, the effective ceiling is
3 seconds: the 10-second freshness window minus the 1-second activity-hint
deadline, 5-second capture-result deadline and 1-second timer spacing. The
ceiling is capped at 6 seconds but never below the configured active interval.
This scheduling budget is not a CPU or child-process lifetime bound; the result
deadline does not cancel an unfinished capture. One shared tmux
activity read wakes idle rotations; a changed hint restores the active cadence.
Missing/failed hints retain the configured full-capture cadence. A hint read
that takes longer than one second becomes unknown without starting additional
metadata probes while its underlying call is still pending. Periodic full
capture reconciles output even when a hint fails to change (tmux timestamps have
one-second resolution). Longer configured active intervals retain that interval
instead of silently increasing capture frequency.
This does not change provider readiness, delivery guards, seat activity or queue
state. Capture activity is not proof an agent is healthy or finished. As before,
transcripts are bounded trailing snapshots, not lossless terminal archives: an
output burst larger than retained tmux scrollback or the configured line limit
can exceed the saved trail. Interactive terminal streaming is unchanged.
## Change capture settings without restarting
```bash
rig config set transcripts.poll_interval_seconds 3
rig config set transcripts.lines 2000
rig ps --resources
rig config reset transcripts.poll_interval_seconds
```
Running rotations reread shared settings at most once per second and adopt the
next valid policy without starting overlapping captures. Intervals accept whole
seconds from 1 to 3600; trailing lines accept integers from 1 to 1000000. The
existing environment variables take precedence over file settings. Malformed
intermediate JSON retains the last usable policy, is shown in the resource view,
and is retried; normal config
writes reject invalid values. Changing `transcripts.enabled` or
`transcripts.path` still requires a daemon restart. This feature does not impose
CPU quotas or allocate build slots.
+11
View File
@@ -104,3 +104,14 @@ runtime reads for compatibility. Treat the two-home state as an explicit
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.
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
preparing the replacement, or renaming it, fails with `EACCES`, `EPERM`, or `EBUSY`,
the stores write the writable target in place, preserving compatibility with
unwritable directories and single-file bind mounts. This fallback is not atomic
and retains the previous partial-write risk. Read-only config files are refused.
Other errors, including `ENOSPC`, leave the original file intact on the atomic
path. Atomic replacement does not carry extended ACLs or other inode metadata;
a hard-linked second name continues to refer to the previous file.
+32 -5
View File
@@ -247,7 +247,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Pod identifier. Must not contain dots. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
| `id` | string | yes | — | Pod identifier. Must not contain dots or `@`. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
| `label` | string | yes | — | Human-readable pod name. Shown in UI explorer, graph groupings, and detail surfaces. |
| `summary` | string | no | — | Pod description. |
| `continuity_policy` | ContinuityPolicy | no | — | Pod-level continuity/restore policy. Controls compaction recovery, artifact management, and peer-driven restoration. |
@@ -258,6 +258,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
### Pod ID Rules
- Must not contain dots (`.`)
- Must not contain `@`, which separates the pod/member portion from the rig name in session addresses
- Must be unique across all pods in the rig
- Becomes the first segment of the qualified logical ID: `{podId}.{memberId}`
- Becomes the first segment of the canonical session name: `{podId}-{memberId}@{rigName}`
@@ -268,17 +269,35 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Member identifier. Must not contain dots. Must be unique within the pod. |
| `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. |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `terminal`. |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
| `cwd` | string | yes | — | Working directory for the agent. Resolved relative to the rig root (the directory containing the rig spec). Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. |
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
| `restore_policy` | string | no | `resume_if_possible` | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. |
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
### 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`.
- **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`, `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` or `KIMI_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>` or `kimi-coding/<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.
### Oh My Pi (`runtime: omp`)
`runtime: omp` launches Oh My Pi through OpenRig's RPC runner. It is separate from `runtime: pi`; OMP does not use Pi's `--name` or `--approve` flags.
- **State:** Each seat uses `$OPENRIG_HOME/state/omp/<seat>/agent` and `sessions/` instead of your default `~/.omp` profile, and runs with that seat directory as `HOME`. OpenRig does not copy OMP credentials. The runner finds the real `omp` binary before switching `HOME`, so a version-manager shim such as mise on the daemon's `PATH` still works.
- **Credentials:** Provision each seat separately, or put the provider's key variable in `recovery.provider_auth_env_allowlist` (for example `ANTHROPIC_API_KEY` or `MISTRAL_API_KEY`). The allowlist accepts the key variable of every provider in OpenRig's OMP provider map, from `anthropic` through `litellm`. A seat receives a key only when its `model` is written as `provider/id`, such as `anthropic/claude-sonnet-4-5`. Short names such as `opus` pass no key. OMP also reads the launch directory's `.env`, so use a trusted working directory.
- **Approval posture:** The default floor is `--approval-mode always-ask`. Because the runner is headless, OMP approval requests are cancelled and the seat stays in needing-attention after the turn ends, until the next agent run starts. A `full_bypass` permission policy selects `--approval-mode yolo`.
- **Model errors:** A rejected prompt, a provider or authentication error during a turn, or exhausted automatic retries is printed in the pane and keeps the seat in needing-attention until the next agent run starts.
- **Restore:** OMP creates its session file after the first persisted turn. A new seat with no persisted turn has no resume token; restoring it requires `rig up --existing <rig> --fresh <seat>`. After that file exists, OpenRig restores that exact session file. If a full rig restore leaves an OMP seat in `attention_required` or `failed`, `rig seat clear-attention` cannot yet reconcile it to `operator_recovered`, even with `--reason`, because restore reconciliation only verifies Claude Code and Codex processes ([#41](https://github.com/mvschwarz/openrig/issues/41)). Relaunch that seat with `rig up --existing <rig> --fresh <seat>`, or restore it manually.
### Terminal Nodes
Terminal nodes are infrastructure processes (servers, log tails, build watchers) that are not agent runtimes. They require an exact triple:
@@ -309,6 +328,9 @@ The canonical session name is derived from the pod ID, member ID, and rig name:
Example: pod `dev`, member `impl`, rig `my-team` → session `dev-impl@my-team`
Pod and member IDs cannot contain `@` because the first `@` separates their portion
of the session address from the rig name. Rig names may still contain `@`.
This is human-authored (you choose the pod/member IDs) and system-validated (the system enforces the format).
---
@@ -369,6 +391,7 @@ Startup blocks can appear at three levels: rig, pod, and member. They are merged
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | yes | — | Relative path to the file. Must be a safe relative path. |
| `orientation` | string | no | — | Set to `role` to identify a per-seat role file for `rig queue whoami --json` and refocus. Does not change delivery or replay startup. |
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
@@ -454,7 +477,8 @@ Each target must define exactly one of `service`, `url`, or `tcp`:
```yaml
wait_for:
# HTTP probe — hits the URL, expects 2xx
# HTTP probe — any 2xx or 3xx response passes the HTTP wait target; redirects are not followed.
# This proves the server answered, not application readiness.
- url: http://127.0.0.1:8200/v1/sys/health
# TCP probe — connects to host:port
@@ -536,7 +560,10 @@ continuity_policy:
## Validation Rules Summary
These rules are enforced by the validator. A spec that violates any of these will be rejected by `rig spec validate` and `rig up`.
Schema validation checks the structural rules below. Pod and member IDs containing
`@` are additionally rejected during preflight and member creation or launch; a
spec can pass `rig spec validate` and then fail at preflight or import. Run
`rig spec preflight` before creation or import.
1. `version` and `name` are required non-empty strings.
2. `pods` must be a non-empty array.
+6 -3
View File
@@ -100,9 +100,12 @@ Each slice directory carries:
approval starts here.
- **`## Proof contract`** — a checkbox list of promised deliverables (see
§3). The UI's DELIVERED section pairs each item with its proof.
- **`PROGRESS.md`** — the slice's acceptance checklist: the durable
mission/slice-level to-do checked at acceptance. In-process steps stay in
the working agent's own todo tool.
- **`PROGRESS.md`** — the slice's current delivery state and retained outcome
log. Record material changes with links to proof; historical checkboxes are
testimony, not current acceptance authority. For a selected proof policy,
`rig proof show <slice>` derives current readiness from the proof contract
and attributed judgments. In-process steps stay in the working agent's
own todo tool.
- **`PROOF.md`** — the retained proof summary, explicitly paired to the
`SPEC.md` proof contract.
- **`proof/`** — the proof-artifact directory. Curated canonical evidence
+6
View File
@@ -76,6 +76,12 @@ So after installing, compare the granted scopes Slack shows for the app with all
The app subscribes to messages in public channels it is a member of (`message.channels`) and to
mentions of the app (`app_mention`). It does not request direct-message or private-channel access.
The manifest also turns on **Interactivity**, so the human can answer a decision's structured
questions by clicking a button (`rig queue create --human-questions-file`). In Socket Mode the
clicks arrive over the same socket, so no request URL is needed. An app created from an older
manifest has Interactivity off: turn it on under **Interactivity & Shortcuts**, or the buttons
will do nothing. A typed reply in the thread still answers the decision either way.
## What the connector does with the tokens
The tokens stay in the env file you created. The connector reads them from that file and uses them
+61 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.6.3",
"version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.6.3",
"version": "0.6.5",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -4025,6 +4025,18 @@
"node": ">=6.9.0"
}
},
"node_modules/get-east-asian-width": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -5694,6 +5706,49 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi/node_modules/ansi-regex": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -7013,7 +7068,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.6.3",
"version": "0.6.5",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7027,6 +7082,7 @@
"hono": "^4.7.0",
"jsonc-parser": "^3.3.1",
"smol-toml": "^1.8.0",
"string-width": "^8.3.0",
"tar": "^7.5.13",
"ulid": "^2.3.0",
"yaml": "^2.8.3",
@@ -7047,7 +7103,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.6.3",
"version": "0.6.5",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7088,7 +7144,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.6.3",
"version": "0.6.5",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.6.3",
"version": "0.6.5",
"private": true,
"license": "Apache-2.0",
"type": "module",
+2 -1
View File
@@ -2,5 +2,6 @@
daemon/
ui/
# Root license copied by the CLI build
# Root license and README copied by the CLI build
/LICENSE
/README.md
+4 -3
View File
@@ -1,8 +1,8 @@
{
"name": "@openrig/cli",
"version": "0.6.3",
"version": "0.6.5",
"type": "module",
"description": "Local control plane for multi-agent coding topologies",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
"ai",
"agents",
@@ -38,7 +38,7 @@
"scripts"
],
"scripts": {
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/ && cp ../../LICENSE LICENSE",
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/ && cp ../../LICENSE LICENSE && cp ../../README.md README.md",
"postinstall": "node scripts/check-abi.mjs",
"prepublishOnly": "bash ../../scripts/build-package.sh",
"test": "vitest run",
@@ -55,6 +55,7 @@
"hono": "^4.7.0",
"jsonc-parser": "^3.3.1",
"smol-toml": "^1.8.0",
"string-width": "^8.3.0",
"tar": "^7.5.13",
"ulid": "^2.3.0",
"yaml": "^2.8.3",
+64
View File
@@ -0,0 +1,64 @@
import { constants, writeFileSync, unlinkSync, lstatSync, readlinkSync, statSync, chmodSync, chownSync, openSync, closeSync, renameSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { dirname, join, resolve } from "node:path";
/** Publish complete text while retaining the previous file on a failed staged write. */
export function writeTextAtomically(path: string, content: string, label = "file"): void {
// Keep stream/device semantics, including symlinks to pipes such as /dev/stdout.
// Probing a FIFO for write access would deliver an empty EOF to its reader.
const direct = statSync(path, { throwIfNoEntry: false });
if (direct && !direct.isFile()) {
writeFileSync(path, content, "utf-8");
return;
}
let target = path;
// Follow file links just as writeFileSync did; rename the target,
// never the link. A dangling final target is still created normally.
for (let depth = 0; ; depth++) {
const entry = lstatSync(target, { throwIfNoEntry: false });
if (!entry?.isSymbolicLink()) break;
if (depth >= 40) throw Object.assign(new Error(`Too many ${label} symlinks`), { code: "ELOOP" });
target = resolve(dirname(target), readlinkSync(target));
}
const existing = statSync(target, { throwIfNoEntry: false });
// Match direct writes: a writable directory must not bypass a read-only
// target. Opening without truncation also checks native ACL permissions.
if (existing) closeSync(openSync(target, constants.O_WRONLY));
const temporary = join(dirname(target), `.openrig.tmp-${randomUUID()}`);
let owned = false;
let canFallBack = true;
try {
// Atomic replacement requires directory create/rename permission. Keep
// staged bytes private until the original ownership and mode are restored.
const fd = openSync(temporary, "wx", 0o600);
owned = true;
// A failed data write must never retry against the original file.
canFallBack = false;
try { writeFileSync(fd, content, "utf-8"); } finally { closeSync(fd); }
canFallBack = true;
if (existing && process.platform !== "win32") {
const staged = statSync(temporary);
if (staged.uid !== existing.uid || staged.gid !== existing.gid) {
try { chownSync(temporary, existing.uid, existing.gid); }
catch (error) {
throw Object.assign(new Error(`Cannot preserve ${label} ownership at ${target}: ${(error as Error).message}`),
{ code: (error as NodeJS.ErrnoException).code });
}
}
}
// chown may clear permission bits, so apply the final mode afterward.
chmodSync(temporary, existing ? existing.mode & 0o777 : 0o666 & ~process.umask());
renameSync(temporary, target);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (canFallBack && (code === "EACCES" || code === "EPERM" || code === "EBUSY")) {
// Preserve setups that permit writing the file but not replacing it,
// such as an unwritable parent or a single-file bind mount.
writeFileSync(target, content, "utf-8");
return;
}
throw error;
} finally {
if (owned) try { unlinkSync(temporary); } catch { /* Renamed or already removed. */ }
}
}
+16 -3
View File
@@ -13,6 +13,7 @@
import type { Command } from "commander";
import { InvalidArgumentError } from "commander";
import { DaemonConnectionError, DaemonResponseError, DaemonTimeoutError } from "./client.js";
import { blockedConnectionGuidance, isBlockedConnectionCode } from "./daemon-reachability.js";
/**
* Commander option parser — a positive integer (>= 1). Rejects negative, zero,
@@ -98,7 +99,7 @@ export function renderDaemonTransportError(
e: unknown,
io: { out: (l: string) => void; err: (l: string) => void; json: boolean },
): boolean {
let parts: { fact: string; consequence: string; action: string } | undefined;
let parts: { fact: string; consequence: string; action: string; causeCode?: string } | undefined;
// DaemonTimeoutError is a subclass of DaemonConnectionError — check it FIRST.
if (e instanceof DaemonResponseError) {
parts = {
@@ -115,10 +116,22 @@ export function renderDaemonTransportError(
"Check the command's effect before any retry, then inspect daemon load with 'rig status' and the daemon logs if it remains slow. This is a slow response, not a stopped daemon.",
};
} else if (e instanceof DaemonConnectionError) {
const unknownWrite = e.writeOutcome === "unknown";
const reconcile = "Reconcile using the recovery ID before any retry. A lost connection does not prove the write failed.";
const delivery = unknownWrite
? "The command's outcome is UNKNOWN — no usable response confirmed whether the write was applied."
: "The command was not delivered.";
// #275: a connection this machine blocked (EPERM/EACCES) is not a stopped daemon.
const blocked = isBlockedConnectionCode(e.causeCode) ? blockedConnectionGuidance(e.causeCode) : undefined;
parts = {
fact: e.message,
consequence: "The command was not delivered.",
action: "Confirm the daemon is reachable with 'rig daemon status'; if it is down, start it with 'rig up' or 'rig daemon start'.",
consequence: blocked ? `${delivery} ${blocked.consequence}` : delivery,
action: blocked
? (unknownWrite ? `${blocked.action} ${reconcile}` : blocked.action)
: unknownWrite
? `Check 'rig daemon status' on the affected host; if it is down, start it with 'rig daemon start'. ${reconcile}`
: "Confirm the daemon is reachable with 'rig daemon status'; if it is down, start it with 'rig up' or 'rig daemon start'.",
...(e.causeCode ? { causeCode: e.causeCode } : {}),
};
}
if (!parts) return false;
+57 -6
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { ConfigStore } from "./config-store.js";
import { readOpenRigEnv } from "./openrig-compat.js";
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
import { readLocalOrigin } from "./local-origin.js";
@@ -57,10 +57,24 @@ function resolveTerminalToken(): string | null {
}
}
/** The OS code behind a failed connection (`EPERM`, `ECONNREFUSED`, …) when Node exposes one.
* `fetch` reports every connection failure as "fetch failed" and keeps the code on `cause`. */
export function connectionErrorCode(err: unknown): string | undefined {
const e = err as { code?: unknown; cause?: { code?: unknown } } | null | undefined;
const code = e?.cause?.code ?? e?.code;
return typeof code === "string" && code ? code : undefined;
}
export class DaemonConnectionError extends Error {
constructor(message: string) {
/** A write caller cannot infer nondelivery from losing the response. */
writeOutcome?: "unknown";
/** #275: the OS cause, so a connection this machine blocked is not rendered as a stopped daemon. */
causeCode?: string;
constructor(message: string, causeCode?: string) {
super(message);
this.name = "DaemonConnectionError";
if (causeCode) this.causeCode = causeCode;
}
}
@@ -98,6 +112,17 @@ export interface DaemonResponse<T = unknown> {
data: T;
}
/**
* Issue #425: wrap bare IPv6 literals in brackets for URL hosts. `::1` must
* render as `[::1]` — `http://::1:7433` never parses in fetch. Hostnames,
* IPv4, and already-bracketed literals pass through unchanged.
*/
export function formatDaemonHostForUrl(host: string): string {
const trimmed = host.trim();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
}
interface DaemonRequestOptions {
timeoutMs?: number;
/** Per-call header overrides (e.g., `Authorization: Bearer ...`). */
@@ -109,6 +134,22 @@ interface DaemonClientOptions {
timeoutMs?: number;
}
/** Routing state published by daemon start, not a health check or sibling-home discovery. */
function localDaemonUrl(): string | undefined {
try {
// Resolve at construction time: embedders may change OPENRIG_HOME after import.
const state = JSON.parse(fs.readFileSync(path.join(getOpenRigHome(), "daemon.json"), "utf-8"));
if (!state || !Number.isSafeInteger(state.pid) || state.pid <= 0
|| !Number.isInteger(state.port) || state.port < 1 || state.port > 65535
|| (state.host !== undefined && (typeof state.host !== "string" || !state.host.trim()))) return undefined;
// A stale PID must not redirect reads or writes to another configured daemon.
// Keep the recorded endpoint; the request decides reachability, even after exit.
return `http://${formatDaemonHostForUrl(state.host ?? "127.0.0.1")}:${state.port}`;
} catch {
return undefined;
}
}
export class DaemonClient {
readonly baseUrl: string;
private fetchImpl: typeof fetch = fetch;
@@ -157,9 +198,15 @@ export class DaemonClient {
if (envUrl) {
this.baseUrl = envUrl;
} else {
// Resolve from config (env > file > defaults)
const config = new ConfigStore().resolve();
this.baseUrl = `http://${config.daemon.host}:${config.daemon.port}`;
// --port/--host can differ from configured startup defaults. Match the
// lifecycle-aware commands by preferring this home's recorded endpoint.
const localUrl = localDaemonUrl();
if (localUrl) {
this.baseUrl = localUrl;
} else {
const config = new ConfigStore().resolve(); // env > file > defaults
this.baseUrl = `http://${formatDaemonHostForUrl(config.daemon.host)}:${config.daemon.port}`;
}
}
}
@@ -257,7 +304,11 @@ export class DaemonClient {
// Headers prove that the daemon received the request, but not that a write
// finished. A dropped body must preserve unknown-outcome guidance.
if (responseStatus !== undefined) throw new DaemonResponseError(responseStatus, "");
throw new DaemonConnectionError(`Cannot connect to the OpenRig daemon at ${this.baseUrl}: ${msg}`);
const causeCode = connectionErrorCode(err);
throw new DaemonConnectionError(
`Cannot connect to the OpenRig daemon at ${this.baseUrl}: ${msg}${causeCode && !msg.includes(causeCode) ? ` (${causeCode})` : ""}`,
causeCode,
);
}
}
+1
View File
@@ -130,6 +130,7 @@ Examples:
try {
const client = await getClient();
const res = await client.get<AgentImageEntryWire[]>("/api/agent-images/library");
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
let entries = res.data ?? [];
if (opts.runtime) entries = entries.filter((e) => e.runtime === opts.runtime);
if (opts.json) {
+11 -1
View File
@@ -68,6 +68,9 @@ interface AskResult {
guidance?: string;
}
/** Largest delay a Node timer honours (2^31-1 ms); execFile's timeout uses one. */
const MAX_WAKE_TIMEOUT_MS = 2_147_483_647;
interface AskCommandDeps extends StatusDeps {
identityResolver?: typeof resolveIdentitySource;
wakeRunner?: WakeRunner;
@@ -126,7 +129,14 @@ Exit codes:
// an unresolvable seat REFUSES with teaching (never a guessed wake).
if (opts.wake) {
const target = opts.wake;
const timeoutMs = opts.wakeTimeout ? Math.max(1, Number(opts.wakeTimeout)) * 1000 : undefined;
// execFile throws on a NaN, infinite or fractional timeout, and Node timers run a
// delay above 2^31-1 ms after 1ms, so validate the milliseconds before any call.
const timeoutMs = opts.wakeTimeout ? Math.round(Math.max(1, Number(opts.wakeTimeout)) * 1000) : undefined;
if (timeoutMs !== undefined && !(timeoutMs <= MAX_WAKE_TIMEOUT_MS)) {
console.error(`--wake-timeout must be a number of seconds from 1 to ${Math.floor(MAX_WAKE_TIMEOUT_MS / 1000)}; got '${opts.wakeTimeout}'`);
process.exitCode = 1;
return;
}
let token = target;
let runtime: "claude" | "codex" = opts.runtime === "codex" ? "codex" : "claude";
+4 -7
View File
@@ -1,7 +1,7 @@
import nodePath from "node:path";
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -29,10 +29,7 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
printDaemonNotRunning();
return null;
}
if (!daemonStatusGuard(status)) return null;
return deps.clientFactory(getDaemonUrl(status));
}
@@ -135,8 +132,8 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
const resultStatus = (res.data["status"] as string) ?? "";
if (res.status === 409) {
process.exitCode = 1; // blocked
} else if (res.status >= 500) {
process.exitCode = 2; // failure
} else if (res.status >= 400) {
process.exitCode = 2; // failure (same convention as --plan)
} else if (resultStatus === "partial") {
process.exitCode = 1; // partial is not clean success
}
+1 -1
View File
@@ -34,7 +34,7 @@ export function broadcastCommand(depsOverride?: BroadcastDeps): Command {
cmd
.argument("[text]", "Message text to broadcast (optional with --context)")
.option("--rig <name>", "Broadcast to all sessions in a rig")
.option("--pod <name>", "Broadcast to all sessions in a pod")
.option("--pod <name>", "Broadcast to matching pod names across all rigs unless --rig is set")
.option("--force", "Send even if targets appear mid-task")
.option("--host <id>", "Broadcast on a remote host declared in ~/.openrig/hosts.yaml (http hosts only — CLI-direct to the remote daemon's fan-out)")
.option("--context <ref>", "Broadcast a context pack by its path-like ref (e.g. packs/fleet-update). Resolved content is fanned out; an oversized ref is flagged 'walk-sized'.")
+24 -1
View File
@@ -115,6 +115,13 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
if (opts.json) {
console.log(JSON.stringify(res.data));
if (res.status >= 400) process.exitCode = 1;
return;
}
if (res.status >= 400) {
console.error((res.data as { error?: string })?.error ?? `Failed (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
@@ -259,6 +266,16 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
.option("--timeout <seconds>", "Timeout in seconds", "120")
.option("--json", "JSON output")
.action(async (rig: string, opts: { after?: string; topic?: string; sender?: string; timeout: string; json?: boolean }) => {
// A NaN timeout would never expire and would poll with a ~1ms sleep.
// Preserve numeric forms and legacy numeric prefixes (e.g. 30s) without truncating fractions.
const numericTimeout = Number(opts.timeout);
const timeoutSeconds = Number.isNaN(numericTimeout) ? Number.parseFloat(opts.timeout) : numericTimeout;
if (opts.timeout.trim() === "" || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) {
console.error(`--timeout must be a non-negative number of seconds; got '${opts.timeout}'`);
process.exitCode = 1;
return;
}
const client = await getClient();
if (!client) return;
@@ -271,7 +288,7 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
return;
}
const timeoutMs = parseInt(opts.timeout, 10) * 1000;
const timeoutMs = timeoutSeconds * 1000;
const pollIntervalMs = 3000;
// Bootstrap cursor: use --after if provided, otherwise generate a ULID at start time
@@ -299,6 +316,12 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
`/api/rigs/${encodeURIComponent(rigId)}/chat/history?${params}`,
);
if (res.status >= 400) {
console.error((res.data as { error?: string })?.error ?? `Failed (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
if (res.data && res.data.length > 0) {
if (opts.json) {
console.log(JSON.stringify(res.data));
+37 -5
View File
@@ -91,7 +91,13 @@ interface SkippedEntry extends BasePlanEntry {
reason: string;
}
interface IncompleteRig {
rig: string;
status: number | "invalid_inventory" | "request_failed";
}
interface CompactPlanResult {
incompleteRigs: IncompleteRig[];
summary: {
totalSeats: number;
claudeSeats: number;
@@ -296,7 +302,7 @@ function analyzeNode(node: NodeEntry, thresholds: CompactPlanThresholds): Candid
};
}
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): CompactPlanResult {
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds(), incompleteRigs: IncompleteRig[] = []): CompactPlanResult {
const candidates: CandidateEntry[] = [];
const blocked: BlockedEntry[] = [];
const skipped: SkippedEntry[] = [];
@@ -315,6 +321,7 @@ function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): Compac
});
return {
incompleteRigs,
summary: {
totalSeats: nodes.length,
claudeSeats: nodes.filter(isClaude).length,
@@ -344,6 +351,9 @@ function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): Compac
function printHuman(plan: CompactPlanResult): void {
console.log("READ-ONLY PLAN - does not compact");
if (plan.incompleteRigs.length > 0) {
console.error(`Warning: partial plan; incomplete rigs: ${plan.incompleteRigs.map(({ rig, status }) => `${rig} (${typeof status === "number" ? `HTTP ${status}` : status})`).join(", ")}`);
}
console.log("Policy: read_only_plan; one-seat-at-a-time marshal triage; autoCompactAllowed=false; explicit authorization required.");
console.log(`Thresholds: ${plan.policy.thresholdTokens} estimated tokens; ${plan.policy.thresholdPercent}% when context window size is missing.`);
console.log(`Summary: ${plan.summary.candidateCount} candidates | ${plan.summary.blockedCount} blocked | ${plan.summary.skippedCount} skipped`);
@@ -420,7 +430,13 @@ Examples:
try {
const psResult = await client.get<RigEntry[]>("/api/ps");
const rigs = psResult.data ?? [];
if (psResult.status !== 200) {
throw new Error(`Rig inventory returned HTTP ${psResult.status}`);
}
if (!Array.isArray(psResult.data)) {
throw new Error("Rig inventory is not an array");
}
const rigs = psResult.data;
const targetRigs = opts.rig ? rigs.filter((rig) => rig.name === opts.rig) : rigs;
if (opts.rig && targetRigs.length === 0) {
@@ -450,19 +466,35 @@ Examples:
}
const allNodes: NodeEntry[] = [];
const incompleteRigs: IncompleteRig[] = [];
for (const rig of targetRigs) {
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
if (Array.isArray(nodesResult.data)) {
let failureStatus: IncompleteRig["status"] = "request_failed";
try {
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
failureStatus = nodesResult.status;
if (nodesResult.status !== 200) {
throw new Error(`Node inventory for rig "${rig.name}" returned HTTP ${nodesResult.status}`);
}
failureStatus = "invalid_inventory";
if (!Array.isArray(nodesResult.data)) {
throw new Error(`Node inventory for rig "${rig.name}" is not an array`);
}
allNodes.push(...nodesResult.data);
} catch (err) {
// An explicitly selected rig has no useful partial inventory. For
// fleet plans, keep healthy seats while making every omission visible.
if (opts.rig) throw err;
incompleteRigs.push({ rig: rig.name, status: failureStatus });
}
}
const plan = buildPlan(allNodes, thresholds.value);
const plan = buildPlan(allNodes, thresholds.value, incompleteRigs);
if (opts.json) {
console.log(JSON.stringify(plan, null, 2));
} else {
printHuman(plan);
}
if (incompleteRigs.length > 0) process.exitCode = 1;
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
console.error("Fix: check daemon status with: rig daemon status");
+24 -7
View File
@@ -3,8 +3,7 @@ import { DaemonClient, terminalAuthHeaders } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
// The trigger is two-phase (prep → wait-for-idle → /compact); the wait-for-idle
// half can take up to the daemon's manual-prep ceiling (~120s). Give the HTTP
// Preparation (exact map plus idle wait) shares the existing 120s ceiling. Give the HTTP
// call generous headroom so the client does not time out mid-sequence.
const MANUAL_COMPACT_REQUEST_TIMEOUT_MS = 180_000;
@@ -24,6 +23,9 @@ export function compactCommand(depsOverride?: CompactDeps): Command {
.description("Manually run the guided compaction sequence (prep → /compact → restore → audit) for one Claude seat")
.argument("<session>", "Target Claude session name (e.g. dev-impl@my-rig)")
.option("--json", "JSON output for agents")
.option("--skip-map", "Skip the restore-map prerequisite once; keep target, idle and permission checks")
.option("--cancel", "End the current preparation and disarm its future compact")
.option("--state", "Show the current preparation, expected map and deadline without triggering")
.addHelpText("after", `
Examples:
rig compact dev-impl@my-rig Manually compact one Claude seat now
@@ -33,14 +35,26 @@ Runs the SAME guided lifecycle the auto-compaction policy runs (pre-compact
prep → /compact with the trust-bridge → restore-from-marker → read-depth audit)
on demand, for ONE Claude seat, without waiting for the context threshold. It is
NOT a bare /compact and NOT the read-only 'rig compact-plan' triage. Non-Claude
seats are rejected. The /compact is sent only AFTER the prep turn completes.`);
seats are rejected. Managed /compact waits for the exact attempt/occupant restore
map named by preparation, published atomically with its completion marker. This
checks completion identity, not map quality. Manual preparation and idle wait share
120 seconds; expiry disarms that attempt. --state shows the map/deadline/reason;
--cancel ends preparation. --skip-map skips only the map requirement for this one
manual request. Cancel an active preparation before retrying or using --skip-map.
Automatic preparation waits at most 25 minutes from prep delivery, using existing
polls. A stopped attempt does not restart automatically; rerun this command to
start a fresh attempt. Cancellation disarms future input; it cannot undo a command
already submitted. Raw provider /compact and normal messages are unchanged.`);
const getDepsF = (): CompactDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd.action(async (session: string, opts: { json?: boolean }) => {
cmd.action(async (session: string, opts: { json?: boolean; skipMap?: boolean; cancel?: boolean; state?: boolean }) => {
if ([opts.skipMap, opts.cancel, opts.state].filter(Boolean).length > 1) {
console.error("Choose only one of --skip-map, --cancel or --state."); process.exitCode = 1; return;
}
const deps = getDepsF();
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -51,9 +65,9 @@ seats are rejected. The /compact is sent only AFTER the prep turn completes.`);
}
const client = deps.clientFactory(getDaemonUrl(status));
const res = await client.post<Record<string, unknown>>(
"/api/compaction/trigger",
{ session },
const res = opts.state ? await client.get<Record<string, unknown>>(`/api/compaction/state?session=${encodeURIComponent(session)}`, { headers: terminalAuthHeaders() }) : await client.post<Record<string, unknown>>(
opts.cancel ? "/api/compaction/cancel" : "/api/compaction/trigger",
{ session, ...(opts.skipMap ? { skipMap: true } : {}) },
{ headers: terminalAuthHeaders(), timeoutMs: MANUAL_COMPACT_REQUEST_TIMEOUT_MS },
);
@@ -70,6 +84,9 @@ seats are rejected. The /compact is sent only AFTER the prep turn completes.`);
return;
}
if (opts.state || opts.cancel) {
console.log(JSON.stringify(res.data, null, 2)); return;
}
const stage = res.data["stage"] as string | undefined;
console.log(`Manual compaction triggered for ${session}${stage ? ` (stage: ${stage})` : ""}.`);
console.log("The restore + read-depth audit prompts follow automatically as the seat drains below threshold.");
+3 -4
View File
@@ -10,11 +10,9 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
return store.resolveAllWithSource();
}
// SWEEP-c (shape f2576102) — keys the daemon reads ONLY at boot: a set while it runs
// is stale-until-restart; the honest floor is the loud notice (live-reload = its own
// arch item, not built here).
// These settings remain boot-only; capture interval/line settings reload live.
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "db.path"];
const BOOT_ONLY_PREFIXES = ["transcripts."];
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
function isBootOnlyKey(key: string): boolean {
return BOOT_ONLY_KEYS.includes(key) || BOOT_ONLY_PREFIXES.some((p) => key.startsWith(p));
@@ -77,6 +75,7 @@ Keys:
files.allowlist name:/abs/path,name:/abs/path
progress.scan_roots name:/abs/path,name:/abs/path
ui.timezone TUI IANA timezone (default America/Los_Angeles; reopen TUI after changing)
ui.enabled serve the web UI and its terminal WebSocket (default off; restart the daemon)
ui.preview.* refresh_interval_seconds, max_pins, default_lines
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
agents.* advisor_session, operator_session
+18 -14
View File
@@ -23,7 +23,7 @@ import {
writeFileSync,
} from "node:fs";
import { dirname } from "node:path";
import { basename, join, resolve } from "node:path";
import { join, resolve } from "node:path";
import { parse as parseYaml } from "yaml";
import { assertSafeInstallRef, assertTreeHasNoSymlinks, assertDestinationNamespaceContained, validateContextPackManifestForInstall } from "../lib/context-install.js";
import { addGitContext, inspectGitContext, updateGitContext } from "../lib/context-git.js";
@@ -377,7 +377,7 @@ Examples:
.description("Walk the topology tree for one chain filename (instance -> rig -> optional pod -> optional seat), keyed off topology.root")
.requiredOption("--rig <rig>", "Rig name (the rigs/<rig> altitude)")
.option("--pod <pod>", "Pod id (the pods/<pod> altitude); omit when no pod context is selected")
.option("--seat <seat>", "Seat id (the seats/<seat> altitude); omit for a rig-level trace")
.option("--seat <seat>", "Seat folder name (<pod>-<member>, e.g. dev1-qa); omit for a rig-level trace")
.requiredOption("--name <file>", "Chain filename, identical at every altitude (e.g. LEARNED.md, CULTURE.md)")
.option("--json", "JSON output for agents")
.action(async (opts: { rig: string; pod?: string; seat?: string; name: string; json?: boolean }) => {
@@ -466,6 +466,7 @@ Examples:
try {
const client = await getClient();
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const entries = res.data ?? [];
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
@@ -671,9 +672,10 @@ Examples:
const res = await client.get<{
profileId?: string;
phases?: Array<{ id: string; kind: string; sources?: string[]; estimatedTokens: number }>;
pieces?: Array<{ atomId: string; address: string; sourceKind: string; text: string; estimatedTokens: number }>;
pieces?: Array<{ atomId: string; address: string; sourceKind: string; text: string; estimatedTokens: number; writtenAt?: string }>;
totalEstimatedTokens?: number;
budget?: { limitTokens: number; overageTokens: number; dropCandidates: Array<{ atomId: string; priority: string; estimatedTokens: number }> };
warnings?: string[];
provenanceWarnings?: string[];
message?: string;
error?: string;
@@ -695,6 +697,7 @@ Examples:
}
// Warnings and the budget report ride stderr so stdout is exactly the
// composed walk an agent consumes.
for (const w of profile.warnings ?? []) console.error(`WARNING ${w}`);
for (const w of profile.provenanceWarnings ?? []) console.error(`PROVENANCE ${w}`);
if (profile.budget) {
console.error(
@@ -708,7 +711,8 @@ Examples:
// outside its root — self-describing payload, zero composed bytes
// touched.
const escaped = (p as { provenance?: { escapesRoot?: boolean } }).provenance?.escapesRoot ? " !ESCAPED-ROOT" : "";
console.log(`=== ${p.atomId} [${p.sourceKind}${escaped}] ${p.address} (~${p.estimatedTokens} tokens)`);
const written = p.writtenAt ? ` written ${p.writtenAt}` : "";
console.log(`=== ${p.atomId} [${p.sourceKind}${escaped}] ${p.address} (~${p.estimatedTokens} tokens)${written}`);
console.log(p.text);
console.log("");
}
@@ -818,15 +822,8 @@ Examples:
throw new Error(`Source directory must contain manifest.yaml: ${source}`);
}
validateContextPackManifestForInstall(manifestPath);
const installName = opts.name ?? (() => {
try {
const raw = readFileSync(manifestPath, "utf-8");
const m = raw.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m);
return m?.[1]?.trim() || basename(source);
} catch {
return basename(source);
}
})();
const manifest = parseYaml(readFileSync(manifestPath, "utf-8")) as { name: string };
const installName = opts.name ?? manifest.name;
assertSafeInstallRef(installName);
assertTreeHasNoSymlinks(source);
mkdirSync(targetRoot, { recursive: true });
@@ -842,7 +839,14 @@ Examples:
if (targetExists) {
throw new Error(`A context pack named '${installName}' already exists at ${targetDir}. Remove it first or use --name to install under a different name.`);
}
cpSync(source, targetDir, { recursive: true });
const staging = mkdtempSync(join(targetRoot, ".tmp-add-"));
try {
cpSync(source, staging, { recursive: true });
mkdirSync(dirname(targetDir), { recursive: true });
renameSync(staging, targetDir);
} finally {
rmSync(staging, { recursive: true, force: true });
}
}
// Sync the daemon library so the new pack appears immediately.
const client = await getClient();
+2 -1
View File
@@ -3,6 +3,7 @@ import { copyFileSync, lstatSync, mkdtempSync, readFileSync, rmSync } from "node
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { formatDaemonHostForUrl } from "../client.js";
// `rig crash-cart --json` — the daemon-DOWN recovery verdict emit (plan c015d9ed §C3, coupling ruling
// option A). Prints ONE JSON = the 3-state detector verdict + (on DOWN) the discovery — READ-ONLY, a
@@ -87,7 +88,7 @@ async function realEmit(): Promise<CrashCartEmit> {
: "no daemon.json";
const url = openrigUrl
? `${openrigUrl.replace(/\/$/, "")}/healthz`
: `http://${state?.host ?? "127.0.0.1"}:${state?.port ?? 7433}/healthz`;
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
const probeResult = await probeClassified(url);
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
},
+40 -16
View File
@@ -2,6 +2,7 @@ import { Command } from "commander";
import fs from "node:fs";
import { randomUUID } from "node:crypto";
import { acquireDaemonStartLock } from "../daemon-start-lock.js";
import { formatDaemonHostForUrl } from "../client.js";
import { execFileSync, spawn } from "node:child_process";
import { fetchWithTimeout } from "../fetch-with-timeout.js";
import {
@@ -11,6 +12,7 @@ import {
readLogs,
tailLogs,
type LifecycleDeps,
type ProcessLiveness,
OPENRIG_DIR,
STATE_FILE,
resolveBindIntent,
@@ -32,6 +34,35 @@ export function createIsProcessAlive(deps: ProcessAliveDeps): (pid: number) => b
};
}
export type SignalOutcome = "sent" | "missing" | "not-permitted";
/** kill(pid, 0) as an outcome. POSIX: only ESRCH means no such process; EPERM means the process
* EXISTS but this shell may not signal it (another user's process, or a sandbox such as Codex's). */
export function signalProbe(pid: number, kill: (pid: number, signal: 0) => unknown = process.kill): SignalOutcome {
try {
kill(pid, 0);
return "sent";
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-permitted" : "missing";
}
}
/** #275 — three-state liveness for status reads. Only a missing process (ESRCH) or a zombie is
* dead. A process that exists but cannot be inspected (EPERM, or `ps` cannot run, as inside
* Codex's macOS sandbox) is UNKNOWN, never dead: calling it dead turned a running daemon into
* `stale` before any health probe. The boolean createIsProcessAlive (start/stop) is unchanged. */
export function createProcessLiveness(deps: {
signal: (pid: number) => SignalOutcome;
readProcessState: (pid: number) => string | null;
}): (pid: number) => ProcessLiveness {
return (pid: number) => {
if (deps.signal(pid) === "missing") return "dead";
const state = deps.readProcessState(pid)?.trim();
if (!state) return "unknown";
return state.startsWith("Z") ? "dead" : "alive";
};
}
type ExecFile = (file: string, args: string[], options: { encoding: "utf-8" }) => string;
// Windows has no ps(1) and no zombie state, so the signal probe alone decides liveness there.
@@ -50,18 +81,16 @@ export function readProcessState(
export function realDeps(): LifecycleDeps {
const isProcessAlive = createIsProcessAlive({
signalCheck: (pid) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
},
signalCheck: (pid) => signalProbe(pid) === "sent",
readProcessState: (pid) => readProcessState(pid),
});
const processLiveness = createProcessLiveness({
signal: (pid) => signalProbe(pid),
readProcessState: (pid) => readProcessState(pid),
});
return {
processLiveness,
acquireStartLock: () => acquireDaemonStartLock(OPENRIG_DIR),
spawn: (cmd, args, opts) => spawn(cmd, args, opts as Parameters<typeof spawn>[2]),
fetch: async (url) => {
@@ -189,13 +218,8 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
contextRoot: config.context.root,
skillsRoot: config.skills.root,
topologyRoot: config.topology.root,
// V1 pre-release CLI/daemon Item 1 — project the
// ConfigStore-resolved rotation tunables into the daemon
// process env so file-stored values
// (`rig config set transcripts.lines 500`) actually
// reach the rotation hook.
transcriptsLines: config.transcripts.lines,
transcriptsPollIntervalSeconds: config.transcripts.pollIntervalSeconds,
// Live transcript tunables are read from the shared config file.
// Only operator-provided environment overrides should mask edits.
// V0.3.1 slice 05 kernel-rig-as-default — propagated via
// OPENRIG_NO_KERNEL env var so the daemon's kernel-boot
// check in startup.ts honors the flag.
@@ -215,7 +239,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
? parseInt(opts.waitForKernelMs, 10)
: 60_000;
const baseUrl = `http://${state.host}:${state.port}`;
const baseUrl = `http://${formatDaemonHostForUrl(state.host ?? "127.0.0.1")}:${state.port}`;
const result = await waitForKernelReady(baseUrl, timeoutMs);
if (result.ok) {
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
+3 -2
View File
@@ -2,6 +2,7 @@ import { Command } from "commander";
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { formatDaemonHostForUrl } from "../client.js";
import { realDeps } from "./daemon.js";
import { getDefaultOpenRigPath, getPreferredOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
import {
@@ -73,7 +74,7 @@ function realDestroyDeps(): DestroyDeps {
stopDaemon: async () => { await stopDaemon(lifecycleDeps); },
inspectListener: async (host: string, port: number): Promise<ListenerInspection> => {
try {
const res = await fetch(`http://${host}:${port}/healthz`);
const res = await fetch(`http://${formatDaemonHostForUrl(host)}:${port}/healthz`);
if (!res.ok) {
return {
kind: "other_http",
@@ -131,7 +132,7 @@ function printPlan(
console.log("DESTROY PLAN");
console.log(` scope: ${scope}`);
console.log(` state root: ${runtimeConfig.stateRoot}`);
console.log(` daemon: http://${runtimeConfig.daemonHost}:${runtimeConfig.daemonPort}`);
console.log(` daemon: http://${formatDaemonHostForUrl(runtimeConfig.daemonHost)}:${runtimeConfig.daemonPort}`);
console.log(` backup: ${backup ? "enabled" : "disabled"}`);
console.log(` tmux cleanup: ${scope === "all" ? `enabled (${managedTmuxSessions.length} managed session${managedTmuxSessions.length === 1 ? "" : "s"})` : "disabled"}`);
for (const warning of warnings) {
+2 -5
View File
@@ -1,6 +1,6 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -10,10 +10,7 @@ export function discoverCommand(depsOverride?: StatusDeps): Command {
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
printDaemonNotRunning();
return null;
}
if (!daemonStatusGuard(status)) return null;
return deps.clientFactory(getDaemonUrl(status));
}
+3 -2
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import net from "node:net";
import { execSync } from "node:child_process";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { formatDaemonHostForUrl } from "../client.js";
import { ConfigStore } from "../config-store.js";
import { buildWritableHomeCheck } from "../system-preflight.js";
import {
@@ -212,7 +213,7 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
// configured host/port must not be reported missing). Defaults stay 127.0.0.1:7433.
const daemonHost = config.daemon.host ?? "127.0.0.1";
const daemonPort = config.daemon.port ?? DEFAULT_PORT;
const daemonBase = `http://${daemonHost}:${daemonPort}`;
const daemonBase = `http://${formatDaemonHostForUrl(daemonHost)}:${daemonPort}`;
const fetchFn = deps.fetch ?? globalThis.fetch;
const portCheck = deps.checkPort(daemonPort, daemonHost).then(async (available): Promise<DoctorCheck> => {
if (available) {
@@ -406,7 +407,7 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
fetchLiveLogicalIds: async (rigName: string): Promise<string[] | null> => {
try {
const cfg = new ConfigStore().resolve();
const base = `http://${cfg.daemon.host ?? "127.0.0.1"}:${cfg.daemon.port ?? DEFAULT_PORT}`;
const base = `http://${formatDaemonHostForUrl(cfg.daemon.host ?? "127.0.0.1")}:${cfg.daemon.port ?? DEFAULT_PORT}`;
const rigsRes = await fetch(`${base}/api/rigs`);
if (!rigsRes.ok) return null;
const rigs = (await rigsRes.json()) as Array<{ id?: string; name?: string }>;
+7
View File
@@ -47,6 +47,13 @@ Examples:
const res = await client.get<Record<string, unknown>>(`/api/rigs/${encodeURIComponent(rigId)}/env`);
if (res.status >= 400) {
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
else console.error(res.data?.["error"] ?? `Failed to get service status (HTTP ${res.status})`);
process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
+2 -2
View File
@@ -1,5 +1,5 @@
import { Command } from "commander";
import fs from "node:fs";
import { writeTextAtomically } from "../atomic-text-write.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
@@ -14,7 +14,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
const getDeps = (): ExportDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
writeFile: (p, content) => fs.writeFileSync(p, content, "utf-8"),
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
};
cmd
+23 -3
View File
@@ -97,7 +97,13 @@ export async function daemonQueueRows(address: string): ReturnType<HumanRowsLook
const res = await client.get<Array<{ qitemId?: string; state?: string; summary?: string | null }>>(
`/api/queue/list?destinationSession=${encodeURIComponent(address)}&state=pending,in-progress,blocked&limit=${limit}&compact=1`,
);
const data = Array.isArray(res.data) ? res.data : [];
if (res.status !== 200) {
throw new Error(`HTTP ${res.status}: queue enumeration refused`);
}
if (!Array.isArray(res.data)) {
throw new Error("HTTP 200: malformed queue enumeration response");
}
const data = res.data;
if (data.length < limit) {
return {
ok: true,
@@ -128,7 +134,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.requiredOption("--display-name <name>", "Human-readable display name")
.requiredOption(
"--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>",
"Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary. Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
"Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
)
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
@@ -191,7 +197,21 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
});
console.log(JSON.stringify({ ok: true, entityId, path: result.value.path, receipt: result.receipt }));
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
});
})
.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').
- role: "primary" (exactly one binding must be primary) or "secondary"
- handle: Optional platform user identifier (e.g. "handle=U12345678") for inbound routing
Example:
rig gateway human add alice --display-name "Alice" \\
--binding "slack:openrig-personal:file:/etc/openrig/slack.env:primary:handle=U12345678" \\
--delivery-class B`);
// ── S12 (OPR.0.5.5.12): the fragment lifecycle beyond add. Every verb operates through
// fragments + regeneration (the registry surface); none writes the generated projection.
+8
View File
@@ -252,6 +252,14 @@ function scopeLabel(scope: HealthScope): string {
function renderList(projection: HealthListProjection): void {
const evaluated = projection.evaluatedAt ?? "unavailable";
console.log(`Fleet health — evaluated=${evaluated} findings=${projection.total} limit=${projection.limit}`);
for (const c of projection.coverage ?? []) {
if (!c.partial) continue;
if (c.status === "unavailable") {
console.log(`UNAVAILABLE: ${c.source}: ${c.reason}. This source was not assessed; the result is partial.`);
continue;
}
console.log(`PARTIAL: ${c.source} evaluated ${c.evaluated} of ${c.total} ${c.unit} (limit ${c.limit}; ${c.omitted} omitted; order: ${c.order}). Omitted ${c.unit} were not evaluated and are not healthy.`);
}
if (projection.records.length === 0) {
console.log("No health findings match this bounded query. This is not a healthy assertion.");
console.log("Next inspection: widen only as needed with `rig health --instance --json`.");
+12 -3
View File
@@ -119,7 +119,7 @@ export async function probeHostStatus(
port = 22;
} else {
const u = new URL(host.url);
target = u.hostname;
target = u.hostname.replace(/^\[|\]$/g, "");
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
}
const r = await tcpProbe(target, port, timeoutMs);
@@ -160,10 +160,11 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
rows.push({ step: "remote-rig-binary", status: "pass", detail: `remote rig ${version.stdout.trim() || "(version unreadable)"}` });
const daemon = await deps.run(host, ["rig", "daemon", "status"]);
const daemonUp = daemon.ok && /running/i.test(daemon.stdout);
const daemonUp = daemon.ok && /^Daemon running\b/im.test(daemon.stdout)
&& !/\bUNHEALTHY\b/i.test(daemon.stdout);
if (daemonUp) {
rows.push({ step: "remote-daemon-health", status: "pass", detail: "remote daemon reports running" });
} else if (!daemon.ok) {
} else if (!daemon.ok || /\bUNVERIFIED\b/i.test(daemon.stdout)) {
rows.push({
step: "remote-daemon-health",
status: "unknown",
@@ -171,6 +172,14 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
fix: "on the host: verify with `rig daemon status` and an actual daemon-backed operation such as `rig ps --json`",
});
return rows;
} else if (/\bUNHEALTHY\b/i.test(daemon.stdout)) {
rows.push({
step: "remote-daemon-health",
status: "fail",
detail: "remote daemon reports a present process but unhealthy control plane",
fix: "on the host: inspect `rig daemon status` and /healthz; follow the reported recovery guidance",
});
return rows;
} else {
rows.push({
step: "remote-daemon-health",
+10 -2
View File
@@ -122,6 +122,7 @@ interface AgentReferenceWire {
agentName: string;
sourcePath: string;
profiles: string[];
kind?: "consumer" | "definition";
}
export function pluginCommand(depsOverride?: StatusDeps): Command {
@@ -167,6 +168,7 @@ export function pluginCommand(depsOverride?: StatusDeps): Command {
if (opts.source) params.push(`source=${encodeURIComponent(opts.source)}`);
const path = `/api/plugins${params.length > 0 ? `?${params.join("&")}` : ""}`;
const res = await client.get<PluginEntryWire[]>(path);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const entries = res.data ?? [];
if (opts.json) {
@@ -288,12 +290,13 @@ export function pluginCommand(depsOverride?: StatusDeps): Command {
// -- rig plugin used-by <id> --
cmd.command("used-by")
.argument("<id>", "Plugin id (e.g., openrig-core)")
.description("List agents referencing this plugin in their profile.uses.plugins[]")
.description("List profile consumers and resource definitions for this plugin")
.option("--json", "JSON output")
.action(async (id: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<AgentReferenceWire[]>(`/api/plugins/${encodeURIComponent(id)}/used-by`);
if (res.status === 404) throw new Error(`Plugin "${id}" not found`);
if (res.status !== 200) {
throw new Error(`Daemon returned HTTP ${res.status}`);
}
@@ -309,10 +312,15 @@ export function pluginCommand(depsOverride?: StatusDeps): Command {
return;
}
for (const r of refs) {
for (const r of refs.filter(ref => ref.kind !== "definition")) {
const profilesStr = r.profiles.length > 0 ? r.profiles.join(",") : "(none)";
console.log(`${r.agentName.padEnd(36)} [${profilesStr.padEnd(20)}] ${r.sourcePath}`);
}
const definitions = refs.filter(ref => ref.kind === "definition");
if (definitions.length > 0) {
console.log("Plugin definitions:");
for (const r of definitions) console.log(`${r.agentName.padEnd(36)} ${r.sourcePath}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
+40 -2
View File
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { Command } from "commander";
import YAML from "yaml";
import { DaemonClient } from "../client.js";
@@ -458,10 +459,12 @@ checkboxes do not accept an item under the selected proof policy.
}
fs.mkdirSync(proofDir, { recursive: true });
const frontmatter = YAML.stringify(header).trimEnd();
const content = `---\n${frontmatter}\n---\n\n${body}`;
try {
fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, { encoding: "utf8", flag: opts.replace ? "w" : "wx" });
if (opts.replace) replaceArtifactFile(target, content);
else fs.writeFileSync(target, content, { encoding: "utf8", flag: "wx" });
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
if (opts.replace || (err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
throw new ScopeCliError({
fact: `Proof artifact '${fileName}' already exists.`,
consequence: "The existing artifact was not changed.",
@@ -506,3 +509,38 @@ checkboxes do not accept an item under the selected proof policy.
return cmd;
}
/** Explicit --replace swaps the artifact's directory entry; it never writes through it. A symlink or hard
* link at the artifact name therefore keeps its other path's bytes. The staging file is created
* exclusively under a unique name, so cleanup only ever removes a file this call created, and a failed
* write, close or rename leaves the target as it was. An existing regular artifact's permission bits carry
* over, so a replacement never broadens access; a symlinked or new name gets default permissions. The
* first failure is the one reported. There is no fsync: this is a same-directory swap, not a
* crash-durability guarantee. */
export function replaceArtifactFile(target: string, content: string, stagingId: string = randomUUID()): void {
const staging = path.join(path.dirname(target), `.${path.basename(target)}.${stagingId}.replace-tmp`);
const existing = fs.lstatSync(target, { throwIfNoEntry: false });
const keepMode = existing?.isFile() ? existing.mode & 0o777 : undefined;
const fd = fs.openSync(staging, "wx", keepMode ?? 0o666); // never created wider than the artifact it replaces
let failure: unknown;
try {
if (keepMode !== undefined) fs.fchmodSync(fd, keepMode);
fs.writeFileSync(fd, content, "utf8");
} catch (err) {
failure = err;
}
try {
fs.closeSync(fd);
} catch (closeErr) {
if (failure === undefined) failure = closeErr;
else console.error(`warning: closing staging file ${staging} also failed: ${(closeErr as Error).message}`);
}
try {
if (failure !== undefined) throw failure;
fs.renameSync(staging, target);
} catch (err) {
try { fs.rmSync(staging, { force: true }); }
catch (cleanupErr) { console.error(`warning: could not remove staging file ${staging}: ${(cleanupErr as Error).message}`); }
throw err;
}
}
+107 -43
View File
@@ -1,4 +1,5 @@
import { Command } from "commander";
import stringWidth from "string-width";
import { resolveEffectiveHost } from "../host-selection.js";
import { sessionRigOf } from "../session-name.js";
import { DaemonClient, remoteDaemonClient } from "../client.js";
@@ -218,6 +219,7 @@ const ALLOWED_NODE_FIELDS = new Set([
interface PsCliOptions {
json?: boolean;
resources?: boolean;
nodes?: boolean;
full?: boolean;
verbose?: boolean;
@@ -823,8 +825,9 @@ Exit codes:
cmd
.option("--json", "JSON output for agents")
.option("--resources", "Show this host’s load and transcript capture cost (also supports --host)")
.option("--nodes", "Show per-node detail (current rig; -A for all rigs)")
.option("--full", "Show all node-list fields per node (uncompacted rows; node-list recoveryGuidance/currentUsage live on the node detail, not the list)")
.option("--full", "Show all rig rows without cell truncation, or all node-list fields with --nodes (recoveryGuidance/currentUsage live on node detail)")
.option("--verbose", "Alias for --full")
.option("--limit <n>", "Limit number of entries (rigs or nodes)")
.option("--fields <list>", "Comma-separated field list to project (JSON only)")
@@ -856,6 +859,11 @@ Exit codes:
// every remote path).
const sessionName = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
const callerRig = sessionName ? extractRigName(sessionName) : undefined;
if (opts.resources && (opts.nodes || opts.allRigs || opts.rig || opts.session || opts.allHosts || opts.hosts || opts.limit || opts.fields || opts.filter || opts.summary || opts.active || opts.includeArchived || opts.full || opts.verbose)) {
console.error("rig ps --resources supports --json and one --host; rig/node filters and fan-out do not apply to host measurements.");
process.exitCode = 1;
return;
}
const ladderError = validatePsLadder(opts, callerRig);
if (ladderError) {
console.error(ladderError);
@@ -913,6 +921,10 @@ Exit codes:
const client = deps.clientFactory(getDaemonUrl(status));
if (opts.resources) {
await handleResources(client, opts.json);
return;
}
if (opts.nodes) {
await handleNodes(client, opts, parsedFilter, limit, fields, useEnvelope, undefined, scopedToSessionRig);
return;
@@ -924,6 +936,8 @@ Exit codes:
const res = await client.get<PsEntry[]>("/api/ps?includeArchived=true");
if (res.status >= 400) {
const reason = (res.data as unknown as { error?: unknown } | null)?.error;
if (typeof reason === "string" && reason) console.error(reason);
console.error(`Failed to fetch rig list from daemon (HTTP ${res.status}). Check daemon status with: rig status`);
process.exitCode = 2;
return;
@@ -1014,35 +1028,39 @@ Exit codes:
const humanList = (opts.full || limit !== null) ? tableRows : tableRows.slice(0, HUMAN_RIG_BUDGET);
const humanTruncated = !opts.full && limit === null && tableRows.length > HUMAN_RIG_BUDGET;
const header = padRigRow("RIG", "NODES", "RUNNING", "ACTIVE", "WORK", "ATTN", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
console.log(header);
let anyArchivedShown = false;
for (const e of humanList as PsEntry[]) {
// OPR.0.3.3.19 - archived rigs only appear under --include-archived;
// mark them with a trailing "*" (legend footer below) so the operator
// can tell archived from active at a glance.
if (e.isArchived) anyArchivedShown = true;
console.log(padRigRow(
e.isArchived ? `${e.rigName ?? e.name} *` : (e.rigName ?? e.name),
String(e.nodeCount),
String(e.runningCount),
// Slice 15 — "—" when daemon predates the field; honest absence.
e.activeCount !== undefined ? String(e.activeCount) : "—",
e.hasWorkCount !== undefined ? String(e.hasWorkCount) : "—",
// OPR.0.4.4.21 — the founder's field-of-view anchor: where is
// something that might concern me. "—" = daemon predates the field.
e.attentionCount !== undefined ? (e.attentionCount > 0 ? `▲${e.attentionCount}` : "0") : "—",
e.status,
abbrevRigLifecycle(e.lifecycleState),
e.uptime ?? "—",
e.latestSnapshot ?? "—",
));
const headers = ["RIG", "NODES", "RUNNING", "ACTIVE", "WORK", "ATTN", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT"];
const rows = (humanList as PsEntry[]).map((e) => [
e.isArchived ? `${e.rigName ?? e.name} *` : (e.rigName ?? e.name),
String(e.nodeCount),
String(e.runningCount),
e.activeCount !== undefined ? String(e.activeCount) : "—",
e.hasWorkCount !== undefined ? String(e.hasWorkCount) : "—",
e.attentionCount !== undefined ? (e.attentionCount > 0 ? `▲${e.attentionCount}` : "0") : "—",
e.status,
abbrevRigLifecycle(e.lifecycleState),
e.uptime ?? "—",
e.latestSnapshot ?? "—",
]);
const widths = [24, 7, 9, 8, 6, 6, 10, 11, 11];
if (opts.full) {
for (const row of rows) {
for (let i = 0; i < widths.length; i++) {
widths[i] = Math.max(widths[i]!, stringWidth(row[i]!));
}
}
} else {
for (let i = 0; i < widths.length; i++) widths[i] = widths[i]! - RIG_COLUMN_SEPARATOR.length;
}
console.log(padRigRow(headers, widths));
for (const row of rows) console.log(padRigRow(row, widths));
const anyArchivedShown = humanList.some((e) => e.isArchived);
const hiddenHistory = stoppedCount > 0 || (!opts.includeArchived && archivedCount > 0);
if (anyArchivedShown || hiddenHistory || bareDefault || humanTruncated || truncated) console.log("");
if (anyArchivedShown) {
console.log("* = archived (hidden from the default view; shown via --include-archived). Reverse with: rig unarchive <rig>");
}
// FR-1 display element 2: history as ONE count line, never rows.
if (stoppedCount > 0 || (!opts.includeArchived && archivedCount > 0)) {
if (hiddenHistory) {
const parts: string[] = [];
if (stoppedCount > 0) parts.push(`${stoppedCount} stopped (rig ps --filter status=stopped)`);
if (!opts.includeArchived && archivedCount > 0) parts.push(`${archivedCount} archived (rig ps --include-archived)`);
@@ -1077,6 +1095,8 @@ async function handleNodes(
): Promise<void> {
const rigRes = await client.get<PsEntry[]>(psApiPath(opts), requestHeaders ? { headers: requestHeaders } : undefined);
if (rigRes.status >= 400) {
const reason = (rigRes.data as unknown as { error?: unknown } | null)?.error;
if (typeof reason === "string" && reason) console.error(reason);
console.error(`Failed to fetch rig list from daemon (HTTP ${rigRes.status}). Check daemon status with: rig status`);
process.exitCode = 2;
return;
@@ -1259,24 +1279,27 @@ function fitCell(value: string, width: number): string {
return truncate(value, width).padEnd(width);
}
function padRigRow(rig: string, nodes: string, running: string, active: string, work: string, attn: string, status: string, lifecycle: string, uptime: string, snapshot: string): string {
return [
fitCell(rig, 24),
fitCell(nodes, 7),
fitCell(running, 9),
// Slice 15 — distinct columns for the three orthogonal primitives.
// RUNNING = process-alive (legacy); ACTIVE = terminal-active (tmux);
// WORK = has-assigned-work (queue). UI/CLI render them separately so
// operators see which dimension differs at a glance.
fitCell(active, 8),
fitCell(work, 6),
// OPR.0.4.4.21 — ATTN: seats needing attention (the field-of-view anchor).
fitCell(attn, 6),
fitCell(status, 10),
fitCell(lifecycle, 11),
fitCell(uptime, 11),
snapshot,
].join("");
const RIG_COLUMN_SEPARATOR = " ";
const rigCellSegmenter = new Intl.Segmenter();
function fitRigCell(value: string, width: number): string {
if (stringWidth(value) > width) {
const limit = width - 1;
let prefix = "";
let used = 0;
for (const { segment } of rigCellSegmenter.segment(value)) {
const segmentWidth = stringWidth(segment);
if (used + segmentWidth > limit) break;
prefix += segment;
used += segmentWidth;
}
value = prefix + "…";
}
return value + " ".repeat(width - stringWidth(value));
}
function padRigRow(cells: string[], widths: number[]): string {
return cells.map((cell, i) => i < widths.length ? fitRigCell(cell, widths[i]!) : cell).join(RIG_COLUMN_SEPARATOR);
}
export function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, model: string, status: string, startup: string, oriented: string, lifecycle: string, terminal: string, work: string, activity: string, ctx: string, restore: string, error: string): string {
@@ -1374,6 +1397,7 @@ async function runCrossHostPs(
// SSH path — reconstruct argv
const argv: string[] = ["rig", "ps"];
if (opts.resources) argv.push("--resources");
if (opts.nodes) argv.push("--nodes");
if (opts.full) argv.push("--full");
// OPR.0.4.0.34: forward the breadth flag so `--host h -A` keeps all-rigs
@@ -1446,6 +1470,10 @@ async function runHttpPs(
const headers = buildRemoteHeaders(bearerResult.token);
try {
if (opts.resources) {
await handleResources(client, opts.json, headers, host.id);
return;
}
if (opts.nodes) {
await handleNodes(client, opts, parsedFilter, limit, fields, useEnvelope, headers);
return;
@@ -1667,3 +1695,39 @@ async function runFanOutPs(
if (hasFailure) process.exitCode = 3;
}
interface HostResources {
sampledAt: string; cpuCount: number; runningSeats: number;
loadAverage: number[] | null; loadPerCpu: number[] | null;
capture: {
rotatingSeats: number; idleSeats: number; captures: number; failures: number;
capturedBytes: number; captureDurationMs: number;
activeIntervalMs: number | null; lines: number | null; maxIdleIntervalMs: number | null;
settingsReloadError?: string | null;
};
}
async function handleResources(client: DaemonClient, json?: boolean, headers?: Record<string, string>, hostId?: string): Promise<void> {
const response = await client.get<HostResources>("/api/ps/resources", headers ? { headers } : undefined);
if (response.status >= 400) {
if (hostId && response.status !== 404) {
emitCrossHostError(hostId, classifyHttpFailedStep(response.status), `HTTP ${response.status}`, json);
return;
}
console.error(`Host resource measurements unavailable (HTTP ${response.status}); this host may need a newer daemon.`);
process.exitCode = 2;
return;
}
const data = response.data;
if (json) { console.log(JSON.stringify(data)); return; }
console.log(`Host: ${hostId ? `${hostId} · ` : ""}${data.cpuCount} available CPUs · ${data.runningSeats} running seats`);
console.log(data.loadAverage && data.loadPerCpu
? `Load (1/5/15m): ${data.loadAverage.map((n) => n.toFixed(2)).join(" / ")} · per CPU: ${data.loadPerCpu.map((n) => n.toFixed(2)).join(" / ")} (not CPU utilization)`
: "Load average: unavailable on this platform");
const capture = data.capture;
console.log(`Transcript capture: ${capture.rotatingSeats} rotating · ${capture.idleSeats} backed off · ${capture.captures} attempts · ${capture.failures} failures`);
console.log(`Capture cost for currently rotating seats: ${capture.capturedBytes} bytes · ${capture.captureDurationMs.toFixed(1)} ms elapsed across captures (not daemon CPU time)`);
console.log(`Effective capture: ${capture.activeIntervalMs ?? "unavailable"} ms active · ${capture.maxIdleIntervalMs ?? "unavailable"} ms idle ceiling · ${capture.lines ?? "unavailable"} trailing lines`);
if (capture.settingsReloadError) console.log(capture.settingsReloadError);
}
+134 -17
View File
@@ -1,4 +1,5 @@
import fs from "node:fs";
import { randomBytes } from "node:crypto";
import { Command } from "commander";
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
@@ -39,6 +40,8 @@ export interface VerifiedDeliveryResult {
connectorAccepted: boolean | null;
/** A connector receipt can never prove that a person read the message. */
humanReadership: "unknown";
/** #96: present for a --reply-to update once posted; false = it posted top-level instead. */
threaded?: boolean;
detail?: string;
nextAction: string | null;
}
@@ -59,7 +62,17 @@ export async function waitForDeliveryOutcome(
if (response.status !== 200) throw new Error(`receipt lookup returned HTTP ${response.status}`);
const outcome = response.data.deliveryOutcome;
if (outcome === "posted") {
return { outcome, connectorAccepted: true, humanReadership: "unknown", nextAction: null };
// #96: a --reply-to update reports whether it joined the earlier item's thread.
const replyTo = typeof response.data.replyTo === "string" ? response.data.replyTo : null;
const fallback = typeof response.data.replyToFallback === "string" ? response.data.replyToFallback : null;
return {
outcome,
connectorAccepted: true,
humanReadership: "unknown",
...(replyTo ? { threaded: fallback === null } : {}),
...(replyTo && fallback ? { detail: `posted as a new top-level message, not in ${replyTo}'s thread: ${fallback}` } : {}),
nextAction: null,
};
}
if (outcome === "transport-failed" || outcome === "never-posted") {
return {
@@ -137,6 +150,11 @@ async function withClient<T>(
}
function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400 && body && typeof body === "object"
&& (body as { error?: unknown }).error === "remote_queue_write_failed"
&& (body as { outcome?: unknown }).outcome === "indeterminate") {
console.error("The write outcome is INDETERMINATE if the request may have reached a daemon — reconcile by ID before any retry.");
}
if (json) {
console.log(JSON.stringify(body));
} else {
@@ -145,6 +163,33 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
// Recovery belongs to the selected daemon's reply; never infer a host from an ID.
async function printQueueItemResult(
client: DaemonClient,
qitemId: string,
json: boolean,
body: unknown,
status: number,
): Promise<void> {
if (status >= 400 && body && typeof body === "object"
&& (body as { error?: unknown }).error === "qitem_not_found") {
let selfHostId: string | undefined;
try {
const health = await client.get<{ selfHostId?: unknown }>("/healthz", { timeoutMs: 1_000 });
if (health.status === 200 && typeof health.data?.selfHostId === "string" && health.data.selfHostId.trim()) {
selfHostId = health.data.selfHostId;
}
} catch { /* Identity enrichment must not replace the original queue failure. */ }
const daemon = selfHostId ? `daemon ${JSON.stringify(selfHostId)}` : "the selected daemon (host ID unavailable)";
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`,
};
}
printResult(json, body, status);
}
// OPR.0.4.3.03 — `rig queue show` body preview.
//
// Default `show` renders a BOUNDED body preview instead of dumping the whole
@@ -397,7 +442,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
cmd
.command("create")
.description("Create a new qitem")
.option("--source <session>", "(deprecated, ignored) the source is derived from the seat env (X-OpenRig-Session); P21 I3 made the create route derive it from the transport header")
.option("--source <session>", "Declared source outside a managed seat (recorded as claimed:v1); the managed seat env takes precedence")
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
.option("--body <text>", "Qitem body inline (use - to read from stdin; mutually exclusive with --body-file)")
.option("--body-file <path>", "Read qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class for multiline bodies.")
@@ -409,11 +454,13 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
.option("--id <qitemId>", "Retry identity: reuse for the same create after an unknown outcome; otherwise generated and printed before sending")
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
.option("--summary <text>", "Short human-readable subject, shown in the needs-you view. For a human destination, --body-file is the complete decision brief or update; keep technical continuation in the owning agent row and evidence.")
.option("--human-intent <intent>", "decision (default) or update: a quiet informational delivery, never an approval request")
.option("--human-detail-file <path>", "One explicitly authored supplemental thread reply; keep the complete action/options in --body-file")
.option("--reply-to <qitemId>", "Post this update into an earlier qitem's Slack thread (requires --human-intent update; posts as a new top-level message instead if that thread can't be used, e.g. it is missing or still has an open human decision; --verify reports why)")
.option("--human-questions-file <path>", "#193: JSON array of 1-4 questions for a decision, each {id, question, options: [{id, label, recommended?}]} with 2-4 options; Slack shows them as buttons")
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: pointer to the durable artifact a human judges (e.g. a PROOF.md path). Required by the daemon when the item is human-routed; optional otherwise.")
.option("--host <id>", QUEUE_HOST_OPTION_HELP)
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
@@ -436,6 +483,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
targetRepo?: string;
humanIntent?: string;
humanDetailFile?: string;
replyTo?: string;
humanQuestionsFile?: string;
summary?: string;
evidenceRef?: string;
host?: string;
@@ -470,6 +519,35 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
return;
}
}
// #96: a thread root routes replies to the item that opened it, so only an update
// (which never takes a reply) may join another item's thread. Fail before the daemon.
if (opts.replyTo !== undefined && opts.humanIntent !== "update") {
const message = "reply_to_requires_update: --reply-to is accepted only with --human-intent update; a decision keeps its own thread so its reply stays unambiguous.";
if (opts.json) console.error(JSON.stringify({ error: "reply_to_requires_update", message }));
else console.error(message);
process.exitCode = 1;
return;
}
// #193 — read and parse the questions locally too; the daemon validates their shape.
let humanQuestions: unknown;
if (opts.humanQuestionsFile) {
let text: string;
try {
text = await resolveQueueBody({ bodyFile: opts.humanQuestionsFile });
} catch (err) {
emitBodyResolveError(err as Error & { fact?: string; consequence?: string; action?: string }, opts.json ?? false);
return;
}
try {
humanQuestions = JSON.parse(text);
} catch (err) {
emitBodyResolveError(Object.assign(new Error(`--human-questions-file ${opts.humanQuestionsFile} is not valid JSON: ${(err as Error).message}`), {
consequence: "The queue command did not run; the daemon was not contacted.",
action: "Pass a JSON array of questions, each {id, question, options: [{id, label, recommended?}]}.",
}), opts.json ?? false);
return;
}
}
// OPR.0.4.1.18 (FR-7, warn-then-require grace): a summary SHOULD accompany
// every new qitem (it feeds the Story node + helps humans skim). Warn — to
// stderr so --json stdout stays clean — but do NOT hard-break existing
@@ -479,9 +557,11 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
"warning: rig queue create called without --summary. Pass --summary <text> to set the new qitem's short human-readable summary; without it, the Story node falls back to a bounded body preview. A good summary is 1-2 plain sentences a human skims in the needs-you view — what the work is and why it needs this seat, not the agent-speak --body. Proceeding (pre-18 callers exempt).\n"
);
}
// P21 I3 reconcile: the source is DERIVED from the seat env (X-OpenRig-Session) — --source
// deprecated + ignored, no body sourceSession. Verify the env or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
if (!resolveCurrentSession(undefined, "source")) return;
// A managed seat keeps transport-derived identity; an external caller can
// name the existing claimed:v1 body actor without forging a transport header.
const managedSource = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
const source = resolveCurrentSession(managedSource ?? opts.source, "source");
if (!source) return;
const deps = getDeps();
// OPR.0.3.2.21.FR-4(b) — first-class --mission / --slice flags
// translate to canonical mission:<id> / slice:<id> tags. Composes
@@ -517,12 +597,29 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
return;
}
}
// Allocate before the write: a timeout/abort cannot tell us whether the
// daemon committed. The existing primary-key absorb makes a retry with
// THIS id safe; a negative read is not permission to mint another one.
// Keep timestamp-based fallback consumers, with 64 random bits so an
// accidental collision is not mistaken for an intentional retry.
const qitemId = opts.id ?? `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
const show = `rig queue show ${shellQuote(qitemId)} --full --json`;
const reconcile = hostResolved.hostId && hostResolved.hostId !== "local"
? `For destination host ${shellQuote(hostResolved.hostId)}, replace <destination-daemon-url> with its registered daemon URL and run: OPENRIG_URL='<destination-daemon-url>' ${show}.`
: `At the same endpoint and OPENRIG_HOME, run: ${show}.`;
const recovery = `${reconcile} If retrying, repeat the same create with --id ${shellQuote(qitemId)} and unchanged source, destination, body and options. A missing row does not rule out a pending commit; retrying without this ID creates new work.`;
// stderr survives an interrupted wait without adding a second JSON
// document to stdout. This is a request identity, NOT a commit receipt.
console.error(`Queue create request ID: ${qitemId} (not proof of persistence). ${recovery}`);
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
qitemId: opts.id,
...(managedSource === undefined ? { sourceSession: source } : {}),
qitemId,
destinationSession: hostResolved.destination,
body: resolvedBody,
humanIntent: opts.humanIntent,
humanDetail: opts.humanDetailFile ? await resolveQueueBody({ bodyFile: opts.humanDetailFile }) : undefined,
replyTo: opts.replyTo,
humanQuestions,
summary: opts.summary,
evidenceRef: opts.evidenceRef,
priority: opts.priority,
@@ -534,7 +631,27 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
// OPR.0.4.6.MH3 FR-1: the out-of-band host envelope (omitted for
// plain local writes — the local path stays byte-identical).
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
}).catch((error: unknown) => {
// A pre-header disconnect can follow a committed create. Preserve the
// typed error, but do not let the shared renderer assert nondelivery.
if (error instanceof DaemonConnectionError) error.writeOutcome = "unknown";
if (error instanceof DaemonConnectionError || error instanceof DaemonResponseError) {
// Keep the shared typed error/exit path, including --json, while
// supplying the identity missing from an unreadable/late response.
error.message += ` ${recovery}`;
}
throw error;
});
if (res.status >= 400 && res.data?.outcome === "indeterminate") {
// Forwarded writes may return a structured unknown outcome rather
// than throw. Preserve that failure and expose the same recovery ID.
printResult(opts.json ?? false, { ...res.data, qitemId, recovery }, res.status);
return;
}
const createWarning = res.data?.createWarning as { code?: unknown; message?: unknown } | undefined;
if (res.status < 400 && createWarning?.code === "qitem_body_not_saved" && typeof createWarning.message === "string") {
console.error(`Warning: ${createWarning.message}`);
}
if (opts.verify && res.status < 400) {
const created = res.data;
const qitemId = typeof created.qitemId === "string" ? created.qitemId : null;
@@ -566,7 +683,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -585,7 +702,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/unclaim`, {
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -636,7 +753,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
evidenceRef: opts.evidenceRef,
transitionNote: opts.note,
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -692,7 +809,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
wakeAfterSeconds: opts.wakeAfter,
transitionNote: opts.continuation ? `continuation: ${opts.continuation}` : (opts.note ?? `parked on ${opts.on}`),
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -735,7 +852,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
},
bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined,
);
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -828,7 +945,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
nudge: opts.nudge,
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -921,7 +1038,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
nudge: opts.nudge,
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -958,7 +1075,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
fallbackDestination: opts.destination,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -978,7 +1095,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)) {
printResult(json, item, res.status);
await printQueueItemResult(client, qitemId, json, item, res.status);
return;
}
const { preview, bodyBytes, bodyTruncated } = previewBody(item.body);
@@ -1002,7 +1119,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`);
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
+4 -3
View File
@@ -24,9 +24,10 @@ export function requirementsCommand(depsOverride?: StatusDeps): Command {
const res = await client.post<Record<string, unknown>>("/api/bootstrap/plan", { sourceRef: spec });
if (res.status >= 500) {
console.error(res.data["errors"] ?? res.data["error"] ?? "Failed to check requirements");
process.exitCode = 2;
if (res.status >= 400) {
if (opts.json) console.log(JSON.stringify(res.data));
else console.error(res.data["errors"] ?? res.data["error"] ?? "Failed to check requirements");
process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
+6 -1
View File
@@ -441,8 +441,13 @@ export function rigModeCommand(depsOverride?: RigModeDeps): Command {
if (opts.qitem) qs.set("qitem", opts.qitem);
const path = qs.toString() ? `/api/rig-mode/effective?${qs.toString()}` : "/api/rig-mode/effective";
const res = await client.get<EffectiveResponse>(path);
if (res.status >= 400) {
console.error(JSON.stringify(res.data, null, 2));
process.exitCode = 1;
return;
}
emitOperatingPosture(res.data.operatingPosture);
if (res.status >= 400 || !res.data.effective) {
if (!res.data.effective) {
console.log("Operating without an explicit operator-context-mode binding (unknown_posture).");
return;
}
+17 -22
View File
@@ -187,7 +187,7 @@ export function rigCommand(depsOverride?: RigDeps): Command {
// rig spec validate <path>
cmd
.command("validate <path>")
.description("Validate a rig spec (pure schema validation)")
.description("Validate a rig spec locally (no daemon required)")
.option("--json", "JSON output")
.action(async (filePath: string, opts: { json?: boolean }) => {
const deps = getDeps();
@@ -201,31 +201,26 @@ export function rigCommand(depsOverride?: RigDeps): Command {
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonStatusGuard(status)) return;
const client = deps.clientFactory(getDaemonUrl(status));
const res = await client.postText<{ valid?: boolean; errors?: string[]; name?: string; advisories?: string[] }>("/api/rigs/import/validate", yaml);
const { RigSpecParseError, validateRigSpecImport } = await import("@openrig/daemon/spec-validation");
let data: ReturnType<typeof validateRigSpecImport>;
try {
data = validateRigSpecImport(yaml);
} catch (err) {
if (!(err instanceof RigSpecParseError)) {
const error = "Internal validator error; validation did not complete.";
if (opts.json) console.log(JSON.stringify({ error }));
else console.error(error);
process.exitCode = 1;
return;
}
data = { valid: false, errors: [err.message] };
}
if (opts.json) {
console.log(JSON.stringify(res.data));
if (res.status >= 400 || !res.data.valid) process.exitCode = 1;
console.log(JSON.stringify(data));
if (!data.valid) process.exitCode = 1;
return;
}
if (res.status >= 400) {
const data = res.data;
if (data.errors && data.errors.length > 0) {
console.error(`Rig spec invalid:\n${data.errors.map((e) => ` ${e}`).join("\n")}\nFix: update ${filePath} and re-validate.`);
} else {
console.error(`Validation failed (HTTP ${res.status}). Check rig spec YAML syntax.`);
}
process.exitCode = 1;
return;
}
const data = res.data;
// OPR.0.5.3.3 — advisories are printed regardless of validity (fail-open; never an error).
if (data.advisories && data.advisories.length > 0) {
for (const a of data.advisories) console.error(`⚠ spec advisory: ${a}`);
+22
View File
@@ -356,6 +356,13 @@ function buildSliceCreateCommand(): Command {
const id = sliceIdFromMission(missionId, nn);
const dependsOn = Array.isArray(opts.dependsOn) ? [...new Set(opts.dependsOn as string[])] : [];
for (const dependency of dependsOn) {
if (dependency === id) {
throw new ScopeCliError({
fact: `Slice ${id} cannot depend on itself.`,
consequence: "Slice not created.",
action: "Use a different sibling slice ID, or omit --depends-on.",
});
}
if (!isSliceDotId(dependency) || !dependency.startsWith(`${missionId}.`)) {
throw new ScopeCliError({
fact: `Dependency "${dependency}" is not a sibling slice dot-ID under ${missionId}.`,
@@ -613,6 +620,12 @@ function buildSliceMoveCommand(): Command {
const newName = `${pad2(newNN)}-${slug}`;
const destAbs = path.join(targetSlicesDir, newName);
const sourceMission = findMission(missionsRoot, slice.missionName);
// Cross-mission dependencies cannot remain sibling edges. Name affected dependents at the move.
const dependencyWarnings = slice.id === null ? [] : listSlices(sourceMission, "all")
.filter((sibling) => sibling.absPath !== slice.absPath
&& Array.isArray(sibling.frontmatter.depends_on)
&& sibling.frontmatter.depends_on.includes(slice.id))
.map((sibling) => `${sibling.id ?? sibling.name} still has depends_on ${slice.id}; the moved slice is no longer a sibling. Review depends_on in ${sibling.readmePath ?? sibling.absPath}.`);
const edits = [
planMissionMembershipRemove(sourceMission.absPath, sliceManifestRef(sourceMission.absPath, slice.absPath)),
planMissionMembershipAdd(target.absPath, `slices/${newName}/slice.yaml`, nextMissionMembershipOrder(target.absPath)),
@@ -637,6 +650,7 @@ function buildSliceMoveCommand(): Command {
const { usedGit, repoRoot } = moveResult;
emit(out, {
ok: true,
warnings: dependencyWarnings,
moved: {
from: { mission: slice.missionName, name: slice.name, id: slice.id },
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
@@ -646,6 +660,7 @@ function buildSliceMoveCommand(): Command {
`Moved ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
` id: ${newSliceId}`,
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
...dependencyWarnings.map((warning) => `Warning: ${warning}`),
]);
} catch (error) {
if (moveResult) rollbackMovedSlice(slice.absPath, destAbs, moveResult);
@@ -819,6 +834,13 @@ function buildMissionCreateCommand(): Command {
const dependsOn = Array.isArray(opts.dependsOn) ? [...new Set(opts.dependsOn as string[])] : [];
const project = id.split(".")[0];
for (const dependency of dependsOn) {
if (dependency === id) {
throw new ScopeCliError({
fact: `Mission ${id} cannot depend on itself.`,
consequence: "Mission not created.",
action: "Use a different sibling mission ID, or omit --depends-on.",
});
}
if (!isMissionDotId(dependency) || dependency.split(".")[0] !== project) {
throw new ScopeCliError({
fact: `Dependency "${dependency}" is not a sibling mission dot-ID in project ${project}.`,
+43 -7
View File
@@ -163,7 +163,7 @@ function printHuman(status: SeatStatusResponse): void {
}
console.log(`Startup: ${display(status.startup_status, "unknown")}`);
console.log(`Occupant lifecycle: ${status.occupant_lifecycle}`);
console.log(`Continuity outcome: ${display(status.continuity_outcome, "unknown")}`);
console.log(`Continuity outcome: ${display(status.continuity_outcome, "unverified")}`);
console.log(`Handover result: ${display(status.handover_result)}`);
console.log(`Previous occupant: ${display(status.previous_occupant)}`);
console.log(`Handover at: ${display(status.handover_at)}`);
@@ -350,7 +350,7 @@ Examples:
.option("--operator <address>", "Operator initiating the handover")
.option("--dry-run", "Plan the handover without changing topology")
.option("--json", "JSON output for agents")
.description("Plan a safe two-phase seat handover")
.description("Hand a seat to a successor (two-phase). Pass --dry-run to plan without changing topology.")
.addHelpText("after", `
Examples:
rig seat handover spec-writer@openrig-pm --reason context-wall --dry-run
@@ -427,14 +427,22 @@ rig seat handover, THEN retarget the view. Examples:
cmd
.command("clear-attention")
.argument("<session>", "Canonical session name (e.g. dev-impl@my-rig)")
.option("--reason <text>", "Operator attestation override (skip evidence gate)")
.option("--reason <text>", "Attest startup or subset-restore attention; cannot bypass full-restore or pane-identity checks")
.option("--json", "JSON output for agents")
.description("Clear stuck attention_required startup status with evidence or operator attestation")
.description("Reconcile seat attention using the checks for its attention class")
.addHelpText("after", `
Examples:
rig seat clear-attention dev-impl@my-rig
rig seat clear-attention dev-impl@my-rig --reason "founder re-authed, confirmed live"
rig seat clear-attention dev-impl@my-rig --json
--reason can acknowledge startup-status and subset-restore attention. It does
not bypass full-restore continuity or pane-identity checks. Acknowledgment alone
does not prove that the original conversation resumed.
If the recorded native token needs correction and you know the actual token:
printf '%s' "$TOKEN" | rig seat set-resume-token dev-impl@my-rig --token-stdin --reason "verified native token"
Then rerun clear-attention to check the live evidence. Setting the token alone,
or stopping and relaunching the seat, does not prove continuity.
`)
.action(async (session: string, opts: { reason?: string; json?: boolean }) => {
const deps = getDeps();
@@ -482,7 +490,11 @@ Examples:
const route = `/api/seat/${path}/${encodeURIComponent(seat)}`;
res = path === "launch"
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 120_000 })
: await client.post<Record<string, unknown>>(route, body);
// #260: a dynamic Claude mode waits up to 5 s for the capability query before the
// daemon answers, so the 5 s default deadline would abort before its refusal arrives.
: path === "set-permissions"
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 10_000 })
: await client.post<Record<string, unknown>>(route, body);
} catch (err) {
if (path !== "launch" || !(err instanceof DaemonTimeoutError)) throw err;
const error = {
@@ -710,12 +722,36 @@ export async function runSeatHandover(seat: string, opts: HandoverActionOpts, de
if (!daemonStatusGuard(daemon)) return; // B8-1b: epistemic-matched
const client = deps.clientFactory(getDaemonUrl(daemon));
const res = await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(`/api/seat/handover/${encodeURIComponent(seat)}`, {
const handoverRoute = `/api/seat/handover/${encodeURIComponent(seat)}`;
const handoverBody = {
source: opts.source,
reason: opts.reason,
operator: opts.operator,
dryRun: opts.dryRun === true,
});
};
let res;
try {
// #260: a mutating handover launches and readies the successor, so it gets the
// launch request window. A dry run only plans, and keeps the default deadline.
res = opts.dryRun === true
? await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(handoverRoute, handoverBody)
: await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(handoverRoute, handoverBody, { timeoutMs: 120_000 });
} catch (err) {
// The daemon keeps working when the client stops waiting, so reaching the bound leaves a
// mutating handover's outcome unknown. One request; no retry.
if (opts.dryRun === true || !(err instanceof DaemonTimeoutError)) throw err;
const error = {
ok: false as const,
code: "handover_outcome_unknown",
status: "unknown",
message: "The CLI stopped waiting for the daemon after 120 seconds, so the handover outcome is unknown. The daemon may still be working on it.",
guidance: `Inspect the seat before considering another handover: rig seat status ${seat}. A handover result shown there may belong to an earlier attempt.`,
};
if (opts.json) console.log(JSON.stringify(error, null, 2));
else printSeatError(error, error.message);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
+6 -5
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
import { parseDocument } from "yaml";
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { formatDaemonHostForUrl } from "../client.js";
import { ConfigStore } from "../config-store.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
@@ -121,8 +122,8 @@ const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
{
scope: "global",
runtime: "claude-code",
path: "~/.claude.json",
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete.",
path: "~/.claude.json or <CLAUDE_CONFIG_DIR>/.claude.json",
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete. Explicit permission modes use the launch-selected HOME/config; classic startup uses the daemon home.",
},
{
scope: "project",
@@ -140,7 +141,7 @@ const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
{
scope: "global",
runtime: "codex",
path: "~/.codex/config.toml",
path: "$CODEX_HOME/config.toml (default: ~/.codex/config.toml)",
purpose: "Pre-trust managed workspaces and apply selected Codex config runtime-resource fragments.",
},
];
@@ -242,14 +243,14 @@ async function probeDaemonCmuxStatus(doctorDeps?: DoctorDeps): Promise<"availabl
const port = config.daemon.port;
try {
const healthRes = await fetchFn(`http://${host}:${port}/healthz`);
const healthRes = await fetchFn(`http://${formatDaemonHostForUrl(host)}:${port}/healthz`);
if (!healthRes.ok) return "skipped";
} catch {
return "skipped";
}
try {
const cmuxRes = await fetchFn(`http://${host}:${port}/api/adapters/cmux/status`);
const cmuxRes = await fetchFn(`http://${formatDaemonHostForUrl(host)}:${port}/api/adapters/cmux/status`);
if (!cmuxRes.ok || !cmuxRes.json) return "unavailable";
const data = (await cmuxRes.json()) as { available?: boolean };
return data.available ? "available" : "unavailable";
+8 -1
View File
@@ -201,14 +201,21 @@ export function slackCommand(deps: SlackDeps = {}): Command {
const scope = verification.value.scope!;
const member = verification.value.member;
const ready = scope.ok && (member ? member.isMember : false);
// Failed scope requests or an absent/empty grant header cannot prove
// which optional features are available. Baseline readiness is unchanged.
const missingFeatures = scope.error || scope.granted.length === 0 ? null
: surface.FEATURE_SCOPES.filter((feature) => !scope.granted.includes(feature.scope));
if (opts.json) {
log(JSON.stringify({ scope, member, ready, receipt: verification.receipt }));
log(JSON.stringify({ scope, member, ready, missingFeatures, receipt: verification.receipt }));
} else {
log(`granted scopes: ${scope.granted.join(", ") || "(none)"}`);
if (!scope.ok) log(`✗ MISSING scopes (configured != granted — reinstall the app): ${scope.missing.join(", ")}${scope.error ? ` [${scope.error}]` : ""}`);
else log("✓ all required scopes granted");
if (member) log(member.isMember ? `✓ channel member (${member.name ?? cfg.channel})` : `✗ NOT a member of channel ${cfg.channel} — invite the app`);
else log("… channel not configured — set --channel to verify membership");
for (const feature of missingFeatures ?? []) {
log(`⚠ ${feature.scope} missing: ${feature.usedBy}. Reinstall the app with this scope to use the feature.`);
}
log(ready ? "READY" : "NOT ready");
}
if (!ready) process.exitCode = 1;
+1
View File
@@ -206,6 +206,7 @@ Examples:
const client = await getClient();
const url = opts.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const entries = res.data ?? [];
if (opts.json) {
+5 -5
View File
@@ -212,8 +212,6 @@ Examples:
db: resolvedConfig.db.path,
transcriptsEnabled: resolvedConfig.transcripts.enabled,
transcriptsPath: resolvedConfig.transcripts.path,
transcriptsLines: resolvedConfig.transcripts.lines,
transcriptsPollIntervalSeconds: resolvedConfig.transcripts.pollIntervalSeconds,
workspaceRoot: resolvedConfig.workspace.root,
contextRoot: resolvedConfig.context.root,
skillsRoot: resolvedConfig.skills.root,
@@ -243,7 +241,8 @@ Examples:
if (kernelResult.kernelState === "skipped") {
if (!opts.json) console.log("Kernel auto-boot skipped (--no-kernel or test mode).");
} else {
console.error(`Kernel failed to start: state=${kernelResult.kernelState ?? "unknown"}, detail=${kernelResult.detail ?? "none"}`);
const terminalFailure = ["auth_blocked", "spec_missing", "bootstrap_failed", "degraded"].includes(kernelResult.kernelState ?? "");
console.error(`${terminalFailure ? "Kernel failed to start" : "Kernel did not report ready before the deadline"}: state=${kernelResult.kernelState ?? "unknown"}, detail=${kernelResult.detail ?? "none"}`);
console.error("Cannot proceed to rig restore without a working kernel.");
console.error("Fix: resolve the kernel issue, then rerun: rig start");
process.exitCode = 1;
@@ -269,9 +268,10 @@ Examples:
let allSummaries: RigSummary[];
try {
const res = await client.get<RigSummary[]>("/api/rigs/summary");
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
allSummaries = res.data ?? [];
} catch {
console.error("Failed to list rigs. Daemon may not be ready.");
} catch (err) {
console.error(`Failed to list rigs: ${err instanceof Error ? err.message : String(err)}. Daemon may not be ready.`);
process.exitCode = 1;
return;
}
+2 -1
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { formatDaemonHostForUrl } from "../client.js";
export interface StartupProofDeps {
env?: NodeJS.ProcessEnv;
@@ -47,7 +48,7 @@ export function resolveStartupProofEndpoint(deps: StartupProofDeps = {}): Endpoi
if (!baseUrl) {
const port = first(env["OPENRIG_PORT"], env["RIGGED_PORT"]);
if (port) baseUrl = `http://${first(env["OPENRIG_HOST"], env["RIGGED_HOST"]) ?? "127.0.0.1"}:${port}`;
if (port) baseUrl = `http://${formatDaemonHostForUrl(first(env["OPENRIG_HOST"], env["RIGGED_HOST"]) ?? "127.0.0.1")}:${port}`;
}
return baseUrl && token ? { baseUrl, token } : null;
+8
View File
@@ -56,6 +56,14 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
return;
}
if (status.state === "unverified") {
console.log(statusGuardMessage(status).fact);
if (status.siblingHint) {
console.log(` note: OPENRIG_HOME may be wrong — resolved ${status.siblingHint.resolvedHome}, live sibling ${status.siblingHint.siblingHome}`);
}
return;
}
// state === "running"
if (status.healthy === false) {
console.log(`Daemon running (pid ${status.pid}) but unhealthy — healthz failed`);
+4
View File
@@ -106,6 +106,10 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
await withClient(deps, async (client) => {
const body = { view, ...(opts.provider ? { provider: opts.provider } : {}) };
const res = await client.post<OpenViewResult>("/api/terminal/open", body);
if (!Array.isArray(res.data?.opened)) {
printResult(opts.json ?? false, res.data, res.status);
return;
}
printOpen(opts.json ?? false, res.data, res.status);
});
});
+4 -4
View File
@@ -183,11 +183,11 @@ function renderTranscript(data: Record<string, unknown>, useGrep: boolean): void
if (content) {
// Print each line via console.log for consistent capture in tests and terminal
const lines = content.split("\n");
// console.log supplies the final newline; keep every other empty line.
if (lines.at(-1) === "") lines.pop();
for (const line of lines) {
if (line) {
console.log(line);
printed++;
}
console.log(line);
if (line) printed++;
}
}
// OPR.0.5.3.1 item 4 — point-of-use hint: a near-empty transcript for a Claude
+17
View File
@@ -6,8 +6,13 @@ import { realDeps } from "./daemon.js";
export interface UiDeps {
lifecycleDeps: LifecycleDeps;
exec: (cmd: string, args: string[]) => Promise<void>;
/** Reads the daemon's own answer for the UI page. Defaults to fetch with a short timeout. */
probeUi?: (url: string) => Promise<{ status: number; headers: { get(name: string): string | null } }>;
}
export const UI_DISABLED_GUIDANCE =
"The OpenRig web UI is off on this daemon. To turn it on, run `rig config set ui.enabled true`, then stop and start the daemon (`rig daemon stop`, `rig daemon start`).";
export const UI_MAINTENANCE_NOTICE =
"The OpenRig UI is experimental and in maintenance mode. It is not under active development; support is best-effort. The CLI is the primary supported interface. Contributions welcome.";
@@ -47,6 +52,18 @@ export function uiCommand(depsOverride?: UiDeps): Command {
if (!daemonStatusGuard(status)) return;
const url = getDaemonUrl(status);
// Ask the running daemon, not local config: the setting takes effect when the daemon starts.
const probeUi = deps.probeUi ?? ((target: string) => fetch(target, { signal: AbortSignal.timeout(3000) }));
try {
const res = await probeUi(url);
if (res.status === 404 && res.headers.get("x-openrig-web-ui") === "off") {
console.error(UI_DISABLED_GUIDANCE);
process.exitCode = 1;
return;
}
} catch {
// No answer: open the URL as before; the browser shows whatever the daemon serves.
}
console.log(url);
try {
+73 -4
View File
@@ -30,7 +30,7 @@ async function defaultPromptYesNo(question: string): Promise<boolean> {
// inside the daemon install root without importing the daemon package.
function isPathInsideRoot(candidate: string, root: string): boolean {
const relative = nodePath.relative(nodePath.resolve(root), nodePath.resolve(candidate));
return relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative));
return relative === "" || (relative !== ".." && !relative.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(relative));
}
// OPR.0.4.4.11 (arch return R11-2) — pre-dispatch detection of a topology
@@ -126,7 +126,7 @@ Examples:
} else if (result.ok) {
console.log(JSON.stringify(result.data, null, 2));
} else {
console.error(`Error on host ${opts.host}: ${result.error}`);
for (const line of formatRemoteUpFailure(opts.host, result)) console.error(line);
process.exitCode = 1;
}
return;
@@ -193,8 +193,6 @@ Examples:
db: resolvedConfig?.db.path,
transcriptsEnabled: resolvedConfig?.transcripts.enabled,
transcriptsPath: resolvedConfig?.transcripts.path,
transcriptsLines: resolvedConfig?.transcripts.lines,
transcriptsPollIntervalSeconds: resolvedConfig?.transcripts.pollIntervalSeconds,
workspaceRoot: resolvedConfig?.workspace.root,
contextRoot: resolvedConfig?.context.root,
skillsRoot: resolvedConfig?.skills.root,
@@ -644,6 +642,77 @@ Examples:
return cmd;
}
/** Render a failed remote `rig up` response without stringifying an unknown
* body as `[object Object]`. JSON mode keeps the complete RemoteOpResult;
* human mode shows known daemon guidance and safely falls back for older or
* partial error bodies. */
export function formatRemoteUpFailure(
hostId: string,
result: { error?: string; data?: unknown },
): string[] {
const lines = [`Error on host ${hostId}${result.error ? `: ${result.error}` : ""}`];
const payload = result.data !== null && typeof result.data === "object" && !Array.isArray(result.data)
? result.data as Record<string, unknown>
: undefined;
const error = payload?.["error"];
let detailAdded = false;
if (typeof error === "string" && error.trim()) {
lines.push(error.trim());
detailAdded = true;
} else if (error !== null && typeof error === "object" && !Array.isArray(error)) {
const body = error as Record<string, unknown>;
const fact = typeof body["fact"] === "string" ? body["fact"] : undefined;
const consequence = typeof body["consequence"] === "string" ? body["consequence"] : undefined;
const action = typeof body["action"] === "string" ? body["action"] : undefined;
const message = typeof body["message"] === "string" ? body["message"].trim() : undefined;
if (fact && consequence && action) {
lines.push(...formatThreePart({ fact, consequence, action }));
detailAdded = true;
} else {
if (fact) lines.push(`Error: ${fact}`);
if (consequence) lines.push(consequence);
if (action) lines.push(action);
if (message) lines.push(message);
detailAdded = Boolean(fact || consequence || action || message);
}
}
if (!detailAdded && typeof payload?.["message"] === "string" && payload["message"].trim()) {
lines.push(payload["message"].trim());
detailAdded = true;
}
if (!detailAdded && Array.isArray(payload?.["errors"])) {
const errors = payload["errors"].filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
if (errors.length > 0) {
lines.push(...errors.map((entry) => ` ${entry.trim()}`));
detailAdded = true;
}
}
if (typeof payload?.["code"] === "string" && payload["code"].trim()) {
lines.push(`Code: ${payload["code"].trim()}`);
}
for (const node of Array.isArray(payload?.["attentionNodes"]) ? payload["attentionNodes"] : []) {
if (node === null || typeof node !== "object" || Array.isArray(node)) continue;
const entry = node as Record<string, unknown>;
if (typeof entry["logicalId"] !== "string" || typeof entry["reason"] !== "string") continue;
const session = typeof entry["sessionName"] === "string" ? ` (${entry["sessionName"]})` : "";
lines.push(` ${entry["logicalId"]}${session}: ${entry["reason"]}`);
detailAdded = true;
}
if (Array.isArray(payload?.["warnings"])) {
for (const warning of payload["warnings"]) {
if (typeof warning === "string" && warning.trim()) lines.push(` warning: ${warning.trim()}`);
}
}
if (!detailAdded && typeof result.data === "string" && result.data.trim()) {
lines.push(result.data.trim());
detailAdded = true;
}
if (!detailAdded) lines.push("Remote daemon did not return a recognized error message.");
return lines;
}
interface RestoreBlocker {
code: string;
severity?: string;
+3
View File
@@ -377,11 +377,14 @@ initial record is explicitly reported as unverified delivery.`)
if (opts.budget !== undefined) params.set("budget", opts.budget);
const res = await client.get<{
pieces?: Array<{ atomId: string; address: string; text: string }>;
warnings?: string[];
message?: string; error?: string;
}>(`/api/context-packs/library/by-ref/profile?${params.toString()}`);
if (res.status !== 200) {
throw new Error(res.data?.message ?? res.data?.error ?? `Daemon returned HTTP ${res.status} composing the profile.`);
}
// A skipped piece (a post-compaction compose with no seat recap) is reported, never silent.
for (const w of res.data.warnings ?? []) console.error(`WARNING ${w}`);
const profilePieces = res.data.pieces ?? [];
return {
// NO-COPY: content is the SERVED text, byte-for-byte; the label carries
+8 -4
View File
@@ -1,4 +1,5 @@
import type { DaemonClient } from "../client.js";
import { fetchWithTimeout } from "../fetch-with-timeout.js";
/**
* OPR.0.4.6.WF3 FR-1 — the shared follow engine behind `rig workflow
@@ -85,6 +86,8 @@ export interface FollowOptions {
io?: FollowIo;
/** SSE reconnect attempts before degrading to the poll fallback. */
maxReconnects?: number;
/** Deadline for opening SSE response headers; streamed bodies remain unbounded. */
streamConnectTimeoutMs?: number;
/** Poll fallback interval (ms). */
pollIntervalMs?: number;
}
@@ -178,6 +181,7 @@ export async function followInstance(
const io = opts.io ?? realFollowIo();
const maxReconnects = opts.maxReconnects ?? 3;
const pollIntervalMs = opts.pollIntervalMs ?? 3000;
const streamConnectTimeoutMs = opts.streamConnectTimeoutMs ?? 5000;
// Walk-caught (VM iteration 2): without aborting the SSE connection
// on return, the open socket keeps the node event loop alive and the
@@ -192,10 +196,10 @@ export async function followInstance(
const sseUrl = `${client.baseUrl}/api/workflow/sse`;
let streamRes: Response | null = null;
try {
streamRes = await io.fetchImpl(sseUrl, {
streamRes = await fetchWithTimeout(io.fetchImpl, sseUrl, {
headers: { Accept: "text/event-stream" },
signal: aborter.signal,
});
}, { timeoutMs: streamConnectTimeoutMs, timeoutMessage: "Workflow stream connection timed out." });
if (!streamRes.ok || !streamRes.body) streamRes = null;
} catch {
streamRes = null;
@@ -229,10 +233,10 @@ export async function followInstance(
reconnectsLeft -= 1;
io.err(`stream dropped — reconnecting (${maxReconnects - reconnectsLeft}/${maxReconnects})`);
try {
const retry = await io.fetchImpl(sseUrl, {
const retry = await fetchWithTimeout(io.fetchImpl, sseUrl, {
headers: { Accept: "text/event-stream" },
signal: aborter.signal,
});
}, { timeoutMs: streamConnectTimeoutMs, timeoutMessage: "Workflow stream connection timed out." });
if (retry.ok && retry.body) {
streamRes = retry;
continue;
+2 -5
View File
@@ -1,7 +1,7 @@
import { resolve } from "node:path";
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { followInstance } from "./workflow-follow.js";
@@ -35,10 +35,7 @@ async function withClient<T>(
fn: (client: DaemonClient) => Promise<T>,
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
printDaemonNotRunning();
return undefined;
}
if (!daemonStatusGuard(status)) return undefined;
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
+2 -5
View File
@@ -10,7 +10,7 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
import * as path from "node:path";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -40,10 +40,7 @@ async function withClient<T>(
fn: (client: DaemonClient) => Promise<T>,
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
printDaemonNotRunning();
return undefined;
}
if (!daemonStatusGuard(status)) return undefined;
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
+32 -7
View File
@@ -1,5 +1,6 @@
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { readFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
import { writeTextAtomically } from "./atomic-text-write.js";
import { join, dirname, resolve } from "node:path";
import { homedir } from "node:os";
import {
getDefaultOpenRigPath,
@@ -76,6 +77,8 @@ export interface RiggedConfig {
// Preview Terminal v0 (PL-018) — UI-side preferences for the live
// terminal preview pane.
ui: {
/** Serve the web UI and its terminal WebSocket. Off by default; the daemon reads it at start. */
enabled: boolean;
timezone: string;
preview: {
refreshIntervalSeconds: number;
@@ -239,6 +242,7 @@ const DEFAULTS = {
files: { allowlist: "" },
progress: { scanRoots: "" },
ui: {
enabled: false,
timezone: "America/Los_Angeles",
preview: {
refreshIntervalSeconds: 3,
@@ -377,6 +381,7 @@ export const VALID_KEYS = [
"ui.preview.max_pins",
"ui.preview.default_lines",
"ui.timezone",
"ui.enabled",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 Phase 4 SC-29 exception — allowlist-only additions.
@@ -471,6 +476,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
"ui.timezone": { primary: "OPENRIG_UI_TIMEZONE" },
"ui.enabled": { primary: "OPENRIG_UI_ENABLED" },
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
@@ -551,6 +557,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
"ui.timezone": ["ui", "timezone"],
"ui.enabled": ["ui", "enabled"],
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
@@ -764,6 +771,16 @@ function percentageConstraint(key: string) {
}
const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
"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]");
}
},
"transcripts.poll_interval_seconds": (raw, value) => {
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 3600) {
throw new Error("Invalid transcripts.poll_interval_seconds: must be an integer in [1, 3600]");
}
},
"ui.timezone": (_raw, value) => {
try {
if (typeof value !== "string" || !value || /^[+-]/.test(value)) throw new Error();
@@ -915,6 +932,11 @@ export class ConfigStore {
// resolves the legacy ~/.rigged sidecar), so a persisted read here IS proof the daemon sees it.
// A read-back MISMATCH means the write silently did not take — we REFUSE loudly rather than report
// a phantom success (the accept-and-drop / config-set-success-without-persist class).
/** Publish a complete config without truncating the previous usable file. */
private writeConfig(content: string): void {
writeTextAtomically(this.configPath, content, "config");
}
private verifyPersisted(keyPath: string[], expected: unknown): void {
let reread: Record<string, unknown>;
try {
@@ -999,6 +1021,7 @@ export class ConfigStore {
scanRoots: v("progress.scan_roots") as string,
},
ui: {
enabled: v("ui.enabled") as boolean,
timezone: v("ui.timezone") as string,
preview: {
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
@@ -1192,7 +1215,7 @@ export class ConfigStore {
const fcDyn = this.readConfigFile();
setNestedValue(fcDyn, ["feed", "subscriptions", feedHost.hostId, "enabled"], coercedDyn);
mkdirSync(dirname(this.configPath), { recursive: true });
writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8");
this.writeConfig(JSON.stringify(fcDyn, null, 2) + "\n");
this.verifyPersisted(["feed", "subscriptions", feedHost.hostId, "enabled"], coercedDyn);
return;
}
@@ -1215,7 +1238,7 @@ export class ConfigStore {
}
}
mkdirSync(dirname(this.configPath), { recursive: true });
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
this.writeConfig(JSON.stringify(fileConfig, null, 2) + "\n");
this.verifyPersisted(KEY_TO_PATH[key], coerced);
}
@@ -1226,7 +1249,9 @@ export class ConfigStore {
*/
reset(key?: string): void {
if (key === undefined) {
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
try { unlinkSync(this.configPath); } catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
return;
}
const removedMessage = removedContextSettingMessage(key);
@@ -1238,7 +1263,7 @@ export class ConfigStore {
const fcDyn = this.readConfigFile();
const subsParent = getNestedValue(fcDyn, ["feed", "subscriptions"]) as Record<string, unknown> | undefined;
if (subsParent && feedHost.hostId in subsParent) delete subsParent[feedHost.hostId];
writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8");
this.writeConfig(JSON.stringify(fcDyn, null, 2) + "\n");
return;
}
if (!isValidKey(key)) {
@@ -1265,7 +1290,7 @@ export class ConfigStore {
}
}
}
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
this.writeConfig(JSON.stringify(fileConfig, null, 2) + "\n");
}
private readConfigFile(): Record<string, unknown> {
+71 -19
View File
@@ -1,6 +1,8 @@
import path from "node:path";
import { existsSync } from "node:fs";
import type { ChildProcess } from "node:child_process";
import { connectionErrorCode, formatDaemonHostForUrl } from "./client.js";
import { blockedConnectionGuidance, isBlockedConnectionCode } from "./daemon-reachability.js";
import type { DaemonStartLock } from "./daemon-start-lock.js";
import { DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
import { ConfigStore } from "./config-store.js";
@@ -58,8 +60,13 @@ export interface DaemonStatus {
reason?: "unresponsive" | "event-loop-starved";
/** OPR.0.4.3.21 — event-loop evidence when healthz answered with a monitor. */
eventLoop?: DaemonEventLoopEvidence;
/** #275 — the OS code behind a failed health probe (e.g. EPERM when a sandbox blocked it). */
probeErrorCode?: string;
}
/** "unknown": the process may exist but this shell can neither signal nor inspect it. */
export type ProcessLiveness = "alive" | "dead" | "unknown";
export interface GetDaemonStatusOptions {
/** Observers may disable cleanup; only a matching clean shutdown permits removal. */
cleanupStaleState?: boolean;
@@ -67,7 +74,7 @@ export interface GetDaemonStatusOptions {
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
export function getDaemonUrl(status: DaemonStatus): string {
return `http://${status.host ?? DEFAULT_HOST}:${status.port}`;
return `http://${formatDaemonHostForUrl(status.host ?? DEFAULT_HOST)}:${status.port}`;
}
/**
@@ -83,6 +90,8 @@ export interface DaemonNotRunningError {
fact: string;
consequence: string;
action: string;
/** The OS code behind the failed probe, when known. */
causeCode?: string;
}
export function daemonNotRunningError(): DaemonNotRunningError {
@@ -106,11 +115,22 @@ export function statusGuardMessage(status: DaemonStatus): DaemonNotRunningError
if (status.state === "stopped" || status.state === "stale") {
return daemonNotRunningError();
}
const causeCode = status.probeErrorCode ? { causeCode: status.probeErrorCode } : {};
if (isBlockedConnectionCode(status.probeErrorCode)) {
const blocked = blockedConnectionGuidance(status.probeErrorCode);
return {
fact: `Daemon could not be checked: the health probe was blocked (${status.probeErrorCode}).`,
consequence: `This command needs a responsive daemon. ${blocked.consequence}`,
action: blocked.action,
...causeCode,
};
}
// unverified, or running-but-unhealthy: we do NOT know it is down.
return {
fact: "Daemon did not respond — it may be busy or stopped (state not confirmed).",
consequence: "This command needs a responsive daemon; the outcome of proceeding would be indeterminate.",
action: "Re-check with 'rig daemon status'. If it is confirmed stopped, run 'rig up' or 'rig daemon start'.",
...causeCode,
};
}
@@ -193,6 +213,9 @@ export interface LifecycleDeps {
openForAppend: (path: string) => number;
closeFile?: (fd: number) => void;
isProcessAlive: (pid: number) => boolean;
/** #275 — three-state liveness for status reads (production: realDeps). Optional so existing
* mocks keep the boolean rule; absent = derived from isProcessAlive. */
processLiveness?: (pid: number) => ProcessLiveness;
// OPR.0.4.2.1 — optional injectable delay for the status-probe bounded settle/retry.
// Defaults to a real setTimeout in production; tests pass a no-op to stay fast. Optional so
// existing deps / mocks / callers are untouched.
@@ -372,7 +395,7 @@ async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openr
if (!deps.isProcessAlive(state.pid)) return "dead";
const host = state.host ?? DEFAULT_HOST;
try {
await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
await fetchDaemonProbe(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
// Any response (ok or not) means something is listening on our port → OpenRig
return "openrig";
} catch (err) {
@@ -552,7 +575,7 @@ export async function verifyRequiredListeners(input: {
for (const host of required) {
// NO catch-collapse here (r2 finding): the probe classifies its own errors; an
// exception reaching this point is a wiring bug and should surface, not convert.
const outcome = await input.probe(`http://${host}:${input.port}/healthz`);
const outcome = await input.probe(`http://${formatDaemonHostForUrl(host)}:${input.port}/healthz`);
if (outcome === "healthy") verified.push(host);
else if (outcome === "unhealthy") missing.push(host);
else indeterminate.push(host);
@@ -619,7 +642,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
} else {
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${probeHost}:${port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
await fetchDaemonProbe(deps, `http://${formatDaemonHostForUrl(probeHost)}:${port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
@@ -690,7 +713,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
if (!Number.isSafeInteger(pid) || pid! <= 0) throw new Error("Daemon spawn returned no valid child PID");
if (hasExited()) throw new Error(`Daemon child ${pid} exited before startup completed`);
};
const healthzUrl = `http://${probeHost}:${port}/healthz`;
const healthzUrl = `http://${formatDaemonHostForUrl(probeHost)}:${port}/healthz`;
type StartHealth = { pid?: unknown; bind?: { mode: "explicit" | "default"; hosts: string[]; tailscaleDetected: boolean } };
const readOwnedHealth = async (url: string): Promise<StartHealth | null> => {
assertChild();
@@ -826,8 +849,8 @@ export async function stopDaemon(deps: LifecycleDeps): Promise<"stopped" | "no-t
const state = readState(deps);
const configured = resolveConfiguredDaemonTarget();
const explicitUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const stateUrl = state ? `http://${state.host ?? DEFAULT_HOST}:${state.port}` : undefined;
const target = stateUrl ?? explicitUrl?.replace(/\/+$/, "") ?? `http://${configured.host}:${configured.port}`;
const stateUrl = state ? `http://${formatDaemonHostForUrl(state.host ?? DEFAULT_HOST)}:${state.port}` : undefined;
const target = stateUrl ?? explicitUrl?.replace(/\/+$/, "") ?? `http://${formatDaemonHostForUrl(configured.host)}:${configured.port}`;
if (stateUrl && explicitUrl && new URL(explicitUrl).origin !== new URL(stateUrl).origin) {
throw new Error(`Cannot stop safely: addressed ${explicitUrl}/healthz does not match local PID ${state!.pid} at ${stateUrl}/healthz; no signal sent.`);
}
@@ -954,8 +977,8 @@ export async function getDaemonStatus(
const url = new URL(openrigUrl);
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop };
} catch (err) {
// 1ae863d2: refusal = positive down; anything else (timeout/wedged) = unverified.
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified" };
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...probeErrorCodeOf(err) };
}
}
@@ -963,7 +986,7 @@ export async function getDaemonStatus(
if (!state) {
const configured = resolveConfiguredDaemonTarget();
try {
const res = await probeHealthzWithSettle(deps, `http://${configured.host}:${configured.port}/healthz`);
const res = await probeHealthzWithSettle(deps, `http://${formatDaemonHostForUrl(configured.host)}:${configured.port}/healthz`);
const ev = await readHealthEvidence(res);
return {
state: "running",
@@ -977,12 +1000,15 @@ export async function getDaemonStatus(
// 1ae863d2: the resolved home has NO daemon state — before asserting anything,
// look for a live sibling home / HOME-MOVED marker (the wrong-home class).
const siblingHint = findSiblingHome(deps);
if (siblingHint) return { state: "unverified", siblingHint };
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified" };
if (siblingHint) return { state: "unverified", siblingHint, ...probeErrorCodeOf(err) };
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...probeErrorCodeOf(err) };
}
}
if (!deps.isProcessAlive(state.pid)) {
const liveness: ProcessLiveness = deps.processLiveness
? deps.processLiveness(state.pid)
: deps.isProcessAlive(state.pid) ? "alive" : "dead";
if (liveness === "dead") {
// A status read must not erase the identity needed to judge a failed stop.
const stateFile = resolveLifecycleFile(deps, "daemon.json");
const { receipt } = readShutdownReceipt(deps, stateFile);
@@ -992,26 +1018,47 @@ export async function getDaemonStatus(
return { state: "stale" };
}
// Process alive — check healthz
const host = state.host ?? DEFAULT_HOST;
if (liveness === "unknown") {
// #275: this shell can neither signal nor inspect the recorded PID (a sandbox, or another
// user's process). Never call that stale; let the daemon answer, and touch no state file.
try {
const res = await probeHealthzWithSettle(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`);
const ev = await readHealthEvidence(res);
return { state: "running", port: state.port, host, pid: state.pid, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop };
} catch (err) {
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", pid: state.pid, ...probeErrorCodeOf(err) };
}
}
// Process alive — check healthz
let healthy = false;
let reason: DaemonStatus["reason"];
let eventLoop: DaemonEventLoopEvidence | undefined;
let probeFailure: Pick<DaemonStatus, "probeErrorCode"> = {};
try {
const res = await probeHealthzWithSettle(deps, `http://${host}:${state.port}/healthz`);
const res = await probeHealthzWithSettle(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`);
const ev = await readHealthEvidence(res);
healthy = ev.healthy;
reason = ev.reason;
eventLoop = ev.eventLoop;
} catch {
} catch (err) {
// OPR.0.4.3.21 — pid alive but healthz timed out: the honest wedged-loop
// signal. Report process-present/unhealthy (NOT "stopped") with the
// "unresponsive" reason so the operator knows it's the control plane.
reason = "unresponsive";
probeFailure = probeErrorCodeOf(err);
}
// pid alive = running (state file preserved either way)
return { state: "running", port: state.port, host, pid: state.pid, healthy, reason, eventLoop };
return { state: "running", port: state.port, host, pid: state.pid, healthy, reason, eventLoop, ...probeFailure };
}
/** #275 — keep the OS code of a failed health probe (absent for a timeout). */
function probeErrorCodeOf(err: unknown): Pick<DaemonStatus, "probeErrorCode"> {
const code = connectionErrorCode(err);
return code ? { probeErrorCode: code } : {};
}
export function readLogs(deps: LifecycleDeps): string | null {
@@ -1194,8 +1241,11 @@ export async function waitForKernelReady(
const deadline = Date.now() + timeoutMs;
let last: KernelReadyResult = { ok: false, kernelState: null, variant: null, detail: null };
while (Date.now() < deadline) {
const remainingMs = Math.max(1, deadline - Date.now());
try {
const res = await fetch(`${baseUrl}/api/kernel/status`);
const res = await fetch(`${baseUrl}/api/kernel/status`, {
signal: AbortSignal.timeout(remainingMs),
});
if (res.ok) {
const body = (await res.json()) as {
kernel_state?: string;
@@ -1223,7 +1273,9 @@ export async function waitForKernelReady(
} catch {
// Transient fetch failure; keep polling until the deadline.
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
const sleepMs = Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()));
if (sleepMs <= 0) break;
await new Promise((r) => setTimeout(r, sleepMs));
}
return last;
}
+57
View File
@@ -0,0 +1,57 @@
// #275 (OPR.0.6.5.5) — "my rig command can't reach the daemon": BLOCKED is not DOWN.
//
// The ONE blocked-connection guidance, shared by the transport render (cli-error.ts) and the
// precheck guard (daemon-lifecycle.ts) so both entry points say the same thing. Kept apart from
// both so neither depends on the other.
import path from "node:path";
import { existsSync } from "node:fs";
const BLOCKED_CONNECTION_CODES = new Set(["EPERM", "EACCES"]);
/** True when THIS machine refused to make the connection (a sandbox or security policy), as
* opposed to a daemon that refused it. The daemon may still be running. */
export function isBlockedConnectionCode(code: string | undefined): code is string {
return code !== undefined && BLOCKED_CONNECTION_CODES.has(code);
}
const HELP_SECTION = "The agent is waiting for permission or can't reach the daemon";
/** Never advises starting a daemon, and names no access setting: whether a seat gets network
* access is the user's choice, taught in the help guide section it routes to. The guide is the
* offline copy, because `rig context get` itself needs the daemon this shell cannot reach. */
export function blockedConnectionGuidance(
code: string,
env: NodeJS.ProcessEnv = process.env,
helpPath: string | undefined = getOfflineHelpPath(),
): { consequence: string; action: string } {
const guide = helpPath
? `the help guide at ${helpPath}, section "${HELP_SECTION}"`
: `the help guide (https://www.openrig.dev/help/agents), section "${HELP_SECTION}"`;
const blocked = `This machine blocked the connection (${code}), so the daemon may still be running.`;
if (env.CODEX_SANDBOX_NETWORK_DISABLED === "1") {
return {
consequence: `${blocked} This shell runs in a Codex sandbox with network access off, which blocks the local OpenRig daemon (#275). The seat cannot change its own sandbox.`,
action: `Do not start another daemon. Tell the user this seat cannot reach OpenRig from its sandbox; whether it gets network access is their choice. See ${guide}.`,
};
}
return {
consequence: blocked,
action: `Do not start another daemon. Check what restricts this shell's network access, such as a sandbox or security policy. See ${guide}.`,
};
}
/** The help guide that ships with this CLI, readable without the daemon. `baseDir` is this
* module's directory (`src/` in a checkout, `dist/` when installed). In a source checkout its
* `docs/` wins, as in resolveDaemonPath; then the copy the package build places under
* `daemon/docs/reference/`. An installed package (under `node_modules`) never looks outside
* itself. Pure: `exists` is injected. */
export function resolveOfflineHelpPath(baseDir: string, exists: (p: string) => boolean): string | undefined {
const bundled = path.resolve(baseDir, "../daemon/docs/reference/help.md");
const installed = path.resolve(baseDir).split(path.sep).includes("node_modules");
const candidates = installed ? [bundled] : [path.resolve(baseDir, "../../../docs/reference/help.md"), bundled];
return candidates.find((candidate) => exists(candidate));
}
export function getOfflineHelpPath(): string | undefined {
return resolveOfflineHelpPath(import.meta.dirname, existsSync);
}
+17 -5
View File
@@ -54,13 +54,14 @@ export type ParsedFileArg =
export type FileArgParse = { ok: true; arg: ParsedFileArg } | { ok: false; error: string };
const HOST_ID_SHAPE = /^[A-Za-z0-9_-]+$/;
const DOTTED_HOST_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const SSH_USER_SHAPE = /^[A-Za-z0-9._-]+$/;
export const REMOTE_PATH_CHARSET = /^[A-Za-z0-9._/-]+$/;
/** The FR-4 short closed named deny list (dot-directory segments). */
export const DENIED_SEGMENTS = [".openrig", ".ssh", ".codex", ".claude"] as const;
export function parseFilePathArg(raw: string): FileArgParse {
export function parseFilePathArg(raw: string, isRegisteredHost: (id: string) => boolean = () => false): FileArgParse {
if (raw === "") return { ok: false, error: "empty path operand" };
if (raw.startsWith("-")) {
// G18-P1 belt: a flag-shaped operand is refused outright; the ./ escape
@@ -78,7 +79,10 @@ export function parseFilePathArg(raw: string): FileArgParse {
if (colon >= 0) {
const prefix = raw.slice(0, colon);
const rest = raw.slice(colon + 1);
if (HOST_ID_SHAPE.test(prefix)) {
// Dotted prefixes have always been valid local filenames. Only a positive
// registry match selects their remote meaning; undotted grammar stays lexical.
const registeredDottedHost = prefix.includes(".") && DOTTED_HOST_ID_SHAPE.test(prefix) && isRegisteredHost(prefix);
if (HOST_ID_SHAPE.test(prefix) || registeredDottedHost) {
// Id-shaped prefix: this IS a host qualifier — it must resolve or the
// command fails loudly (fail-closed; never a silent local fallback).
if (rest === "") return { ok: false, error: `remote path missing after '${prefix}:' — expected ${prefix}:<absolute-path>` };
@@ -221,9 +225,17 @@ function resolveRemoteSide(hostId: string, rawPath: string, deps: PlanDeps): { o
/** Validate the whole invocation — EVERY rejection here happens before any
* spawn (the fail-closed set; spawn-spy-asserted in tests). */
export function planFileCopy(rawSrc: string, rawDst: string, opts: { dryRun?: boolean } & PlanDeps = {}): PlanResult {
const srcParse = parseFilePathArg(rawSrc);
// Use one registry snapshot for both classification and validation. A remote
// choice must never turn into a local copy after a later resolution error.
let registry: ReturnType<typeof loadHostRegistry> | undefined;
const registryLoader = () => registry ??= (opts.registryLoader ?? loadHostRegistry)();
const isRegisteredHost = (id: string) => {
const loaded = registryLoader();
return loaded.ok && resolveHost(loaded.registry, id).ok;
};
const srcParse = parseFilePathArg(rawSrc, isRegisteredHost);
if (!srcParse.ok) return { ok: false, error: srcParse.error, code: "bad_operand" };
const dstParse = parseFilePathArg(rawDst);
const dstParse = parseFilePathArg(rawDst, isRegisteredHost);
if (!dstParse.ok) return { ok: false, error: dstParse.error, code: "bad_operand" };
if (srcParse.arg.kind === "remote" && dstParse.arg.kind === "remote") {
@@ -233,7 +245,7 @@ export function planFileCopy(rawSrc: string, rawDst: string, opts: { dryRun?: bo
const sides: CopySide[] = [];
for (const parsed of [srcParse.arg, dstParse.arg]) {
if (parsed.kind === "remote") {
const side = resolveRemoteSide(parsed.hostId, parsed.path, opts);
const side = resolveRemoteSide(parsed.hostId, parsed.path, { registryLoader });
if (!side.ok) return { ok: false, error: side.error, code: side.code };
sides.push(side.side);
} else {
@@ -1,12 +1,16 @@
# Progress — {{sliceName}}
> Durable acceptance state for this slice. In-process steps belong in the
> working agent's todo tool. See the `mission-slice-sop` skill and the
> conventions SSOT (`docs/reference/sdlc-conventions.md` in the repo,
> Current state and retained outcomes for this slice. In-process steps belong
> in the working agent's todo tool. For current acceptance under the selected
> proof policy, run `rig proof show <slice>`; this log is not a second checklist.
> See the `mission-slice-sop` skill and the conventions SSOT
> (`docs/reference/sdlc-conventions.md` in the OpenRig source repository,
> `$OPENRIG_HOME/reference/sdlc-conventions.md` when installed).
## Acceptance
## Current state
- [ ] Implementation complete
- [ ] Tests passing
- [ ] Review approved
[Record the current delivery state and the next action.]
## Outcomes
[Log material outcomes with links to the supporting proof. Retain historical entries.]
@@ -69,6 +69,17 @@ export function nextMissionMembershipOrder(missionPath: string): number {
/** Replace one or more already-validated manifests with rollback on write error. */
export function applyMissionCompositionEdits(edits: MissionCompositionEdit[]): void {
const unique = Array.from(new Map(edits.map((edit) => [edit.manifestPath, edit])).values());
// Check the whole batch before staging so an already-stale plan cannot
// overwrite another agent's authored membership or partially change a move.
for (const edit of unique) {
if (fs.readFileSync(edit.manifestPath, "utf8") !== edit.original) {
throw new ScopeCliError({
fact: `Mission composition changed since this edit was planned: ${edit.manifestPath}.`,
consequence: "Scope composition was not changed.",
action: "Read the current mission.yaml and retry the scope operation.",
});
}
}
const staged: Array<{ edit: MissionCompositionEdit; temporary: string }> = [];
const applied: MissionCompositionEdit[] = [];
try {
@@ -85,10 +85,10 @@ export function isPristineScaffoldSection(body: string | null): boolean {
});
}
/** The generic acceptance triple scaffolded by
* `packages/cli/src/lib/scope-templates/slice-progress.md` — exact trimmed
* literals, sync-tested against the shipped template so constant/template
* drift fails CI instead of silently reviving the bogus pristine count. */
/** The generic acceptance triple emitted by older slice-progress templates.
* Keep recognizing these exact literals in existing PROGRESS.md files so
* legacy scaffold marks never become authored acceptance evidence. New
* templates emit an outcome log instead; existing files are not migrated. */
export const GENERIC_SCAFFOLD_ACCEPTANCE: readonly string[] = [
"Implementation complete",
"Tests passing",
+9 -16
View File
@@ -22,7 +22,6 @@ import {
} from "./dot-id.js";
import { deriveMissionDependencyGraph, type MissionDependencyGraph } from "./scope-audit.js";
const FRONTMATTER_DELIM = "---\n";
const SLICE_DIRNAME_RE = /^(\d+)-(.+)$/;
// ---------------------------------------------------------------------
@@ -35,21 +34,15 @@ export function splitFrontmatter(content: string): {
frontmatter: Record<string, unknown>;
body: string;
} {
if (!content.startsWith(FRONTMATTER_DELIM)) {
return { frontmatter: {}, body: content };
}
const rest = content.slice(FRONTMATTER_DELIM.length);
const endIdx = rest.indexOf(`\n${FRONTMATTER_DELIM.trim()}\n`);
if (endIdx === -1) {
// Allow trailing `---` without a final newline (last char of file).
const endIdx2 = rest.indexOf(`\n---`);
if (endIdx2 === -1) return { frontmatter: {}, body: content };
const raw = rest.slice(0, endIdx2);
return { frontmatter: parseYamlSafely(raw), body: rest.slice(endIdx2 + 4).replace(/^\n/, "") };
}
const raw = rest.slice(0, endIdx);
const body = rest.slice(endIdx + `\n${FRONTMATTER_DELIM.trim()}\n`.length);
return { frontmatter: parseYamlSafely(raw), body };
const opening = /^---[ \t]*\r?\n/.exec(content);
if (!opening) return { frontmatter: {}, body: content };
const rest = content.slice(opening[0].length);
const closing = /^---[ \t]*(?:\r?\n|(?![\s\S]))/m.exec(rest);
if (!closing) return { frontmatter: {}, body: content };
return {
frontmatter: parseYamlSafely(rest.slice(0, closing.index)),
body: rest.slice(closing.index + closing[0].length),
};
}
function parseYamlSafely(raw: string): Record<string, unknown> {
@@ -195,6 +195,7 @@ export function parseClaudeTranscript(content: string): StructuredTranscript {
continue;
}
if (record === null || typeof record !== "object" || Array.isArray(record)) continue;
const recordType = typeof record.type === "string" ? record.type : "";
typeCounts[recordType] = (typeCounts[recordType] ?? 0) + 1;
Loaded 100 of 724 files, more files were not shown because too many files have changed in this diff. Show more