Compare commits

...
Author SHA1 Message Date
dev60-review a226b1b6cb docs(release): note the scrolled-pane stale-binding fix for 0.7.10
Add the #2644 entries to the 0.7.10 CHANGELOG section and whats-new, after
#2594's tmux mode item.
2026-11-07 07:51:52 +00:00
review50-r3 d83962a843 fix(transport): leave a pane's mode through the seat's address
A send to a seat whose pane was scrolled into copy mode tried to leave the
mode by the pane's raw tmux id. The input guard resolves a write's target to
exactly one seat, and a pane id can also match a stale binding elsewhere,
because tmux reuses pane ids after a server restart. The guard then refused
the write before typing anything, the pane stayed in its mode, and the send
was refused as pane_in_mode. Text and Enter were never affected: they are
addressed by the seat's session name.

Leave the mode through the session name too. The guard resolves the seat and
writes to its bound pane, as it does for text and Enter; the mode reads still
use the bound pane.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 3e0d7e7865)
2026-11-07 07:51:15 +00:00
dev60-review f90481bc23 docs(release): qualify plain rig in Codex seats by allow_login_shell support
The 0.7.10 notes and the capability-map line now say plain rig works however OpenRig was installed with a Codex that supports allow_login_shell, and the notes add that it hasn't yet run with a live Codex seat.
2026-11-07 06:10:10 +00:00
dev60-review cd8e32f18d docs(release): describe non-login Codex tool shells for 0.7.10
Carries #2613's 0.7.10 notes (the CHANGELOG section and whats-new) and adds one capability-map line: managed Codex seats run tool commands in a non-login shell, and launch.codex_login_shell defers to Codex's own setting at each seat's next launch.
2026-11-07 06:00:42 +00:00
dev-driver 6130b4308c test(codex): update six expectations for the non-login flag and its setting
Four daemon tests pin Codex launch commands or the resume call's shape.
They now expect -c 'allow_login_shell=false' where the launch adds it.

Two settings tests list the keys. They now include
launch.codex_login_shell, and the routes test also checks its default.

No assertion is weakened.

(cherry picked from commit 3c0d90e530)
2026-11-07 05:58:06 +00:00
dev-driver 6f0f0888c1 docs(config): name what non-login Codex tool shells can lose
The launch.codex_login_shell help and the getting-started reference now
name the trade-off. Tool commands inherit the seat's environment instead
of re-running login setup for each command. Setup that lives only in
login or rc files, including a tmux default-command that isn't a login
shell, may be missing, and so may shell functions and aliases.

Turning the option on omits OpenRig's override and defers to Codex's own
configuration. It doesn't force login shells. It applies at each seat's
next launch, and a restart in place keeps the running choice.

(cherry picked from commit 1c32da8ea1)
2026-11-07 05:58:06 +00:00
dev-driver d98f4759fe feat(config): launch.codex_login_shell keeps Codex's own login shells
Off by default, so managed Codex seats keep OpenRig's command folder on
their tool PATH. A seat's pane normally starts a login shell, so its tools
already see what login files export. A person whose panes don't (a tmux
default-command) and whose tools need setup that only their login files
make can turn it on. The rig config help says when, and what it costs.

Read at each Codex launch, by the runtime adapter and the legacy resume.

(cherry picked from commit b02bdef63a)
2026-11-07 05:58:06 +00:00
dev-driver bc793ba3ff fix(codex): keep OpenRig's command folder on managed seats' tool PATH
Codex gives each tool command the user's login-shell environment, then puts
back only its own directories. When a login file sets PATH outright, the
folder OpenRig puts first on a seat's PATH is dropped. A seat whose rig isn't
on the login PATH, such as one from a project-local install, then reports
"rig: command not found".

Managed Codex launches now pass -c allow_login_shell=false. Tool commands then
run in a non-login shell with the environment the seat's Codex was started
with, OpenRig's command folder included. Fresh, fork and resume launches add
it, including the legacy resume path.

An in-place restart keeps what the running Codex was launched with, as it
already does for --no-daemon and the network override. A seat launched
earlier picks the flag up at its next managed launch.

(cherry picked from commit 2385d48e41)
2026-11-07 05:58:06 +00:00
dev60-review 5eb917891a docs(release): a kept Herdr viewer keeps its earlier sizing while it runs 2026-11-07 04:45:43 +00:00
dev60-review 32531ae08e docs(release): 0.7.10's kept Herdr views keep working 2026-11-07 04:39:02 +00:00
Mike Schwarz c17f8a53d6 fix(terminal): keep Herdr views from earlier releases after upgrading (#2634)
Kept Herdr viewer journal entries and follow-view saved tiles store the whole tile attach argv. #2614 changed the writable tile script, and #2464 changed both scripts before it, so views kept from 0.7.5-0.7.9 failed the strict comparison after upgrading: a kept viewer read unconfirmed and a kept follow view stopped adding and closing tiles. earlierTileAttachment accepts a kept argv only when it exactly equals what 0.7.9's or 0.7.5-0.7.8's generator makes for the current session, tmux, server and recorded read-only intent; the entry then moves to the current command. A different target, a changed read-only intent, or any other script (including one sharing the old prefix) still mismatches as before. The earlier generators live in src/domain/terminal/earlier-tile-attach.ts, shared with the tests. The approach follows natefaerber's #2467. No new refusal, error code or setting.

(cherry picked from commit 6a46172df1)
2026-11-07 04:38:35 +00:00
dev60-review 3c8b72d54c docs(release): name the Herdr views kept from before 0.7.9 in 0.7.10 known limits 2026-11-07 03:02:10 +00:00
dev60-review 14d89431f9 docs(capability-map): teach what 0.7.10 added, and correct two lines it made stale
The packaged capability map takes in 0.7.10's changes, and its header marker
moves to capability-delta-v0.7.10, with the verb count at ninety-two (rig exec
is the new top-level verb).

- rig exec: a finite Claude Code or Codex turn through the harness's ACP
  adapter, run by a detached connector; the row stays the custody record, one
  run per row, status reads interrupted runs, and cancel's acknowledgement
  isn't a settled shutdown or a statement about spend. Not yet run with a real
  adapter.
- rig seat status: the latest observed permission request, with a fixed
  command descriptor that hides arguments and bounded suggested rules that can
  contain paths, patterns or sensitive values. Evidence only: it applies and
  approves nothing, lasts five minutes in memory and is lost on a restart.
- rig agent validate and launch advisories: unknown uses keys and a single
  value for uses.<category> are advisories; that value is a one-item list, so a
  member selecting an unavailable resource fails its resolution as before.
- Managed compaction: typed, unreadable or changed input still holds the
  preparation prompt and /compact; after it, a typed draft holds the step, and
  two idle polls that can't read the box send it, recorded as delivered over an
  unreadable box, so an unrecognised draft may receive the restore text.

Corrected: a send leaves every tmux mode, not one, and the OpenShell line names
0.7.10's source repairs while saying the worker hasn't run end to end. The
agent spec reference gains the single-value rule for uses. Docs only.
2026-11-06 21:48:43 +00:00
dev60-review c105b9c2f3 docs(release): describe 0.7.10 2026-11-06 20:43:28 +00:00
dev60-review 52d6dff860 chore(release): prepare 0.7.10 2026-11-06 20:42:47 +00:00
Mike Schwarz d539277a5f fix(terminal): let writable tiles follow their visible size (#2614)
tileAttachArgv chose ignore-size for a writable tile whenever another normal client was attached to the session, and under window-size latest such a tile could never size the pane, so the pane kept an older client's dimensions. Writable initial, fallback and replacement attaches, and the writable recovery command, now attach without ignore-size; read-only attaches keep -r and are byte-identical to before. This covers writable rig, pod and seat views, saved writable members through composeView, and interactive task viewers, locally, over inherited SSH and through the remote helper. The tmux control client's no-output,ignore-size and the CLI's attach-flag recognizer are unchanged. A real tmux test with a competing older client fails on the previous code (14x18 where 101x32 is expected) and passes now (101x32, then 121x43 on resize), with a larger read-only observer keeping its size. Limits: different simultaneous sizes on one shared pane follow tmux's latest-client rule; clients attached before this change keep their flags until re-attached.
2026-11-06 12:38:33 -08:00
Mike Schwarz bf5e8d8c21 fix(daemon): warn about unknown agent profile uses keys (#2618)
validateAgentSpec adds an advisory for any key under a profile's uses that isn't one of the five known categories, naming them, on the existing advisory channel, so it reaches the person at rig up, restore, retry and rig agent validate. It never makes a spec invalid, and hooks stays a validation error with no second advisory. The advisories for shapes that normalization drops now end "; ignored", and a non-string or blank list entry, which is kept and fails resolution when its profile is selected, now ends "; a member using this profile fails to launch". The agent spec reference says so in one sentence. No new validation error, refusal or setting.
2026-11-06 12:21:35 -08:00
Mike Schwarz b75ab56552 docs(changelog): carry the published 0.7.9 section onto main (#2616)
CHANGELOG.md gains the [0.7.9] section from the v0.7.9 tag (ddb9d25b), placed above [0.7.8], with the status line "Published 2026-11-06 as @openrig/[email protected], built from the commit tagged v0.7.9 (ddb9d25b)." Where the tag's section and the published release notes differ, it uses the published wording: the summary (including its credit to the people who reported or contributed), the OpenShell worker section, and the Known limits, including "What we ran end to end under the runner contract". The OpenShell heading is "The OpenShell worker in 0.7.9" so its anchor is unique in the file. Nothing outside [0.7.9] changes; whats-new.md stays as it is, since main's package test pins its heading to main's package version.
2026-11-06 11:43:34 -08:00
Wu Shuwen 033587c042 fix: validate agent profile resource selections (#2585)
A profile's uses.<category> written as a non-blank scalar validated but was silently dropped, so an agent could start without the selected skill. normalizeUses now turns a non-blank string into a one-item list for all five categories, and validation adds an advisory suggesting list syntax. A uses value that isn't an object, a category that isn't an array, and a blank or non-string entry are advisories, not new validation errors, so a malformed unused profile can't stop a spec; arrays reach resolution unchanged. When a profile is selected, its coerced scalar resolves exactly like a one-item list: a resource the pool doesn't hold fails that member's resolution with "not found in resource pool", and a non-string skill ID now returns "has a non-string skill ID" before discovery instead of throwing. The advisories travel with the spec's existing advisory channel. Limits: no trimming of scalar values (as with list entries); unknown uses keys are not yet advisories.
2026-11-06 11:31:36 -08:00
Mike Schwarz ab7f32e096 chore(release): bind the packaged-plugin baseline to published 0.7.9 and advance openrig-core to 0.1.26 (#2615)
scripts/fixtures/packaged-plugins-last-release.json now names release 0.7.9, its published archive (sha256 668b9305...) and the openrig-core 0.1.25 payload it shipped (f1838261...), byte-equal to a --snapshot of the published npm tarball and to the v0.7.9 tag's plugin tree. openrig-core moves from 0.1.25 to 0.1.26 in both harness manifests: its files changed on main after 0.7.9 (permission-stop descriptor, refocus and the trace script), and check-packaged-plugin-versions.mjs requires a greater version when packaged bytes change, so existing installs don't keep old content. The check fails at 0.1.25 and passes at 0.1.26; nothing else pins 0.1.25. No other file.
2026-11-06 11:17:20 -08:00
Mike Schwarz 829ddfccc7 docs(rigs): try a user-writable npm prefix before a project-local install (#2611)
The rigs skill's Install step offered only npm install -g @openrig/cli. When npm's global prefix isn't writable (often a system Node with prefix /usr), an installing agent improvised a project-local install, and Codex seats then reported rig: command not found. The step now has the agent first point npm's prefix at a folder the person owns whose bin is on their login PATH (for example npm config set prefix ~/.local when ~/.local/bin is on it), saying first that this changes their npm settings and, if needed, their shell profile; use the Node of a version manager such as nvm instead, since nvm refuses an npm prefix setting; and install inside the project only if neither is possible. It links getting-started's "Before you start" for setting and checking the login PATH. Both copies, skills/rigs/SKILL.md and packages/daemon/assets/skills/rigs/SKILL.md, stay identical. Text only; no code, command or gate. Limits: it makes the project-local fallback rarer but doesn't fix why managed Codex seats lose OpenRig's command folder after one (separate work); not run with a live agent install.
2026-11-06 09:38:15 -08:00
Mike Schwarz c97cd5c964 fix(events): page the notification drain (#2610)
The post-commit notification drain in EventBus read the whole remaining event tail into one array. It now reads ordered SQL pages of at most 500 rows (seq after the cursor, ordered by seq) and keeps paging until a read comes back empty. The per-row cursor, subscriber fanout, nested standalone and envelope emits, and poison recording are unchanged; replayAll(), SSE replay and client buffering, and event retention are untouched. One test commits a tail larger than two pages through real SQLite, with subscriber emits at page boundaries and a malformed row, and checks the same order for two subscribers, one poison notification, no redelivery, an uncapped replayAll(), and that no SQL read returns more than 500 rows. No new refusal, error code or setting. Limits: this bounds rows per query, not bytes; the drain still runs synchronously to quiescence; no latency change on a live install is claimed.
2026-11-06 08:20:25 -08:00
Mike Schwarz b571206ffd fix(daemon,cli): show agent spec advisories at launch and in rig agent validate (#2606)
validateAgentSpec already produced non-blocking advisories, but loadSpec dropped them, so only rig agent validate --json and the HTTP route ever showed them. loadSpec now carries them on ResolvedAgentSpec.advisories, rigPreflight turns each base or imported spec's advisory into a warning naming the member, the spec and its path, beside the existing import-collision warning, and those warnings ride the existing preflightWarnings that rig up prints. A recipe-backed fresh restore (launchExistingAgentMember) and a successful first-start retry now return the same advisories through their existing returned warnings, once per member, spec and advisory, without doubling where preflight warnings are already merged. Human rig agent validate prints one agent spec advisory line per item, valid or not; --json is unchanged. No new refusal, error code or setting. Limits: a native resume that uses saved context and doesn't reload a spec shows none; unknown uses keys are not yet advisories (a follow-up after #2585).
2026-11-06 06:55:58 -08:00
Samuel f4d1d962a9 docs(telemetry): define API contract and link external exporter (#2589)
docs/reference/telemetry.md gains a boundary: OpenRig's telemetry guarantee ends at the finite HTTP/CLI read envelope, and any projection of it is owned by an external consumer. It describes the separately maintained openrig-telemetry exporter's mapping and limits as that exporter's choices (sampling, observed state intervals, no native execution or trace attribution, downstream freshness), says arbitratedNeedsInput is true, false or null with null meaning no evidence either way, and links the exporter's repository, which documents its installation and supported versions; OpenRig doesn't test or guarantee it. It states OpenRig's own fact that the finite reads are versioned by schemaVersion 1 while the cached rig and node inventory endpoints are operational projections. finite-telemetry.test.ts adds one contract cell through the production telemetry routes: the envelope and limits, decimal IDs above JavaScript's safe-integer range across the archive and active tables, the note-free row shape, an opaque cursor that round-trips, page counters, the cap reason and a retained sequence gap. No exporter code, configuration or tests are included, and no daemon or CLI behaviour changes. Limits: the contract cell pins the envelope strictly, so an additive field under schemaVersion 1 needs the cell updated; the exporter itself wasn't reviewed or run here.
2026-11-06 05:59:17 -08:00
Wu Shuwen f68d28e417 docs: clarify Claude core plugin skill loading (#2584)
The agent-spec reference described plugin skills as always projected into the seat's skill folder. Since the duplicate-loading fix related to #2426, a Claude seat that selects the vendored openrig-core gets that plugin loaded directly, skills and hooks included, and resolveNodeConfig skips projecting its skills; other selected plugins, Codex seats and non-vendored copies still project. The skills section and the plugin table now say so, and that a seat that didn't select openrig-core doesn't get its hooks this way. Docs only. Limits: one sentence says the daemon loads the plugin through CLAUDE_CODE_PLUGIN_DIRS; the daemon sets that variable and Claude loads the plugin, as the table row says. The direct load depends on a Claude Code version that reads that variable, which refocus-channel.md states for the hooks.
2026-11-06 04:40:15 -08:00
Mike Schwarz ff49a2c362 docs: say a config fragment cannot install a Codex profile, and how to keep one (#2604)
agent-spec.md's fragment section now says a fragment only adds tables to config.toml, so it can't install a named Codex profile in either form: Codex reads a profile from its own <CODEX_HOME>/<name>.config.toml with top-level keys and won't load one while a legacy [profiles.<name>] table remains; it points to creating the file and selecting it with codex_config_profile, and notes that the profile preflight checks it before a launch or restore and names the file for a legacy table. getting-started.md says builtin:yolo drops the whole profile, not only its sandbox and approval settings, and how to keep a profile: full access inside the profile with permission_policy absent, or a workspace-write or read-only codex_permissions envelope, which keeps -p and adds its flags; full access isn't an envelope. rig-spec.md's codex_config_profile row says the same in one sentence. Docs only; no behaviour or contract change.
2026-11-06 02:57:33 -08:00
Mike Schwarz 06a0663b42 fix(openshell): use confirmed engine cleanup inventories (#2603)
Podman registers its native /libpod routes only under a version prefix, so the observer's unversioned /libpod container list and inspect calls got the router's plain-text Not Found, which the reader then failed to parse as JSON; an owned delete stopped unconfirmed at the cleanup inventory. The observer now uses the compatible /containers/json?all=true and /containers/<id>/json routes, which Podman serves with or without a prefix and whose Id, Names, Labels and Mounts fields the existing selection already reads. Volume absence no longer comes from a per-volume 404: one /volumes listing must be a 200 with an array of named Volumes and no Warnings, and presence is name membership in it; anything else is environment_cleanup_unconfirmed with the HTTP status. The reader parses JSON only for a 200, and malformed 200 inventory is a typed unconfirmed error. Socket identity, workload and supervisor selection, ownership and lifecycle are unchanged, and nothing is sent to the engine but GETs. Limits: container rows are still checked only for status and array shape, so a 200 of [{}] with an empty volume list would read as absent (unchanged here; Podman emits full records or an error); a thrown cleanup error still omits its stage and dispatch status; this is a snapshot, not an atomic inventory; maintained native owned-delete isn't requalified by this change.
2026-11-06 02:52:35 -08:00
Mike Schwarz 3a31311795 fix(cli): arm rig exec --wait's Ctrl-C before announcing the run (#2601)
rig exec run --wait installed its SIGINT listener only when polling began, after it had printed that the run had started, so a Ctrl-C landing between the two met the default action and killed the CLI with no exit code (the run itself, in its own session, was never affected). armWait now installs the listener before each path announces the run: a run the row already has, a caller that lost the claim to another, and a fresh start right after the connector is spawned, where the start-wait loop also stops when the waiter leaves. report removes the listener in a finally, and waitForEnd takes the waiter instead of arming its own. A tests-only afterAnnounce seam stalls the CLI just after the announcement; the new cell sends a Ctrl-C to the whole process group into that stall and expects exit 3, the stopped-waiting line and the run completing. Limits: a Ctrl-C before the connector is spawned still meets the default; an exception before report would skip the listener cleanup, though the CLI exits on it; the pre-announcement path is traced at source, not separately exercised; a run that has already ended still exits 0 or 2 by its outcome.
2026-11-06 02:32:50 -08:00
Mike Schwarz 6e55271447 fix(openshell): preserve supplied guest CA references (#2602)
The OpenShell guest's launch builds the Claude child's environment from scratch and copied only ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY, so a supervisor-supplied CA never reached the worker and its TLS failed with SELF_SIGNED_CERT_IN_CHAIN. The same explicit list now also carries NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, REQUESTS_CA_BUNDLE and CURL_CA_BUNDLE, each copied only if the guest's exec environment has it; when they're absent the launch is as before. No broad environment copy, no TLS bypass, no credential, and provider references, argv, latch and lifecycle are unchanged. Limits: NODE_EXTRA_CA_CERTS is the demonstrated repair for Claude; the other three are the CA selectors this exec supplies, kept for the agent's own OpenSSL-based tools, and no record isolates each as needed. This preserves the supplied references; it isn't a claim that the maintained OpenShell worker is qualified end to end (export, finite completion and exact restore are unproven).
2026-11-06 02:29:19 -08:00
Devulapalli Naga Sri Vaishnavi b12934a53f fix(cli): route self-qualified sends to the local host (#2600)
resolveCrossHostTarget returns localHost: true only from its self branch, when the qualifier is the daemon's own host ID, and only after the existing check that refuses an explicit --host naming a different host. rig send then leaves opts.host unset for that target, so a persisted remote selection no longer catches a send that names this host; the documented order (explicit --host, then target sugar, then the persisted selection) holds again. The early --context refusal now covers only fan-out, and the remote --context refusal runs after host resolution, so explicit, persisted and @remote sends with --context are still refused before any remote dispatch, now after the local health read. Limits: when the self-host ID can't be read, the qualifier isn't recognized as local and the selected host may still be used, as before; rig capture and rig transcript don't pass the self ID, so @<self> isn't stripped there (unchanged); the tests use a local HTTP fixture, not a real second host.
2026-11-06 01:27:20 -08:00
Mike Schwarz b79c76f0b6 feat(cli): rig exec runs a row's task on Claude Code or Codex in a detached connector (#2597)
rig exec runs a queue row's task on Claude Code or Codex through their maintained ACP adapters, detached: a hidden rig exec connector process owns the run and outlives the calling CLI, the run file under $OPENRIG_HOME/acp/runs records its process identity and typed state (requested, started, finished, interrupted), and the queue row keeps custody and acceptance and is never closed by the run. rig exec harnesses and run --plan show the adapter and native versions and how each harness answers. A row gets one run: claims are published once per generation with a create-exclusive hard link, a retry after an uncertain submit returns the existing run, and --again starts another only after it ended. Each run's start and end are separate slots, each published once, so a reconciler and the connector can't both settle the same transition. --wait only polls and survives Ctrl-C; its exit follows the outcome (0 completed, 2 otherwise, 3 if the waiter leaves). A dead or reused process, or a connector that never recorded itself, reads interrupted, outcome unknown. Cancel is acknowledged, not settled; usage and cost carry their source, and unavailable cost reads unknown. rig acp run stays synchronous and adds only a usage line. Limit: the crash and cancel guarantees are rig exec's; rig acp cancel given an exec run's id while it's requested can signal the requester rather than the connector, so use rig exec cancel.
2026-11-06 00:40:34 -08:00
Aummadour 430b676727 fix(queue): treat informational human updates as readable correspondence (#2586)
QueueRepository.isRegisteredHumanUpdate(row) is true only for a pending row with human_intent update whose destination resolves to a currently registered person; it reads false for an agent, an unregistered destination or an unavailable registry. The waiting view treats such a row as informational correspondence with no claim backstop, and the stuck sweep skips it as an unclaimed obligation, except when its quiet-hours hold has been orphaned (no live release job), which is still reported as before. The new resolution reason closes only the sweep's own unclaimed-obligation finding, never while the row is quiet-held or has a delivery outcome. A shared recovery for such an update can be relabelled to an undelivered human notice instead of being closed, so a later delivery failure stays visible. Posted and on-Steering rows keep today's paths. This covers the informational-update part of #514, not the rest of it.
2026-11-05 23:40:16 -08:00
Mike Schwarz 86c677837b fix(daemon): leave every tmux mode before a delivery (#2594)
With mouse mode on, scrolling a seat's pane puts it in copy mode, and tmux stacks modes: copy mode can sit under run-shell output (view mode), a chooser, customize or clock mode. A single send-keys -X cancel leaves only the top copy or view mode, so a pane with two modes stayed in a mode, and its sends and wakes were refused with pane_in_mode until someone left the mode by hand. cancelPaneMode now runs tmux copy-mode -q, which leaves every mode the pane is in and types nothing on a pane in no mode; when that fails it falls back to send-keys -X cancel, checking the input guard again first, as the Enter fallback does, so input that arrived meanwhile stops it. leavePaneMode reads the result and adds the reason to its refusal. Behaviour change: a send or wake now also leaves a chooser, customize or clock mode a person has open in a seat's pane, where it was refused before; #912 already leaves scrollback.
2026-11-05 23:28:05 -08:00
Mike Schwarz 77d6cfa09c docs: run Claude Code and Codex through rig acp run with their ACP adapters (#2596)
docs/reference/acp-agents.md adds "Claude Code and Codex, through their ACP adapters", checked on 2026-11-06 with OpenRig 0.7.8 on one small file task per case. It covers the install for @agentclientprotocol/claude-agent-acp 0.101.0 and @agentclientprotocol/codex-acp 2.5.0 and the native versions each runs (Claude through CLAUDE_CODE_EXECUTABLE when set, otherwise the SDK's platform binary, unchecked; installing with --omit=optional leaves that binary out, and without CLAUDE_CODE_EXECUTABLE the adapter then refuses to start rather than falling back to a claude on PATH). Each agent keeps its own permission mode; the settings that make each one ask are the caller's choice, not a recommended default. Claude's reject lets the turn continue, while Codex's only refusal, cancel, ends it. A missing session is refused by both with -32002 or -32603, and neither code alone means a missing session. A completed turn isn't finished work: check the work before closing the row; beyond one observed case, long and background work weren't checked. rig acp cancel was acknowledged in 20-30 ms for both, sent while the agent waited on a permission answer; a cancel mid-generation or mid-tool wasn't timed, and an acknowledgement isn't a settled shutdown or zero further spend. Docs only.
2026-11-05 22:51:56 -08:00
Mike Schwarz f78bbe759f docs: say what has run with a real dsh, and at what scope (#2595)
docs/reference/community-harness-examples/dsh/dsh.yaml's first limit now names the two stacks a real dsh 0.2.1-rc.2 has run on with this runner: dsh's Claude route with a Claude Haiku model in 0.7.8's release testing (an allow-once and a reject-once), and dsh's pi-ai OpenRouter chat-completions profile with deepseek/deepseek-v4-flash on OpenRig 0.7.8 (queue work, activity through a quiet tool call, an allow-once and a reject-once, and cancel). In both, the permission answers came from a test answerer registered as a person; dsh's DeepSeek Messages adapter via OpenRouter failed with HTTP 400, cause not established. Its resume limit says what was observed (a real dsh reopened and recalled a conversation with resume added to a copy of the preset; a missing conversation's refusal wasn't run) and resume stays undeclared. harness-runner-contract.md adds a "Since 0.7.8, on one stack" paragraph after its not-shown list. CHANGELOG.md's [0.7.8] Known limits line that said the dsh example was checked only against stand-ins is corrected in place, with the old sentence struck. Docs only.
2026-11-05 22:36:57 -08:00
NIKHILA cfa6118815 fix(daemon): resolve authored recap by bound session (#2579) (#2593)
rig seat set-runtime's handover read the seat's authored recap with authoredRecapResolver(ctx.seatRef), the logical ID (for example dev.impl), while recap folders follow the bound canonical session's member (dev-impl), so a pod seat's switch missed its own recap. It now passes the bound sessionName. A test covers a seat whose logical ID and member differ. Limit: the ordinary handover path still resolves recaps from its own input.seatRef, which already accepts logical aliases; that path is unchanged here.
2026-11-05 21:35:34 -08:00
billbenetti cdac54fef7 perf(queue): build one recovery index per listing instead of a json_each scan per row (#2588)
A queue listing asks findQueueRecovery once per row, and each ask ran json_each over every recovery-tagged row. withRecoveryIndex(db, fn) now opens a scope for one synchronous listing; inside it, the first lookup builds an index with the same json_each, the same recovery tag filter and order and text values only, and later lookups read it, while a compact listing that never asks builds nothing. Outside the scope every lookup is the original per-row query, and nested calls share the scope. QueueRepository.list wraps its existing body. Limits: the index assumes recovery data doesn't change during the listing; nothing a listing calls changes recovery membership, state or its ordering time, although the activity reader it calls can write other events, and a callee made async would simply fall back to the per-row query. Equivalence was traced in source and checked against an offline copy of a real queue; it wasn't measured on a live daemon, and malformed Unicode tag values weren't compared.
2026-11-05 21:34:39 -08:00
billbenetti f520ed5b0d fix(refocus): report a timed-out work lookup as an advisory (#2581) (#2587)
The openrig-core refocus hook marks a `rig queue whoami` lookup that ended in ETIMEDOUT and passes `--work-unknown-timed-out` with `--work-unknown`. With `--unresolved-work-advisory` (passed since #2582), trace-to-root.py prints "TRACE GAP — current work node UNKNOWN: queue whoami failed: spawnSync rig ETIMEDOUT" plus the project-level chain, labelled a broad orientation and not evidence of a current mission, and doesn't fail `--check`, so the timeout no longer counts toward "Refocus failed on N consecutive attempts". Non-zero exits, other spawn errors, unreadable answers, no remaining budget and a run without the advisory flag stay strict. A timed-out refocus now clears the pending post-compaction retry, as #2582's advisory does, so a slow daemon isn't queried again on every prompt. The skill digest is regenerated. Refs #2581: the part #2582 left open.
2026-11-05 21:08:25 -08:00
Mike Schwarz 42d8a74b91 docs: say what the runner contract has shown natively, and what it has not (#2592)
docs/reference/harness-runner-contract.md replaced "No harness has been run end to end under this contract yet, and the native verdict is pending" with what has run natively in 0.7.8's release testing, against a Claude Haiku model: dsh-seat 0.3.0 (dsh 0.2.1-rc.2 over ACP) for launch and first input, queue work, a multiline message with an /abort line as one prompt, working through a 52-second quiet tool interval, cancel by a bare /abort, the same conversation after rig down --snapshot and rig up, and the runner and child gone after the managed down; and the dsh example runner, whose native permission request reached a person, with allow-once taking effect and reject-once not. It also lists what wasn't shown natively: a DeepSeek model, the example runner's own resume, the default permit when nobody can be asked, a typed or Slack answer or a second person's answer, reboot recovery, and the model OpenRig observes. Any other runner, provider or path stays unqualified until it's shown. The status line above now matches. docs/reference/author-contracts.md's dsh/ACP row says the same in one sentence. Docs only.
2026-11-05 20:58:42 -08:00
Wu Shuwen 983c80f3a5 fix(cli): report conflicting roster activity as unknown (#2574)
rig roster show and find can see one seat through more than one route, for example two host aliases. They now build each route's activity (state, reason, sampled time) and compare them. When the routes agree on state and reason, the activity is kept, with the latest time if every route gave a comparable one and no time otherwise. When they disagree, or one route has no activity, the seat reads unknown with the reason "Conflicting activity observations" and no time, whatever the route order. A seat seen through one route reads exactly as before. Only the CLI's roster enrichment changes; the daemon is unchanged.
2026-11-05 19:19:03 -08:00
Wu Shuwen 8fa8e83d46 fix(refocus): report unresolved typed work as an advisory (#2582)
The openrig-core refocus hook passes a new --unresolved-work-advisory flag to the refocusing trace script. With that flag, when the work basis says typed work did not resolve (no matching node, or a mission or slice that resolves to more than one directory), the trace prints "TRACE GAP — no single current work node" with the reason, then the project-level chain labelled as broad orientation, not evidence of a current mission. The hook can then tell that advisory from an operational trace failure. Without the flag, and for conflicting tags, several fully resolved nodes, or an unreadable queue response, the script stays strict and exits nonzero as before. The skill's edge digest is regenerated. Refs #2581; the separate queue whoami timeout case is unchanged.
2026-11-05 18:48:24 -08:00
Wu Shuwen 0ff8482e76 feat(slack): report local relay attempts in flight (#2569)
rig slack status adds "Relay attempts awaiting response: N" for the local Slack relay, beside the retained inbound records. The daemon's inbound router counts each forward to another host while it's awaiting a response (incremented before the send, decremented when it settles, success or not), and the connector status reports that number. It's point-in-time and covers only the current connector instance: after the Slack connector is restarted, a relay the previous instance started can still be awaiting its response (up to the forwarder's 10 s timeout) while the new count reads 0. Zero doesn't prove quiescence across hosts or instances. Routing, retries, retained records and the forwarder are unchanged.
2026-11-05 18:45:13 -08:00
Mike Schwarz bd39da9872 fix(compaction): deliver a held post-compaction step after two idle unreadable polls (#2576)
Since 0.7.7, each managed post-compaction step (the turn boundary, the restore request, the audit) was pasted only into an input box that read empty or held a likely suggestion. A box OpenRig couldn't read as empty, such as one with the cursor outside it or a layout it doesn't recognise, held the step on every poll with no end, so a seat could sit idle with its restore never requested. Now, after two consecutive idle polls that read the box as unreadable (not typed and not typed with a suggestion), the next poll sends that step and records it as delivered over an unreadable input box, never as empty. A typed draft, typed text with a suggestion, a seat whose draft-aware delivery or typing guard holds OpenRig's input, and the step after a /compact whose result was lost all keep holding as before. rig compact --state and rig seat status say which applies. The owner notice names the manual takeover: rig compact <seat> --cancel, then a restore request naming the newest map, the restore marker and its packet; a step already being pasted can still land. An unrecognised draft in an unreadable box may receive the restore text. Refs #2575.
2026-11-05 13:03:09 -08:00
Mike Schwarz 74ca621314 fix(daemon): read the execution view's review registry once per read (#2578)
GET /api/views/execution (the TUI's execution view and rig view show execution) read and parsed every file in the global review registry once for each slice it built, all synchronously. With 450 review files that was 3,600 reads for an 8-slice mission and 13,500 for a 30-slice one, per request. It now reads the registry once per build and shares it across that build's slices and dependency ladders, and it yields to the event loop every 32 files or 8 ms while it walks them. The next build reads it again, so there's no cache across requests. Each slice's join is unchanged: slice-local proof drops come first, then the registry's, with the same slice match, the same local QA rule, and INDETERMINATE when neither root can be listed. A project-scoped view still never reads the global registry. The other synchronous parts of the view (the candidate query, the wave map, the default mission and serialization) are unchanged.
2026-11-05 12:52:54 -08:00
Aummadour 143bac76ef fix(daemon): identify terminal bearer authentication failures (#2577)
A rejected request on a route that uses the terminal bearer token (terminal follow and viewer, transport, compaction, and the terminal-protected session and node guards) now gets a 401 that says "Terminal auth failed" and tells the person to send OPENRIG_TERMINAL_BEARER_TOKEN, or OPENRIG_AUTH_BEARER_TOKEN when terminal auth falls back to it. Before, every such 401 said "Mission Control auth failed" and suggested setting OPENRIG_AUTH_BEARER_TOKEN and restarting the daemon, which was wrong when a separate terminal token was set. An optional diagnostic context on the shared bearer middleware changes only the wording. Token lookup, parsing, constant-time comparison, the access decision, the status code and the response's fields are unchanged, and no token value appears in a response. Mission Control routes keep their wording. Refs #2484.
2026-11-05 12:42:24 -08:00
Mike Schwarz 73cf8138e7 docs: teach what 0.7.9 changed in the map, references and three help texts (#2571)
The packaged capability map takes in 0.7.9's changes, and its header marker moves to capability-delta-v0.7.9. Two lines 0.7.9 made stale are corrected: an emit now opens or notes an intake row for queue.worker, and any registered person can settle an ask asked of someone else, recorded as themselves. It also covers indeterminate receipts, --closure-required-at, guidance placement for fresh Pi and Codex 0.164.0+ seats with the cases that still use the repository's AGENTS.md, what rig down takes back and keeps, the rebuild for wrapper and symlink Claude launches, and the daemon HTTP client reference. rig-spec's managed_blocks and guidance_merge rows and agent-spec's target row say where guidance normally goes. Help text: rig stream emit names the intake row and how to keep emits passive, rig send --verify says exit 1 can mean unconfirmed, and rig down says what it takes back and what it keeps. Docs and help text only.
2026-11-05 09:19:51 -08:00
Mike Schwarz af4aff14fe fix(cli,daemon): name pre-0.7.9 handovers and unconfirmed broadcast recipients (#2572)
rig seat stop, clean and in-place restart still refuse a session recorded as adopted. When the seat's last completed handover was a fresh, fork or rebuild handover, the refusal now says that a successor from before OpenRig 0.7.9 keeps the adopted origin until its next launched handover or managed launch. It no longer suggests rig unclaim, which would end OpenRig's management of the seat. rig seat stop's help says the same. rig broadcast prints UNCONFIRMED instead of FAILED for a recipient whose text or Enter may have reached it (input attempted or written), and points to rig capture before any resend. No refusal, error code or setting is added or removed.
2026-11-05 09:19:07 -08:00
Aummadour 4168fb468d Show sanitized permission request observations in seat status (#2562)
An optional Claude PermissionRequest hook in openrig-core records each permission request's time, actor category and a fixed tool descriptor, plus Claude's own permission_suggestions in their native spelling, labelled as suggestions (missing, unsupported or size-cut entries say so), in memory within the seat's actor, generation and expiry bounds. rig seat status and both seat-status routes show it when an observation exists, escaped as data. Nothing is applied, approved or persisted, and no hook decision or permission write is made. Refs #915. Contributed by @Aummadour.
2026-11-05 07:47:09 -08:00
Mike Schwarz 781b5fbf64 docs(capability-map): teach what 0.7.8 added and drop three sentences it made false (#2568)
The packaged capability map, which agents read on every install, drops three sentences 0.7.8 made untrue (mission following isn't included; a community harness gets no approvals; a later hold notice can show a generic reason) and adds what 0.7.8 shipped: the answered runner permission path with rig seat set-permissions-to, rig environment (with its 0.7.8 not-usable caveat), mission following, rig host edit and remove, terminal.tmux_server isolated, claude_mcp: none, doctor's port-owner warning, rig daemon status's pending-start line, the stopped-seat token switch, and the finished-map expiry states for automatic compaction. Docs only.
2026-11-05 06:46:36 -08:00
Mike Schwarz ec926c2e2d fix(cli): bound doctor cmux status and topology reads (#2564)
rig doctor bounds the cmux status read and the live rig and node reads behind --spec (2 s each, headers and body), through the existing fetchWithTimeout, so a listener that accepts connections but never answers can no longer hold it. A timeout keeps today's outcome: a cmux warning, or conformance unknown, never a matching topology. This finishes the waits #2549 left. Refs #2531.
2026-11-05 05:06:45 -08:00
Mike Schwarz e0a285968d fix(daemon): stream intake reads past default-permit records (#2563)
Stream intake reads past OpenRig's record of a default permit (the runner-permission-permit stream item): it advances its position with no intake row, note or wake, on live intake and after a restart, so the record stays pull-only and wakes no one. Ordinary observations, coalescing and the default permit are unchanged. Refs #2361. Refs #2321.
2026-11-05 04:56:41 -08:00
Wu Shuwen fe7e5393a5 fix(tui): preserve daemon launch failure messages (#2556)
When a daemon write fails with a JSON body that carries a message but no error field, the TUI's error now includes that message, so launch's session-unconfirmed advice reaches the person. An error field still takes priority, and a body that isn't JSON still shows only the status. Contributed by @dajiaohuang.
2026-11-05 04:35:21 -08:00
Wu Shuwen d84aeac91a test(daemon): wait for shell before transport probe (#2559)
The real-tmux transport test waits for a fixture-defined shell prompt before sending input, so a login shell that is still starting can't share a line with the command's output. The fixture's .profile sets that prompt, so the wait holds on Linux and macOS. Test-only. Contributed by @dajiaohuang.
2026-11-05 04:05:32 -08:00
Mike Schwarz 911c407d46 fix(daemon): say what other words do on a runner permission ask (#2561)
A runner permission ask now says what a reply other than yes or no does: it chooses nothing, OpenRig's default permits the request when the agent offered an allow option, and a runner that forwards the words passes them on only after its turn. Text only: answer parsing, the default permit and the optional forwarding are unchanged. Refs #2363.
2026-11-05 03:40:24 -08:00
Wu Shuwen f7113e5a5d fix(cli): bound doctor health header probes (#2549)
rig doctor bounds the wait for /healthz response headers when the daemon port is held by a listener that accepts connections but never replies, and reports an unknown listener instead of waiting forever; the conditional cmux health probe uses the same bound. Reported in #2531. Contributed by @dajiaohuang.
2026-11-05 03:37:15 -08:00
Wu Shuwen 80e568e9d9 fix(activity): expire stale window sampling evidence (#2558)
Window-sampling evidence older than the larger of 15 seconds and three poll intervals no longer decides a seat's activity, the same bound hook evidence already has. A seat whose pane the sweep can no longer read now reads unknown rather than its last idle or working verdict, so a stale idle sample can't support a parked diagnosis or an idle-gate wake. Fresh evidence behaves as before. Contributed by @dajiaohuang.
2026-11-05 03:01:53 -08:00
Mike Schwarz 0042a91277 feat(daemon): record each permission OpenRig grants a runner by default (#2361)
From 0.7.9, each permit OpenRig decides by default on a runner's permission request is recorded once, as a stream item from the seat (tag runner-permission-permit): when no one can be asked, and when an ask is closed without a choice, including a row passed to an agent seat. The record names the tool call, why no person answered (for a closed ask, who closed which row and how), the option the agent got with its kind, and the conversation and request or the ask. A record wakes no one, and one that can't be written doesn't change the permit. For an ask opened before 0.7.9, the record may omit the option kind and whether the harness remembers it. The harness runner contract's section 7 describes it.
2026-11-05 02:51:20 -08:00
Wu Shuwen 9b9e6c4097 docs: clarify kernel provider selection at first boot (#2560)
The quickstart explains that rig setup --providers records the provider choice a kernel uses when it is first created: with both providers signed in but one selected, the first kernel uses the selected provider. It also covers the OPENRIG_PROVIDER_SELECTED override the daemon reads, an existing kernel keeping its provider, and the warning when setup can't save the choice. Contributed by @dajiaohuang.
2026-11-05 02:47:31 -08:00
Wu Shuwen 512040e5c3 fix(cli): report ambiguous spec conformance targets (#2550)
rig doctor --spec no longer reports conformance against the first of several live rigs with the spec's name: it skips the check, says conformance is unknown, and lists the matching rig IDs. A unique name is checked as before. Reported in #2535. Contributed by @dajiaohuang.
2026-11-05 02:39:43 -08:00
Wu Shuwen faccc35040 fix(cli): clarify saved context pressure validation (#2553)
A refused context-pressure set or reset now says what it compared: the config file's pair, with defaults for values the file doesn't set. Environment overrides apply when each process resolves its configuration and are not part of this check. The validation itself is unchanged. Reported in #2529. Contributed by @dajiaohuang.
2026-11-05 02:39:11 -08:00
Wu Shuwen 78afeaa2e2 fix(daemon): show the current reason for a retained recovery (#2552)
A stuck-sweep recovery that stays open now names the reason that remains, so a row that is only overdue no longer carries a stale "stalled after claim" body. Reported in #2533. Contributed by @dajiaohuang.
2026-11-05 02:11:30 -08:00
Wu Shuwen acdf61532f fix(cli): reject ambiguous env rig names (#2548)
rig env down, status and logs resolve an exact rig ID first, and when an active rig name matches more than one rig they list the matching IDs and stop before any service request, instead of acting on whichever rig comes first. Reported in #2532. Contributed by @dajiaohuang.
2026-11-05 02:10:56 -08:00
Wu Shuwen aeb218a112 fix(cli): create namespace for URL context packs (#2546)
rig context add <url> can install into a fresh nested name: the URL installer now creates the missing parent namespace after the staged pack passes validation and just before the rename, the same step the local-directory installer takes. Reported in #2525. Contributed by @dajiaohuang.
2026-11-05 02:10:20 -08:00
Wu Shuwen d33aca86ea fix(daemon): preserve tmux 3.4 default command (#2557)
tmux 3.4's show-options -v output adds a backslash before a dollar sign followed by a letter, an underscore or a brace. When OpenRig reads back a scoped launch's default command from a stable tmux 3.4 server, it now removes exactly that added backslash, so the command runs as configured. Other and unknown tmux versions keep the existing reading, and a failed version read doesn't refuse the launch. Reported in #2555. Contributed by @dajiaohuang.
2026-11-05 01:55:57 -08:00
Mike Schwarz aaaeb8edc6 chore(release): bind the packaged-plugin baseline to published 0.7.8 and advance openrig-core to 0.1.25 (#2554)
The packaged-plugin version check now compares against the published 0.7.8 package (archive f175a5b1, openrig-core 0.1.24), and openrig-core moves to 0.1.25 in both harness manifests. Since 0.7.8, the plugin's seat-continuity-and-handover skill changed (#2420) while its version stayed 0.1.24, and the old 0.7.6 baseline let that pass, so existing installs could have kept the old file under the same version. Against the new baseline the check fails at 0.1.24 and passes at 0.1.25.
2026-11-05 01:53:41 -08:00
Wu Shuwen 59247e7a8f docs: correct installer setup incomplete output (#2551)
The README and docs/reference/getting-started.md describe the installer's real step-4 outcome: when the CLI installs but setup doesn't finish, it prints "INSTALLED, SETUP NOT COMPLETE (exit N)" and points to setup's own details and Fix. The "FAILED [n/4]" form is described for steps 1 to 3 only. Reported in #2538. Contributed by @dajiaohuang.
2026-11-05 01:34:37 -08:00
Mike Schwarz 16946c577a fix(daemon): the Pi runner shows a failed or cancelled compaction as such (#2537)
A Pi seat whose compaction Pi reports as cancelled now shows the compaction as cancelled, and one that failed shows it as failed with Pi's reason, instead of always reporting it done. The reason goes through the runner's existing diagnostic cleaner and is bounded to 400 characters. An event without either field still reads done. Reported in #2521 by @eisenrigbot.
2026-11-04 23:43:22 -08:00
Wu Shuwen 2a1e609cb1 docs(reference): clarify bundle archive determinism (#2543)
The bundle reference's determinism section now matches the packer: portable mode drops machine-specific owner metadata but can keep file mode bits, so identical bytes with different modes can produce different archive hashes. The package digest compares packaged content independently of file modes and manifest metadata. Reported in #2523. Contributed by @dajiaohuang.
2026-11-04 23:07:04 -08:00
Mike Schwarz fa3262d360 docs(roadmap): name 0.7.8 as the current release (#2534)
ROADMAP.md's "Released" section now names 0.7.8, the current release, instead of 0.7.6. Its summary is the published 0.7.8 notes' first sentence. Nothing else on the roadmap changes.
2026-11-04 21:42:45 -08:00
Mike Schwarz 94230b17bb fix(docs): the conformance check's Ctrl-C turn stays open until the cancel arrives (#2530)
The runner conformance check (`docs/reference/runner-conformance/check-runner.mjs`) could falsely fail a correct runner on a busy machine in case 2: Ctrl-C outside a paste must send a second `session/cancel`. The fake agent ended that turn on a fixed timer from the prompt, so a runner that handled Ctrl-C more than about two seconds after the press found the turn already over, and sent nothing. The turn now waits for the cancel, within the check's existing ceiling, so a late but correct runner passes. A runner that ignores Ctrl-C still fails; a new `ctrl-c-ignored` break pins that. 0.7.8's release notes name this false failure as a known limit.
2026-11-04 21:42:25 -08:00
Mike Schwarz 840aeaa687 ci: give the package test jobs 60 minutes instead of 45 (#2526)
The package-tests matrix's job limit in .github/workflows/tests.yml goes from 45 to 60 minutes, for all four package legs. On 2026-11-05 a daemon package job was cancelled at 45.5 minutes on a busy runner, while others ran 33 to 35 minutes, and each such cancellation costs a full rerun. Nothing else in the workflow changes.
2026-11-04 21:27:48 -08:00
Mike Schwarz b21466e8ae fix(preflight): warn when login shells cannot find rig (#2522)
A first user whose Node came from a tarball or a manual install, so it isn't on the login shell's PATH, could see Codex seats report `rig: command not found`. Preflight now runs the seat's login-shell lookup for `rig` with the declared environment, through the existing bounded probe, for Codex seats only, since their tools use a login shell. When it can't confirm `rig`, it prints an advisory warning with the fix, and launch continues. getting-started.md explains putting the Node bin directory on PATH in `~/.profile` (with Bash's profile order), and checking with `bash -lc 'command -v node rig'`. Found in Quality's 0.7.8 first-user test.
2026-11-04 21:27:27 -08:00
Mike Schwarz 190b0de73d test(compaction): keep manual-trigger cells' deadline on a fixed clock (#2527)
Five cells in claude-compaction-enforcer.test.ts now advance the fixture clock instead of waiting on the wall clock, as #2450 and #2463 did for their neighbours. Two of them failed unrelated pull requests' daemon jobs on 2026-11-05 when the runner was busy. The assertions are unchanged; test only.
2026-11-04 21:24:52 -08:00
Mike Schwarz f692215383 fix(daemon): a quoted permission question above Claude's empty input box is not a dialogue (#2520)
A finished Claude reply that quotes a permission question (for example "Do you want to proceed?") above the empty input box no longer makes the seat read as needing input, so a scheduled wake is no longer refused until the quote scrolls away. Above Claude's complete, empty, framed input box with its mode bar, a permission question now counts only when a numbered choice follows it, which every native permission menu draws. Real permission dialogs, including one drawn above the input box, still stop a send. A quote followed by a numbered list still reads as a dialog, as before. Found in the review of #2513.
2026-11-04 20:55:31 -08:00
Mike Schwarz de3b0658a6 docs: show a client reading people and open updates (#2524)
docs/reference/daemon-http-clients.md now has a recipe for a remote client that needs people and their updates. It covers:
- the registered participants and the default person (`GET /api/gateway/participants`);
- a person's open informational updates (`GET /api/queue/open-human-updates`, with its 1-100 limit, person view and `truncated` flag);
- one rig's outbox (`outbox/list?rig=`).

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

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

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

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

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

Reported in #2427; thanks to @gifflen.
2026-11-03 00:29:40 -08:00
Mike Schwarz 6a35cd39f3 fix(daemon): load the vendored openrig-core plugin's skills once on Claude seats (#2433)
A Claude seat no longer loads openrig-core's skills twice. Each seat's own `openrig` skill plugin received a copy of every selected plugin's skills, and the seat also loads the vendored `openrig-core` plugin directly for its hooks, which includes its skills. The copy is now skipped exactly when that direct load applies (a Claude seat, the vendored manifest present, and a selection that is `<plugins>/openrig-core`). Both routes reach Claude through `CLAUDE_CODE_PLUGIN_DIRS`, so no skill is lost. Existing seats change at their next fresh launch. Codex is unchanged.

Thanks to @billbenetti for the report.
2026-11-03 00:13:38 -08:00
Mike Schwarz 85881245b4 fix(typing guard): read a Claude suggestion with plain spaces between its words as empty input (#2435)
The draft-aware typing guard's input reader treated a Claude prompt suggestion (ghost text) as a person's draft unless every cell, spaces included, was dim, so a multi-word suggestion with plain spaces held every send and wake. It now uses the rule `rig capture`'s classifier already applies: a cursor-to-end suffix is a suggestion when every non-space character is dim, and any undimmed word keeps it a draft. Text before the cursor stays authored input. The test uses a retained Claude 2.1.296 frame with synthetic dim styling, not a capture of the reporter's 2.1.316 session; grey text without SGR dim is outside this change.

Reported in #2428 by @billbenetti.
2026-11-02 23:55:36 -08:00
Mike Schwarz e5cd19e25a fix(seat): record launched handover successors as managed (#2440)
A handover whose source OpenRig launches (fresh, fork or rebuild) records its successor session as launched, in the same transaction that registers it, as a runtime switch already does. `rig seat stop`, `restart` and `clean` then treat the seat like any seat OpenRig launched, instead of refusing it as "adopted, not launched by OpenRig". A discovered-source handover, and genuine `claim`, `adopt` and `attach` sessions, stay adopted and are still refused. Tenure, the reserved occupant generation, predecessor supersession and events are unchanged. Seats handed over before this keep their adopted origin until their next launched handover or managed launch.
2026-11-02 23:40:35 -08:00
Mike Schwarz 9d2d0d47d8 fix(pi): a Pi seat's managed guidance goes to its agent folder, never the tracked AGENTS.md (#2432)
A Pi seat's managed guidance now goes to its own agent folder (`PI_CODING_AGENT_DIR`'s `AGENTS.md`), so a person's tracked repository `AGENTS.md` stays byte-identical across launch, `rig down`, restore and exact resume. A fresh launch asks the adapter once and records the answer in a new nullable `node_startup_context.guidance_file` (migration 146); restore, the exact-resume put-back, teardown, inventory and snapshots all read that one target through `seatGuidanceFile`. Rows and snapshots recorded before this keep the repository file. An exact restore of an older snapshot records that snapshot's choice, and a symlink or hard link introduced after launch is reported as a gap rather than written through. Codex, OMP and Claude are unchanged; this does not stop Codex's tracked-file writes, and #875 stays open.
2026-11-02 23:19:22 -08:00
genius8267 7b858418f3 fix(cli): mark cut queue previews inside the body (#2422)
A bounded `rig queue show` body now ends with `…[cut at N of M chars; rig queue show '<id>' --full]`, so a reader that keeps only `.body` can see that more text exists and how to get it. Counts are code points, like the preview; the row ID is shell-quoted. `bodyTruncated`, `bodyBytes`, `readView`, the stderr notice and `--full` are unchanged, and short or empty bodies are untouched.

Thanks to @genius8267.
2026-11-02 21:15:44 -08:00
Mike Schwarz d7fc1fce0c fix(slack): a click on a re-parked ask records a new answer for that park and wakes its asker (#2421)
A click on a re-parked Slack ask records the person's new choice for that park and wakes the agent, instead of silently resuming it with the person's earlier answer. The click branch looks up the current park's answer, as typed replies already do; a direct answer to a blocked ask, typed or clicked, carries the park's episode key, so a re-park's click gets its own answer row, which is the owner's wake; and a complete stored set is final only for a pending ask, or for a park this person has already answered, with other questions' answers carried forward as on a typed re-park. The unused typedReply input flag is removed. The committed-answer guard for another person's answer to the current park and the winner check are unchanged, and a pending ask's clicks behave as before.

Source +11/-7 across three files; the pinned re-park click test now expects the new answer, plus one cell for a re-park over a complete set.
2026-11-02 20:34:26 -08:00
Mike Schwarz fa257fa12c fix(feed): say a person with no connector wasn't sent an ask in Slack (#2419)
For an ask to, or a park on, a registered person with no connector binding, the Feed's detail now reads "Slack: not sent: this person has no connector binding, so they're asked on Steering and in the Feed only", and its route line says "answer on Steering; it wasn't sent to Slack", the wording quiet asks already use. A real Slack receipt still takes precedence, and a Slack-bound person's wording is unchanged. Wording and row selection only, local to the attention route.

routes/attention.ts +16/-5; one test cell.
2026-11-02 20:21:25 -08:00
Mike Schwarz ba8d259966 fix(seat): a handover's receipt says whether its lineage record was written and how the old process ended (#2420)
A rebuild handover's receipt now says what happened. sideEffects.provenanceRecordWritten is measured: commit() reads the node's occupant tenure before and after recording the handover in its own transaction, and reports true when a new handover-kind tenure exists for the reserved generation; a swallowed mint failure reads false. A new sideEffects.departingProcess reports ended-graceful, ended-forced or not-touched, from the in-place termination the successor launch already performs. departingSessionKilled stays false, which is accurate: the tmux session is kept and the pane respawns. The CLI's human lines and the seat-continuity-and-handover skill now say whether the lineage record was written, how the old process ended, and that a bound seat whose record is missing shouldn't be handed over again.

7 files, +125/-20, most of it tests.
2026-11-02 20:06:56 -08:00
Mike Schwarz b35c2c5e93 fix: deliver maintained environment startup to the guest worker (#2418)
An environment seat's startup identity, selected role and context references, and text actions travel to the guest through a hash-checked first-input file, with a bounded initial prompt pointing to it; nothing is typed through the host viewer, so startup text can't reach the host shell if the viewer exits. Readiness is the guest execution's identity, generation and running state; only environment connect execs; the viewer does one live identity and generation lookup (driver 30 s, client 35 s) instead of serial file validation, while launch and the guest latch still validate. A failed owned delete reports its observed stage and error and whether dispatch is unknown, keeping the original intent, claim and same-operation reconciliation. An older guest helper's rejection of first input names the likely outdated image and the rebuild step.

Limits: a same-generation reconciled launch opens no viewer, and a non-text interactive startup action on an environment seat fails without host fallback.

313 lines added and 18 removed across 15 files, about two thirds of them tests.
2026-11-02 19:47:28 -08:00
Mike Schwarz 72d321228f fix(dsh example): read DSH_SEAT_BIN and DSH_SEAT_HOME, which a rig spec can set, and report dsh's real version (#2414)
The dsh example runner reads DSH_SEAT_BIN and DSH_SEAT_HOME, which a member's env: can set (OPENRIG_* stays reserved for OpenRig's own per-launch values and is still read first when exported). It doesn't read dsh's own DSH_HOME: a seat's shell runs the person's startup files after the seat's env:, so an exported DSH_HOME would rewrite their personal dsh profile and skip the uploads-off patch; any inherited DSH_HOME is still replaced with the seat's home. The launch record's producer takes dsh's version from dsh --version, killed with SIGKILL at a 5-second timeout so a wrapper that ignores SIGTERM can't hold the seat's start, falling back to the handshake's version, labelled as such. Not verified: a real dsh's --version output and whether it makes a network call.

Runner +23/-6; README, one preset limit line and the dsh example test.
2026-11-02 18:49:26 -08:00
Mike Schwarz a7683fd14d feat(daemon): any registered person settles another's ask as themselves, and a person may have no connector binding (#2386)
Any registered person in a shared workspace settles another person's ask as themselves, from the Feed, Steering, the TUI or Slack, and the record keeps who answered whose ask. The first committed answer is the answer on every surface and in either order: a later answer from someone else gets 409 qitem_already_terminal with answeredBy and records nothing, a click honours an answer already committed (on a pending ask or a park's current question), an interrupted close finishes as the first answerer, and a typed reply routed to a person who isn't the asker stays correspondence. A person may have no connector binding (rig gateway human add without --binding): they load through every registry reader, are reached in the Feed and Steering only, get no Slack post, and their asks never read never-posted or undelivered. The terminal's primary-user act is recorded as human@host, unattributed, whoever is registered.

Known limit: a click's option written before its answer row (and, on an ask with several questions, partial clicks completed or closed by another person) can stand beside or inside the closer's answer without the first clicker's authorship.

Source +210/-90, tests +455/-119.
2026-11-02 18:28:41 -08:00
Mike Schwarz 5de5c851aa fix: detect hook review in shell-backed kernel Codex panes (#2416)
The kernel hook-review reader now recognises a managed Codex seat whose pane reports the launcher shell: OpenRig runs managed Codex through a staged `/bin/sh` script, so the pane command reads `sh` and the reader's `codex` check never passed. For a non-`codex` pane it now requires the existing `verifyCodexPaneProcess` proof before and after the screen capture, with the same pane PID and lineage fingerprint, and keeps every binding, session, pane and dead check; an unverified chain stays unknown and neither raises nor clears an escalation. The direct `codex` path is unchanged. Nothing approves, edits or trusts hooks. The scheduler's tmux dependency gains `getPanePid`. Source proof, not a native pass.

Daemon +14/-5, tests +65.
2026-11-02 17:19:05 -08:00
w4rc0n 9e8ce9e078 fix(ui): For You no longer closes an ask that has questions with Approve (#2415)
For You shows Approve only on an ask with no structured questions, or a runner permission request, where a close means permitted. For an ask with questions it says to answer in the TUI (`rig tui`) or tell the agent in Chat, so the request stays open; an ask from a remote host names that host and doesn't suggest Chat. "Has questions" is a non-empty `humanQuestions`, the rule the daemon's attention view already uses, and the card's payload now carries the ask's questions and tags from the start. This is a UI guard only: the action API still accepts approve.

Reported in #2406. Contributed by @w4rc0n. UI +29/-7, tests +86/-1.
2026-11-02 17:09:04 -08:00
w4rc0n 4f61275582 fix(ui): keep the maintenance notice above the header, not over it (#2412)
The web UI's maintenance notice now renders in its own row above the header rather than over it, so the header's name, host label and Theme control are visible on first visit; dismissing it gives the space back. The notice and its existing dismissal storage move out of `App.tsx` into `UiMaintenanceNotice`, and the shell, explorer, shared detail drawer and proof image viewer take the notice's height into account on phones, and the phone rail's links stay above the bottom navigation and scroll.

Contributed by @w4rc0n. UI +68/-68, tests +38/-6.
2026-11-02 16:51:07 -08:00
w4rc0n cd01d2ac34 fix(ui): the Dashboard kernel card follows the dark theme (#2413)
The Dashboard's kernel card uses the UI's surface token instead of a fixed white background, so it follows the dark theme and its heading and status text stay readable.

Contributed by @w4rc0n. UI +3/-3, tests +9.
2026-11-02 16:29:10 -08:00
w4rc0n 6a2a4855b0 fix(ui): typing in the Topology table filter no longer hangs the tab (#2411)
The Topology table no longer auto-resets its page index when its data changes (`autoResetPageIndex: false`). The table has no pagination, and each reset queued a state change that re-rendered, rebuilt the data and reset again, so one keypress in "Filter agents" kept the tab busy. Filtering, clearing and sorting while filtered now settle. A regression test types into the filter and checks that it settles.

Contributed by @w4rc0n. UI +3, tests +44.
2026-11-02 16:28:33 -08:00
Mike Schwarz 95f0ae6d67 fix(compaction): report manual idle-wait expiry by map readiness (#2400)
A manual `rig compact` whose idle wait expires now reports `map_complete_seat_never_idle` when a completed restore map exists and `preparation_incomplete` when no completed map remains, matching the automatic preparation-expiry path. Other transport failures, the idle requirement, the timeout budget and command delivery are unchanged. HTTP-route tests cover a complete map, an absent map (the published and `.tmp` paths both removed) and a non-timeout refusal.

Credit to Seán McNamara (@seanmcn) for the timeout-reason mapping in his fork PR #3. Enforcer +5/-1, tests +28.
2026-11-02 16:06:03 -08:00
Mike Schwarz 0ed456e68f docs(openshell): use the actual kernel operator address (#2404)
The OpenShell README's actor-attribution example now uses `operator-agent@kernel`, the kernel operator's actual address (as the Slack CLI's default already names it), in place of `operator@kernel`, which isn't a kernel seat. The surrounding instruction to use the actual acting member's address is unchanged.

Docs +1/-1.
2026-11-02 15:03:03 -08:00
Mike Schwarz f913586a38 docs: make the in-repo docs and two help texts match what 0.7.8 ships (#2403)
Fact-only corrections so the in-repo docs and `--help` match what 0.7.8 ships: the CLI reference gains the `rig environment` section and the `host edit`, `host remove` and `terminal attach` rows; both contracts link the in-tree presets; the guest listener's permission routes, port and host defaults are stated as they are; status labels say what 0.7.8 ships for grants, the answered permission path and `rig environment`, keeping "no harness end to end" and "native verdict pending"; `rig provider switch --help` says a stopped seat isn't relaunched (it prints the `rig launch` that resumes it) and that the switch asks to keep the running effort, using the saved effort when it can't, unless the seat chose its own; `rig host` help says add, edit and remove rewrite hosts.yaml from the known fields, dropping comments and unknown keys; the capability map's verb count reads 91; and `/abort` and the shipped dsh preset's resume behaviour are described as they are. Section 7's text is unchanged.

Docs and help text +70/-28; no behaviour change.
2026-11-02 14:52:28 -08:00
Mike Schwarz 8fca221aab fix(kernel): start a new kernel in the workspace's own kernel folder (#2402)
A newly created default kernel starts in `<workspace.root>/kernel` instead of `workspace.root`, through both creation paths (daemon start and the TUI's first setup), so a Claude kernel seat's managed `CLAUDE.md` no longer lands above the default projects folder and isn't loaded by every project seat. If the folder can't be prepared, the kernel falls back to `workspace.root` with a warning; nothing refuses a boot. Existing kernels keep their stored working folder and restored conversations; nothing is moved or cleaned up. The operator role's working-directory line follows the new folder, and `docs/reference/agent-startup-guide.md` explains the optional move for existing users: a fresh launch starts a new native conversation, custom text in a shared `CLAUDE.md` is kept, and step 3 waits until no running rig's seat still works in the old folder, as `rig down` checks.

Reported by @ch-davidriott in #2390.

Daemon +24/-11, guidance and docs +28/-1, tests +59/-6.
2026-11-02 14:21:42 -08:00
Mike Schwarz 0b60f8da4a feat(cli): assistants connected through a grant service work with each other and the team (#2384)
An assistant connected through `rig mcp serve --http --grant-service` can now claim, update and park the rows it owns and see its team, with the scope that show, list and create already apply to external callers: claim and update responses go through `projectForExternal`, so a blocker, closure target or waiting detail the caller can't read stays out; an external owner needs both the exact destination and the existing item scope, so a known id outside the grant gets `out_of_scope` and stays unchanged; an external park must name a `blockedOn` the caller can read, so a bare re-park can't restore or reveal the blocker saved at claim; and `rig_team`'s `lastWake` for a hosted member counts only rows in the caller's item scope. Local and token-grant behaviour and the error codes are unchanged; refusals use the existing `out_of_scope`. `docs/reference/hosted-members.md` describes the loop.

MCP server +51/-16, docs +32, tests +329.
2026-11-02 14:08:23 -08:00
Mike Schwarz fbca740dd1 feat(daemon): Pi's stream beat becomes the runner contract's working beat (#2399)
Pi's stream heartbeat (#2336, by @porterchild: one 5 s tick, a beat once 5 s pass since the runner's last post, stopping when streaming clears or the runner exits) now posts the runner contract's working beat (section 4): `active` / `liveness` with the open run's `turnId` and `livenessSeconds: 30`, in place of `active` / `streaming`. The Pi runner now posts `UserPromptSubmit` / `accepted` with the next `turnId` for each prompt it gives Pi: a prompt or `/followup` sent into an open run is posted as it's sent, and a prompt sent while idle is posted when its run opens, so a command Pi handles without a run (such as `/model`) opens no turn. `/abort` opens no turn, and OMP is unchanged.

Named limit: after a daemon restart in the middle of a run, the run's beats are ignored until the next prompt.

Pi runner +30/-4, tests +145/-19.
2026-11-02 13:59:26 -08:00
Mike Schwarz 522d635cdf docs(slack): correct missed-message recovery channel coverage (#2401)
docs/reference/slack-app-setup.md's channel-map section said missed-message recovery covers the default channel only. Since #1536 it recovers the default channel and every mapped channel, except a channel relayed to another machine, as the guide's reconnect-recovery sections already say. The historical 0.6.12 release note is unchanged.

Docs +1/-1. Reported by @ziadeh.
2026-11-02 13:36:26 -08:00
Mike Schwarz 8834943465 feat(docs): the dsh example runner beats, and the conformance check orders idle reports by the runner's sequence (#2388)
The dsh example runner now posts the runner contract's working beat (section 4): while a turn runs and nothing waits on a person, it posts `active` / `liveness` every 10 s with the turn's `turnId`, `livenessSeconds: 30` and its rising `sequence` and `occurredAt`, and stops at the turn's `Stop` and at dsh's exit. It pauses from a permission ask until the agent has its answer, and posts `active` / `permission_resolved` with the turn and `askId` once no ask waits and none is being submitted, never after a refusal, a cancel, a permit with no ask open, or dsh's exit. The agent's own reports go out only when a beat could.

The conformance check's cases 4b and 7 now count only the non-exit `Stop`s posted after the turn's accepted prompt, ordered as OpenRig orders them (`occurredAt` when it reads as a time, else arrival; then a positive `sequence`), so a runner's idle report from before the turn is no longer a false failure. The check still doesn't validate those two fields' types, and it is stricter than OpenRig for a `Stop` that precedes a later post that arrived first.

Example and check +69/-16, tests +213/-16.
2026-11-02 13:24:09 -08:00
Aditya Chandrajit Deore b9a4390b96 fix(daemon): recover current Claude hooks when saved startup context is absent (#2373)
A restore whose snapshot has no saved startup context reaches #2338's existing hook recovery only for a legacy (non-pod) seat whose live runtime still matches the snapshot; a changed runtime or a pod-aware seat gets no new hooks write, and the restore itself is unchanged. Every restore step that uses the seat's directory now takes the launch directory (the snapshot's cwd, else the live node's, as the launcher does): the projected hooks, the startup replay's plan and binding, the legacy resume's managed launch and read policy, and both saved-guidance checks. A snapshot with no cwd therefore no longer writes the context collector or settings into the daemon's own folder, or prepares a resume at the filesystem root. Seats whose snapshot has a cwd are unchanged.

Restore orchestrator +48/-12, tests +232/-6.
2026-11-02 12:19:22 -08:00
NIKHIL c3eb2f73e5 fix(tui): pick JUST FINISHED by finish time so an old item closed last shows (#2392)
The queue list route accepts a sort by last update, and the TUI uses it for JUST FINISHED on Pulse and RECENTLY FINISHED on an agent's page, so those lanes hold the items closed most recently instead of those created most recently. It is a read order only: no queue state, write or delivery path changes, and the default list order is unchanged.

Source +13/-10, tests +33/-8.
2026-11-02 11:37:38 -08:00
JUSHUANGHUI LI d942e12954 fix(daemon): avoid rescanning long transcript lines during grep (#2397)
The transcript store's forward reader keeps an unfinished line as fragments until a newline arrives, instead of prepending it to each new 64 KiB chunk and splitting the whole result again. A very long captured line is therefore scanned once rather than once per chunk, so `rig transcript --grep` holds the daemon for less time. The lines it yields, and so the grep output, are unchanged.

Transcript store +11/-5, tests +14/-0.
2026-11-02 11:33:09 -08:00
NIKHIL c1783989c8 fix(tui): show JUST FINISHED clocks in the configured time zone (#2393)
`rig tui`'s Pulse shows each JUST FINISHED row's clock in the TUI's configured time zone (`ui.timezone`), through the existing display-time helper, rather than as UTC with no zone mark. Other Pulse lanes and the stored timestamps are unchanged.

TUI +12/-16, tests +23/-2.
2026-11-02 11:29:35 -08:00
JUSHUANGHUI LI 551fb0e2f7 fix(daemon): report malformed optional workspace frontmatter (#2396)
The workspace frontmatter validator reports an opened but unclosed frontmatter block, and a YAML sequence instead of a metadata mapping, as a parse-error gap, including when no workspace kind is selected. An unclosed block is recognized as present rather than silently treated as body text. The report path, gap kinds and HTTP response are unchanged, so `rig workspace validate` now exits 1 for such a file through its existing gap exit status; nothing else gates on it.

Validator +4/-3, tests +50/-0.
2026-11-02 11:28:42 -08:00
JUSHUANGHUI LI d1839dfcf1 fix(cli): keep progress edits out of fenced code blocks (#2395)
`rig scope ... progress` edits only the tasks a reader sees. `--set` no longer changes or counts example checkboxes inside fenced code blocks, and a text found only inside an example takes the existing no-match error, leaving the example untouched. `--add` ignores example headings and rows when it picks a section, and inserts before an unfinished trailing fence rather than inside it. The fence rules match the progress indexer's.

Command +36/-7, tests +42/-0.
2026-11-02 11:26:43 -08:00
Mike Schwarz 2b929b3d76 fix(daemon): rig down takes back OpenRig's own additions once nothing uses them (#875) (#2387)
`rig down` removes OpenRig's managed blocks from the guidance file of every seat runtime that writes one, now including Pi and omp (AGENTS.md), with the same target used for teardown, live-file protection, exact resume and node inventory. Once a working folder has no live Claude seat of any rig, down also removes OpenRig's own Claude additions there: the collector status line and the relay hook groups in .claude/settings.local.json (matched exactly as launch claims them, keeping other hooks and fields), and .openrig/context-collector.cjs when its bytes still match the shipped asset. Empty .openrig and .claude folders are removed without recursion, and settings.local.json is kept as {}. Edited, unreadable and still-shared files, including targets another live seat reaches through a symlink or hardlink, are kept and reported. An exact resume puts its activity hooks back.

Source +211/-64, tests +248/-8, docs +4/-2.
2026-11-02 00:44:55 -08:00
NIKHIL b28dcd8f21 fix(transport): stop reporting delivery on a broadcast that reached nobody (#2359)
The /broadcast route adds its unsigned-sender notice only when the broadcast reached at least one recipient, counting sends a recipient holds for later delivery (sent + retained > 0). When every recipient holds the text, the notice begins "Held for delivery" rather than "Delivered"; when it reached nobody, there is no notice. The CLI prints the route's warning as before.

Source +9/-4, tests +31/-0.
2026-11-02 00:19:58 -08:00
Mike Schwarz da9ef8273c feat(daemon): a runner's working beat keeps a quiet turn reading working for its live, open turn (#2370)
A harness runner may post `active` / `liveness` beats while a prompt is outstanding and the agent it started is alive, so a long tool or model call keeps the seat reading working. A beat counts only when it carries the seat's live generation, names the latest accepted turn that hasn't stopped, carries a sequence above every earlier counted post, and the seat has no attention outstanding; otherwise it is ignored, and it never starts or extends a turn or clears attention. After delivering an answer or permit, a runner posts `active` / `permission_resolved`, which clears attention. Section 4 of the runner contract documents both from 0.7.9; section 7 is unchanged. The conformance checker gains the beat cells. A runner that sends no beats behaves as before.

Source +181/-8, contract and conformance kit +100/-2, tests +313/-7.
2026-11-02 00:06:58 -08:00
Mike Schwarz 16ac9bfabd docs(daemon): set-runtime names the route for a seat that has no pane to switch (#2383)
`rig seat set-runtime`'s refusal for a seat with no bound tmux pane now says what to do: start a stopped seat first, then switch it; for a seat that never started, declare the other runtime for the member in its rig spec and bring the team up again from it, which makes a new generation without the seat's edges, settings or queued work. `rig seat set-runtime --help` says the same. The existing refusal passes one guidance line; nothing changes in how a seat switches or starts.

Source +11/-2 (text and help), tests +6.
2026-11-01 23:50:14 -08:00
NIKHIL 94f7b27ac1 fix(cli): normalize documented --priority values and note the rest (#2358)
`rig queue create`, `handoff` and `handoff-and-complete` lowercase a documented --priority (routine, urgent, critical) given in another case, so `--priority Critical` files as critical. Any other value is kept exactly as given, with one note on stderr that it is outside the documented choices. create still defaults to routine, and a handoff with no --priority still sends none, so it inherits. --tier is unchanged.

Command +14/-3, tests +56/-3.
2026-11-01 23:47:08 -08:00
NIKHIL 3c0fee3e2d fix(cli): warn on an unknown --state in rig queue list (#2357)
`rig queue list --state` warns on stderr about any name that isn't a queue state, naming the supported states, then sends the query exactly as before. A mixed filter still returns its known-state matches; an all-unknown filter still returns nothing, and its warning says an empty result doesn't mean an empty queue. Stdout, --json, the empty and default filters and the HTTP API are unchanged.

Command +13/-0, tests +55/-0.
2026-11-01 23:46:31 -08:00
NIKHIL 978e95fbff fix(cli): finish a default rig export beside an existing rig.yaml (#2356)
`rig export <rig>` with no -o no longer replaces an existing rig.yaml. It claims the next unused sibling (rig-export.yaml, then rig-export-2.yaml and so on) by creating it exclusively, writes the export there, and prints the path only after the write succeeds. An explicit -o keeps its replacement behaviour, including -o rig.yaml, and filesystem or export failures still exit nonzero.

Command +39/-5, tests +79/-1.
2026-11-01 23:45:49 -08:00
Mike Schwarz b78894736d docs: 0.7.7 is published (#2385)
0.7.7 is published, so main's CHANGELOG records it: the [0.7.7] section reads published 2026-11-02 as @openrig/[email protected], built from the commit tagged v0.7.7, and the 0.7.7 status paragraph inside [0.7.8] no longer says it isn't published. [0.7.8] itself stays not yet published. docs/reference/whats-new.md, versions and pins stay as main has them.

CHANGELOG.md +2/-1.
2026-11-01 23:42:34 -08:00
Mike Schwarz cca17f54d5 feat(daemon): a person's typed words on a permission ask reach the agent (#2363)
When a person answers a runner's permission ask in words that are neither a choice nor a plain yes or no, the read result (`GET /api/activity/permission-requests/<askId>`) now carries those words, whole, as `said`, beside `permitted` (closed without a choice) or `refused` / `no_allow_offered`. The words come from the row that holds the answer, so a colleague's words count when the ask was routed to them. A plain yes or no keeps today's mapping with no `said`, and words are never read as a no.

The harness runner contract's section 7 gains two passages describing `said` and how a runner delivers it: queued for the agent as an ordinary prompt, after the current turn and behind any prompt already waiting, never as the answer. A runner that drops them still keeps the contract. The dsh example runner and the minimal ACP conformance fixture deliver the outcome as before and queue the words; the conformance check covers it.

10 files: runtime +20/-4; the dsh example runner and the conformance fixture +18/-2; the contract, the dsh README, the conformance README and check +34/-6; tests +61/-7.
2026-11-01 22:29:21 -08:00
516 changed files with 24752 additions and 1972 deletions

No files matched your search

+1 -1
View File
@@ -116,7 +116,7 @@ jobs:
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
# file-inode behaviour and release tags.
runs-on: macos-latest
timeout-minutes: 45
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
+735 -26
View File
@@ -8,14 +8,723 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.10]
OpenRig 0.7.10 is a patch for people running their own agent teams. After a
managed compaction, a step held because OpenRig can't read the seat's input box
now goes in after two idle polls instead of waiting with no end. Wakes and sends
get through a seat pane left in more than one tmux mode, and `rig exec` hands a
queue row's task to Claude Code or Codex as one finite run that outlives the
command (not yet run with a real adapter). Installing through the rigs skill tries a user-writable npm prefix
before a project-local install, and agent spec advisories now show at launch.
There are fixes across refocus, routing, status and speed under load, many
contributed by people using OpenRig. The two problems that kept the OpenShell
worker from working in 0.7.9 are fixed in the source, but the worker hasn't run
end to end on 0.7.10: see [the OpenShell worker](#the-openshell-worker-in-0710).
### Before you upgrade
- **Restart the daemon after installing:**
```bash
npm install -g @openrig/cli@0.7.10
rig daemon stop && rig daemon start
```
This release adds no migrations.
- **The bundled openrig-core plugin advances from 0.1.25 to 0.1.26.** Its Claude
hooks add a `PermissionRequest` hook that reports permission requests to seat
status, and its refocus hook and the refocusing skill's trace script changed.
Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from that copy. ([#2615](https://github.com/mvschwarz/openrig/pull/2615), [#2562](https://github.com/mvschwarz/openrig/pull/2562), [#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
- **Check your agent specs:** `rig agent validate <path>` now prints each
advisory. A profile's `uses.<category>` written as a single value now selects
that resource instead of being dropped, so a member using that profile fails to
resolve if the resource pool doesn't hold it (see Behaviour that changed).
([#2585](https://github.com/mvschwarz/openrig/pull/2585), [#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **If you run an OpenShell environment, rebuild its guest image** with this
release's `guest.py` (`daemon/assets/openshell/README.md` describes the build).
The CA repair below is in that file, which the image carries. ([#2602](https://github.com/mvschwarz/openrig/pull/2602))
### Finite runs: `rig exec`
- **`rig exec` runs one turn of a queue row's task on Claude Code or Codex**
through its maintained ACP adapter, in a connector that outlives the command.
`rig exec harnesses` shows the versions installed,
`rig exec run <row> --harness claude` (or `codex`) starts it (`--plan` starts
nothing, `--wait` waits), and `rig exec status` and `rig exec cancel` follow
it. ([#2597](https://github.com/mvschwarz/openrig/pull/2597))
- **One run per row, and the row stays open.** Running it again returns the
row's run instead of sending the task twice; `--again` starts another turn once
it has ended. A run whose connector died without recording an outcome reads
`interrupted`, outcome unknown. The outcome is the agent's report, and
accepting the work stays with you. `--wait` exits 0 for `completed`, 2 for any
other outcome and 3 when you stop waiting; a Ctrl-C there stops the wait, not
the run. ([#2597](https://github.com/mvschwarz/openrig/pull/2597), [#2601](https://github.com/mvschwarz/openrig/pull/2601))
- **An ACP run's outcome note,** `rig acp run`'s included, carries the usage and
cost the agent reported, each with its source, or unknown. ([#2597](https://github.com/mvschwarz/openrig/pull/2597))
- **`docs/reference/acp-agents.md`** covers both adapters
(`@agentclientprotocol/claude-agent-acp` 0.101.0 and
`@agentclientprotocol/codex-acp` 2.5.0): installing them, which native
versions run, and how their permissions, refusals and cancels differ. ([#2596](https://github.com/mvschwarz/openrig/pull/2596))
### The OpenShell worker in 0.7.10
**The OpenShell worker hasn't run end to end on 0.7.10. Don't rely on this path
yet.**
0.7.10 fixes, in the source, the two problems our release testing on 0.7.9
found:
- **The guest's CA settings reach its Claude.** The guest launcher built
Claude's environment from scratch with only the provider settings, so a CA the
OpenShell exec supplied never arrived, and API calls failed with
`SELF_SIGNED_CERT_IN_CHAIN`. It now also passes `NODE_EXTRA_CA_CERTS`,
`SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE`, each only when the
exec supplies it. Nothing else is copied, and nothing turns TLS checks off.
Rebuild the guest image to get it (above). ([#2602](https://github.com/mvschwarz/openrig/pull/2602))
- **Deleting an owned environment reads Podman's inventory.** The cleanup step
asked for routes Podman serves only under a version prefix, misread the "Not
Found" reply, and never sent the delete. It now uses routes Podman serves
without one, and a volume counts as gone only when one complete volume listing
leaves it out. Any other answer stops as `environment_cleanup_unconfirmed`,
with the HTTP status. ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
A verified export, a finished task and an exact restore haven't been shown.
### Compaction, delivery and terminals
- **A held post-compaction step now goes in.** Each step after a managed
compaction (the turn boundary, the restore request, the audit) went only into
an input box that read empty, so a box OpenRig couldn't read held the restore
request on every poll, and the seat sat idle without its restore. Now, after
two idle polls in a row that can't read the box, the next poll sends the step,
and `rig compact <seat> --state` and `rig seat status` say it went in over a
box OpenRig couldn't read, never that the box was empty. Some cases still hold
(see Known limits). ([#2576](https://github.com/mvschwarz/openrig/pull/2576), refs [#2575](https://github.com/mvschwarz/openrig/issues/2575))
- **Wakes and sends leave every tmux mode.** A pane scrolled into copy mode, with
run-shell output, a chooser, customize or clock mode on top, kept a mode after
one cancel, and the send was refused until someone left it by hand. Delivery
now runs tmux's `copy-mode -q`, which leaves them all, with a single cancel
where that fails (tmux before 3.1a), and a refusal names why leaving failed.
([#2594](https://github.com/mvschwarz/openrig/pull/2594))
- **A scrolled pane with a stale record no longer blocks delivery.** tmux
reuses pane IDs after a server restart, so a stale binding, from another rig
for example, can name the same pane as a live seat. Leaving the mode was
addressed by that raw pane ID, which then matched two seats, so it was
refused before anything was written and the send failed with nothing sent.
Leaving the mode now goes through the seat's address, as the text and Enter
writes already did. The stale binding itself is unchanged.
([#2644](https://github.com/mvschwarz/openrig/pull/2644))
- **A writable rig, pod or seat view sizes the pane it shows,** instead of
keeping an older attached client's size. Read-only views are unchanged. ([#2614](https://github.com/mvschwarz/openrig/pull/2614))
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
as the same attachment when only OpenRig's tile script changed, and moves to
the current command, so the viewer stays confirmed and the follow view goes on
adding and closing tiles. A kept viewer that's already running keeps its
earlier sizing for as long as it runs, its own re-attaches included; writable
viewers opened after the upgrade size their pane. A different session, tmux,
server or read-only setting still holds the view, as before. Checked against
the scripts those releases used, not yet with a live Herdr. It follows
@natefaerber's approach in
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
### Coordination and status
- **Routing and receipts:**
- a send to `<seat>@<rig>@<this host's ID>` goes to this host even when a
remote host is selected;
- `rig broadcast` prints `UNCONFIRMED`, not `FAILED`, for a recipient its text
or Enter may have reached, and points to `rig capture` before any resend;
- `rig seat stop`, `clean` and in-place restart, when they refuse a seat
handed over before 0.7.9, say why and no longer suggest `rig unclaim`;
- `rig roster show` and `find` read a seat's activity as unknown
("Conflicting activity observations") when two routes to it disagree;
- `rig seat set-runtime` finds a pod seat's authored recap.
([#2600](https://github.com/mvschwarz/openrig/pull/2600), [#2572](https://github.com/mvschwarz/openrig/pull/2572), [#2574](https://github.com/mvschwarz/openrig/pull/2574), [#2593](https://github.com/mvschwarz/openrig/pull/2593))
- **Refocus:** unresolved typed work, and a `rig queue whoami` that timed out,
print a named trace gap with project-level orientation, labelled as not
evidence of a current mission, instead of counting as a refocus failure.
([#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
- **Updates to people:** the stuck sweep no longer files an informational update
to a registered person as an unclaimed obligation, unless its quiet-hours hold
was orphaned. ([#2586](https://github.com/mvschwarz/openrig/pull/2586))
- **Slack:** `rig slack status` shows relay attempts to another host still
awaiting a response. It's a count for the current connector at that moment, so
zero doesn't prove nothing is in flight. ([#2569](https://github.com/mvschwarz/openrig/pull/2569))
- **Permission requests in seat status:** `rig seat status` shows a Claude
seat's latest permission request as openrig-core's hook observed it, with
Claude's own suggested rules labelled as suggestions. Nothing is applied,
approved or stored. ([#2562](https://github.com/mvschwarz/openrig/pull/2562))
### Install, specs and errors
- **The rigs skill's install step,** when npm's global prefix isn't writable,
now has the agent point npm's prefix at a folder the person owns whose `bin` is
on their login PATH, or use a version manager's Node, saying first what that
changes, and install inside the project only if neither works. ([#2611](https://github.com/mvschwarz/openrig/pull/2611))
- **Plain `rig` works in managed Codex seats however OpenRig was installed,**
with a Codex that supports `allow_login_shell` (checked in Codex's source at
0.162.1 and 0.166); not yet run with a live Codex seat. OpenRig now passes Codex `-c allow_login_shell=false`, so tool commands run in
a non-login shell and inherit the environment their pane's shell set up,
OpenRig's command folder included. To defer to Codex's own setting instead,
run `rig config set launch.codex_login_shell true`; it applies at each seat's
next launch, and a restart in place keeps the running choice. ([#2613](https://github.com/mvschwarz/openrig/pull/2613))
- **Agent spec advisories show where you'll see them:** `rig up`, a fresh restore
and a first-start retry report each as a launch warning naming the member,
spec and path, and `rig agent validate` prints them whether or not the spec is
valid. They never stop a launch. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **An unknown key under a profile's `uses`,** such as a typo,
gets an advisory instead of being ignored silently. ([#2618](https://github.com/mvschwarz/openrig/pull/2618))
- **Clearer errors:**
- a rejected terminal-token request says "Terminal auth failed" and names
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when
terminal auth falls back to it;
- the TUI shows the daemon's own message when it refuses a launch;
- `rig doctor` gives up on its cmux status and `--spec` topology reads after 2
seconds each, so a listener that never answers can't hold it.
([#2577](https://github.com/mvschwarz/openrig/pull/2577), [#2556](https://github.com/mvschwarz/openrig/pull/2556), [#2564](https://github.com/mvschwarz/openrig/pull/2564))
- **Docs:** a Codex config fragment can't install a named Codex profile, with
how to keep a profile with full access or a `codex_permissions` envelope; and
a Claude seat that selects openrig-core loads its skills with the plugin.
([#2604](https://github.com/mvschwarz/openrig/pull/2604), [#2584](https://github.com/mvschwarz/openrig/pull/2584))
### Speed under load
- **The execution view** (`GET /api/views/execution`, behind the TUI's execution
view and `rig view show execution`) reads the review registry once per request
instead of once per slice, and yields to the event loop while it does. Its
other synchronous steps are unchanged. ([#2578](https://github.com/mvschwarz/openrig/pull/2578))
- **A queue listing** looks recovery rows up through one index per listing
instead of a scan for each listed row. It hasn't been measured on a live
daemon. ([#2588](https://github.com/mvschwarz/openrig/pull/2588))
- **The event notification drain** reads pages of at most 500 rows instead of
the whole remaining tail. It bounds rows per read, not bytes, and still runs to
the end synchronously; no latency change on a live install is claimed.
([#2610](https://github.com/mvschwarz/openrig/pull/2610))
### For runner authors and integrators
- **The runner contract says what has run with a real runner.**
`docs/reference/harness-runner-contract.md` and the dsh example say what has
run natively, at what scope, and what hasn't; any other runner, provider or
path stays unqualified until shown. The 0.7.8 Known limits line below that said
no harness had run end to end is struck and corrected in place.
([#2592](https://github.com/mvschwarz/openrig/pull/2592), [#2595](https://github.com/mvschwarz/openrig/pull/2595))
- **Telemetry:** `docs/reference/telemetry.md` says OpenRig's guarantee ends at
its finite reads, versioned by `schemaVersion` 1, and links the separately
maintained openrig-telemetry exporter, which OpenRig doesn't test or
guarantee. ([#2589](https://github.com/mvschwarz/openrig/pull/2589))
- **Also:** the real-tmux transport test waits for the shell's prompt before
typing. ([#2559](https://github.com/mvschwarz/openrig/pull/2559))
### Behaviour that changed
- **Managed Codex seats run tool commands in a non-login shell** (above).
Inherited setup isn't re-run for each command, so setup that lives only in
login or rc files (such as `~/.bashrc` lines your login file doesn't source, or
a tmux `default-command` that doesn't start a login shell), shell functions and
aliases may be missing from tool commands. A model request for `login: true`
gets Codex's own refusal, "login shell is disabled by config". With
`launch.codex_login_shell` on, OpenRig omits its override rather than forcing
login shells, so a login file that sets `PATH` outright can then hide `rig`.
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
- **A held post-compaction step goes in after two idle polls that can't read the
input box** (above). A draft OpenRig can't recognise in that box may receive
the restore text. ([#2576](https://github.com/mvschwarz/openrig/pull/2576))
- **A send or wake leaves every tmux mode in a seat's pane,** including a
chooser, customize or clock mode a person has open, where before it was
refused. ([#2594](https://github.com/mvschwarz/openrig/pull/2594))
- **A send to `<seat>@<rig>@<this host's ID>`** goes to this host, not the
selected remote host. ([#2600](https://github.com/mvschwarz/openrig/pull/2600))
- **A profile's `uses.<category>` written as a single value** (for example
`uses.skills: review`) used to validate and then be dropped. It now counts as a
one-item list, with an advisory suggesting list syntax: a member using that
profile gets the resource, or fails to resolve with "not found in resource
pool" when the pool doesn't hold it. Other malformed `uses` shapes are
advisories, not new errors. ([#2585](https://github.com/mvschwarz/openrig/pull/2585))
- **`rig up` can print new warnings,** one per agent spec advisory, and
`rig agent validate` prints them. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **`rig broadcast`** prints `UNCONFIRMED` for a recipient it may have reached;
its exit code and `N/M delivered` count are unchanged. ([#2572](https://github.com/mvschwarz/openrig/pull/2572))
- **`rig roster show` and `find`** read conflicting activity as unknown.
([#2574](https://github.com/mvschwarz/openrig/pull/2574))
- **The refocus hook** no longer counts unresolved typed work or a timed-out
`rig queue whoami` as a failure, and a timed-out refocus clears the pending
post-compaction retry. ([#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
- **The stuck sweep** no longer flags informational updates to registered people
as unclaimed obligations. ([#2586](https://github.com/mvschwarz/openrig/pull/2586))
- **A rejected terminal-token request** gets "Terminal auth failed" instead of
"Mission Control auth failed"; its status code and fields are unchanged.
([#2577](https://github.com/mvschwarz/openrig/pull/2577))
### Known limits
- **OpenShell:** the two fixes haven't run end to end (above). A cleanup error
that's thrown still omits its stage and whether its delete was sent, and the
inventory is a snapshot, not an atomic read. 0.7.8's requirements, cost note
and cleanup limits still apply, including "Never grant the guest access to the
daemon's main API port (normally 7433)." A same-generation reconciled launch
opens no viewer, so run `rig environment connect` yourself; non-text startup
actions aren't supported on environment seats. ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
- **`rig exec`:** no real adapter has run under it yet; one run per row,
detached ownership and reconciling were checked against a stand-in agent.
Concurrent runs, long or background tasks, recovery across a reboot, MCP
servers and Windows aren't covered. A cancel is acknowledged, not settled, and
says nothing about further spend. Cancel an exec run with `rig exec cancel`:
`rig acp cancel` given its ID while it's still requested can signal the wrong
process. A Ctrl-C just before the connector is launched isn't caught by
`--wait`.
([#2597](https://github.com/mvschwarz/openrig/pull/2597), [#2601](https://github.com/mvschwarz/openrig/pull/2601))
- **After a managed compaction, a step can still hold:** when the input box
reads as typed, with or without a suggestion; on a seat whose draft-aware
delivery or typing guard holds OpenRig's input; and after a `/compact` whose
result was lost. To take over by hand, run `rig compact <seat> --cancel`, then
send the seat a restore request naming its newest restore map, its restore
marker and its packet; a step already being pasted can still land. Which
reading the reported seat's box gave isn't established, so the report stays
open. ([#2575](https://github.com/mvschwarz/openrig/issues/2575))
- **Draft-aware delivery to Claude:** on 0.7.8 with Claude Code 2.1.319,
deliveries to idle Claude agents, queue wakes included, were reported held at
an empty prompt when a narrow pane showed
`✔ Update installed · Restart to update` on its own line. 0.7.10 still doesn't
recognize that bare banner, though it recognizes `/rc` and the full versioned
banner. A message the draft-aware delivery path holds isn't replayed
automatically by that path. ([#2605](https://github.com/mvschwarz/openrig/issues/2605))
- **Grant-service assistants aren't woken:** "an item stays queued until its
receiver claims it."
- **Open updates for a person** read a 1,001-row window, so a page can come back
short, marked `truncated`.
- **Slack:** a private channel needs the documented route, which we don't test,
and direct messages aren't supported. The channel and people reads, and a
click on a re-parked ask, haven't been run against a live Slack workspace.
Redaction of the bot token from a transport error can still leave a malformed
token fragment shorter than six characters, so review an error before sharing
it. ([#2503](https://github.com/mvschwarz/openrig/pull/2503))
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
isn't proof the connection is broken. ([#2488](https://github.com/mvschwarz/openrig/pull/2488))
- **Shared asks:** "a click's option written before its answer row … can stand
beside or inside the closer's answer without the first clicker's authorship."
- **Existing seats** last launched fresh before 0.7.9 pick up Codex launch
instructions and the single skill load at their next fresh launch. A
`CLAUDE.md` or `AGENTS.md` that already grew isn't trimmed.
- **Permit records** last as long as stream retention keeps them. A permit a
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
an ask opened before 0.7.9, a record may omit the option's kind. ([#2361](https://github.com/mvschwarz/openrig/pull/2361))
- **Permission requests in seat status** show only the latest one, held in
memory for five minutes and lost on a daemon restart. Claude's suggested rules
can contain sensitive values, and `rig seat status` and both seat-status routes
can return them. ([#2562](https://github.com/mvschwarz/openrig/pull/2562))
- **Agent spec advisories** don't show for a native resume that continues a
saved conversation, since it doesn't reload the spec. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
- **Seats handed over before 0.7.9** keep their adopted origin until a later
launched handover or managed launch records a new session, so until then
`rig seat stop`, `restart` and `clean` refuse them; the refusal now says so.
([#2440](https://github.com/mvschwarz/openrig/pull/2440), [#2572](https://github.com/mvschwarz/openrig/pull/2572))
- **Codex hooks after an upgrade:** Codex sessions kept running across the
upgrade to 0.7.9 showed "Hook failed — plugin contents changed; hook approval
required" until relaunched; in that upgrade test, one seat restarted with
OpenRig's managed restart showed no review afterwards. 0.7.10 changes
openrig-core's files again.
- **The runner contract** has run natively only at the scope its status section
states (`docs/reference/harness-runner-contract.md`); any other runner,
provider or path is unqualified until shown.
- **Fixed or changed from 0.7.9's limits:** the OpenShell worker's CA settings
and owned-delete inventory (fixed in the source, not yet run end to end), a
restore request held after a managed compaction, `rig broadcast`'s `FAILED`
for an uncertain recipient, the refusal for a seat handed over before 0.7.9,
where to read what has run under the runner contract, and Codex seats'
`rig: command not found` (now a non-login shell by default; see Behaviour that
changed).
- **The 0.7.8 limits that 0.7.9 didn't fix or change, and the 0.7.7 and 0.7.6
limits, below still apply.**
### Credits
Thanks to @dajiaohuang, @Aummadour, @billbenetti, @smota, @Nikhila-Dhevi-R and
@Vaishnavi220506 for merged contributions, and to @AlephCasara, whose work on a
fork pointed us to the unpaged event notification drain
([#2610](https://github.com/mvschwarz/openrig/pull/2610)), and to @natefaerber, whose
approach in [#2467](https://github.com/mvschwarz/openrig/pull/2467) the Herdr view fix follows. Thanks to
@billbenetti, @KM199, @ziadeh, @dajiaohuang, @jajabong, @george661 and
@aanestesia for reports carried into this release, and to @smota for the
OpenTelemetry discussion behind [#2589](https://github.com/mvschwarz/openrig/pull/2589).
## [0.7.9]
Published 2026-11-06 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.9` (`ddb9d25b`).
OpenRig 0.7.9 follows through on 0.7.8. A person's typed words on a runner
seat's permission ask now reach the agent, each permit OpenRig grants a runner
by default is recorded, and a runner can keep a long, quiet turn reading
working. New stream observations go to the queue worker by default. Fresh Pi
seats, and fresh Codex seats on Codex 0.164.0 or later, can keep OpenRig's
guidance out of your repository's `AGENTS.md`. There are fixes across delivery,
asks, handover, install and recovery, many reported or contributed by people
using OpenRig. The OpenShell worker that wasn't usable in 0.7.8 still isn't
usable in 0.7.9; read [The OpenShell worker](#the-openshell-worker-in-079)
before relying on it.
### Before you upgrade
- **Restart the daemon after installing:**
```bash
npm install -g @openrig/cli@0.7.9
rig daemon stop && rig daemon start
```
Four migrations (146 to 149) record a Pi seat's guidance file, what OpenRig
last wrote in each managed guidance block, an index for reading a rig's
outbox, and the stream intake position.
([#2432](https://github.com/mvschwarz/openrig/pull/2432), [#2461](https://github.com/mvschwarz/openrig/pull/2461), [#2496](https://github.com/mvschwarz/openrig/pull/2496), [#2321](https://github.com/mvschwarz/openrig/pull/2321))
- **Stream intake is on by default.** Each `rig stream emit` now opens an
intake row for the kernel queue worker (`queue.worker`), or adds a note to the
one still unclaimed. The row wakes the queue worker like any queue row, and
earlier stream history isn't replayed. **To keep 0.7.8's behaviour,** with no
intake rows, run this after installing: `rig config set stream.intake off`.
([#2321](https://github.com/mvschwarz/openrig/pull/2321))
- **The bundled openrig-core plugin advances from 0.1.24 to 0.1.25.** Its
seat-continuity-and-handover skill changed. Managed refresh preserves replaced
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
you still need from that copy. ([#2420](https://github.com/mvschwarz/openrig/pull/2420), [#2554](https://github.com/mvschwarz/openrig/pull/2554))
- **If you run an OpenShell environment, rebuild its guest image** with this
release's helper (`daemon/assets/openshell/README.md`). An older helper
rejects the new startup step, and the error names the rebuild. ([#2418](https://github.com/mvschwarz/openrig/pull/2418))
### Runner seats
- **A person's typed words reach the agent, and the ask says what they do.** A
reply of just yes or no counts as the agent's allow or reject. "Any other
words choose nothing": OpenRig's default permits the request when the agent
offered an allow option, and the words come back whole in the read result as
`said`. Section 7 of `docs/reference/harness-runner-contract.md` lets a runner
queue them for the agent "as an ordinary prompt" after the current turn:
"They're never the answer." A runner that drops them still keeps the contract.
([#2363](https://github.com/mvschwarz/openrig/pull/2363), [#2561](https://github.com/mvschwarz/openrig/pull/2561))
- **Each permit OpenRig grants by default is recorded,** once, as a stream item
from the seat: when no one can be asked, and when an ask is closed without a
choice (a row passed to an agent seat included). The record names the tool
call, why no person answered (for a closed ask, who closed which row and how),
the option the agent got with its kind, and the conversation and request or
the ask. Read the records with `rig stream list --tag runner-permission-permit`,
or `--source <seat>`. A record has no destination, and one that can't be
written doesn't change the permit. Stream intake reads past it, so it opens no
intake row and wakes no one. ([#2361](https://github.com/mvschwarz/openrig/pull/2361), [#2563](https://github.com/mvschwarz/openrig/pull/2563))
- **A working beat keeps a long, quiet turn reading working.** A runner may post
`active` / `liveness` while a prompt is outstanding and its agent is alive
(section 4); one that sends no beats behaves as before. The Pi runner and the
dsh example post it. ([#2370](https://github.com/mvschwarz/openrig/pull/2370), [#2336](https://github.com/mvschwarz/openrig/pull/2336), [#2399](https://github.com/mvschwarz/openrig/pull/2399), [#2388](https://github.com/mvschwarz/openrig/pull/2388))
- **The conformance check** covers the beat, and no longer falsely fails a
correct runner on a busy machine (cases 1, 2 and 4b).
([#2368](https://github.com/mvschwarz/openrig/pull/2368), [#2375](https://github.com/mvschwarz/openrig/pull/2375), [#2388](https://github.com/mvschwarz/openrig/pull/2388), [#2530](https://github.com/mvschwarz/openrig/pull/2530))
- **Section 8 names ACP's `session/resume`,** and the dsh example reads
`DSH_SEAT_BIN` and `DSH_SEAT_HOME`, which a member's `env:` can set, and
reports dsh's real version. ([#2364](https://github.com/mvschwarz/openrig/pull/2364), [#2414](https://github.com/mvschwarz/openrig/pull/2414))
### The OpenShell worker in 0.7.9
**The OpenShell worker still doesn't work in 0.7.9. Don't rely on this path.**
0.7.9 repairs the layer that stopped it in 0.7.8: in our release testing the
guest's agent now starts and reaches its first input. Startup reaches the guest
through a hash-checked first-input file, and nothing is typed through the host
viewer; the case where the viewer exits wasn't run in our release testing.
Readiness checks the guest's own execution, and a failed owned delete reports
the stage that failed.
([#2418](https://github.com/mvschwarz/openrig/pull/2418), [#2404](https://github.com/mvschwarz/openrig/pull/2404))
Our release testing on 0.7.9 then found two more problems:
- **The worker can't reach its model.** The guest's Claude starts, but every API
call fails with `SELF_SIGNED_CERT_IN_CHAIN`. The guest launcher builds a clean
environment and leaves out the CA settings the OpenShell exec supplies
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and
`CURL_CA_BUNDLE`).
- **Deleting an owned environment doesn't run.** Its inventory step misreads a
"Not Found" reply, so the delete is never sent and the owned resources stay.
In our test they were removed with the vendor's own cleanup.
Attached resources were kept, as intended, and the worker stopped cleanly. With
the four CA settings passed through by hand, a worker did useful work; a
verified export, an exact restore and a finished task weren't shown. Fixes for
both problems are on `main` ([#2602](https://github.com/mvschwarz/openrig/pull/2602),
[#2603](https://github.com/mvschwarz/openrig/pull/2603)). They aren't in 0.7.9
and haven't been run end to end.
### Assistants connected through a grant service (experimental)
**Experimental and unsupported. Off by default. Use at your own risk.** This path
needs a grant service that you supply and run; ordinary use of OpenRig doesn't
need one. With `rig mcp serve --http --grant-service <url>`, a connected
assistant can now claim, update and park the rows addressed to its own seat, and
see its team with `rig_team`, within the scope its existing tools have. Serving
MCP over HTTP behind your own tunnel "is like opening a port in your firewall."
See `docs/reference/hosted-members.md`. ([#2384](https://github.com/mvschwarz/openrig/pull/2384))
### For client authors
- **New reads:** `GET /api/gateway/participants` (people and the default
person), `GET /api/queue/open-human-updates` (a person's open updates, without
Slack), `GET /api/queue/outbox/list?rig=<rig>` (a rig's messages in one
request), and `GET /api/gateway/slack/channels` and `/users` (a page of public
channels or of people, without the client holding the bot token).
([#2495](https://github.com/mvschwarz/openrig/pull/2495), [#2497](https://github.com/mvschwarz/openrig/pull/2497), [#2496](https://github.com/mvschwarz/openrig/pull/2496), [#2501](https://github.com/mvschwarz/openrig/pull/2501))
- **Settle exactly the decision a person reviewed:** `GET /api/queue/:qitemId`
returns `decisionRevision`, and answer, resolve, approve and deny take an
optional `expectedRevision`. A stale one gets 409 `decision_revision_changed`
with nothing written; a caller that sends none acts as before. ([#2448](https://github.com/mvschwarz/openrig/pull/2448))
- **A client reference:** `docs/reference/daemon-http-clients.md`, with a recipe
for people, open updates and a rig's outbox. ([#2490](https://github.com/mvschwarz/openrig/pull/2490), [#2524](https://github.com/mvschwarz/openrig/pull/2524))
### Seats, delivery and the TUI
- **Delivery:**
- a Claude prompt suggestion isn't taken for a person's draft;
- a long line Claude wraps short of the box is still submitted;
- a guarded wake recovers its own paste instead of holding later deliveries;
- a wake isn't typed into Claude's open Theme picker, or refused because a
reply quotes a permission question;
- a seat that starts, or takes over a seat, with held messages is told about
them; each watchdog job holds one reminder, and handoffs pass a full held
quota;
- a reminder that couldn't reach its seat, for example after a reboot, stays
owed and retries;
- a stalled tmux client is ended after 30 seconds.
([#2435](https://github.com/mvschwarz/openrig/pull/2435), [#2436](https://github.com/mvschwarz/openrig/pull/2436), [#2447](https://github.com/mvschwarz/openrig/pull/2447), [#2514](https://github.com/mvschwarz/openrig/pull/2514), [#2520](https://github.com/mvschwarz/openrig/pull/2520), [#2437](https://github.com/mvschwarz/openrig/pull/2437), [#2442](https://github.com/mvschwarz/openrig/pull/2442), [#2438](https://github.com/mvschwarz/openrig/pull/2438), [#2478](https://github.com/mvschwarz/openrig/pull/2478), [#2452](https://github.com/mvschwarz/openrig/pull/2452))
- **Status and receipts:**
- an idle Claude with newer footer rows reads idle, and a busy Codex with a
quiet window reads working;
- window samples older than their bound no longer decide a seat's activity, so
a seat whose pane can't be read reads unknown, not its last verdict;
- `rig send --verify` and `rig walk` report unconfirmed when they can't see the
result, and uncertain sends and broadcast recipients read `indeterminate`;
- a broadcast that reached nobody doesn't say "Delivered";
- replies to `human@kernel`, `human@host` and people forwarded from another
host get a usable `rig queue create` hint.
([#2470](https://github.com/mvschwarz/openrig/pull/2470), [#2500](https://github.com/mvschwarz/openrig/pull/2500), [#2558](https://github.com/mvschwarz/openrig/pull/2558), [#2509](https://github.com/mvschwarz/openrig/pull/2509), [#2510](https://github.com/mvschwarz/openrig/pull/2510), [#2505](https://github.com/mvschwarz/openrig/pull/2505), [#2507](https://github.com/mvschwarz/openrig/pull/2507), [#2512](https://github.com/mvschwarz/openrig/pull/2512), [#2359](https://github.com/mvschwarz/openrig/pull/2359), [#2493](https://github.com/mvschwarz/openrig/pull/2493), [#2504](https://github.com/mvschwarz/openrig/pull/2504))
- **Guidance and handover:**
- an edit to a culture or startup file reaches the next start, and a block you
edited by hand is kept and named;
- a shared `CLAUDE.md` or `AGENTS.md` stops growing by a line each launch;
- a Claude seat loads openrig-core's skills once;
- a fresh or rebuild handover successor gets its guidance back for every
runtime and a list of its startup files, role first, and is marked when a
required file is missing;
- launched successors can be stopped, restarted and cleaned, and the rebuild
receipt reports its lineage record and the old process truthfully;
- a Claude launched through the native installer's symlink can be rebuilt, a
slow classic resume under a non-exec launch shell no longer lands in
attention, and a `local:` Claude seat gets its activity hooks back on a
contained resume; restores use the seat's launch directory.
([#2441](https://github.com/mvschwarz/openrig/pull/2441), [#2443](https://github.com/mvschwarz/openrig/pull/2443), [#2432](https://github.com/mvschwarz/openrig/pull/2432), [#2461](https://github.com/mvschwarz/openrig/pull/2461), [#2376](https://github.com/mvschwarz/openrig/pull/2376), [#2433](https://github.com/mvschwarz/openrig/pull/2433), [#2457](https://github.com/mvschwarz/openrig/pull/2457), [#2387](https://github.com/mvschwarz/openrig/pull/2387), [#2402](https://github.com/mvschwarz/openrig/pull/2402), [#2474](https://github.com/mvschwarz/openrig/pull/2474), [#2458](https://github.com/mvschwarz/openrig/pull/2458), [#2466](https://github.com/mvschwarz/openrig/pull/2466), [#2440](https://github.com/mvschwarz/openrig/pull/2440), [#2420](https://github.com/mvschwarz/openrig/pull/2420), [#2371](https://github.com/mvschwarz/openrig/pull/2371), [#2518](https://github.com/mvschwarz/openrig/pull/2518), [#2479](https://github.com/mvschwarz/openrig/pull/2479), [#2373](https://github.com/mvschwarz/openrig/pull/2373))
- **Asks and answers:**
- in a shared workspace, any registered person settles another's ask as
themselves, and the first answer wins everywhere;
- a person may have no connector binding, and is then asked on Steering and in
the Feed only;
- a click on a re-parked Slack ask records a new answer and wakes its asker;
- a Slack ask's post shows it was handled once its row settles;
- For You no longer offers Approve on an ask with questions.
([#2386](https://github.com/mvschwarz/openrig/pull/2386), [#2419](https://github.com/mvschwarz/openrig/pull/2419), [#2421](https://github.com/mvschwarz/openrig/pull/2421), [#2472](https://github.com/mvschwarz/openrig/pull/2472), [#2415](https://github.com/mvschwarz/openrig/pull/2415))
- **Compaction:** managed compaction reads a small Claude pane at a readable
size; a manual `rig compact` that times out says whether the restore map was
ready; a Pi seat shows a failed or cancelled compaction as such.
([#2445](https://github.com/mvschwarz/openrig/pull/2445), [#2400](https://github.com/mvschwarz/openrig/pull/2400), [#2537](https://github.com/mvschwarz/openrig/pull/2537))
- **Install, hosts and setup:**
- the openrig-upgrade skill backs up without the sqlite3 CLI when Node has
`node:sqlite`;
- preflight warns when a Codex seat's login shell can't find `rig`;
- the experiment config can be written where Node has no `process.getuid`;
- bundle unpack restores archived owner bits;
- the Pi Node check is bounded, and `rig doctor` no longer hangs on a port held
by a listener that never replies;
- a scoped launch on tmux 3.4 keeps its default command;
- the kernel's template survives a daemon restart;
- `rig host ls` reads your ssh config with `ssh -G`, which runs any
`Match exec` command in it, and `rig host pair` names the bearer token the
target needs;
- the install docs describe the installer's step-4 outcome and the first
kernel's provider choice.
([#2394](https://github.com/mvschwarz/openrig/pull/2394), [#2522](https://github.com/mvschwarz/openrig/pull/2522), [#2449](https://github.com/mvschwarz/openrig/pull/2449), [#2475](https://github.com/mvschwarz/openrig/pull/2475), [#2451](https://github.com/mvschwarz/openrig/pull/2451), [#2549](https://github.com/mvschwarz/openrig/pull/2549), [#2557](https://github.com/mvschwarz/openrig/pull/2557), [#2471](https://github.com/mvschwarz/openrig/pull/2471), [#2488](https://github.com/mvschwarz/openrig/pull/2488), [#2491](https://github.com/mvschwarz/openrig/pull/2491), [#2551](https://github.com/mvschwarz/openrig/pull/2551), [#2560](https://github.com/mvschwarz/openrig/pull/2560))
- **The CLI:**
- unknown `--state`, `--kind` and `--priority` values warn and still run;
- `rig workspace validate` prints a readable refusal, and `rig seat set-runtime`
says what to do for a seat with no pane;
- a cut `rig queue show` body says how to get the rest;
- `rig queue update --closure-required-at` moves a claimed row's deadline;
- `rig seat set-typing-guard` applies from an ordinary terminal, recorded as
unattributed;
- `rig scope` tells shipped, superseded and closed slices apart, flags a slice
name two missions share, and skips fenced examples;
- `rig context add <url>` installs into a new nested name;
- a retained stuck-sweep recovery names its current reason, and a refused
context-pressure setting says what it compared.
([#2357](https://github.com/mvschwarz/openrig/pull/2357), [#2365](https://github.com/mvschwarz/openrig/pull/2365), [#2358](https://github.com/mvschwarz/openrig/pull/2358), [#2367](https://github.com/mvschwarz/openrig/pull/2367), [#2383](https://github.com/mvschwarz/openrig/pull/2383), [#2422](https://github.com/mvschwarz/openrig/pull/2422), [#2460](https://github.com/mvschwarz/openrig/pull/2460), [#2351](https://github.com/mvschwarz/openrig/pull/2351), [#2434](https://github.com/mvschwarz/openrig/pull/2434), [#2492](https://github.com/mvschwarz/openrig/pull/2492), [#2395](https://github.com/mvschwarz/openrig/pull/2395), [#2546](https://github.com/mvschwarz/openrig/pull/2546), [#2552](https://github.com/mvschwarz/openrig/pull/2552), [#2553](https://github.com/mvschwarz/openrig/pull/2553))
- **Launch options:** `specs.builtins_visible: false` leaves built-in specs out
of the inventory (`rig specs ls --include-builtins` still lists them).
`plugin_channels` passes a Claude member's own plugin channels to
`--channels`; beside `claude_mcp: none` it warns that no channel could
register, and the member still launches. We haven't run it with a real channel
plugin. ([#2379](https://github.com/mvschwarz/openrig/pull/2379), [#2439](https://github.com/mvschwarz/openrig/pull/2439))
- **The TUI and web UI:**
- body text reads on a light terminal, and the selected row shows in
16-colour mode;
- JUST FINISHED and RECENTLY FINISHED show what closed last, in the configured
time zone, even on a busy fleet;
- a fresh seat opened in tiles is sized to its tile, and grid fillers open your
login shell;
- in the web UI, the Topology filter no longer hangs, and the kernel card and
maintenance notice display correctly.
([#2499](https://github.com/mvschwarz/openrig/pull/2499), [#2506](https://github.com/mvschwarz/openrig/pull/2506), [#2392](https://github.com/mvschwarz/openrig/pull/2392), [#2393](https://github.com/mvschwarz/openrig/pull/2393), [#2459](https://github.com/mvschwarz/openrig/pull/2459), [#2464](https://github.com/mvschwarz/openrig/pull/2464), [#2465](https://github.com/mvschwarz/openrig/pull/2465), [#2411](https://github.com/mvschwarz/openrig/pull/2411), [#2413](https://github.com/mvschwarz/openrig/pull/2413), [#2412](https://github.com/mvschwarz/openrig/pull/2412))
- **Also:**
- transcript search scans a long line once instead of once per chunk, with the
same results;
- a Slack transport error has the bot token removed (see Known limits);
- the hook-review check recognises a kernel Codex pane that runs under a shell;
- the reference docs cover a Slack private-channel route, missed-message
recovery, checked stops and slice status words, the spec scan, bundle hashes
and the Pi selector path.
([#2397](https://github.com/mvschwarz/openrig/pull/2397), [#2503](https://github.com/mvschwarz/openrig/pull/2503), [#2416](https://github.com/mvschwarz/openrig/pull/2416), [#2494](https://github.com/mvschwarz/openrig/pull/2494), [#2401](https://github.com/mvschwarz/openrig/pull/2401), [#2489](https://github.com/mvschwarz/openrig/pull/2489), [#2462](https://github.com/mvschwarz/openrig/pull/2462), [#2543](https://github.com/mvschwarz/openrig/pull/2543), [#2477](https://github.com/mvschwarz/openrig/pull/2477), [#2369](https://github.com/mvschwarz/openrig/pull/2369), [#2403](https://github.com/mvschwarz/openrig/pull/2403))
### Behaviour that changed
- **Stream intake is on by default** (above). `off` keeps 0.7.8's behaviour.
- **Fresh Pi seats, and fresh Codex seats on 0.164.0 or later, get OpenRig's
guidance outside the repository `AGENTS.md`:** Pi in its own agent folder
(unless that file or folder is a link), Codex as launch developer
instructions. Codex still uses the repository file for a resume, fork,
restore or handover successor, an older or unknown version, or a config layer
that sets `developer_instructions` or can't be read. If only your shell
startup sets `CODEX_HOME`, OpenRig reads `~/.codex`, so a
`developer_instructions` in your home is replaced for that session; declare
the same absolute path as `CODEX_HOME` in the member's or rig's `env:` to have
it checked.
- **`rig down`** removes OpenRig's own additions from a working folder once no
live seat uses them.
- **After upgrading, the Slack connector edits asks that ended earlier** to show
they were handled, at Slack's edit rate: about an hour for 450 asks.
`rig slack status` shows pending edits.
- **A later answer to a settled ask** gets 409 `qitem_already_terminal` and
records nothing.
- **`rig send --verify`** reports unconfirmed and exits 1, not delivered, when it
can't see what its Enter did.
- **`rig export` with no `-o`** writes `rig-export.yaml` beside an existing
`rig.yaml` instead of replacing it. ([#2356](https://github.com/mvschwarz/openrig/pull/2356))
- **`rig workspace validate`** exits 1 for malformed optional frontmatter.
([#2396](https://github.com/mvschwarz/openrig/pull/2396))
- **An active rig name that matches more than one rig** makes `rig env down`,
`status` and `logs` list the matching rig IDs and stop, and
`rig doctor --spec` report conformance as unknown, instead of using the first
match. ([#2548](https://github.com/mvschwarz/openrig/pull/2548), [#2550](https://github.com/mvschwarz/openrig/pull/2550))
- **New default kernels** start in `<workspace.root>/kernel`. Existing kernels
stay where they are; `docs/reference/agent-startup-guide.md` describes the
optional move.
### Known limits
- **OpenShell:** the worker doesn't work in 0.7.9 (see
[The OpenShell worker](#the-openshell-worker-in-079)). 0.7.8's requirements,
cost note and cleanup limits still apply, including "Never grant the guest
access to the daemon's main API port (normally 7433)." A same-generation
reconciled launch opens no viewer, so run `rig environment connect` yourself;
non-text startup actions aren't supported on environment seats.
- **Grant-service assistants aren't woken:** "an item stays queued until its
receiver claims it."
- **Open updates for a person** read a 1,001-row window, so a page can come back
short, marked `truncated`.
- **Slack:** a private channel needs the documented route, which we don't test,
and direct messages aren't supported. The channel and people reads, and a
click on a re-parked ask, haven't been run against a live Slack workspace in
our release testing. Redaction of the bot token from a transport error can
still leave a malformed token fragment shorter than six characters, so review
an error before sharing it. ([#2503](https://github.com/mvschwarz/openrig/pull/2503))
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
isn't proof the connection is broken. ([#2488](https://github.com/mvschwarz/openrig/pull/2488))
- **Shared asks:** "a click's option written before its answer row … can stand
beside or inside the closer's answer without the first clicker's authorship."
- **Existing seats** pick up Codex launch instructions and the single skill load
at their next fresh launch. A `CLAUDE.md` or `AGENTS.md` that already grew
isn't trimmed.
- **Permit records** last as long as stream retention keeps them. A permit a
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
an ask opened before 0.7.9, a record may omit the option's kind. ([#2361](https://github.com/mvschwarz/openrig/pull/2361))
- **Draft-aware delivery to Claude:** on 0.7.8 with Claude Code 2.1.319,
deliveries to idle Claude agents, queue wakes included, were reported held at
an empty prompt when a narrow pane showed
`✔ Update installed · Restart to update` on its own line. 0.7.9 still doesn't
recognize that bare banner, though it recognizes `/rc` and the full versioned
banner. A message the draft-aware delivery path holds isn't replayed
automatically by that path.
([#2605](https://github.com/mvschwarz/openrig/issues/2605))
- **After a managed compaction, the restore request can stay held.** OpenRig
delivers each post-compaction step only when it can read the seat's input box
as empty. When it can't, it holds the restore request and keeps retrying on
its polls, so the seat can sit idle at an empty prompt with its restore not
yet requested. `rig compact <seat> --state` and `rig seat status` show the
restore as owed, and from the second failed attempt the seat's owner gets a
queue item naming it. To restore by hand, send the seat a restore request
naming its newest restore map, its restore marker and its packet, then settle
the owed attempt with `rig compact <seat> --cancel`; a poll can still race
that step. Reported on 0.7.8, and 0.7.9 behaves the same way. ([#2575](https://github.com/mvschwarz/openrig/issues/2575))
- **Seats handed over before 0.7.9** keep their adopted origin until a later
launched handover or managed launch records a new session, so until then
`rig seat stop`, `restart` and `clean` refuse them as before. ([#2440](https://github.com/mvschwarz/openrig/pull/2440))
- **`rig broadcast`:** an uncertain recipient's outbox receipt now reads
`indeterminate`, but the broadcast's own output for that recipient still says
FAILED. Check that seat with `rig capture` before resending. ([#2507](https://github.com/mvschwarz/openrig/pull/2507))
- **Codex hooks after an upgrade:** Codex sessions kept running across the
upgrade show "Hook failed — plugin contents changed; hook approval required"
until relaunched. In our upgrade test from 0.7.8, one seat restarted with
OpenRig's managed restart showed no review afterwards.
- **Codex seats can still report `rig: command not found`** when `rig` isn't on
the login shell's PATH: with Node outside it, or when npm can't install
globally and OpenRig is installed inside the project instead. Preflight now
warns at launch. In our testing the agents recovered by calling `rig` by its
full path; the getting-started guide describes a `~/.profile` fix, which we
didn't run.
- **What we ran end to end under the runner contract:** this release's dsh
example runner, natively, with dsh 0.2.1-rc.2 on DeepSeek's
`deepseek/deepseek-v4-flash` through dsh's pi-ai OpenRouter chat-completions
profile, against 0.7.8 Core: two useful queue tasks, queued and multiline
input, a quiet tool interval of about 26 seconds that still read working,
allow-once and reject-once with their file effects, cancel, a return to the
same conversation with recall, and shutdown. The permission answers came from
an automated test operator, and the run's preset added `resume: true`, which
the shipped preset doesn't declare. dsh's own DeepSeek Messages route through
OpenRouter failed with HTTP 400, and the cause isn't established. Not run: a
missing-conversation refusal, cross-host restore, OpenShell, and other
adapters, models or providers. 0.7.8's release tests had already run its dsh
example end to end, so the 0.7.8 CHANGELOG's "No harness has run end to end
under the contract yet" was out of date.
- **Fixed or changed from 0.7.8's limits:** the dsh example's `env:` names, the
conformance check's false failures, a person's typed words, the README's
operator address, a rebuild behind a symlink, the rebuild receipt, a click on
a re-parked ask, and recording a permit made when no one can be asked: a
person's answer is recorded on the ask, and a default permit as the stream
item above.
- **The other 0.7.8 limits, and the 0.7.7 and 0.7.6 limits, below still apply.**
### Credits
Thanks to @porterchild, @Mohsintl, @Nikhi00718, @oodadoudou, @AdityaxDeore,
@w4rc0n, @genius8267, @Aschenbath, @DeryFerd, @mohitdixit02, @Vaishnavi220506,
@Nikhila-Dhevi-R, @natefaerber, @dajiaohuang, @Aummadour, @KM199 and @Totopo27
for merged contributions, and to @todie and @seanmcn, whose fork work is carried
in [#2439](https://github.com/mvschwarz/openrig/pull/2439) and [#2400](https://github.com/mvschwarz/openrig/pull/2400). Thanks to @ziadeh, @billbenetti, @eisenbergjerry,
@ch-davidriott, @puniserv, @oneandonlydean, @sabnanikl-dev, @gifflen,
@pvl-agent, @EZotoff, @adam-nectar, @bpamiri, @tgrundtvig, @aanestesia,
@AdamWhitehurst and @eisenrigbot for reports carried into this release. Thanks
also to @z4cc, @hitianqi, @jimallen, @m3ac-AllbrittenJ and @lkraider for reports
and field feedback.
## [0.7.8]
**Not yet published.**
Published 2026-11-05 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.8` (`12df562c`).
OpenRig 0.7.8 lets a person answer a runner seat's permission requests, with a
conformance check and an example runner to build against. It adds a maintained
way to run a Claude worker in an NVIDIA OpenShell sandbox, and includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI.
conformance check and an example runner to build against. It includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI. It also includes a
reference for running a Claude worker in an NVIDIA OpenShell sandbox, but that
worker isn't usable in this release: see [the OpenShell reference](#the-openshell-reference-isnt-usable-in-078).
### Before you upgrade
@@ -61,20 +770,15 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
- **A seat activity grant** lets a runner outside the host's hook setup report
activity for one seat and launch, without the install-wide hook token. ([#2295](https://github.com/mvschwarz/openrig/pull/2295))
### Claude workers in OpenShell sandboxes
### The OpenShell reference isn't usable in 0.7.8
- **`rig environment setup` installs a maintained reference** that binds a seat
to one NVIDIA OpenShell sandbox (`rig environment create` or `attach`, then
`rig up`).
- **Context and results:** the seat's guidance, skills, plugins and startup
files go into the sandbox, and verified JSON results come back through
queue work.
- **Stop and delete:** stopping the agent, stopping compute and deleting the
sandbox are separate actions.
- **Restore:** a recreated sandbox resumes the same conversation.
The steps are in `daemon/assets/openshell/README.md`.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300))
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
**In our release testing the worker never became usable:** after the sandbox was
created and the native process started, `rig environment connect` failed when its
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
0.7.8.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300), [#2378](https://github.com/mvschwarz/openrig/pull/2378))
### Seats, delivery and the TUI
@@ -136,8 +840,13 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
### Known limits
- **No harness has run end to end under the contract yet.** The dsh example and
the conformance check were checked against stand-ins.
- ~~**No harness has run end to end under the contract yet.** The dsh example and
the conformance check were checked against stand-ins.~~ *Corrected after
release:* 0.7.8's release testing ran a real dsh 0.2.1-rc.2 through the
shipped dsh example runner for a person's allow-once and reject-once, and
through an outside runner, dsh-seat 0.3.0, for launch, queue work, multiline
input, activity, cancel and exact resume. The conformance check itself runs
against a stand-in agent.
- **The answer owner is a registered person.** "A colleague's answer is
followed on this host. If the ask is handed to another host, the request
waits, shown as needs input, until the agent's turn ends."
@@ -150,14 +859,13 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
- **The dsh example keeps one hold per seat:** "if dsh asks twice in one turn,
settling one ask releases the hold while the other may still wait on a
person."
- **The OpenShell reference requires:**
- NVIDIA OpenShell 0.1.6;
- a Linux image;
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
credentials aren't supported.
- **The worker isn't usable** (above). The reference also requires NVIDIA
OpenShell 0.1.6, a Linux image, and an API-backed provider using
`ANTHROPIC_API_KEY`; subscription or OAuth credentials aren't supported.
"Provider registration and image construction can incur costs." The worker's
activity reads unknown, from pane sampling, never idle.
- **The OpenShell README example uses `operator@kernel`;** the actual kernel
operator is `operator-agent@kernel`.
- **"Never grant the guest access to the daemon's main API port (normally
7433)."** The guest-only listener is what confines a guest's reach. Its grant
only authorizes requests for its own seat and launch.
@@ -174,7 +882,8 @@ stand-in follows.
## [0.7.7]
**Not yet published.** 0.7.7 is a separate reliability patch for 0.7.6. Its three
Published 2026-11-02 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.7` (`f3d8bac4`). 0.7.7 is a separate reliability patch for 0.7.6. Its three
fixes are also in 0.7.8.
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
+6 -6
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.10/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.10/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
@@ -68,14 +68,14 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.10` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh
```
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
**Or step by step:**
+2 -2
View File
@@ -6,9 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
## Released in 0.7.6
## Released in 0.7.8
[0.7.6 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6): follow a slice's work in one terminal workspace, plus steadier daemon start and delivery. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
[0.7.8 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.8): it lets a person answer a runner seat's permission requests, with a conformance check and an example runner to build against. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
## How to help
@@ -66,7 +66,7 @@ resolve → project → deliver pre-launch files → persist replay context →
## 2. Execution and projection types
The member's optional `claude_mcp: none` is normalized as `claudeMcp` and
persisted independently in `nodes.claude_mcp` (migration 141). The
persisted independently in `nodes.claude_mcp` (migration 142). The
expansion HTTP route preserves both `claude_mcp` and `claudeMcp`, including
invalid values for the canonical validator to reject. An authored relaunch
updates the seat choice after launch eligibility and startup-source resolution;
@@ -461,6 +461,13 @@ in the current update window. Current-update coverage is independent of the
receipt window, so an unavailable current reader cannot retain closed delivery
history beyond a successful history read. Both reads leave queue state intact.
Clients that need only current FYIs can use `GET /api/queue/open-human-updates`.
It returns `{items, limit, truncated}` with open human-addressed updates, ordered
critical then urgent then routine, without requiring a delivery receipt. `limit`
defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
same person perspective as `/api/attention`; a perspective read scans at most
1000 rows and signals `truncated` when the window may omit matching items.
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
+48 -4
View File
@@ -25,7 +25,7 @@ below `rig` (including groups and the hidden `restore apply` command), and
**1,304 explicitly registered option objects**, including the root version
option. Aliases do not add command objects; short/long spellings of one
option do not add option objects. Commander-generated help is additional.
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts.
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts. The `environment` section and the `host edit`, `host remove` and `terminal attach` rows were added later from source at `522d635c`, and aren't in the snapshot counts either. With `environment`, main registers 91 top-level commands.
## Reading this reference
@@ -350,10 +350,19 @@ the sections that were not read are `null`.
| `rig host add` | — | `--id <id>` **required**<br>`--transport <transport>` **required**<br>`--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--bearer-env <name>`<br>`--bearer-file <path>`<br>`--notes <text>`<br>`--json` |
| `rig host select <id>` | — | `--json` |
| `rig host rename <name>` | — | `--json` |
| `rig host edit <id>` | — | `--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--notes <text>`<br>`--json` |
| `rig host remove <id>` | — | `--json` |
| `rig host pair <url>` | — | `--id <id>`<br>`--timeout <seconds>`<br>`--human <address>`<br>`--json` |
| `rig host list` | `ls` | `--json` |
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
`rig host pair` needs the target daemon to run with `OPENRIG_AUTH_BEARER_TOKEN` set,
even when it is bound to a tailnet address and so starts without one: pairing hands
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
registered person can approve (none, or several with no default; name one with
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
Registration makes a host visible in topology; remote FEED attention remains opt-in.
The human-readable success output of `host add` and `host pair` now includes the FEED
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
@@ -377,7 +386,7 @@ do not establish connectivity to a live peer.
|---|---|---|
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`. `startedWithoutRequired` lists the required startup files a handover's successor started without, and the compact read keeps it.
### roster
@@ -408,6 +417,17 @@ do not establish connectivity to a live peer.
| `rig acp cancel <runId>` | — | — |
| `rig acp stop <runId> [stopCommand...]` | — | — |
### exec
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig exec` | — | — |
| `rig exec harnesses` | — | `--json` |
| `rig exec run <qitemId>` | — | `--harness <name>` **required**<br>`--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--wait`<br>`--plan`<br>`--again`<br>`--poll-seconds <n>` |
| `rig exec status <runIdOrQitemId>` | — | `--json` |
| `rig exec cancel <runId>` | — | — |
| `rig exec connector <runId>` (hidden) | — | — |
### agent
| Invocation | Aliases | Declared options |
@@ -453,6 +473,11 @@ do not establish connectivity to a live peer.
| `rig stream read` | — | `--limit <n>`<br>`--reader <session>`<br>`--json` |
| `rig stream act <streamItemId>` | — | `--work <qitemId>`<br>`--decline <reason>`<br>`--duplicate <id>`<br>`--reader <session>`<br>`--json` |
Stream intake: the `stream.intake` setting (default `queue.worker`, or a canonical `<seat>@<rig>`) receives each emitted item.
It opens an intake row for that seat, which wakes it, or adds a note to that seat's intake row still pending; a `critical`
or interrupting item makes the row urgent. `rig config set stream.intake off` keeps emits passive. A runner's
default-permit record (tag `runner-permission-permit`) opens no row.
### queue
| Invocation | Aliases | Declared options |
@@ -461,7 +486,7 @@ do not establish connectivity to a live peer.
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
@@ -541,6 +566,7 @@ do not establish connectivity to a live peer.
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
| `rig terminal views` | — | `--json` |
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
| `rig terminal attach <session>` | — | `--read-only` |
See [named terminal sessions](../reference/terminal-sessions.md) to select a
Herdr session per request, including session-bound previews and window reuse.
@@ -628,7 +654,7 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig specs` | — | — |
| `rig specs ls` | — | `--kind <kind>`<br>`--json` |
| `rig specs ls` | — | `--kind <kind>`<br>`--include-builtins`<br>`--json` |
| `rig specs show <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs preview <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs add <path>` | — | `--json` |
@@ -697,6 +723,24 @@ Only Claude and Codex tenures can be woken.
| `rig community-harness` | — | — |
| `rig community-harness list` | — | `--json` |
### environment
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig environment` | — | — |
| `rig environment setup` | — | `--directory <path>` **required**<br>`--binding <id>`<br>`--seat <address>`<br>`--rig <name>`<br>`--mcp-url <url>`<br>`--guest-base-url <origin>`<br>`--artifact-root <path>`<br>`--json` |
| `rig environment create <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json` |
| `rig environment attach <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json`<br>`--resource-id <id>` **required**<br>`--fingerprint <value>` |
| `rig environment inspect <binding>` | — | `--live`<br>`--json` |
| `rig environment connect <binding>` | — | `--generation <id>` **required** |
| `rig environment export <binding>` | — | `--request <path>` **required**<br>`--json` |
| `rig environment checkpoint <binding>` | — | `--generation <id>` **required**<br>`--operation <id>` **required**<br>`--json` |
| `rig environment recreate <binding>` | — | `--request <path>` **required**<br>`--json` |
| `rig environment completion <binding>` | — | `--request <path>` **required**<br>`--json` |
| `rig environment effect <binding>` | — | `--effect <name>` **required**<br>`--generation <id>`<br>`--resource-id <id>`<br>`--operation <id>` **required**<br>`--engine-socket <path>`<br>`--json` |
It drives the maintained OpenShell reference; see the [OpenShell example](../reference/community-harness-examples/openshell/README.md).
### fork
| Invocation | Aliases | Declared options |
+1 -1
View File
@@ -540,7 +540,7 @@ Before you start, two honest constraints:
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
| coordination | `queue` | (partial) handoff → successor owned by `--to` and source asserted closed (`handed-off`), block → parked blocked, resolve → unpark to in-progress (owner nudge suppressed), unclaim → pending with destination kept — each with exact items and destinations asserted via `expect queue` (`rig queue list --json --full --all`) in `queue-transitions-reach-exact-states.yaml`; baton restart coverage stays in `queue-baton-survives-restart.yaml`. |
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
+108 -2
View File
@@ -13,7 +13,8 @@ The row's notes get, in order:
- **progress,** about once a minute (tool calls so far, output so far); the full transcript is in the run's log file, named on the row;
- **each permission request and its answer** (below);
- **the outcome:** `completed`, `stopped` (for example `max_tokens`), `cancelled`, `failed` or `unknown`, with the
agent's output;
agent's output and the tokens and cost it reported, each labelled with where it came from (unknown when it reported
none);
- **what happened to the agent's processes** after the run ended.
The outcome is the agent's report, not a judgment of the work. The row stays open and yours: read the result, then
@@ -62,6 +63,78 @@ shared one running; stop your own with its own command.
- `failed`: the agent returned an error. The row keeps its original code, message and data.
- `cancelled`, `stopped`: as above.
## Claude Code and Codex, through their ACP adapters
Both have maintained ACP adapters. What follows was checked on 2026-11-06 with OpenRig 0.7.8: one small file task per
case, with `claude-haiku-5-5` and with `gpt-6.1-sol` at low effort. Other models weren't checked.
Install the adapters once; running them through `npx` adds about 10 seconds a turn:
```sh
npm install --prefix <dir> @agentclientprotocol/[email protected] @agentclientprotocol/[email protected]
export PATH="<dir>/node_modules/.bin:$PATH"
rig acp run <row> --cwd <work folder> -- env CLAUDE_CODE_EXECUTABLE=<your claude> ANTHROPIC_MODEL=<model> claude-agent-acp
rig acp run <row> --cwd <work folder> -- codex-acp
```
**Which versions run.** The binding note on the row names the adapter and its version.
- `claude-agent-acp` 0.101.0 runs Claude Code through the Claude Agent SDK 0.3.318. With `CLAUDE_CODE_EXECUTABLE` set,
it runs that `claude`, with that `claude`'s own login; the check ran Claude Code 2.1.310 this way. Without it, an npm
install runs the Claude binary from the SDK's platform package (Claude Code 2.1.318, by the SDK's package metadata),
which wasn't checked. Installing with `--omit=optional` leaves that binary out, and then, without
`CLAUDE_CODE_EXECUTABLE`, the adapter refuses to start; it never falls back to a `claude` on PATH. The adapter also
loads your Claude configuration, skills included.
- `codex-acp` 2.5.0 depends on `@openai/codex` `^0.165.0`, and 0.165.0 was the only release in that range when this
was checked. A later install can resolve a newer 0.165 release; `npm ls @openai/codex` in `<dir>` shows yours. It uses
the machine's existing Codex login.
- `@zed-industries/claude-code-acp` and `@zed-industries/codex-acp` are deprecated on npm in favour of these. The zed
`codex-acp` 0.16.0's Codex was too old for the model in the check.
**Permissions.** Each agent keeps its own permission mode, and OpenRig adds no restriction. In the check, neither
asked anything for file writes or shell commands: Claude started in `auto` mode and Codex in `workspace-write`. To
have the agent ask, so that each request becomes a permission row:
- **Claude:** give it a dedicated `CLAUDE_CONFIG_DIR` whose settings set `permissions.defaultMode` to `"default"`.
- **Codex:** set `INITIAL_AGENT_MODE=read-only` in its environment. Approval and sandbox keys in `CODEX_CONFIG` didn't
make it ask.
These are the settings the check used to make the agents ask. Whether to use them is the caller's choice; they're not a
recommended default. Send permission rows to a seat that can be woken: a row to a `terminal` seat sets off the stuck
sweep.
**Answers differ between the two.**
- **Claude** offers `allow-once`, `allow-with-updates` (allow always) and `reject`. `reject` refuses that one action,
and the turn carries on.
- **Codex** offers `allow_once`, `accept_execpolicy_amendment` (allow always) and `cancel`. Its only refusal, `cancel`,
ends the whole turn, and the outcome is `cancelled`.
**Continuing, and a session that doesn't exist.**
- **Continuing:** `--load-session` continued an earlier session on a second row with both adapters.
- **A made-up session id:** both refused it and started nothing, but they report it differently. Claude returns
`-32002 Resource not found`, and Codex returns `-32603 Internal error` with "no rollout found" in its data. The row
shows `failed` with the agent's own error. Neither code by itself means a missing session, so read the message.
**A completed turn isn't finished work.** In the check, Claude was asked to run a command and wait for it. It moved the
command into a background task, said it would report when it finished, and ended the turn `completed`. The task died
with the adapter, and its file never appeared. Check the work itself before you close the row. Beyond this one case,
long and background work weren't checked.
**Cancel.** `rig acp cancel` was acknowledged in 20-30 ms for both, sent while the agent waited on a permission
answer; a cancel mid-generation or mid-tool wasn't timed. Straight after, Claude's run reported `incomplete`
with one process still running, which was gone a few seconds later; Codex's reported `settled`. An acknowledgement
isn't a finished shutdown, and it says nothing about further spend.
**Usage and cost.** Claude's adapter reports token usage and a cost in USD; Codex's reports tokens only. Both are in
the run's log file, not on the row.
**Not checked:**
- long or multi-tool tasks, and background work;
- concurrent runs;
- `rig acp stop` with a real stop command;
- an adapter that crashes mid-turn;
- continuing after a daemon restart;
- MCP servers.
## Notes for specific agents
- **OpenClaw:** `openclaw acp` is a bridge to a running OpenClaw Gateway, so start the Gateway first, then run, for
@@ -70,8 +143,41 @@ shared one running; stop your own with its own command.
- **Continuing a session:** `--load-session <id>` continues an earlier session when the agent advertises loading. If it
doesn't, or the load fails, the run reports that and starts nothing new.
## Running it in the background: `rig exec`
`rig exec` runs the same single turn on a named harness, `claude` or `codex`, through that harness's ACP adapter, in a
connector that outlives the command:
```sh
rig exec harnesses # the adapter and native versions installed here, and how each differs
rig exec run <row> --harness claude --plan # what would run, with versions; starts nothing
rig exec run <row> --harness claude [--wait] # start it; --wait waits here for the outcome
rig exec status <runId|row> # state, outcome, usage and cost, and what still runs
rig exec cancel <runId>
```
- **The connector owns the run.** Closing the terminal, or leaving `--wait` with Ctrl-C, doesn't stop it. Its record
in `$OPENRIG_HOME/acp/runs/` holds its process and its state: `requested`, `started`, `finished` or `interrupted`.
The row gets the same notes as with `rig acp run`.
- **One run per row.** Running it again returns the row's run, still going or ended, instead of sending the task
twice. `--again` starts another turn once that run has ended.
- **Interrupted.** A run whose connector died without recording an outcome reads `interrupted`, outcome unknown, with
one note on the row, the next time `status`, `run` or `--wait` looks at it. Whether any of the agent's processes
remain is said there too.
- **Missing.** With `--load-session`, a session reads `missing` only on the adapter's own evidence: for Claude,
`-32002` naming that session; for Codex, its "no rollout found for thread id" error for that session. Any other error
reads `failed`, with the agent's code and message, and no new session is started either way.
- **The outcome is the agent's report.** `finished` with `completed` means the turn ended; accepting the work stays
with you, on the row. `--wait` exits 0 for `completed`, 2 for any other outcome, and 3 when you stop waiting.
Without `--wait`, a run that has already ended exits the same way, and one still going exits 0. Either way the code
follows the row's run, including one another caller started.
- **Permissions:** the agent keeps its own mode, and `rig exec` adds no policy; your environment passes through.
`rig exec harnesses` lists what each harness reads.
`rig acp run` is unchanged: it stays in your terminal for the length of the turn.
## Limits
- One run is one turn for one row.
- The client offers the agent no file-system or terminal methods; the agent works in its own environment.
- Spend is whatever the agent's provider charges; OpenRig doesn't measure it.
- Spend is whatever the agent's provider charges. OpenRig doesn't measure it; it records only what the agent reports.
+21 -6
View File
@@ -229,9 +229,10 @@ This gives access to the shared pool: skills such as `systematic-debugging`, `ve
`development-team` and `test-driven-development`; the `openrig-core` plugin, which carries `openrig-user` and OpenRig's
other skills; and runtime resources such as `claude-default-settings`, `claude-default-mcp`, `codex-default-config` and
`claude-activity-hooks`. Agents select the ones they need via profile `uses`, for example
`plugins: [shared:openrig-core]`. That selection projects the plugin's skills into the seat. For a Claude seat it also loads
the plugin's hooks, through a `CLAUDE_CODE_PLUGIN_DIRS` the daemon sets for a seat that selected `openrig-core` (see
`refocus-channel.md`); a seat that didn't select it gets neither.
`plugins: [shared:openrig-core]`. For a Claude seat selecting the vendored `openrig-core`, the daemon loads the plugin
itself through `CLAUDE_CODE_PLUGIN_DIRS`, including its skills and hooks, without projecting a second copy of its skills
(see `refocus-channel.md`). Other selected plugin skills follow the projection rules below. A seat that didn't select
`openrig-core` does not get its hooks through this selection.
---
@@ -287,7 +288,11 @@ Each array contains resource IDs. These can be:
- **Qualified** (`shared:systematic-debugging`) — resolves against a specific imported spec's resources
The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `runtime_resources`. A `uses.hooks` key, even an
empty list, is refused: hooks ship inside plugins.
empty list, is refused: hooks ship inside plugins. Any other key selects nothing and gets a validation advisory, shown at
launch and by `rig agent validate`, so a typo such as `skils` doesn't go unnoticed. A single value written in place of a
category's list (`skills: vault-user`) is read as a one-item list, with an advisory suggesting list syntax, and resolves
exactly like that list: when the profile is selected, a resource that isn't available fails that member's resolution with
"not found in resource pool".
**Skills resolve more widely than other resources.** For Claude Code and Codex seats, a skill ID that no declared
resource provides is looked up at launch in the project's runtime skill folder in the seat's cwd (`<cwd>/.claude/skills` or
@@ -373,7 +378,7 @@ version.
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Neither Claude Code nor Codex reads skills from that plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Claude Code and Codex do not read skills from that projected plugin folder, so selected plugin skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. The exception is the vendored `openrig-core` selected by a Claude seat: Claude loads that plugin directly, skills included, so its skills are not projected a second time. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. An entry whose `runtime` isn't the seat's runtime is skipped. Pi and OMP project none. |
*Fields marked with `*` are optional.
@@ -419,6 +424,16 @@ Where a fragment's table is one the user already declares, the user's table
wins: the managed table is dropped, their values are never merged, rewritten or
overwritten, and the rest of the fragment still applies.
**A fragment can't install a named Codex profile.** Codex versions with named
profiles read each one from its own file, `<CODEX_HOME>/<name>.config.toml`,
with top-level keys, and won't load a profile while `config.toml` still holds a
legacy `[profiles.<name>]` table. A fragment only adds tables to `config.toml`,
so neither form works there. Create the profile file yourself and select it with
the member's `codex_config_profile`; see "Codex: select sandbox and approvals
together" in [getting-started.md](getting-started.md#codex-select-sandbox-and-approvals-together).
Before a launch or restore, OpenRig checks that a selected profile loads, and for
a legacy table it names the file to move the settings into.
### Resource Path Rules
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths), except a plugin's
@@ -440,7 +455,7 @@ guidance:
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Resource identifier. A `rig-role` guidance resource is never merged; deliver a role with a `send_text` startup file. |
| `path` | string | yes | — | Relative path to the guidance file. |
| `target` | string | no | — | Parsed but not used: the runtime decides the file. Claude merges into the rig's `managed_blocks` file (`CLAUDE.md` by default, or `CLAUDE.local.md`); Codex, Pi and OMP merge into `<cwd>/AGENTS.md`. |
| `target` | string | no | — | Parsed but not used: the runtime decides the file. Claude merges into the rig's `managed_blocks` file (`CLAUDE.md` by default, or `CLAUDE.local.md`); Codex, Pi and OMP merge into `<cwd>/AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's `AGENTS.md` and a fresh Codex 0.164.0+ seat normally gets launch developer instructions; the conditions are in [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). |
| `merge` | string | no | `managed_block` | Only `managed_block` is applied. Any other value, such as `append`, merges nothing. |
---
+115 -3
View File
@@ -214,7 +214,7 @@ After the files, OpenRig sends a session-identity message (rig, pod, member, log
| Delivery Hint | When | How | Use For |
|---------------|------|-----|---------|
| `auto` | Depends on the file | Resolved per file: a `SKILL.md` (or content starting `# SKILL`) is `skill_install`, any other `.md` is `guidance_merge`, and any other file is `send_text` | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged as a managed block into `AGENTS.md` (Codex, Pi) or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`) | Role guidance, culture, project context |
| `guidance_merge` | Before harness boot | Merged as a managed block into the repository `AGENTS.md` (Codex, OMP), a fresh Pi seat's own agent-directory `AGENTS.md` (see [Runtime Differences](#runtime-differences)), or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`). A fresh launch of Codex 0.164.0 or later can carry it as launch developer instructions instead (see [Codex guidance at launch](#codex-guidance-at-launch)) | Role guidance, culture, project context |
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
@@ -367,7 +367,15 @@ Each startup file and action specifies when it applies:
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
- Default: `[fresh_start, restore]` (both)
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: a managed guidance block missing from the guidance file is put back before the harness starts.
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: the guidance file's managed blocks are brought up to date before the harness starts. A missing block is put back, and a stale one is refreshed (see below).
**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance.
**A block you edited by hand is never overwritten.** OpenRig records what it last wrote in each managed block, so a launch, an exact resume, a handover or a runtime switch can refresh a block whose source file changed since. The record follows the file itself, so two links to one file share it. A block you've edited since OpenRig wrote it is left as you wrote it. The start's result names it (`guidance_block_edited`), and the seat is told in one line once it's ready, or in a handover's packet. A block OpenRig has no record of writing (any block written before these records were kept) that differs from its source works like this:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
@@ -528,6 +536,58 @@ This separates provider files, not OS users or accounts. Project-local resources
still use the selected project directory; shared project and system settings
remain shared.
### Codex guidance at launch
On a fresh launch of Codex 0.164.0 or later, OpenRig passes a seat's managed guidance (OpenRig's start file and the
rig's and project's context, culture and SOP files) with the launch, as `-c developer_instructions=…`, instead of
merging it into the repository `AGENTS.md`. Neither the tracked `AGENTS.md` nor the Codex home is written for it, and
both still load as usual. The text opens: "OpenRig seat guidance for this session follows. Where it conflicts with
AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md."
A launch value replaces a configured one, so this happens only when no Codex config layer OpenRig reads sets
`developer_instructions`. OpenRig reads:
- `/etc/codex/config.toml` and `/etc/codex/managed_config.toml`;
- the Codex home's `config.toml`, profiles included, and the selected profile's `<profile>.config.toml`;
- each `.codex/config.toml` from the seat's working directory up.
The Codex home it reads is the seat's own (a `CODEX_HOME` in its `env:`, or a recorded account switch), else the
daemon's `CODEX_HOME`, else `~/.codex`.
The guidance still goes to the repository `AGENTS.md`, as before, when:
- a layer OpenRig reads sets `developer_instructions`, or can't be read. A launch warning names the file;
- the launch resumes or forks a conversation. Developer instructions on a resume haven't been tested, and an exact
resume puts the guidance back in the repository `AGENTS.md`;
- the seat is restored, even when the restore starts a new conversation. A restore keeps the guidance file its launch
recorded, which for Codex is the repository `AGENTS.md`;
- a handover successor starts (fresh, fork or rebuild). Its launch carries no guidance of its own, so the handover
first puts the seat's missing blocks back in the repository `AGENTS.md` from its saved startup context;
- Codex is older than 0.164.0, or its version is unknown.
A seat's role still arrives as startup text, and the seat-skills line, after the person's yes, still goes to
`AGENTS.md`.
A launch that carries developer instructions writes no new blocks to the repository `AGENTS.md`. But blocks an earlier
handover or resume left there are refreshed from their sources, by the same rule as every other managed block. That
matters because the launch text asks Codex to follow `AGENTS.md` where the two differ.
**If your shell sets `CODEX_HOME`.** When neither the seat nor the daemon declares a `CODEX_HOME`, a seat's pane keeps
its own, and OpenRig reads `~/.codex`. A `CODEX_HOME` that only your shell startup sets (in `~/.zshrc`, for example),
with the daemon started without it, isn't seen. A `developer_instructions` set in that home is then replaced by the
launch value for the session. To have it checked, declare that same absolute path as `CODEX_HOME` in the member's or
rig's `env:` (see [Launch environment](rig-spec.md#launch-environment), "A seat's own account"). OpenRig then reads
that home at the seat's next fresh launch, and a `developer_instructions` set there keeps the repository `AGENTS.md`.
The seat keeps the same home and login.
**What it doesn't cover.** OpenRig doesn't read Codex's packaged defaults, or MDM- or enterprise-managed settings. A
`developer_instructions` set there may be replaced for the session. Legacy MDM-managed config ranks above launch flags,
so it can override OpenRig's guidance, and the seat then gets none of it.
**Not established.** The order Codex assembles developer instructions and `AGENTS.md` in, and how it settles every
conflict, aren't established; the opening sentence asks it to follow `AGENTS.md`. The native behaviour was observed on
Codex 0.164.0 alone, on fresh launches without tools.
### Runtime Differences
**Claude Code:**
@@ -558,7 +618,10 @@ remain shared.
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
**Pi and OMP:**
- Read `AGENTS.md` in the working directory; guidance is merged there, added to Pi's own system prompt
- Read `AGENTS.md` in the working directory, added to Pi's own system prompt. Pi also reads the one in its agent
directory: on a fresh launch, a Pi seat's guidance is merged there, under OpenRig's state, not into the
repository's. When that file or its directory is a link, the repository `AGENTS.md` is used instead. A restore keeps
the file the seat launched with. OMP keeps its guidance in the repository `AGENTS.md`
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
workspace folder
@@ -672,6 +735,29 @@ When authoring startup content, note which instructions are runtime-specific. Fo
---
## Files OpenRig writes into a project
These are the files OpenRig writes into a seat's working directory, and what `rig down` does with each. Paths are
relative to that directory.
| Path | When OpenRig writes it | What `rig down` does |
| --- | --- | --- |
| Managed blocks in `CLAUDE.md` (or the rig's `managed_blocks` file, such as `CLAUDE.local.md`) or `AGENTS.md` | Each launch merges the seat's guidance blocks. A fresh Codex launch that takes developer instructions puts them in its launch text instead: in `AGENTS.md` it adds only the seat-skills line, after your yes, and refreshes blocks already there. A fresh Pi seat writes its own agent-directory `AGENTS.md` outside the project, except when that folder or file is a link: then its guidance stays in the repository `AGENTS.md`. | Removes OpenRig's blocks, current or legacy, and keeps everything else. When nothing else is left, it deletes the file if the file began with an OpenRig block, or held only blocks in an older format. A block-only file isn't proof that OpenRig created it. The seat-skills line is kept if you edited it. |
| `.openrig/context-collector.cjs` | Claude seats, at launch. | Removes it while its bytes are still the shipped collector's; otherwise keeps it and says so. |
| `.claude/settings.local.json` | Claude seats, at launch: OpenRig's status line and its activity hooks are merged in, separately. Your own status line is never replaced, but the activity hooks are still merged beside it. A file that doesn't parse gets neither. | Removes OpenRig's status line and hooks and keeps the file, even if `{}` is all that's left. |
| `.claude/settings.local.json` fragments and `.mcp.json` | When a profile or spec declares `claude_settings_fragment` or `claude_mcp_fragment`. | Leaves the merged content in place. |
| `.claude/agents/`, `.claude/plugins/<id>/`, `.claude/extensions/<id>/`; `.agents/<id>.yaml`, `.codex/plugins/<id>/`, `.agents/extensions/<id>/` | When the seat's projection includes subagents, plugins or runtime resources (Claude, then Codex). | Leaves them in place. |
| `.claude/skills/<id>/`, `.agents/skills/<id>/` | Only for a seat without seat storage. New Claude and Codex seats keep their skills under `$OPENRIG_HOME/state/seats/`, outside the project. | Leaves them in place. |
| The managed compaction preparation folder described in [Claude compaction preparation directory](#claude-compaction-preparation-directory), with its `.gitignore` of `*` and each attempt's `RESTORE-MAP.md` | Managed Claude compaction, unless a preparation root is configured elsewhere. | Leaves it in place. |
| `.rigged-checkpoint.md` | A restore that didn't resume a seat's native conversation (by choice or not) and had a checkpoint to hand it. | Leaves it in place. |
`rig down` leaves a file alone while another seat still uses it: a running seat of the same rig, or any seat of
another rig that isn't archived. That is decided file by file. A guidance file is kept while another seat's
guidance targets the same file. The Claude settings and collector are kept while another Claude seat works in that
directory. So another rig's Codex seat keeps `AGENTS.md` without keeping `CLAUDE.md` or the Claude settings. It
removes `.openrig/` and `.claude/` only when they are empty.
`rig down --delete` does nothing more to project files. OpenRig doesn't edit your project's own `.gitignore`.
## Patterns and Anti-Patterns
### Good Patterns
@@ -741,6 +827,32 @@ rig uses it, so it stays until then. To remove it sooner, by hand:
3. Keep a copy of the file, remove only those lines, and compare the result with
the copy: nothing else in the file should differ.
**The kernel's own folder.** A newly created default kernel works in
`<workspace.root>/kernel`, beside your projects (`<workspace.root>/projects` by
default) rather than above them. Claude reads `CLAUDE.md` from a seat's folder
and from every folder above it, so a kernel working in the workspace root put its
guidance into every Claude project seat below it. If that folder can't be made,
the new kernel works in the workspace root, as before.
A kernel that already exists keeps its folders, usually `workspace.root`.
Upgrading moves no seat, changes no restored conversation and removes no file.
To move an existing kernel yourself, if you want to:
1. Save the new folder for each kernel seat with `rig seat set-cwd <seat> --cwd
<workspace.root>/kernel --reason <text>`, using the absolute path. It creates
the folder ([details](#change-an-existing-seats-working-directory)).
2. Choose when each seat adopts it. A seat keeps its current folder until it
starts again, and the new start doesn't continue its current native
conversation: `rig seat launch <seat> --fresh --stop --reason <text>` starts a
blank one, and `rig handover <seat> --source rebuild --reason <text>` carries
durable context into a new one. Nothing does this for you.
3. Tidy the old `<workspace.root>/CLAUDE.md` (or `AGENTS.md`) by hand, once no
running rig's seat still works in that folder: a seat there still reads those
blocks, and `rig down` checks the same before it removes them. Remove only
OpenRig's managed blocks, each from its `<!-- BEGIN OpenRig MANAGED BLOCK: … -->`
line through its `END` line, as in the steps above, and keep everything else,
including your own text. Don't move or delete the whole file.
**Separate project context from role**
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
+23 -2
View File
@@ -23,7 +23,10 @@ Only the activity axis is served today. The session and resumability values are
`activity-taxonomy.ts` but no surface reads them: `rig ps --nodes --full` shows the raw session
STATUS, both node views show a LIFECYCLE of `running`, `detached`, `recoverable` or `attention_required` (`run`, `det`, `rec`,
`att`), and node inventory serves `occupantLifecycle` (`active`, `retiring`, `retired`,
`context_walled`, `compacted`, `crashed`, `unknown`).
`context_walled`, `compacted`, `crashed`, `unknown`). Node inventory's `sessionStatus` is the
recorded session status, with two observed corrections: a running session whose pane is verified
to have exited (identity reason `pane_exited`) reads `exited`, with `startupStatus:
"attention_required"`, and a running session confirmed missing reads `detached`.
The axes never blend. Reachability is strict; revival optimism is a different field
(omnigent's strict-liveness split). `unknown` is a first-class honest value — the oracle
@@ -59,6 +62,21 @@ the existing live-hook fallback in effect; lifecycle, startup, held and error si
can independently raise attention. A false attention flag means no known trigger, not
proof that an unobserved input box is clear.
A handover whose successor started without a required file also raises attention. That's
a projected guidance file or a guidance startup file marked `required` still missing from
the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup
file that's missing. The seat serves `startedWithoutRequired` with each file's path, the
table's reason column names each file, and the handover's own output says it's degraded.
The mark belongs to that successor's session, so the seat's next launch or handover
clears it. Time alone never does. Repairing the file in place doesn't clear it either,
because no route delivers guidance into a running conversation.
A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files,
the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the
successor to read the file; it isn't the pasted text a start sends. A fork successor
carries its parent's conversation and gets no list. `send_text` startup actions, such as
an `after_ready` preload, aren't run for a successor.
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
unavailable. Compact JSON retains `needsAttention`; use `--full` or
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
@@ -116,7 +134,10 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
PER-HARNESS when that harness's hook rung earns trust through a measured agreement
window in production — never by fixture pass alone (symmetric admission: a fixture
pass admits a rung to TRIAL, where it is measured but not consulted; production agreement
promotes it). Undeclared and partial-coverage rungs are identity-only, which is also where
promotes it). For Codex, this fallback also accepts a fresh working status adjacent to
the composer, or an advancing counter across two fresh captures when output separates
them. This positive evidence expires after five seconds; unrecognized, frozen, old or
pre-occupant frames add nothing. It uses the existing pane capture. Undeclared and partial-coverage rungs are identity-only, which is also where
a degraded rung goes, with one declared exception: for a pi or omp seat or a community
harness, sampling is positive-only: a changing pane reads working, and a quiet one never reads idle on its
own, since the pane can be quiet mid-turn.
+6 -6
View File
@@ -1,4 +1,4 @@
# Integration author contracts — DRAFT, published with 0.7.6
# Integration author contracts — DRAFT, first published with 0.7.6
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
@@ -9,16 +9,16 @@ These are draft author responsibilities. Some use implemented OpenRig interfaces
## Release and support status
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. References below use the immutable base instead of a moving branch.
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. **OpenRig 0.7.8** adds seat activity grants, OpenRig's side of the answered permission path and the maintained OpenShell reference driver (`rig environment`); the rows below that say 0.7.8 describe it. References below use the immutable base instead of a moving branch, except the community preset reference, which is the in-tree copy.
| Area | Status at this baseline | What the status means |
| Area | Status | What the status means |
|---|---|---|
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | Proposed author implementation | No end-to-end dsh integration is established. Native reopen capabilities and the tools-off case remain implementer questions. |
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | OpenRig's side released in 0.7.8; runner side shown on two runners, not qualified in general | OpenRig asks a person for a runner's permission requests and returns the answer. In 0.7.8's release testing on a Claude Haiku model, dsh-seat 0.3.0 (dsh 0.2.1-rc.2 over ACP) showed input, activity, cancel, exact resume and shutdown, and the dsh example runner showed a person's allow-once and reject-once; see the [runner contract](harness-runner-contract.md)'s status for what wasn't shown. Native reopen capabilities and the tools-off case remain implementer questions. |
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
| Environment preparation, projection, artifact export and owned-resource cleanup | Measured in a private wrapper; generic seam proposed | The platform chapter makes the responsibilities reusable. Core does not provide a generic platform driver or `DeploySpec` loader at this baseline. |
| Environment preparation, projection, artifact export and owned-resource cleanup | OpenShell reference driver released in 0.7.8; generic seam proposed | The platform chapter makes the responsibilities reusable. Core ships one maintained reference driver, OpenShell 0.1.6, as `rig environment`. It doesn't provide a generic platform driver or `DeploySpec` loader. |
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
@@ -50,7 +50,7 @@ The queue records who owns work. It does not prove that an old provider process
## Start from an existing example
For a terminal harness, begin with the pinned [community preset reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md) and the [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
For a terminal harness, begin with the [community preset reference](community-harness-presets.md) and the pinned [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
+12 -1
View File
@@ -8,4 +8,15 @@ Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-c
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
A `selection_changed` or `stop_uncertain` response from a checked stop says how far the stop got in `stopPhase`. A refusal before the stop starts carries neither `stopPhase` nor `partialEffect`, and nothing was sent. The stop detaches the seat's clients, checks the selection again, then kills the session:
| `stopPhase` | What was sent to the seat |
| --- | --- |
| `none` | Nothing. |
| `detach_attempted` | The client detach, which may not have finished. No kill. |
| `detached` | The client detach. No kill. |
| `kill_attempted` | The kill. |
`partialEffect` is `true` whenever `stopPhase` isn't `none`, so on its own it doesn't say whether the kill went out. `selection_changed` with `stopPhase: "kill_attempted"` is a change seen after the kill, when the stop state is saved: the session was killed, so re-read the seat rather than offering the stop again.
@@ -5,8 +5,9 @@ It runs in the seat's pane, hosts `dsh --profile acp` over ACP on stdio, and doe
section:
- **identity:** the launch record, written once the conversation is open;
- **one prompt per block:** each submitted block becomes one `session/prompt`, using bracketed paste in a TTY;
- **turn boundaries:** reported as activity hooks;
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so;
- **turn boundaries:** reported as activity hooks, with a working beat every 10 s through a long, quiet turn;
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so. Type `/abort`
in the seat's pane: one sent with `rig send` arrives inside a From/To envelope, so it's an ordinary prompt;
- **permissions:** each request goes to a person through OpenRig;
- **resume:** it reopens the exact conversation, or says honestly that it couldn't.
@@ -21,13 +22,19 @@ own. [bpamiri/dsh-seat](https://github.com/bpamiri/dsh-seat) is a fuller dsh run
`$OPENRIG_HOME/community-harnesses/`. Then declare a member with `runtime: dsh`.
3. **Give the seat credentials.** Each seat gets its own dsh home, at `$OPENRIG_HOME/state/dsh/<node>/home`, because dsh
rewrites its profile there on every start. Put a `.credentials.yaml` there, or put a provider key in the seat's
`env:`. To use a home you prepared yourself, set `OPENRIG_DSH_HOME`. `OPENRIG_DSH` names another `dsh` binary.
`env:`. To use a home you prepared yourself, set `DSH_SEAT_HOME` in the member's `env:`; `DSH_SEAT_BIN` there names
another `dsh` binary. The runner doesn't take dsh's own `DSH_HOME` from its environment, because your shell's startup
files run after the seat's `env:` and could set it. A member's `env:` can't set `OPENRIG_*` names, which OpenRig
reserves, but the runner still reads `OPENRIG_DSH_HOME` and `OPENRIG_DSH` when something exports them to it, such as
a preset `command` wrapper.
The runner's own defaults for dsh:
- **Uploads:** the seat home gets a `cordis.patch.yml` that turns off dsh's session-log and plugin-list uploads to
DeepSeek, and the runner starts dsh with `DSH_TELEMETRY_DISABLED=1`.
- **Permission mode:** `DSH_PERMISSION_MODE` is `workspace-write` unless you set it.
- **The hook token:** OpenRig's activity token isn't passed to dsh, whose shell tool inherits the runner's environment.
- **Its version:** the launch record names dsh's version from `dsh --version`. If that fails or takes over 5 seconds, it
names the version dsh's ACP handshake reports, marked as such: dsh 0.2.1-rc.2 reports 0.0.1 there.
## Permissions
@@ -37,6 +44,11 @@ The runner submits each request to OpenRig, which asks one person:
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
**When the person answers in their own words** rather than with a choice, a yes or a no, OpenRig permits by default and
passes their words back as `said`. The runner gives dsh its option as usual, shows the words in the pane, and queues
them like any other message, so dsh gets them after the current turn and after any message already waiting. The words
are never the answer.
**When no one can be asked, the request is permitted** with dsh's own allow option: `allow_once` when dsh offers it,
else `allow_always`. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
without this path, an OpenRig it can't reach, or a refusal other than `--deny`.
@@ -53,8 +65,10 @@ OpenRig says the request isn't the seat's current occupant's (for example, after
## Before you declare resume
The preset leaves `resume` out. The runner reopens a conversation by the method dsh advertises: `session/resume`, or
`session/load`. When dsh advertises neither, it tries `session/resume`. It never starts a new conversation on a resume.
The preset leaves `resume` out, so a restore stops and asks, and a fresh launch starts a new conversation.
`rig seat restart --keep-conversation` is for Codex seats only. The runner reopens a conversation by the method dsh
advertises: `session/resume`, or `session/load`. When dsh advertises neither, it tries `session/resume`. It never starts
a new conversation on a resume.
**Telling a missing conversation from one that won't reopen.** dsh `0.2.1-rc.2` answers `-32602` both for a
conversation it doesn't have and for one already active elsewhere, so the runner never reads that error as missing.
@@ -7,11 +7,11 @@ runner: true
launch_result: true
first_input: true
limits:
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later, and checked against a stand-in agent only
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later; run with a real dsh on two stacks only, dsh's Claude route with a Claude Haiku model in 0.7.8's release testing (with that release's copy of this runner, for an allow-once and a reject-once), and dsh's pi-ai OpenRouter chat-completions profile with the model deepseek/deepseek-v4-flash on OpenRig 0.7.8 (for queue work, activity through a quiet tool call, an allow-once and a reject-once, and cancel); in both, the permission answers came from a test answerer registered as a person, not a live person; dsh's DeepSeek Messages adapter pointed at OpenRouter failed with HTTP 400 in the second, cause not established; no other dsh route or model is checked
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env; set DSH_SEAT_HOME in the member's env to use a home you prepared, and DSH_SEAT_BIN for another dsh binary (a member's env can't set OPENRIG_* names, and the runner doesn't read dsh's own DSH_HOME)
- DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny
- allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked
- resume isn't declared until a real dsh has reopened a conversation and recalled it; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
- resume isn't declared; a real dsh has reopened a conversation after down, snapshot and up and recalled it, with resume added to a copy of this preset, but a missing conversation's refusal hasn't been run; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
- a prompt whose whole text is /abort can't be sent; a Ctrl-C byte inside a paste also aborts; without a TTY, input is read line by line; this isn't a binary-safe channel
- one prompt at a time; a message that arrives mid-turn waits for the next turn
- the seat's model isn't passed; dsh uses its own configured model
@@ -4,11 +4,12 @@
//
// It follows docs/reference/harness-runner-contract.md: it hosts `dsh --profile acp` over ACP on stdio, writes the
// launch record once the conversation is open, turns each submitted block into one prompt, reports turn boundaries as
// activity hooks, cancels on /abort or Ctrl-C, and sends permission requests to OpenRig: a person answers, or OpenRig
// permits when no one can be asked, and so does the runner when OpenRig can't answer (refusing and holding needs input
// only when the seat opted in to refusal). Copy it and maintain it as your own.
// activity hooks with a working beat through a long, quiet turn, cancels on /abort or Ctrl-C, and sends permission
// requests to OpenRig: a person answers, or OpenRig permits when no one can be asked, and so does the runner when OpenRig
// can't answer (refusing and holding needs input only when the seat opted in to refusal). Copy it and maintain it as your
// own.
import { spawn } from "node:child_process";
import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -19,6 +20,9 @@ const RUNNER = "openrig-dsh-runner 1";
const NAME = process.env.OPENRIG_RUNTIME || "dsh"; // the preset's name: OpenRig passes it, and checks the record against it
const LIVENESS_SECONDS = 60;
const BEAT_MS = 30_000;
// The working beat (contract section 4): every 10 s, more often than half its 30 s lifetime.
const WORK_LIVENESS_SECONDS = 30;
const WORK_BEAT_MS = 10_000;
const ACTIVE_EVERY_MS = 15_000;
const POST_TIMEOUT_MS = 2_000;
const WAIT_SECONDS = 25;
@@ -203,10 +207,12 @@ const UPLOADS_OFF = [
"",
].join("\n");
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). OPENRIG_DSH_HOME names
* one you prepared yourself, which is used as it is. */
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). DSH_SEAT_HOME, which a rig
* spec's env: can set, or OPENRIG_DSH_HOME names one you prepared yourself, which is used as it is. Not dsh's own
* DSH_HOME: a person's shell startup files can export it, and they run after the seat's env:. */
function seatHome() {
if (process.env.OPENRIG_DSH_HOME) return process.env.OPENRIG_DSH_HOME;
const prepared = process.env.OPENRIG_DSH_HOME || process.env.DSH_SEAT_HOME;
if (prepared) return prepared;
const seat = process.env.OPENRIG_NODE_ID || process.env.OPENRIG_SESSION_NAME || "default";
const home = join(openrigHome(), "state", "dsh", seat, "home");
mkdirSync(home, { recursive: true, mode: 0o700 });
@@ -215,6 +221,17 @@ function seatHome() {
return home;
}
/** dsh's version as its CLI reports it, or null when it can't say. dsh 0.2.1-rc.2's ACP handshake reports 0.0.1.
* SIGKILL at the timeout, because a synchronous call waits for the child to exit, and a child can ignore SIGTERM. */
function cliVersion(bin, env) {
try {
const out = execFileSync(bin, ["--version"], { encoding: "utf8", env, timeout: 5_000, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "ignore"] });
return out.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)?.[0] ?? null;
} catch {
return null;
}
}
function writeLaunchRecord(fields) {
const path = process.env.OPENRIG_LAUNCH_RESULT;
if (!path) return;
@@ -250,16 +267,23 @@ async function main() {
agentEnv.DSH_PERMISSION_MODE ??= "workspace-write";
if (agentEnv.DSH_TELEMETRY_DISABLED === undefined && agentEnv.DSH_TELEMETRY_MODE === undefined) agentEnv.DSH_TELEMETRY_DISABLED = "1";
const child = spawn(env.OPENRIG_DSH || "dsh", ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
// DSH_SEAT_BIN, which a rig spec's env: can set, or OPENRIG_DSH names another dsh binary.
const bin = env.OPENRIG_DSH || env.DSH_SEAT_BIN || "dsh";
const version = cliVersion(bin, agentEnv);
const child = spawn(bin, ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
child.stdin.on("error", () => {});
const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`));
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0 };
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0, asking: 0, waiting: 0, resolvedAsk: null };
const inflight = new Set();
const track = (promise) => { inflight.add(promise); promise.finally(() => inflight.delete(promise)); return promise; };
let agentExited = false;
let stopping = false;
let heartbeat;
let workBeat;
let tty = false;
// The turn may read working: it runs, nothing holds needs input, no permission request is being submitted or waits for its
// permission_resolved, and dsh is alive. The beat and the agent's own reports both go by it.
const working = () => state.running && !state.holding && state.asking === 0 && !state.resolvedAsk && !agentExited;
const restoreTty = () => {
if (!tty) return;
@@ -269,6 +293,7 @@ async function main() {
};
const finish = async (code) => {
clearInterval(heartbeat);
clearInterval(workBeat);
restoreTty();
await Promise.race([Promise.allSettled([...inflight, activity.flushed()]), sleep(2_000)]);
process.exit(code);
@@ -299,7 +324,7 @@ async function main() {
peer.onNotification("session/update", (params) => {
if (params.sessionId !== state.sessionId || !params.update) return;
const update = params.update;
const reporting = state.running && !state.holding; // while needs input is held, nothing may read working
const reporting = working();
if (update.sessionUpdate === "agent_message_chunk") {
if (update.content?.type === "text") process.stdout.write(update.content.text);
if (reporting && Date.now() - state.lastActive >= ACTIVE_EVERY_MS) { state.lastActive = Date.now(); activity.post("active", "agent_message"); }
@@ -346,10 +371,17 @@ async function main() {
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
const permit = (why) => {
if (!allow) return refuse(`${why}, and the agent offered no allow option`);
state.holding = false;
state.holding = state.refused || state.waiting > 0; // another ask may still wait on a person
say(`[permission] permitted: ${why} (${allow.optionId})`);
return { outcome: { outcome: "selected", optionId: allow.optionId } };
};
// A person who answered in their own words: OpenRig passes them back as `said`. They join the prompt queue like any
// message, so dsh gets them after this turn and after any message already waiting. They're never the answer.
const passOn = (said) => {
if (typeof said !== "string" || !said.trim()) return;
say(`[permission] the person also wrote: ${firstLine(said)}`);
submit(`The person who answered your permission request (${toolCall.title}) also wrote:\n\n${said}`);
};
if (!askId) {
if (submitted.body?.code === "denied_by_seat") return refuse("the seat's operator chose to refuse its permission requests");
if (NOT_LIVE.includes(submitted.body?.code)) {
@@ -360,7 +392,12 @@ async function main() {
: submitted.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't ask anyone (${submitted.body?.code ?? submitted.status})`);
}
state.holding = true; // OpenRig recorded the seat's permission prompt when it opened the ask
state.waiting += 1;
say(`[permission] OpenRig asked a person (${askId}); waiting for their answer`);
// This ask no longer waits on a person; the seat still needs input while another one does, or after its own refusal.
const stopWaiting = () => { state.waiting -= 1; state.holding = state.refused || state.waiting > 0; };
// The agent got an answer or a permit for this ask: once nothing else waits, permission_resolved says the turn goes on.
const resolved = () => { state.resolvedAsk = { turn, askId }; };
return new Promise((resolve) => {
let done = false; // the agent has its answer
let ended = false; // OpenRig was told the request is over
@@ -371,7 +408,7 @@ async function main() {
withdraw(askId, reason);
if (done) return;
done = true;
if (!state.refused) state.holding = false; // the turn ended the person's ask: no one is still being asked
stopWaiting(); // the turn ended the person's ask
resolve(CANCELLED);
} };
state.asks.add(ask);
@@ -387,18 +424,23 @@ async function main() {
// A person's option, or OpenRig's permit when they closed it without one.
if ((answer.state === "answered" || answer.state === "permitted") && offered(answer.optionId) && live()) {
state.asks.delete(ask);
state.holding = false;
stopWaiting();
say(`[permission] ${answer.state}: ${answer.optionId}`);
resolve({ outcome: { outcome: "selected", optionId: answer.optionId } });
resolved();
passOn(answer.said);
} else if (live() && (read.status === 200 ? !ENDED.includes(answer.state) : !NOT_LIVE.includes(answer.code))) {
// OpenRig stopped answering, or answered in a way the contract doesn't list: nobody can be asked. The ask stays
// tracked, so the turn's end still withdraws it.
stopWaiting();
resolve(permit(read.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't answer (${answer.code ?? read.status})`));
if (!state.refused) resolved(); // permitted, not refused for want of an allow option
} else {
state.asks.delete(ask);
state.holding = false; // a person said no, or the request is over: no one is still waiting
stopWaiting(); // a person said no, or the request is over
say(`[permission] not answered (${answer.reason ?? answer.state ?? answer.code ?? read.status}); answered cancelled`);
resolve(CANCELLED);
passOn(answer.said);
}
}
})();
@@ -411,9 +453,19 @@ async function main() {
const options = Array.isArray(params.options) ? params.options : [];
say(`\n[permission] dsh asks: ${toolCall.title} (${toolCall.kind}); options ${options.map((option) => option.optionId).join(", ")}`);
if (!state.running || state.cancelRequestedAt) return CANCELLED;
return askOwner(requestId, toolCall, options);
state.asking += 1; // no beat while a permission request is open: it may be waiting on a person
return askOwner(requestId, toolCall, options).finally(() => { state.asking -= 1; setImmediate(settle); });
});
// Once nothing waits on a person or is being submitted, the seat holds no refusal of its own and dsh is alive, say the turn
// goes on. It goes out after the agent has its answer, and before the next beat.
const settle = () => {
const done = state.resolvedAsk;
if (!done || state.holding || state.asking > 0) return;
state.resolvedAsk = null;
if (state.running && state.turn === done.turn && !agentExited) activity.post("active", "permission_resolved", { turnId: String(done.turn), askId: done.askId });
};
const abort = () => {
if (!state.running) { say("[openrig-dsh] no turn is running"); return; }
if (state.cancelRequestedAt) return;
@@ -429,6 +481,8 @@ async function main() {
state.turn += 1;
state.holding = false;
state.refused = false;
state.waiting = 0;
state.resolvedAsk = null;
state.cancelRequestedAt = null;
state.lastActive = 0;
const turnId = String(state.turn);
@@ -472,7 +526,8 @@ async function main() {
return fatal(`dsh didn't complete the ACP handshake (${error.message})`, 1);
}
const capabilities = init?.agentCapabilities ?? {};
const producer = `${RUNNER}; dsh ${init?.agentInfo?.version ?? "(version not reported)"}; ACP ${init?.protocolVersion ?? "?"}`;
const reported = init?.agentInfo?.version ? `${init.agentInfo.version} (as its ACP handshake reports it)` : "(version not reported)";
const producer = `${RUNNER}; dsh ${version ?? reported}; ACP ${init?.protocolVersion ?? "?"}`;
const resumeRef = env.OPENRIG_RESUME_REF;
// Whether dsh's own session list names a conversation, following its pages: true, false when every page was read and
// none names it, or undefined when the list fails, answers in another shape, or runs past LIST_PAGES pages. An ACP
@@ -522,6 +577,12 @@ async function main() {
say("[openrig-dsh] each submitted message is one prompt; /abort or Ctrl-C cancels the running turn");
heartbeat = setInterval(() => { if (!state.running && !state.holding && !agentExited) postIdle("idle_heartbeat"); }, BEAT_MS);
// While a turn runs and nothing waits on a person: the turn is still outstanding and dsh is alive. Not progress.
workBeat = setInterval(() => {
if (working()) {
activity.post("active", "liveness", { turnId: String(state.turn), livenessSeconds: WORK_LIVENESS_SECONDS });
}
}, WORK_BEAT_MS);
for (const signal of ["SIGHUP", "SIGTERM"]) {
process.on(signal, () => {
if (state.running && !state.cancelRequestedAt) peer.notify("session/cancel", { sessionId: state.sessionId });
+203
View File
@@ -0,0 +1,203 @@
# Writing a daemon client
What a program that talks to the OpenRig daemon over HTTP needs and can't read from `rig --help`: how to find the
daemon, its health route, which bearer token each route family checks, the event streams, and some route
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
listed here can change.
## Finding the daemon
`rig status --json` is the discovery surface. Its `daemon` object gives `state` and `reachable`, and `pid` and
`port` when they are known: a stopped daemon's response has no `port`, and a running daemon found through
`OPENRIG_URL` has no `pid`. `reachable` is `true` when the daemon answered its health probe, `false` when it is
positively down, and `null` when that isn't confirmed (see `rig status` in
[the command reference](../as-built/cli-reference.md#status)). It reads the recorded state and probes; it never
starts a daemon.
The CLI itself finds the daemon in these orders:
- **Data commands** (everything that sends a request): `OPENRIG_URL` (or the legacy `RIGGED_URL`); else
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
the state file below.
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
`~/.rigged/daemon.json`.
`daemon.json` holds `pid`, `port`, `host`, `db` and `startedAt`. `rig daemon start` writes a new daemon's record
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
contract: prefer `rig status --json`.
## `GET /healthz`
The liveness and identity read. It always returns `status: "ok"` and `pid`. Every other field is present only
in the case named:
| Field | When it is present |
| --- | --- |
| `euid` | On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
| `databaseWarnings` | When the daemon opened its database with warnings. |
| `semver`, `commit`, `dirty`, `builtAt` | On a stamped (released) build; a development run has none. |
| `selfHostId`, `selfHostIdSource` | After the boot reconcile has set this host's id. |
| `eventLoop` | When the event-loop monitor runs: `lagMeanMs`, `lagP99Ms`, `utilization` (0 to 1), `lastTickAgeMs` and `healthy`. |
| `routeTimings` | With `eventLoop`. |
| `slowOperations` | When the slow-operation recorder runs. |
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
## Bearer tokens
The daemon reads two tokens from its environment at start:
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
/api/terminal/follow` and `/viewer/ensure`, and every `GET /api/terminal/<name>` route, the terminal websocket
included. When the daemon is bound to an explicit address (`rig daemon start --host`) that isn't loopback or a
tailnet address, and this token isn't set, these routes check `OPENRIG_AUTH_BEARER_TOKEN` instead.
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A token
doesn't get past the browser boundary's 403 refusals, which [Browser access](browser-access.md) describes.
## Event streams
All three are server-sent events (`text/event-stream`):
- **`GET /api/events`**: the daemon's event log. `?rigId=` limits it to one rig. Each event carries `id: <seq>`.
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
Live events that arrive during the replay follow it, without duplicates.
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
again; it carries no derived state.
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
`inbox.absorbed` and `inbox.denied`.
## People, asks and updates without Slack
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
| Read | Meaning |
| --- | --- |
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
A person's view also includes instance or unassigned items when that person is the default. These are views on a
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
or manufactures a delivery receipt.
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
```js
// client-people-example
const [base, personId, rig] = process.argv.slice(1);
async function get(path) {
const response = await fetch(new URL(path, base));
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
const registry = await get("/api/gateway/participants");
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
if (!person) throw new Error("Choose a registered person id");
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
warnings: registry.warnings, participantFile: registry.participantFile,
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
```
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
`outbox/list?senderSession=<session>` read remains available.
## Route details
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items
(`pending`, `in-progress`, `blocked`) first, then the rest by latest update. `limit` defaults to 100 and has no
upper cap. The list is cut after `limit` rows in that order, so when it comes back full, raise `limit` to read
them all. Each item's own history is `GET /api/queue/<qitemId>/transitions`. This read scans the queue table, so
read it occasionally rather than polling it. For live changes, treat `GET /api/queue/sse` events as hints to read
`queue/list?rig=` again. Not every event names both seats (`queue.claimed` and `queue.unclaimed` carry only the
destination), and not every state change is on that stream, so the events alone don't track a rig's items.
- **A rig's messages:** `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to the rig's
seats, newest first; `?senderSession=<session>` still returns one sender's. One of the two is required; with
both, `senderSession` is used and `rig` is ignored. `limit` defaults to 100. No event is sent when a message is delivered, so read this list to see delivery
states.
- **`GET /api/queue/recent-transitions`** returns at most 20 rows. A positive `limit` above 20 is capped at 20;
a missing, zero, negative or unparseable `limit` means 20.
- **`POST /api/transport/send`** takes `urgent: true`, the same as `rig send --urgent`. The daemon records who
marked it from the sender identity, not from the body.
- **`POST /api/scope/approve`** is what `rig scope slice approve` and `rig scope mission approve` call. The body
takes `scopeTier` (`slice` or `mission`), `scopePath`, and optionally `approvalScope` (`spec` or `delivery`, the
default), `missionsRoot`, `onBehalfOf`, `reApprove`, `reason` and `lockedArtifacts`. The approver is the
request's sender identity (`actorSession` in the body is recorded beside it, and a different value is
superseded). Success is 201 with `scopeTier`, `scopeId`, `scopePath`, `approvalScope`, `approvedBy`,
`approvedAt`, `onBehalfOf`, `actionId`, `freezeFired` and `reApproved`. Refusals include 400 for an invalid
`scopeTier`, `scopePath` or `approvalScope`, or a missing actor (`actor_required`); 503 `action_log_unavailable`;
and, from the approval itself, 404 `scope_not_found`, 409 `already_approved` or `nothing_to_reapprove`, 503
`workspace_not_configured`, 500 `audit_write_failed`, and 400 for its other refusals. An unexpected error is 500
`internal`.
- **`POST /api/proof/judge`** is what `rig proof judge` calls. The body takes `scope`, `item`, `verdict`
(`accept`, `reject` or `withdraw`), `reason`, `expectedRevision` and `expectedPrevious` (a judgment id or
`null`), and optionally `evidence`, `expectedEvidence`, `subject`, `operationId`, `replace`, `project` and
`projectRoot`. The judge is the request's sender identity. The response is `judgment`, the slice's `readiness`,
`replayed` and `notification`: 201 for a new judgment, 200 when an `operationId` replays an earlier one.
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
`scopeIgnored` when a scope wasn't applied.
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
readings as one account rests on the deployment assumption the row's `provenance` names (one account per
host), not on a measured identity. Readings whose reset times disagree are reported as `anomalies`, and the
row's `state` can then be `explicit_unknown`. Show the row's `state`, `anomalies` and each reading's seat and
`asOf` as reported, rather than merging readings that happen to match.
- **Slack channels and people:** `GET /api/gateway/slack/channels` and `GET /api/gateway/slack/users` each return
one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are
public and unarchived, as `{ id, name, isMember }`; people are `{ id, name, realName, displayName, isBot,
deleted }`, with no email. Pass `cursor` (the previous page's `nextCursor`, `null` on the last page) and
`limit` (default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503 `slack_not_configured`
when no bot token resolves, which is different from an empty list; 403 `slack_missing_scope` with the scope
Slack names in `needed` (an older app may lack `users:read`); 429 `slack_rate_limited` with Slack's
`Retry-After`; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a
chooser rather than polling it. Private channels and direct messages aren't listed.
- **Slices are found by folder name alone.** `GET /api/slices/:name`, `/:name/doc/*`, `/:name/proof-asset/*` and
`GET /api/review/slice/:name` take only the name; the detail route reads `?mission` only together with
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
across missions, as [Project workspace](project-workspace.md) says.
@@ -2,7 +2,7 @@
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; publishing it does not implement a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. Since 0.7.8, OpenRig also ships one maintained reference driver, OpenShell 0.1.6, as `rig environment` (see the [OpenShell example](community-harness-examples/openshell/README.md)). The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; neither it nor the reference driver implements a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
## Keep the identities and responsibilities separate
+29 -17
View File
@@ -5,15 +5,31 @@ OpenRig's gateway can register **several people** and **shared channels**, group
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
do.
For a remote client, `GET /api/gateway/participants` returns the same participant, organization, group, default and
warning fields as `rig gateway participants --json`. Add `?organization=<id>` to filter the participant list; the default
and organization and group maps still describe the whole registry. The result names the participant file and reports
an unreadable side file without treating it as an empty one. If the human registry itself cannot be read, the route
returns `503` with `error: "human_registry_unavailable"`. The result does not include connector secret pointers.
## Participants
A participant is either:
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
- **a person**, registered with `rig gateway human add`, with or without a connector binding; or
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
sender.
People and channels share one address space: a channel can't take a person's id.
A registered person is addressed as `<id>@external`, including without Slack: for example,
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
`human@host` actor, not the person whose queue is being viewed.
```bash
# An organization and a group inside it (one command each, create or update)
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
@@ -59,30 +75,26 @@ never changes `workspace.operator_seat_name`, which keeps its own meaning.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
whatever person's queue it shows or who is configured as the default.
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
install that never chose one, it opens on the topology, as before.
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
person's. The Feed's first line says whose queue it shows and who acts.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
actor is `human@host`.
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
refused (`answer_not_recorded`) and nothing changes.
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
channel.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the unnamed terminal
actor `human@host` and the person asked in the audit notes and transition. The asking seat reads, for example,
"Unattributed (human@host) answered sarah@external's ask".
- **Viewing Sarah isn't acting as Sarah.** A registered person may answer, approve, deny or resolve another person's
ask as themselves; both the actor and person asked are recorded. No connector binding is required, and viewing
someone else's queue doesn't make its viewer that person.
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
such request already is.
- **Where the authority applies:** primary-user authority is accepted only on a request that carries no seat identity
header, which is how the TUI calls the daemon. A headerless client can instead name a person's `actorSession` without
that authority. Seats and the CLI send the identity header, so their acts stay theirs. This is the existing
trusted-network distinction, not an authentication check.
## Shared-channel conversations
+50 -10
View File
@@ -55,7 +55,8 @@ together in that home; it needs its own working sign-in and doesn't relocate `~/
> - Codex: hooks, workspace trust and team-selected config fragments (including MCP) in the selected
> `CODEX_HOME/config.toml`, default `~/.codex/config.toml`. These affect other Codex sessions using that home.
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
> `.claude/settings.local.json`, plus a status line unless you already have your own.
> `.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks
> and status line back out once no Claude seat uses that directory.
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
> - Each seat's `CLAUDE.md` or `AGENTS.md` gets OpenRig's managed blocks (culture, identity, onboarding). One more
> line, pointing an agent at its seat skills, is added only on a yes: saved at setup, or set where the daemon runs
@@ -75,8 +76,17 @@ You need Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude
Code or Codex account: one is enough, since the kernel operator adapts the
shipped `starter` team to whichever you have. On Linux, the distribution's own
Node.js can be older (Ubuntu 24.04's is 18); install a supported version with
[nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource. On a Mac
with Apple silicon, use Node.js 22 (see the [compatibility
[nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource.
For a tarball or manual Node install on Linux, put its bin directory on PATH in
`~/.profile`, for example `export PATH="/path/to/node/bin:$PATH"`, rather than only
in an interactive `~/.bashrc`. Bash reads the first of `~/.bash_profile`,
`~/.bash_login` and `~/.profile`; if one of the first two exists, source `~/.profile`
there. Other shells use their own login file (for example, zsh's `~/.zprofile`).
Check `bash -lc 'command -v node rig'`: both paths must appear in the output.
Use Codex's tool shell for this check if it differs from Bash.
On a Mac with Apple silicon, use Node.js 22 (see the [compatibility
history](../releases/v0.5.15.md#known-compatibility-limitation)). On a Mac where
you aren't an administrator, or where Homebrew belongs to another account (its
`brew install` is refused), install Node.js in your own home folder with
@@ -191,16 +201,17 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
`FAILED [n/4] <command or check> (exit <code>)`. Where a provider isn't signed in yet, step 4 ends that way and
`rig setup` lists each sign-in under "Some steps need attention". If the only remaining failures are provider
`FAILED [n/4] <command or check> (exit <code>)` for steps 1–3. If step 4 is not ready, it reports
`INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any Fix, then rerun
`rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider
sign-ins, the install steps finished: sign in to each selected provider as below, then continue at
[Start the kernel](#start-the-kernel-and-check-its-state). To install only what the selected providers need, go step
by step instead:
@@ -259,6 +270,17 @@ opt in with
`rig config set terminal.inherit_tmux_environment true`. Setup points out this option
when it detects custom tmux configuration; it never enables it automatically. The setting
applies to future launches and successors, not running agents or your personal tmux server.
Managed Codex seats run tool commands in a non-login shell: OpenRig passes Codex
`-c allow_login_shell=false`. Tool commands inherit the environment their pane's shell set up,
OpenRig's command folder included, so plain `rig` works however OpenRig was installed.
Inherited setup isn't re-run for each command. Setup that lives only in login or rc files, such as
lines in `~/.bashrc` that your login file doesn't source, or a tmux `default-command` that doesn't
start a login shell, may be missing from tool commands, and so may shell functions and aliases.
To defer to Codex's own setting instead, run `rig config set launch.codex_login_shell true`. That
omits OpenRig's override rather than forcing login shells, and a login file that sets `PATH`
outright can then hide `rig`. It applies at each seat's next launch; a restart in place keeps
the running choice.
If the scoped environment cannot be prepared, launch keeps its previous environment and
reports a warning in fresh launch results and the daemon log. For handovers, check
that log for this warning. This is not a promise that shell startup cannot add or override values.
@@ -290,6 +312,17 @@ fine; if both accounts are authenticated, the kernel uses both even when your
later project uses just one. A request to use only one provider for everything
is a separate choice.
`/api/kernel/status` retains the chosen template (`rig.yaml`,
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
recovery, including starts with `--no-kernel`. It reads the rig's latest rig-spec
bootstrap record, including a partial launch, so an older kernel's recorded
selection is also available. This is the original selection; the rig's live spec
describes its current configuration. The saved source must match a template under
the current installation’s built-in specs directory. A custom project path with
the same directory suffix, an old source path that can no longer be verified
(for example, after a Node version change moves the global install), or a kernel
without bootstrap history reports an unknown variant.
**Started is not ready.** Read the `Kernel:` boot state in `rig status` and the
individual seat state in `rig ps --nodes --rig kernel`. The view may open while
agents finish starting; describe that state, without claiming they are ready.
@@ -482,7 +515,7 @@ different points in the same journey:
| What you see | Why and what to do next |
| --- | --- |
| Codex offers to switch to a cheaper model near a rate limit | This is Codex's own menu. Ask the person to choose in the seat's terminal; do not silently change the team's configured model. OpenRig disables Codex's startup update check for managed launches, but does not answer this model-choice menu. |
| A missing Node.js, npm or tmux; an install download or command fails | The named prerequisite or install step did not finish. Read its error and the [platform and provider instructions](#install-and-sign-in), resolve that step, then recheck it. `FAILED [4/4]` with only selected-provider sign-ins outstanding means the package installed but those logins still need the person. |
| A missing Node.js, npm or tmux; an install download or command fails | The named prerequisite or install step did not finish. Read its error and the [platform and provider instructions](#install-and-sign-in), resolve that step, then recheck it. `INSTALLED, SETUP NOT COMPLETE` means the CLI is installed but setup needs attention; follow its output and any Fix, then rerun `rig setup`. |
| The installing harness refuses a tool call or asks for permission | Its native rules, sandbox or automatic permission decision apply before OpenRig can run. Claude Code's [auto mode can deny calls](https://code.claude.com/docs/en/auto-mode-config) as well as approve them. Show the specific refusal and ask the person to review it in the harness permission controls or run that named step themselves. Keep their chosen restrictions; a refused call does not prove the OpenRig command failed. |
| Herdr or Ghostty installation is unavailable or declined | Herdr is optional for the view: `--window` uses plain tmux when Herdr is absent, and its install failure is a warning. On macOS, Terminal.app is the fallback when supported Ghostty is absent. An explicitly requested Ghostty install can fail setup; resolve that install or decline it with `--no-ghostty`, preserving earlier choices. Address separate required-tool or login failures. Existing cmux remains supported. |
| macOS asks for Automation access, or the window action is denied | The command asks the desktop terminal app to open a new surface. Let the person answer the system prompt. Read the error and inspect any new window before retrying an uncertain action; do not silently switch apps and open a duplicate. |
@@ -1128,8 +1161,15 @@ rig up ./openrig-specs/rigs/launch/starter/rig.yaml --cwd .
OpenRig's `permission_policy: builtin:yolo` setting selects
`-s danger-full-access -a never` on fresh, resume and fork launches, replacing
the named-profile argument. The profile recipe above remains useful when you
want to maintain those choices in native configuration. The legacy
the named-profile argument. That drops the whole profile, not only its sandbox
and approval settings. To keep a named profile with full access, put full access
in the profile itself and leave `permission_policy` absent, as in the recipe
above. To keep it with a workspace-write or read-only posture that OpenRig
records, use the member's `codex_permissions` (see
[per-member Codex permissions](rig-spec.md#per-member-codex-permissions)):
OpenRig passes `-p` and adds that envelope's flags, and for that seat the
envelope replaces a policy posture such as `builtin:yolo`. Full access isn't an
envelope. The legacy
environment-only `OPENRIG_YOLO=1` path remains sandbox-only when no resolved
policy is present. A standalone `codex --yolo` command is not an OpenRig setting.
At full access, Codex can show its full-access and GPT-5.1 migration notices at
+95 -16
View File
@@ -3,17 +3,37 @@
> **Status.** This is a draft for contributors.
> - **Released in 0.7.6:** the `runner` field and OpenRig's side of declaration, identity and activity
> (sections 2-4).
> - **Proposed runner-author behavior:** the input, cancellation and permission recipe in sections 5-7, and the
> runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
> - **Released in 0.7.8:** seat activity grants (section 4), and OpenRig's side of the answered permission path
> (section 7).
> - **Runner-author behavior, not yet qualified:** the input, cancellation and permission recipe in sections 5-7, and
> the runner-side shutdown obligation in section 9. Two runners have shown parts of it natively (below); no
> runner is qualified beyond that.
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
> handoff. These interfaces do not establish that a particular runner implements them correctly.
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
>
> No harness has been run end to end under this contract yet. The first candidate is dsh over ACP.
> **What has run natively, in 0.7.8's release testing, against a Claude Haiku model:**
> - **dsh-seat 0.3.0**, an outside runner for dsh 0.2.1-rc.2 over ACP: launch and first input, useful queue work, a
> multiline message with a `/abort` line inside it delivered as one prompt, activity that stayed working through a
> 52-second quiet tool interval and then read idle, cancel by a bare `/abort` typed in the pane, and the same
> conversation resumed by `session/resume` after `rig down --snapshot` and `rig up`, with the runner and its child
> gone after the managed down.
> - **The dsh example runner** (`community-harness-examples/dsh/`): dsh's native `session/request_permission` reached
> a person, whose allow-once took effect and whose reject-once didn't.
>
> **Not shown natively:** a DeepSeek model, the example runner's own resume (its preset declares none), the default
> permit when nobody can be asked (checked without a model only), a typed or Slack answer, a second person's
> answer, reboot recovery, and the model OpenRig observes. Any other runner, provider or path is unqualified until
> it's shown.
>
> **Since 0.7.8, on one stack:** the dsh example runner has run with a real dsh 0.2.1-rc.2 on dsh's pi-ai OpenRouter
> chat-completions profile with `deepseek/deepseek-v4-flash`: queue work, activity, an allow-once and a reject-once
> answered by an automated test operator, cancel, and an exact return with resume added to a copy of the preset. A
> missing conversation's refusal hasn't been run, and the preset still declares no resume.
>
> Release status and pinned references are in the [joined author guide](author-contracts.md). The
> [community presets reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md)
> describes the implemented fields at that base; this chapter also labels proposed author behavior.
> [community presets reference](community-harness-presets.md) describes the implemented fields, including the runner
> preset's exception for approvals; this chapter also labels runner-author behavior that isn't qualified.
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
@@ -104,6 +124,8 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|---|---|---|
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
| Optional working beat, from 0.7.9: the turn is still outstanding and the agent alive, with nothing new to report (a long tool or model call) | `active` / `liveness`, with `turnId`, `sequence` and `livenessSeconds` | working while beats keep coming |
| From 0.7.9: you delivered an answer or permit to a request OpenRig had shown as needing input, and the agent continues the same turn (section 7) | `active` / `permission_resolved`, with `turnId` and `askId` | working |
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
@@ -114,6 +136,41 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
non-exit `Stop` or a beat while holding permission attention.
- **Working evidence lasts 15 seconds.** A working post (`UserPromptSubmit`, `active`, `PreToolUse`) decides the seat's
activity for 15 s. After that the seat reads from its other evidence: the screen can still show work, but it never
reads a runner seat idle, so with nothing else the seat reads unknown.
- **The working beat (from 0.7.9)** keeps a long, quiet turn reading working. It says only that this turn's prompt is
outstanding and the agent process you started is alive. It isn't progress.
- **When to beat:** only while a prompt is outstanding and the agent is alive, more often than every
`livenessSeconds` ÷ 2.
- **When to stop:** stop at once on the turn's `Stop`, on a permission request that needs a person, or on the agent's
exit, and post that boundary first.
- **`livenessSeconds`** is how long one beat counts as working. OpenRig clamps it to 5-60 s, and uses 30 s when a beat
has none. After that the beat stops counting, and the seat reads from its other evidence, as a working post's does.
- **A beat counts only when all of these hold:**
- it carries `generation`, equal to the seat's live occupant generation, and so did the `UserPromptSubmit` /
`accepted` post that opened its turn;
- its `turnId` is your latest `UserPromptSubmit` / `accepted` turn, and you haven't posted that turn's `Stop` or an
exit;
- it carries a `sequence`, greater than every earlier post's in the same generation that OpenRig counted, beats
included;
- the seat has no attention outstanding, such as a permission request waiting on a person.
So number every post with one `sequence` that rises across your whole runner process, beats included. OpenRig
orders your posts by `occurredAt`, then `sequence`. A post older than one OpenRig already counted changes nothing:
a late `Stop` for an earlier turn doesn't end the current one, and an older beat doesn't count. A post without a
valid `occurredAt` is dated when it arrives, by OpenRig's clock, so it can read older than it is when your clock
runs ahead. Give every post its `occurredAt`.
- **After you deliver an answer or permit** to a request OpenRig had shown as needing input (section 7; it created an
ask, and a permit at submit opens none), post `active` / `permission_resolved` with the turn's `turnId` and the
`askId`, before your next beat. That clears the seat's attention. So does any other working or idle post OpenRig
counts, and an operator's `rig seat clear-attention`. OpenRig doesn't match `permission_resolved` to its `askId`.
Beats are ignored until attention clears.
- **A beat that fails any of these is ignored.** The seat reads as it would without it. A beat never starts or extends
a turn, never clears attention, and never stands in for a `Stop`.
- **OpenRig keeps what it needs to judge beats in memory.** After a daemon restart, a beat already counted keeps its
lifetime, but new beats are ignored until your next accepted prompt, and the seat reads as it would without them.
- **A runner that sends no beats** behaves as before.
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
@@ -136,13 +193,17 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
A refused post records nothing, so the seat's activity stays as it was, or unknown.
**The guest's boundary is its listener, not the grant.** A guest reaches OpenRig only through a guest-only listener that
serves this one route and accepts only a grant. It must never reach the daemon's main port. The grant scopes activity
posts only, and the main port's other routes don't check it.
serves this route and section 7's three permission-request routes, and accepts only a grant. It must never reach the
daemon's main port. The grant scopes activity posts and permission requests, and the main port's other routes don't
check it.
- The listener is off by default. Turn it on with `OPENRIG_GUEST_ACTIVITY_PORT`, which must differ from the daemon's
main port or the daemon refuses to start.
- It binds `OPENRIG_GUEST_ACTIVITY_HOST`, default `127.0.0.1`: set it to an address the guest can reach.
## 5. Input: one submitted message is one prompt
Sections 5-7 specify the proposed first dsh/ACP runner recipe. They do not change permission or input handling in
existing built-in runtimes or require every possible runner protocol to use ACP.
Sections 5-7 specify the first dsh/ACP runner recipe; no runner has been qualified against it yet. They do not change
permission or input handling in existing built-in runtimes or require every possible runner protocol to use ACP.
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
- **What arrives:** `rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
@@ -162,6 +223,8 @@ existing built-in runtimes or require every possible runner protocol to use ACP.
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
and record when you sent it.
- A person cancels by typing `/abort` in the seat's pane. A `/abort` sent with `rig send` arrives inside a From/To
envelope, so it's an ordinary prompt.
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
Then post `Stop` / `cancelled`.
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
@@ -198,7 +261,7 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|---|---|
| **There's a default person** (chosen with `rig gateway default`, or the only registered person), and no override | They get an ordinary ask in the Feed and on Slack. No setup is needed. |
| **The seat's operator chose someone** (`rig seat set-permissions-to <seat> --to <person> --reason "…"`) | That person gets the ask instead. |
| **No one can be asked** | **Permitted at once**, with the agent's own allow option. No ask is opened. That covers: no registered person; several and none the default; the chosen person no longer registered; the people unreadable; or options a person can't be shown (bad ids, or not 2-4). The agent's `allow_once` is used, else its `allow_always`. If the agent offers only `allow_always`, the harness then remembers a permission nobody chose. |
| **No one can be asked** | **Permitted at once**, with the agent's own allow option. No ask is opened, and OpenRig records the permit (below). That covers: no registered person; several and none the default; the chosen person no longer registered; the people unreadable; or options a person can't be shown (bad ids, or not 2-4). The agent's `allow_once` is used, else its `allow_always`. If the agent offers only `allow_always`, the harness then remembers a permission nobody chose. |
| **The seat's operator opted in to refusal** (`rig seat set-permissions-to <seat> --deny --reason "…"`) | Refused at once and held as needs input, as in the first version. |
**What the ask shows:**
@@ -217,7 +280,7 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
| replies with just a no ("no", "deny", "reject", "stop" …), or denies it in the Feed | the agent's reject (`reject_once`, else `reject_always`), or, with no reject option, cancelled. A person's no is never a grant |
| routes or hands it to another person | the ask waits for that person, who gets the same options; their answer counts as if they'd been asked. The handoff itself never permits |
| routes or hands it to an agent seat | that seat gets no options. The request waits until that row is closed, by the seat or a person. Closed as done without a choice (including a drop), or as failed, is no answer, so it's permitted. Closed as denied (the Feed's deny, `--closure-reason denied`, or the state `denied`) is a no. A row set to the state `canceled` cancels the request, and a further handoff is followed; a person it reaches gets the same options. Or the request waits until the agent's turn ends |
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow |
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
| doesn't answer | the request **stays pending and visible** (the seat reads needs input) until the agent's turn ends. It's never timed out into a grant. |
- **An answer reaches only the live request it was asked for,** once. A request from an earlier occupant reads
@@ -225,6 +288,17 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
- **Text typed into the runner's pane, an agent's own text, and `rig send` are never an answer.** While a request waits
on a person, the seat reads needs input, and sends are normally refused. A send that does get through reaches the
agent as a prompt.
- **From 0.7.9, each permit OpenRig decides by default is recorded,** once, as a stream item from the seat. That covers
no one to ask, and an ask closed without a choice (the row passed to an agent seat included). The record names:
- the tool call;
- why no person answered, including, for a closed ask, who closed which row and how;
- the option the agent got, with its kind. An `allow_always` option is marked as remembered by the agent's harness;
- the conversation and request, or the ask.
For an ask opened before 0.7.9, the record may omit the option kind and whether the harness remembers it.
Read the records with `rig stream list --tag runner-permission-permit`, or `--source <seat>`. A record wakes no one,
and one that can't be written doesn't change the permit.
**The runner's protocol:**
1. **Submit** the agent's live request to `POST <base>/api/activity/permission-requests`, with the same token or seat
@@ -252,11 +326,15 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
`refused` (with `reason`: `said_no` when the person said no and the agent offered no reject option, or
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
`stale`.
- **From 0.7.9, `said`,** with `permitted` or `refused` (`no_allow_offered`), is the person's own words, when they
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
refused (`409 activity_grant_stale`), even during a read's wait.
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
request is still its live one. For `refused`, `withdrawn` or `stale`, answer `cancelled` and hold no attention: no
person is still waiting. A refusal of the read itself follows the refusals below.
person is still waiting. A refusal of the read itself follows the refusals below. From 0.7.9, when the read carries
`said`, queue those words for the agent as an ordinary prompt: they reach it after the current turn, behind any prompt
already waiting. They're never the answer. A runner that drops them still keeps this contract.
4. **Withdraw** when the turn is cancelled or ends, or the agent exits:
`POST <base>/api/activity/permission-requests/<askId>/withdraw` with
`{"reason": "turn_cancelled" | "turn_ended" | "exited"}`.
@@ -280,20 +358,21 @@ answer. Only the seat's opt-in refuses and holds attention.
**When the runner can't get an answer from OpenRig,** nobody can be asked, so it permits with the agent's own allow
option: `allow_once` when offered, else `allow_always`. Prefer `allow_once`, so the harness doesn't remember a
permission nobody chose. That covers:
permission nobody chose. Such a permit is the runner's own, so OpenRig has no record of it: the runner prints it in
its pane. That covers:
- an OpenRig without this path, which answers 404;
- an OpenRig the runner can't reach, at submit or while reading (a runner may retry a read that got no answer a few
times first);
- a refusal the table marks "permits", and any status or code this section doesn't list.
**Limits in 0.7.8:**
**Limits of this path:**
- **The answer owner is a registered person.** Choosing a seat (another agent) to answer is a later change.
- **A colleague's answer is followed on this host.** If the ask is handed to another host, the request waits, shown as
needs input, until the agent's turn ends.
- **An override or `--deny` is set with the command above, never in a rig spec.** A spec field is a possible later
change.
- **Permits and answers aren't recorded beyond the runner's pane and the ask itself.** A permit with no one to ask
opens no ask.
- **A person's answer is recorded on the ask itself,** and a default permit as the stream item above. That item lasts as
long as stream retention keeps it. A permit the runner decides on its own is in its pane only.
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
+32
View File
@@ -107,6 +107,38 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
## Assistants connected through a grant service
With `rig mcp serve --http --grant-service <url>`, an outside service answers each connection's key with its grant
(`openrig.grant/v1`): the seat it acts as, its rigs, and the object/action pairs it may use. Each pair offers one tool,
and nothing else is offered:
| Pair | Tool |
|---|---|
| `queue`/`create`, `queue`/`show`, `queue`/`list` | `rig_queue_create`, `rig_queue_show`, `rig_queue_list` |
| `queue`/`claim`, `queue`/`update` | `rig_queue_claim`, `rig_queue_update` |
| `team`/`list` | `rig_team` |
| `artifact`/`read` | `rig_artifact_read` |
- **One seat per connected assistant:** the service gives each connection its own seat and keeps it across that
connection's reconnects. The seat must be one of the rig's nodes; a node created without a launch is enough. The caller
is exactly that seat.
- **What it reaches:**
- It hands work to its rigs' other seats, never to a person or another host's seat.
- It reads the items it sent and the items addressed to it from those seats, never the team's internal items.
- With `queue`/`claim` and `queue`/`update`, it claims and closes only items addressed to its own seat that it can read,
and waits (blocked) only on an item it names and can read. Its answers hide what reading the item hides.
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
- **What the sender can tell, from the item it reads:**
- **Queued:** the item exists and is `pending` with no claim.
- **Picked up:** the receiver's claim (`in-progress`).
- **Answered:** closed `done`, with the answer in `resolution`.
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
claims it.
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
+11
View File
@@ -110,6 +110,17 @@ limitation: use the live spec-library commands to determine where a spec is
served from, and do not infer convergence merely because the canonical
directory exists.
The spec library scans the built-in specs, `$OPENRIG_HOME/specs` and, when
present, that legacy library. At any depth below them it skips directories
named `.git`, `.worktrees`, `node_modules`, `dist`, `build`, `.turbo` and
`.next`, which usually hold build output or dependencies (`SKIP_DIRS` in
`packages/daemon/src/domain/spec-library-service.ts`). A spec inside one of
them, such as `specs/build/rig.yaml`, isn't listed, and nothing reports that it
was skipped. To list it, rename its directory and run `rig specs sync`.
`rig specs add <directory>` copies a directory under its own name, so adding a
directory named `build` doesn't help. `rig up <path-to-rig.yaml>` still launches
a spec by its path, without the library.
Config updates publish a complete replacement file atomically where directory
permissions and the filesystem allow it. On POSIX systems, replacement preserves
the existing owner, group, and read/write/execute permission bits. If creating or
+22
View File
@@ -107,6 +107,28 @@ rig queue create --destination <entityId>@external --human-intent update \
- **`posted` means the connector posted it, not that they read it.** Don't say they saw it.
- **If delivery fails or is indeterminate,** inspect that task before anything else. Never resend blindly.
## If the timer cannot reach its recipient
A `periodic-reminder` occurrence that definitely fails before input remains owed. The watchdog
retries through its existing loop, using `queue.wake_retry_interval_seconds` and
`queue.wake_retry_cap` (by default, three retries five minutes apart). A failed attempt does not
advance the reminder to its next regular cycle. A confirmed send advances the normal interval;
recovery does not replay every elapsed cycle.
`rig watchdog show <jobId> --json` includes the failed attempt, its retained outbox IDs, retry
time and next action. If input may have reached the recipient, that occurrence ends unconfirmed
and is not automatically sent again. Inspect the recipient and exact outbox record before any
manual replay. A recurring reminder keeps its next normal cycle under a new occurrence identity. A confirmed receipt survives a daemon restart, so
finishing its accounting does not type the same occurrence twice.
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
correct the destination and register a replacement reminder. A one-shot park timer records its
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
there while the timer stays armed. Deliberately held
input keeps its existing retained-message custody. Condition watches and parked-owner policy
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
## "What are you watching for me?"
List **every active state**, not just set-aside ones. A watch being checked can be in progress, and a new one can be
+2 -1
View File
@@ -25,7 +25,8 @@ that restores nothing is not itself evidence of accidental loss.
## Choose the interface the seat actually has
Pi 1.4.0 includes an opt-in selector at `examples/extensions/tools.ts`. Its
Pi 1.4.0 includes an opt-in selector at
`packages/coding-agent/examples/extensions/tools.ts` in the Pi repository. Its
`/tools` command remembers selection per branch and needs Pi's own TUI. Use it
where that extension and native TUI are available. **Do not send `/tools` to an
agent OpenRig drives over RPC**: that interface cannot operate the selector.
+9 -4
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.10`. If OpenRig is already installed, use the tag
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
you want built.
@@ -90,7 +90,8 @@ Use `codex` or `both` in place of `claude` to match the coding agents you use. A
- it adds an OpenRig block to `~/.tmux.conf` (mouse support and longer scrollback).
It doesn't start anything. It ends with a summary of what's done and what still needs you. A missing sign-in shows
there: run `claude auth login` or `codex login`, then carry on.
there: run `claude auth login` or `codex login`, then carry on. Setup also tries to record the selected providers
for a new kernel's first boot; its summary warns if it could not save the choice.
### 3. Start OpenRig
@@ -100,12 +101,16 @@ rig status
rig ps --nodes --rig kernel
```
The daemon starts and boots the kernel with the coding agents you're signed in to, or both if you're signed in to both.
On the kernel's first boot, the daemon uses the providers selected by `rig setup --providers` that are signed in.
If no provider choice was recorded, it considers every signed-in provider. An existing kernel keeps its seats;
changing the setup choice does not replace them. An `OPENRIG_PROVIDER_SELECTED` value in the daemon's environment
takes precedence over the saved choice.
**What starting OpenRig changes, for the coding agents you use** (paths are the usual defaults):
- **For Codex:** hooks and trust in `~/.codex/config.toml`, when OpenRig or a Codex agent starts.
- **For Claude Code:** workspace trust in `~/.claude.json`, and activity hooks in the project's
`.claude/settings.local.json`, plus a status line unless you already have your own.
`.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks and
status line back out once no Claude seat uses that directory.
- **For both:** small discovery skills in `~/.claude/skills` and `~/.agents/skills`.
- **Kernel access:** by default the kernel gets broader permissions than a project team gets by default. On Codex
that's full access with no approval prompts. On Claude Code, its file tools, web fetches and operational commands,
+2 -2
View File
@@ -91,10 +91,10 @@ my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
The packer produces deterministic output:
- Files are sorted alphabetically
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
- Portable mode normalizes uid/gid/mode
- Portable mode omits machine-specific owner metadata; file mode bits can be retained
- Maximum gzip compression (level 9)
Identical staged bytes (including manifest timestamps and provenance) produce the same archive hash. Use the package digest to compare packaged file content independently of manifest metadata.
Identical staged bytes and file modes (including identical manifest timestamps and provenance) produce the same archive hash. A mode change can change the archive hash even when the file contents are unchanged. Use the package digest to compare packaged file content independently of manifest metadata and file modes.
---
+62 -3
View File
@@ -180,7 +180,7 @@ edges:
| `non_interruptive` | boolean | no | — | Pod-aware rig launch-warning default. With full-bypass Claude/Codex seats, true accepts or hides supported harness warnings using launch flags. Explicit CLI choices override this field; this field overrides the machine default. It does not grant permissions, change native settings files, or bypass login. |
| `permission_policy` | string | no | — | Permission policy attached to the rig. Either a built-in (`builtin:locked`, `builtin:standard`, `builtin:open`, `builtin:yolo`, `builtin:auto`) or a safe relative path to a custom policy file (resolved from this spec's directory; no `..`, no absolute, no empty segments, each segment `[A-Za-z0-9][A-Za-z0-9._-]*`), or `none`, a recorded choice of the floor. Absent uses the default floor with the scoped team launch allowances described below. A bare built-in name such as `yolo` is refused ("use 'builtin:yolo'"), and so is an explicit `null`. A custom file that is missing, unreadable or invalid isn't a validation error: it resolves to the floor, and preflight warns. A member may set its own `permission_policy` (not on a terminal member), which takes precedence over the rig-level one; there is no pod level. See "Attaching a permission policy" below. |
| `env` | map | no | — | Launch environment for every seat in the rig: `NAME: value` pairs. A member's own `env` overrides it name by name. Names match `^[A-Z_][A-Z0-9_]*$`, names starting `OPENRIG_` are reserved, and values must be non-empty strings (quote numbers and booleans). Values are not secret. See "Launch environment" below. |
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex, Pi and OMP members use `AGENTS.md`. See "Choosing the Claude instruction file" below. |
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex, Pi and OMP members use `AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's `AGENTS.md` and a fresh Codex 0.164.0+ seat normally gets launch developer instructions; a link, a conflicting or unreadable Codex config layer, or a resume keeps the repository file. See [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). See "Choosing the Claude instruction file" below. |
| `workspace` | object | no | — | The rig's workspace: `workspace_root` (required), `repos[]` of `{name, path, kind}` with `kind` one of `user`, `project`, `knowledge`, `lab` or `delivery` and unique names, an optional `default_repo` naming one of them, and an optional `knowledge_root`. Relative repo paths resolve against `workspace_root`. |
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
@@ -740,7 +740,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `id` | string | yes | — | Member identifier. Must not contain dots or `@`. Must be unique within the pod. |
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. To keep the profile, put full access in the profile itself, or record a workspace-write or read-only `codex_permissions` envelope, which keeps it. |
| `codex_permissions` | mapping | no | — | Codex-only sandbox and approval envelope, emitted as explicit flags on every managed launch and resume; see [per-member Codex permissions](#per-member-codex-permissions). |
| `claude_token` | mapping | no | — | Claude-only `{ label, file }`: the seat's own subscription token file, by absolute path, used from its first launch; see "A seat's own subscription token" under [launch environment](#launch-environment). |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
@@ -755,6 +755,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
| `plugin_channels` | list | no | — | Claude members only. Plugin channels this agent registers for, as `plugin:<name>@<marketplace>` entries, passed as one `--channels` on every launch; see [Claude plugin channels](#claude-plugin-channels-runtime-claude-code). |
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
@@ -810,6 +811,52 @@ OpenRig keeps the requested opt-out flags, so startup and resume remain subject
to that native refusal; it does not fall back to loading servers. Use a host
without that managed configuration, or omit the opt-out to use the managed set.
### Claude plugin channels (`runtime: claude-code`)
Set `plugin_channels` on a Claude member to register that agent for channel
notifications (messages a plugin's MCP server pushes into the session):
```yaml
members:
- id: support
agent_ref: local:agents/support
profile: default
runtime: claude-code
cwd: .
plugin_channels:
- plugin:example-channel@example-marketplace
```
OpenRig passes `--channels plugin:example-channel@example-marketplace` to this
agent's Claude on every launch, resume, fork, restore and handover. Several
entries follow one `--channels`, space-separated, and OpenRig's own session
options come after them. Claude Code 2.1.316 hides `--channels` from
`claude --help`, so it isn't listed there. An agent without the field gets no
`--channels`, and nothing is added for other agents or other runtimes; there is
no rig-wide or environment default.
Each entry is `plugin:<name>@<marketplace>`, both names made of letters, digits,
`.`, `_` and `-`, starting with a letter or digit. `server:` entries and Claude's
`--dangerously-load-development-channels` aren't supported. Combining a non-empty
channel list with `claude_mcp: none` produces a validation warning: that setting
starts Claude without MCP servers (plugin servers included), so no channel could
register. OpenRig keeps both settings and launches the member normally. Remove
`claude_mcp: none` to use the channels, or remove `plugin_channels` to keep the MCP
opt-out. OpenRig doesn't install plugins or marketplaces, or change the settings
that admit a channel: whether each channel registers is Claude's decision (the plugin installed, and
admitted by your organisation's channel settings or Claude's approved list).
Selecting a channel for one agent doesn't isolate what the plugin shares with
other agents. A token or other credential the plugin keeps in its own
configuration is the same for every agent that loads that plugin.
The selection is saved with the seat's other launch additions when the seat
starts from the spec, and starting it from a spec without the field removes it.
`rig seat launch --plan` and `rig skill loadout --seat` list it, a fork keeps it,
and `rig bundle create` and `rig export` carry it in the member; a bundle's
before-install view names each channel as a need. A running Claude process is
unaffected.
### Claude Advisor (`runtime: claude-code`)
Set `advisor` independently of the primary `model`:
@@ -1030,7 +1077,7 @@ file; missing optional files do not trigger that warning.
| Hint | Behavior |
|------|----------|
| `auto` | System chooses based on file type and context. |
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`. Delivered before harness boot. |
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's and a fresh Codex 0.164.0+ seat normally gets launch developer instructions (the conditions are in [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences)). Delivered before harness boot. |
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
@@ -1274,3 +1321,15 @@ These are the built-in specs shipped with OpenRig. Read them as worked examples.
Also shipped, in the same tree: `launch/kernel`, OpenRig's own team, which starts automatically. `first-project` is
starter's old name and still resolves to it. `workshop`, the four-agent middle team, is a rig bundle rather than a
built-in. List the built-ins with `rig specs ls --kind rig`.
To omit built-in rigs, agents and workflows from the shared spec inventory, run
`rig config set specs.builtins_visible false`. This changes the CLI, TUI and other
consumers of the library listing; user specs remain visible. Use
`rig specs ls --include-builtins` to list them deliberately, and set the value
back to `true` to restore the default. Explicit `show`, `preview`, and launch
lookups can still find hidden built-ins. When built-ins are hidden and a user
spec has the same name, the user spec wins the name lookup; use the built-in's
ID from `rig specs ls --include-builtins --json` to select it. With built-ins visible,
same-name matches remain ambiguous. This is an inventory preference, not an
access-control boundary; other endpoints and on-disk files can still expose the
built-ins.
+5 -3
View File
@@ -18,7 +18,7 @@ node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-har
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
- **`--pass-env NAME`** passes one more variable through. Otherwise the runner gets only `PATH`, `HOME`, `USER`,
`SHELL`, `LANG` and `TMPDIR`, plus the OpenRig variables.
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (7c) never changes it.
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (4c, 7c) never changes it.
In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/docs/reference/runner-conformance/`.
@@ -51,11 +51,13 @@ In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/doc
| 1b | 3, 4 | A handshake that fails at `initialize` or `session/new` (an error or an exit) writes no conversation in a launch record, and posts no accepted prompt or idle report |
| 2 | 5, 6 | One pasted block becomes exactly one `session/prompt`, byte for byte. A line reading `/abort` inside a block isn't control. A block sent mid-turn waits for the next turn. A block that is exactly `/abort`, or Ctrl-C outside a paste, sends one `session/cancel`. `Stop/cancelled` is posted only when the agent itself returns `cancelled` |
| 3 | 4 | `UserPromptSubmit/accepted` with a `turnId`, then `Stop/end_turn`. Every post carries the contract's fields, the hook token, this seat's identity and a rising `sequence`. `livenessSeconds`, when given, is from 1 to 3600 |
| 3b | 4 | **Optional, from 0.7.9; skipped when the runner sends no beat.** In a long, quiet turn, each `active/liveness` beat names the accepted turn, `sequence` rises with the runner's own `occurredAt` across every post, beats come more often than every `livenessSeconds` ÷ 2, and none follows the turn's `Stop`. While a person is asked, no beat is posted; after the answer, `active/permission_resolved` names the turn and the ask, before any beat. A permit at submit opens no ask, so the quiet turn's beats go on |
| 4 | 7 | When no one can be asked, the request is permitted: an OpenRig without the answered path (404) at once, and one the runner can't reach, each get the agent's `allow_once` option, never its `allow_always` while `allow_once` is offered. The turn then reports as usual, with no `permission_prompt`. Only `denied_by_seat`, or a request with no allow option, is answered at once with a refusal, never an allow option. `Notification/permission_prompt` is then posted, and for `denied_by_seat` the pane shows what was asked (the tool call's title). No idle `Stop` or beat is posted while that attention is held, even when `/abort` then cancels the turn. The next prompt is accepted as usual |
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way, and a read that carries the person's own words (`said`) still gives the agent the read's option. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report after the turn's accepted prompt, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
| 4c | 7 | **Advisory.** A read's `said`, the words of a person who answered in words, is queued to the agent as a prompt, after the turn and after any prompt already waiting. Section 7 lets a runner drop them and still keep the contract, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status |
| 5 | 4 | Every post carries `OPENRIG_OCCUPANT_GENERATION` |
| 6 | 8 | A resume asks the agent to reopen `OPENRIG_RESUME_REF` in the launch's working folder, and records that conversation. The agent's own not-found becomes `error: "missing"`. Any other failure is neither missing nor resumed. No case starts a new conversation |
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report after it |
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report for that turn. Posts are taken in the runner's own order, as OpenRig orders them (`occurredAt` when it's a time string, otherwise the post's arrival, then `sequence` when both are positive whole numbers), so an idle report from before the turn began doesn't count, however late it arrives |
| 7b | 4 | A failed `session/prompt` posts `Notification/runtime_error`, not a permission request |
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
@@ -133,8 +133,8 @@ function askState(h, entry) {
const script = h.asks.script;
if (entry.withdrawn) return script.late ?? { state: "withdrawn" };
if (script.answer && Date.now() >= entry.created + (script.answer.afterMs ?? 0)) {
const { state, optionId, reason } = script.answer;
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}) };
const { state, optionId, reason, said } = script.answer;
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}), ...(said ? { said } : {}) };
}
return { state: "pending" };
}
@@ -242,6 +242,19 @@ async function launch(label, { scenario = {}, env = {}, asks } = {}) {
// An ordinary send arrives inside an envelope, so a line in it that reads /abort is still just text.
const message = (h, body) => `From: checker@runner-check\nTo: ${h.session}\n---\n${body}`;
const nonExitStops = (h, after = 0) => h.hooks("Stop").filter((p) => p.at >= after && !String(p.body?.subtype ?? "").endsWith("_exited"));
// Whether post `a` comes before post `b` in the runner's own order, as OpenRig orders them: by `occurredAt` when it's a
// non-blank string that reads as a time, otherwise by its arrival; then by `sequence`, when both carry a positive whole number.
const postedAt = (p) => {
const value = p?.body?.occurredAt;
const t = typeof value === "string" && value.trim() ? Date.parse(value) : NaN;
return Number.isFinite(t) ? t : p?.at ?? 0;
};
const sequenceOf = (p) => { const value = p?.body?.sequence; return Number.isSafeInteger(value) && value > 0 ? value : null; };
const precedes = (a, b) => postedAt(a) !== postedAt(b) ? postedAt(a) < postedAt(b)
: sequenceOf(a) !== null && sequenceOf(b) !== null && sequenceOf(a) < sequenceOf(b);
// Non-exit Stops the runner posted after `post` in that order. An idle report from before the turn began (a runner may report
// idle at startup) is never one made during it, however late its POST arrives.
const stopsAfter = (h, post) => nonExitStops(h).filter((p) => !post || !precedes(p, post));
const ready = (h, ms) => waitFor(() => h.record()?.nativeRef && h.record(), ms);
function result(id, title, { proposed = false } = {}) {
@@ -297,7 +310,7 @@ const cases = {
async "2"() {
const r = result("2", "Input framing, queued messages and cancel");
const h = await launch("2", { scenario: { turns: [{}, { holdMs: scale(15000) }, { holdMs: scale(2500), ignoreCancel: true }, {}] } });
const h = await launch("2", { scenario: { turns: [{}, { holdMs: scale(15000) }, { holdMs: scale(15000), ignoreCancel: true, endAfterCancelMs: scale(300) }, {}] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
const block = message(h, "first line\n/abort\nthird line");
h.paste(block);
@@ -326,10 +339,12 @@ const cases = {
r.check("one /abort sends one session/cancel", h.received("session/cancel").length === 1, `got ${h.received("session/cancel").length}`);
r.check("the waiting block becomes the next prompt", await waitFor(() => h.prompts()[2] === queued, scale(4000)), `prompts: ${JSON.stringify(h.prompts().slice(2))}`);
// The agent keeps this turn open until the cancel arrives and then ends it end_turn, so a runner that handles Ctrl-C
// late on a busy machine still has a turn to cancel. Only its delivery is timed, by the per-step bound.
const interrupted = Date.now();
await sleep(scale(200));
h.keys("C-c");
r.check("Ctrl-C outside a paste sends session/cancel", await waitFor(() => h.received("session/cancel").length >= 2, scale(3000)), "no second session/cancel");
r.check("Ctrl-C outside a paste sends session/cancel", await waitFor(() => h.received("session/cancel").length >= 2), "no second session/cancel");
const ended = await waitFor(() => nonExitStops(h, interrupted)[0], scale(6000));
r.check("cancelled is reported only on the agent's own stop reason", ended?.body?.subtype === "end_turn",
`the agent ended the turn end_turn; the runner reported ${ended ? `Stop/${ended.body.subtype}` : "no Stop"}`);
@@ -364,6 +379,65 @@ const cases = {
return r;
},
// The optional working beat (section 4). When a runner posts active/liveness in a quiet turn, each beat names the open
// turn it belongs to, keeps the post order, comes often enough for its own lifetime, stops at the turn's end, and never
// runs while a person is asked; after an answer, active/permission_resolved comes before the next beat. Order is read
// from the runner's own sequence and times, so a slow delivery never reads as a broken runner.
async "3b"() {
const r = result("3b", "Working beat (optional): beats name their open turn, keep order, and stop at its end and while a person is asked");
const quiet = scale(22000);
const seqOf = (p) => Number(p?.body?.sequence);
const at = (p) => Date.parse(p.body.occurredAt);
const h = await launch("3b", { scenario: { turns: [{ silent: true, holdMs: quiet }, {}] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "a long, quiet turn"));
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
await sleep(scale(11000)); // room for a beat a runner wrongly sends after the Stop
const beats = h.hooks("active", "liveness");
if (!beats.length) return r.skip("the runner sends no working beat in a quiet turn; the beat is optional (section 4)");
r.check("each beat names the turn it belongs to", accepted && beats.every((b) => b.body.turnId === accepted.body.turnId),
`turnIds: ${beats.map((b) => b.body.turnId).join(", ")}`);
// In the runner's own time order, not delivery's: a post delivered late still has to fit where the runner sent it.
const byTime = (a, b) => at(a) - at(b) || seqOf(a) - seqOf(b);
const timed = h.posts().slice().sort(byTime);
r.check("sequence rises with the runner's own time across every post, beats included",
timed.every((p, i) => Number.isFinite(seqOf(p)) && Number.isFinite(at(p)) && (i === 0 || seqOf(p) > seqOf(timed[i - 1]))),
`sequences in occurredAt order: ${timed.map(seqOf).join(", ")}`);
const life = (b) => (Number.isFinite(b.body.livenessSeconds) ? b.body.livenessSeconds : 30);
const inTurn = beats.filter((b) => !end || seqOf(b) < seqOf(end)).sort(byTime);
const gaps = inTurn.map((b, i) => at(b) - at(i === 0 ? accepted : inTurn[i - 1]));
r.check("beats come more often than every livenessSeconds / 2", inTurn.every((b, i) => gaps[i] <= life(b) * 500 + 1000),
`gaps by the runner's own times: ${gaps.join(", ")} ms`);
r.check("no beat after the turn's Stop", end && beats.every((b) => seqOf(b) < seqOf(end)),
`beats after Stop/end_turn: ${beats.filter((b) => end && seqOf(b) > seqOf(end)).length}`);
// A person is asked first, and answers after a while; the agent then carries on, quietly, in the same turn.
const p = await launch("3b-asked", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
asks: { answer: { afterMs: scale(12000), state: "answered", optionId: "allow-once" } } });
if (!r.check("asked: the runner opens its conversation", await ready(p), "no launch record")) return r;
p.paste(message(p, "edit the notes"));
const submitted = await waitFor(() => p.permissionCalls("POST").find((c) => c.path === "/api/activity/permission-requests"), scale(5000));
if (!r.check("asked: the request is submitted", submitted, "no submit")) return r;
await waitFor(() => p.hooks("Stop", "end_turn")[0], scale(12000) + quiet + opts.waitMs);
const resolvedPost = p.hooks("active", "permission_resolved")[0];
const before = p.hooks("active", "liveness").filter((b) => at(b) > submitted.at && (!resolvedPost || seqOf(b) < seqOf(resolvedPost)));
r.check("asked: no beat while the ask waits on a person", before.length === 0, `${before.length} beats while it waited`);
r.check("asked: after the answer, active/permission_resolved names the turn and the ask, before any beat",
resolvedPost && resolvedPost.body.askId && resolvedPost.body.turnId === p.hooks("UserPromptSubmit", "accepted")[0]?.body.turnId,
resolvedPost ? `permission_resolved: ${JSON.stringify({ turnId: resolvedPost.body.turnId, askId: resolvedPost.body.askId })}` : "no permission_resolved");
// OpenRig permits at submit when no one can be asked: no ask opens, so nothing waits and the beats go on.
const q = await launch("3b-permitted", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
asks: { submit: { permitted: true } } });
if (!r.check("permitted at submit: the runner opens its conversation", await ready(q), "no launch record")) return r;
q.paste(message(q, "edit the notes"));
const qEnd = await waitFor(() => q.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
const qBeats = q.hooks("active", "liveness").filter((b) => !qEnd || seqOf(b) < seqOf(qEnd));
r.check("permitted at submit: the quiet turn's beats go on, with no ask to resolve", qBeats.length > 0, "no beat in the quiet turn after the permit");
return r;
},
// Section 7's default is permit: when no one can be asked, the agent gets its own allow option at once, allow_once before
// allow_always. Only the seat's opt-in refusal (denied_by_seat), or a request with no allow option, is refused at once,
// shown, and held as needs input until the next prompt, even when the turn is cancelled.
@@ -449,7 +523,8 @@ const cases = {
const submitted = await waitFor(() => submits(h)[0], scale(3000));
if (!r.check(`${label}: the request is submitted to /api/activity/permission-requests`, submitted, "no submit")) continue;
const asked = h.sent("session/request_permission")[0];
const accepted = h.hooks("UserPromptSubmit", "accepted")[0];
// The submit isn't one of the runner's ordered activity posts, so it can arrive before the accepted prompt does.
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0], scale(3000));
const sent = submitted.body ?? {};
if (label === "allow") {
r.check("the submit passes OpenRig's checks", h.asks.submitted[0]?.problems.length === 0, h.asks.submitted[0]?.problems.join("; "));
@@ -486,6 +561,8 @@ const cases = {
const outcomes = [
["permitted at submit", { submit: { permitted: true } }, "allow-once"],
["permitted at read", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once" } }, "allow-once"],
// A person's own words come back beside the outcome, and never change the option delivered.
["permitted at read, with the person's words", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said: "only the notes file" } }, "allow-once"],
["duplicate_request", { submit: { status: 409, code: "duplicate_request" } }, "allow-once"],
["activity_grant_pending", { submit: { status: 409, code: "activity_grant_pending" } }, "allow-once"],
["activity_grant_revoked", { submit: { status: 401, code: "activity_grant_revoked" } }, "allow-once"],
@@ -530,7 +607,8 @@ const cases = {
await sleep(scale(1500));
r.check("pending: the runner keeps reading the ask", h.permissionCalls("GET").length >= 1, "no read of the ask");
r.check("pending: nothing is delivered while no one has answered", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
r.check("pending: no idle Stop while it waits", nonExitStops(h).length === 0, nonExitStops(h).map((p) => p.body.subtype).join(", "));
const turn = h.hooks("UserPromptSubmit", "accepted").at(-1);
r.check("pending: no idle Stop while it waits", stopsAfter(h, turn).length === 0, stopsAfter(h, turn).map((p) => p.body.subtype).join(", "));
h.paste(message(h, "allow it"));
await sleep(scale(800));
r.check("pending: a pasted message isn't an answer", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
@@ -573,6 +651,22 @@ const cases = {
return r;
},
// Section 7: a person who answers in their own words gets them passed on. OpenRig carries them as the read's `said`, and
// a runner queues them for the agent like any other prompt, after the turn, never as the answer. A runner that drops
// them still keeps the contract, so the result is advisory.
async "4c"() {
const r = result("4c", "The person's words: a read's said is queued to the agent as a prompt (advisory)", { proposed: true });
const said = "Only the notes file, please. Leave the rest alone.";
const h = await launch("4c", { scenario: { turns: [{ permission: true }, {}] },
asks: { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said } } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "edit the notes"));
if (!r.check("the first turn ends", await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(6000)), "no Stop/end_turn")) return r;
const later = await waitFor(() => h.prompts().slice(1).find((prompt) => prompt?.includes(said)), scale(4000));
r.check("the person's words are queued to the agent as a prompt, after the turn", later, `prompts: ${JSON.stringify(h.prompts())}`);
return r;
},
async "5"() {
const r = result("5", "Generation: every post carries the launch's occupant generation");
const h = await launch("5");
@@ -630,7 +724,10 @@ const cases = {
if (!r.check("the agent exits mid-turn", died, "the agent didn't exit")) return r;
const exited = await waitFor(() => h.hooks("Stop", `${opts.name}_exited`)[0], scale(4000));
r.check(`Stop/${opts.name}_exited is posted`, exited, `posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
r.check("no idle Stop after the exit", nonExitStops(h, died.at).length === 0, nonExitStops(h, died.at).map((p) => p.body.subtype).join(", "));
// Any idle report for the turn the agent died in counts, before or after the exit; one from before the turn doesn't.
const turn = h.hooks("UserPromptSubmit", "accepted")[0];
const idle = turn ? stopsAfter(h, turn) : nonExitStops(h, died.at);
r.check("no idle report for the turn it died in", idle.length === 0, idle.map((p) => p.body.subtype).join(", "));
return r;
},
@@ -686,7 +783,7 @@ const cases = {
},
};
const order = ["1", "1b", "2", "3", "4", "4b", "5", "6", "7", "7b", "7c", "8", "9"];
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
const selected = opts.cases ?? order;
const unknown = selected.filter((id) => !order.includes(id));
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
@@ -16,7 +16,9 @@
// stubborn keep running after a hangup or after input closes; only SIGTERM or SIGKILL ends it
// turns one entry per `session/prompt`, in order (the last one repeats):
// holdMs keep the turn open this long; `session/cancel` ends it early as "cancelled"
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway
// silent send no message chunk for this turn, so a held turn is entirely quiet (a long tool or model call)
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway, when
// holdMs runs out or, with endAfterCancelMs, that long after the first cancel if that's sooner
// permission ask `session/request_permission` first, then carry on whatever the answer. As an object:
// options the options offered (default: allow-once, allow_once; reject-once, reject_once)
// waitMs stop waiting for the answer after this long, and end the turn
@@ -65,7 +67,7 @@ async function prompt(id, params) {
turn += 1;
if (spec.exit) exit(3, `turn ${turn}`);
if (spec.error) { fail(id, -32603, "fake agent: this turn fails"); return; }
send({ jsonrpc: "2.0", method: "session/update",
if (!spec.silent) send({ jsonrpc: "2.0", method: "session/update",
params: { sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `turn ${turn}` } } } });
let stopReason = spec.stopReason ?? "end_turn";
if (spec.permission) {
@@ -89,7 +91,12 @@ async function prompt(id, params) {
if (spec.holdMs) {
const cancelled = await new Promise((resolve) => {
const timer = setTimeout(() => { cancelTurn = null; resolve(false); }, spec.holdMs);
cancelTurn = () => { if (spec.ignoreCancel) return; clearTimeout(timer); cancelTurn = null; resolve(true); };
cancelTurn = () => {
if (!spec.ignoreCancel) { clearTimeout(timer); cancelTurn = null; resolve(true); return; }
if (spec.endAfterCancelMs === undefined) return;
cancelTurn = null;
setTimeout(() => { clearTimeout(timer); resolve(false); }, spec.endAfterCancelMs);
};
});
if (cancelled) stopReason = "cancelled";
}
+22 -3
View File
@@ -144,6 +144,23 @@ changes either. `rig scope` reads an `established` or `canonical` slice whose
`verified:` is missing, has no source, names a tool-scaffolded placeholder, or is
older than 90 days as `provisional`.
A slice's frontmatter may also carry `status:`. `GET /api/slices` reports the
bucket each word falls in as the slice's `status`, and its
`?filter=all|active|done|blocked` filters on that bucket:
| `status:` word | Bucket |
| --- | --- |
| `active`, `in-flight`, `ratified` | `active` |
| `draft`, `draft-pending-orch-ratification`, `placeholder` | `draft` |
| `done`, `shipped`, `promoted`, `closed` | `done` |
| `blocked`, `parked-with-evidence` | `blocked` |
Words are matched ignoring case. Any other word is matched by what it contains:
`done`, `ship` or `close` makes it `done`; `block` or `park`, `blocked`; `draft`
or `pending`, `draft`. A word that matches none of these counts as `active`, so a
slice marked `complete` counts as `active`. A slice with no `status:` counts as
`draft`.
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
record an attributed accept, reject or withdraw decision on a contract item,
and `rig proof show` to read derived readiness up through slice, mission and
@@ -178,9 +195,11 @@ missions whose `mission.yaml` carries `metadata.status: active` or
a mission does not change the project's readiness. A `planned` mission stays
outside the rollup, even after its slices are accepted, until someone changes
its status to `active` (or `release-candidate` for a release that is ready to
judge). Nothing changes the status automatically. Missions created before this
default carry no status and are not counted either; add the line by hand to
include them. It also writes `metadata.name`, the mission's folder name, which
judge). Nothing changes the status automatically. A mission can also carry
`metadata.status: complete`, which only `POST /api/missions/:missionId/complete`
writes (no `rig` verb does); a `complete` mission is outside the rollup too.
Missions created before this default carry no status and are not counted either;
add the line by hand to include them. It also writes `metadata.name`, the mission's folder name, which
the project lifecycle compiler requires; a mission created without it refuses to
compile with `lifecycle_field_missing` until you add that line.
+94 -6
View File
@@ -10,7 +10,7 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
| `off` (default) | Existing delivery and prompt checks | Existing send result |
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Hold input and retain this attempt for inspection when bookkeeping is available |
| `openrig-urgent` | OpenRig's own messages (watchdogs, scheduled wakes, notices) and anything marked urgent type, never over a draft | Other agents' messages are held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, `urgent: true` on `POST /api/transport/send`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `sends-only` | Deliberate sends from people and agents type, as with `off`; a terminal member's typing isn't protected, because its shell input can't be read | OpenRig's own notices are held. A terminal member defaults to this level |
| `hold` | No automatic terminal input, even at an empty prompt | Retain for inspection outside the pane |
@@ -41,6 +41,69 @@ nothing to send later.
## Configure and inspect
### Latest permission observation
`rig seat status <seat>` can show the latest observed harness permission request,
with a fixed tool descriptor, source time and actor category. This is one latest
observation across actors, not an aggregate of pending prompts. `request_observed`
leaves the displayed actor's pending state unknown. `completed` applies only to
that actor's later tool batch; other actors' pending state is still unknown. An
unavailable observation proves neither a permission stop nor the absence of a
waiting native prompt. With no observation, human status omits this section;
JSON keeps the explicit unavailable state. The JSON field is `permission_stop`,
and its `pending` value has the same observation-only scope.
An observation may also carry Claude's **suggested rules** as `suggested_rules`.
These are read-only evidence, not applied or approved, not guaranteed effective,
not the exact command, and not a complete list of dialog options. The supported
subset of [Claude's permission update schema](https://code.claude.com/docs/en/hooks#permission-update-entries)
is `addRules`, `replaceRules` and `removeRules`, preserving type, ordered rules
(`toolName`, optional `ruleContent`), behavior and destination exactly. Strings
are neither trimmed nor broadened; absent `ruleContent` remains distinct from
an empty string. Mode/directory updates and malformed entries are withheld.
The envelope distinguishes a missing native field from a present empty array;
`unsupported` and `size_cut` mark incomplete evidence. An older relay without
this field, or a failed formatter, is unavailable rather than proof of native
absence. Bounds are eight entries, sixteen rules per entry, 128 UTF-16 code
units per tool name, 2,048 per rule-content string, and 16,384 UTF-8 bytes of
serialized evidence. Oversized whole entries are dropped with `size_cut`,
never shortened into different plausible rules. Both producer and ingress
validate these bounds independently. Terminal JSON escapes controls and
nonprinting format/separator characters while preserving decoded string values;
its representation can expand, so the byte bound applies to stored/transported
evidence, not the entire formatted status output.
**Suggested rule strings can contain sensitive values.** The relay sends only
this bounded rule subset alongside the fixed descriptor. Rule content can itself
contain sensitive command-like text; raw `tool_input`, arguments outside that
suggestion, and provider reasons are not forwarded. It posts to `/api/activity/hooks` at the
configured `OPENRIG_URL` (or host/port), or the discovered endpoint in
`OPENRIG_HOME/activity-endpoint.json`. Both `GET /api/seat/status/:seatRef` and
`GET /api/seat/status/node/:nodeId` can return the evidence. The daemon adds no
event, log, queue or telemetry persistence for it; a caller can still capture
status output in a transcript or file. Evidence shares the observation's
five-minute lifetime and existing seat/actor limits, is discarded lazily on the
next observation/status read after expiry, and is lost on restart. No rule,
permission/configuration write, hook decision, edited input, prompt submission
or new attention/delivery behavior is produced.
Actor attribution is a convention derived from the [Claude hook schema](https://code.claude.com/docs/en/hooks#common-input-fields): an omitted or null `agent_id`
is treated as main, a valid ID identifies a subagent or in-process teammate, and
an invalid ID is dropped rather than inferred as main. The fixtures are synthetic;
no live PermissionRequest capture qualifies that convention on an installed native
version. A hook denial without a source-bound guard receipt has unknown origin.
This diagnostic adds no guard collector, permission decision or attention policy.
The new PermissionRequest diagnostic is optional for activity-hook readiness.
Every preexisting required event, relay asset and explicit hook-disable check
still applies. An older installed plugin at the same manifest version retains
its bytes and may not send this event: plugin-only delivery needs a future normal
maintainer release refresh, and managed delivery needs normal reprojection. A
manifest or green readiness check does not prove live hook execution. Raw tool
arguments and provider reason text are withheld; diagnostic evidence is bounded,
memory-only and expires after five minutes.
### Who may intervene
`rig seat status <seat>` shows the seat's terminal interaction account alongside
@@ -111,8 +174,18 @@ their existing behaviour.
### Terminal delivery
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
audit actor from transport identity.
`rig seat set-typing-guard` works from an ordinary terminal as well as from a
seat's shell. The daemon takes the recorded actor from transport identity only:
- From a managed seat's shell (`OPENRIG_SESSION_NAME` set), the preference and
its audit entry name that seat.
- From a terminal with no seat identity, the change applies the same way, and
the preference and its audit entry record the actor as
`unattributed (no seat identity)`.
Neither case takes an actor from the request body or from the target seat's name.
`rig seat retire-held-message` still records the seat that retired a message, so
run it from a seat's shell.
~~~sh
rig seat set-typing-guard dev-impl@my-rig --mode draft-aware \
@@ -131,8 +204,21 @@ to finish. The API may return HTTP 202 with `pending: true`; wait for
and effective modes and retains the text `Typing guard: on (hold)` for hold.
A held send immediately returns `outcome: "retained"`, `sent: false`, its exact
reason (such as `draft_input_busy`) and the saved attempt's `outboxIds`. No automatic
retry is scheduled. Each explicit draft-aware request is a new attempt, including
reason and the saved attempt's `outboxIds`. No automatic
retry is scheduled. The reasons are:
- `typing_guard_enabled`: the seat's mode holds this send (`hold`, or a level that
holds this kind of message).
- `runtime_switch_in_progress`: the seat is switching runtime.
- In `draft-aware` mode, a refusal before any input was written:
`draft_input_busy` (the input holds unsubmitted text), `draft_input_unknown` (the
input can't be read), `draft_input_changed` (the input or its target changed during
the delivery), `draft_wake_superseded`, `target_needs_input`,
`wait_for_idle_timeout`, `target_activity_unknown`, `target_runtime_unverified`,
`target_runtime_not_running`, `target_runtime_conflict`, `invalid_wait_for_idle`,
`invalid_dangerously_interact`, `dangerously_interact_requires_reason`,
`prompt_override_audit_unavailable`, `transport_unavailable`, `session_missing` or
`tmux_unavailable`. Each explicit draft-aware request is a new attempt, including
one with a reused `deliveryId`; the result warns older callers that this field no
longer deduplicates input. The CLI does not generate a deduplication key.
@@ -230,7 +316,9 @@ refuses Enter and is not replayed. Unsupported provider layouts remain held.
Use off when that tradeoff is unsuitable.
HTTP configuration uses `POST /api/seat/set-typing-guard/:seatRef` with
`{mode, reason}` or `{enabled, reason}`. Status and held-message routes are
`{mode, reason}` or `{enabled, reason}`. The `X-OpenRig-Session` header, when
present, is the recorded actor; without it the change is recorded as
`unattributed (no seat identity)`. Status and held-message routes are
unchanged; no RigSpec field is required.
## Verification scope
+56 -7
View File
@@ -99,8 +99,24 @@ So after installing, compare the granted scopes Slack shows for the app with all
`rig slack manifest --json` lists.
The app subscribes to messages in public channels it is a member of (`message.channels`), to
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). It does not request
direct-message or private-channel access.
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). The manifest does
not request direct-message or private-channel access.
### Using a private channel
The connector doesn't check whether a channel is public or private, so a private channel can be
the default channel or a mapped one once the app can read it. OpenRig doesn't test this setup. In
the app's settings:
1. Under **OAuth & Permissions**, add the bot scopes `groups:history` (read messages in private
channels the app is in) and `groups:read` (read a private channel's details; `rig slack verify`
needs it to confirm the app is a member).
2. Under **Event Subscriptions**, subscribe to the bot event `message.groups`.
3. Reinstall the app to the workspace, then invite it to the private channel.
4. To have `rig slack verify` require the new scopes too, run
`rig slack setup --required-scopes chat:write,channels:history,channels:read,groups:history,groups:read`.
Direct messages are not supported.
A reaction added to an ask reaches the agent that asked, as a note naming who reacted and with
which emoji; the agent decides what it means. Every message OpenRig posted for the ask counts:
@@ -166,6 +182,9 @@ rig slack channel-map list
rig slack channel-map remove pr@my-rig
```
A client can offer the channels to choose from with `GET /api/gateway/slack/channels`, which lists the public
channels the bot token can see, with their ids ([Writing a daemon client](daemon-http-clients.md#route-details)).
- **Which channel an item posts to.** The item's seat is matched exactly first (`pr@my-rig`),
then its rig (`my-rig`). Anything unmapped posts to the default channel, so a config
without a map behaves exactly as before. Aggregate delivery digests have no single seat and
@@ -207,7 +226,7 @@ rig slack channel-map remove pr@my-rig
their routing also keeps the conversations separate: new messages in a seat's own channel
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
on purpose is fine.
- Missed-message recovery (below) covers the default channel only.
- Missed-message recovery (below) covers the default channel and every mapped channel, except a channel relayed to another machine.
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
membership in the default channel and every mapped channel, and reports NOT ready until the
@@ -221,8 +240,9 @@ configuration matches the file.
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
entries, each with an optional `"inbound"` seat. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
connector reads the file, and `rig slack status` names it. Private channels and direct messages
are not supported targets.
connector reads the file, and `rig slack status` names it. A private channel can be a target once
the app has the private-channel scopes ([Using a private channel](#using-a-private-channel)).
Direct messages are not supported targets.
## Named bots (optional)
@@ -329,8 +349,7 @@ decisions, and posts its own replies.
- **Changing forwarding entries is not a drain or cancellation:** relays already retained keep
their original host and remain visible in `rig slack status`, even after the last entry is
removed. An enabled connector retries them; disabling it pauses retry without deleting them.
Let relays finish before removing a forward entry. A zero dead-letter backlog alone does not
prove they have drained: it does not count relay HTTP requests still in flight.
Let relays finish before removing a forward entry; `rig slack status` shows local in-flight attempts separately from retained records, but zero does not prove global quiescence.
- **Who answered:** every post starts `From <seat> on <machine>`, using the machine's host id (set
it with `host.name`).
- **The hub's own channels** are handled on the hub, as before. A channel can be relayed to one
@@ -366,6 +385,36 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
## Reconnect recovery and status
When a posted ask is answered, closed, canceled or replaced elsewhere, the connector edits its
original primary message with the status, time, actor and closure note. Buttons become option
text and the answer hint disappears; the remaining content and reconciliation marker stay as
posted. Supplemental messages are unchanged. This adds no post or reaction and needs no agent
turn. Separate resolution notices retain their existing behavior.
A deliberately reopened direct ask says **Open again**; its options remain as text. If it has
no committed answer, it tells the person to reply in its thread. Otherwise it says **Earlier
answer retained**: reopening does not accept a second answer. Earlier replaced parks remain
historical; **Asked again** does not claim that the later ask has been posted. Edits reconcile
the current state again after awaited Slack calls, so an obsolete close is not recorded as the
current result after a reopen.
Edits follow committed queue changes, with a startup catch-up for posted asks on retained queue
rows. Each primary costs one exact-message read and one `chat.update`. The serial edit worker
uses the recovery budget helpers (four calls, 100 entries, 15 seconds) and waits for the next
budget window when exhausted. Slack's per-method `Retry-After` is retained across restarts.
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
an archived channel or an uneditable message) remain unfinished and are retried only on the next
connector start. They do not keep consuming edit capacity. These edit calls have their own
budget, separate from missed-message scanning below.
The baseline already includes public-channel history access and `chat:write`. Private-channel
reads may require `groups:history`, which the baseline does not grant; a refused read never
counts as an edit. An unavailable/deleted original cannot be replaced by a new post. Startup
catch-up cannot edit rows already removed by queue retention or posts without their original
notification receipt and channel stamp.
The daemon scans available top-level messages and replies broadcast with **Also send to
channel** in the channels this machine owns: its default channel and its channel map's, except a
channel it relays to another machine. It also scans replies in the threads it tracks in those
+64
View File
@@ -115,3 +115,67 @@ successful empty history. The CLI preserves the error body in JSON mode and exit
1 for request errors, 2 for unavailable reads. Existing SSE and usage APIs retain
their behavior. Collector deployment, exporters and durable store epochs are
separate work.
## Independent exporter mapping and limits
OpenRig's telemetry guarantee ends at the finite HTTP/CLI read envelope above. Any
projection of those finite reads (or of cached rig/node inventory) into
OpenTelemetry logs, custom metrics, or spans is defined and owned by an external
consumer or exporter—not guaranteed by OpenRig's core API.
The external OTLP exporter at `smota/openrig-telemetry` applies the following
exporter-specific mapping choices to OpenRig's source facts (other consumers may
make different choices):
- **Recorded queue-state intervals (not execution time)**: In OpenRig, queue
transition rows record state changes (`pending`, `in-progress`, `blocked`, and
departure states), not agent execution duration or model latency; the first
observation of an item has no prior boundary; repeated same-state rows remain
distinct source records; and any page carrying an ID-range or withheld-record gap
(`before_retained_floor`, `retained_sequence_gap`, `conflicting_transition_id`,
`record_byte_limit`, `historical_transitions_not_read`, truncated gap details, or
a boot/watermark discontinuity) cannot prove what happened in the missing range.
In `smota/openrig-telemetry`, the exporter pairs an entering state with a later
departure state to measure only the recorded interval between two observed rows
with valid, ordered UTC timestamps, suppresses or drops open intervals across gap
boundaries rather than inventing durations, and reports cumulative transition
counts only for rows observed from its checkpoint (including historical rows when
started with `start=retained`), never lifetime daemon totals.
- **No current-owner, seat, tenure, or native-session join**: In OpenRig's finite
API, the `queue-transitions` stream does not join transition rows to current seat
owners, mutable node or rig names, occupant tenures (`tenures`), or native
harness sessions, and it never selects queue bodies or transition notes; each
transition row does include `qitemId`, `actorSession`, `closureTarget`, and
`originalTimestamp`. In its exported OTLP output, `smota/openrig-telemetry`
chooses to emit derived queue spans as root/internal observations without
invented parent/child trace hierarchy, cross-stream causal links, or `gen_ai.*`
model/token/cost attributes; places `qitemId` only on opt-in log/span attributes
rather than metric dimensions; and omits the source fields `actorSession`,
`closureTarget`, and raw `originalTimestamp` from exported OTLP logs, metrics,
and spans.
- **Sampling unknowns on cached seat inventory**: OpenRig's cached rig and seat
inventory endpoints (`GET /api/rigs`, `GET /api/rigs?archived=only`,
`GET /api/rigs/:rigId/nodes`) return point-in-time daemon projections
(`activityState.display`, `sessionStatus`, `arbitratedNeedsInput`, and
`activityState.lastSwap`), not a continuous event stream or independent OS
process probe; intermediate activity changes or multiple occupant handovers
between polls are not observable from those snapshots, and initial
observation has no backfilled history. In OpenRig source,
`arbitratedNeedsInput` is a tri-state boolean (`true`, `false`, or `null`):
`true` means the seat needs input, `false` means it was observed clear of
needing input, and `null` means no evidence exists either way (it does not
indicate `false`). Separately, external consumers or metric mappings must
choose how to interpret or project this tri-state value into their own
metrics or counters. `smota/openrig-telemetry` polls these snapshots as
point-in-time observations and relies on downstream metric storage to enforce
freshness/staleness TTL when polling stops or a seat departs.
### External OTLP exporter repository and version compatibility
An independent, standalone OTLP/HTTP exporter is maintained outside OpenRig core at:
- **Repository**: <https://github.com/smota/openrig-telemetry>
Its repository documents installation, configuration, and supported OpenRig versions; OpenRig does not test or guarantee the exporter.
OpenRig's finite telemetry reads are versioned (`schemaVersion: 1`), while cached rig and node inventory endpoints are operational projections rather than a versioned telemetry contract.
+15 -5
View File
@@ -300,13 +300,23 @@ small presentation provenance, separate from queue membership. Herdr display
labels are not that provenance. Other providers/platforms remain unsupported
for ensure-viewer until qualified; ordinary open retains its existing behavior.
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
`-r` (which also implies ignore-size). This can avoid contributing to sizing
when other clients establish the agent window's dimensions. It is **not resize
isolation**: opening a tile can change the agent's terminal size when it is the
only sizing client. Observers of a Herdr terminal do not themselves resize it;
A writable tile attaches with `tmux attach -f ignore-size` when its session
already has a client that sizes it, so the tile doesn't fight that client over
the agent window's dimensions. When the session has no sizing client, as with a
freshly launched seat, the tile attaches without the flag and sizes the window
itself: tmux applies ignore-size across the whole server, so a session viewed
only by ignore-size clients would otherwise keep its 80x24 creation size while
any other session has a sizing client. Read-only clients keep `-r` (which also
implies ignore-size), and the printed manual attach commands keep
`-f ignore-size`. This is **not resize isolation**: opening a tile can change
the agent's terminal size. Observers of a Herdr terminal do not themselves resize it;
a controlling browser can. No cross-version resize guarantee is implied.
To type into a seat without changing its size, send the text through OpenRig
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
buffers and keys, and no client attaches. There is no supported way to attach a
writable client that is sure never to resize the agent's window.
## Following a slice or mission's current work
+472 -178
View File
@@ -1,192 +1,486 @@
# What changed in OpenRig 0.7.1
# What changed in OpenRig 0.7.10
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
after an upgrade it can still hold the previous version's note.
This note is for an agent on an install that was just upgraded to 0.7.10. OpenRig
0.7.10 adds `rig exec` for finite runs on Claude Code or Codex (not yet run with
a real adapter), sends a held
post-compaction step after two idle polls that can't read the input box, leaves
every tmux mode before a delivery, and shows agent spec advisories at launch. It
fixes in the source the two problems that stopped the OpenShell worker in 0.7.9,
but that worker hasn't run end to end. If you upgraded from 0.7.8 or earlier,
also read "What changed in OpenRig 0.7.9" below, and from 0.7.7 or earlier,
"What changed in OpenRig 0.7.8" too. For the full capability map, read
`rig context get onboarding-width/public-what-you-can-do.md`. After the upgraded
daemon starts, `rig context get reference/whats-new.md` describes this version.
Without a running daemon, read the packaged `daemon/docs/reference/whats-new.md`;
`$OPENRIG_HOME/reference/whats-new.md` can still be the previous copy until
startup.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
`daemon.db_conflict` (`warn`). None is removed.
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`. This release adds no migrations.
- **The bundled openrig-core plugin goes from 0.1.25 to 0.1.26.** Its Claude
hooks add a `PermissionRequest` hook that reports permission requests to seat
status, and its refocus hook and the refocusing skill's trace script changed.
Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from the preserved copy.
- **Check agent specs before relaunching seats:** run `rig agent validate <path>`
and read any `agent spec advisory` line. A profile's `uses.<category>` written
as a single value now selects that resource; if the resource pool doesn't hold
it, a member using that profile fails to resolve with "not found in resource
pool". Ask the person before you change their spec.
- **Codex hooks:** a Codex seat kept running across the upgrade can show "Hook
failed — plugin contents changed; hook approval required" until relaunched; in
0.7.9's upgrade test, one seat restarted with OpenRig's managed restart showed
no review afterwards.
- **OpenShell:** rebuild a guest image with this release's `guest.py` before
using it; the CA repair is in that file, which the image carries.
- **Codex tool commands use a non-login shell:** managed Codex seats now pass
Codex `-c allow_login_shell=false`, so plain `rig` works however OpenRig was
installed, with a Codex that supports `allow_login_shell` (checked in Codex's
source at 0.162.1 and 0.166); not yet run with a live Codex seat. Setup that lives only in login or rc files, and shell functions and
aliases, may be missing from tool commands, and a model request for
`login: true` gets Codex's refusal, "login shell is disabled by config". To
defer to Codex's own setting, run `rig config set launch.codex_login_shell
true`; a login file that sets `PATH` outright can then hide `rig`. It applies
at each seat's next launch, and a restart in place keeps the running choice.
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
## New things you can do
## Finite runs: `rig exec`
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
readable (`rig seat held-messages`) and is never replayed.
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
interventions are guidance only.
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
one line to shared guidance files.
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
--seat <session>` gives each caller its own seat.
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
`docs/reference/a2a-agents.md` covers A2A agents.
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
`docs/reference/community-harness-examples/`.
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
strands an accepted create or join whose effect hadn't landed.
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
`rig stream show <id>` says what became of an observation.
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
the kernel starts seats only for those.
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
deliberate start.
`docs/reference/acp-agents.md` is the rule. Read it before handing a row to
Claude Code or Codex.
- **Install the adapters first:** `@agentclientprotocol/claude-agent-acp`
0.101.0 and `@agentclientprotocol/codex-acp` 2.5.0, as that page shows.
`rig exec harnesses` reports the adapter and native versions it finds.
- **Run:** `rig exec run <row> --harness claude` (or `codex`). `--plan` shows
what would run and starts nothing; `--wait` waits for the outcome. A connector
owns the run, so leaving `--wait` or closing the terminal doesn't stop it.
Running it again returns the row's run instead of sending the task twice;
`--again` starts another turn once that run has ended.
- **Read the outcome** with `rig exec status <runId|row>`. `interrupted` means
the connector died without recording an outcome, so the outcome is unknown. A
`completed` turn is the agent's report, not finished work: check the work
before you close the row. `--wait` exits 0 for `completed`, 2 for any other
outcome and 3 when you stop waiting.
- **Cancel** with `rig exec cancel <runId>`, not `rig acp cancel`. An
acknowledgement isn't a settled shutdown, and it says nothing about further
spend.
- **Usage and cost** the agent reported are on the row's outcome note, for
`rig acp run` too, each with its source; what it didn't report reads unknown.
## Behaviour that changed
## Compaction and delivery
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
what wasn't delivered.
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
at `hold`.
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
shows an MCP enablement menu, a question or a permission prompt.
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
`--yes` command.
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
and `--no-kernel`.
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
listener, such as Tailscale's, can't be verified.
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
writable roots.
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
guidance files byte for byte.
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
nor actor gets the shared `actor_required` error.
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
which clears nothing. Keys for a recognized native menu are sent as before.
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
runs, so it doesn't relaunch them beside themselves.
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
handover. A launch whose staging fails stops instead of falling back.
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
request once.
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
longer stops health diagnoses from listing.
- **A held post-compaction step goes in.** After two idle polls in a row that
can't read a seat's input box, the next poll sends the step (the turn
boundary, the restore request or the audit). `rig compact <seat> --state` and
`rig seat status` say it went in over a box OpenRig couldn't read; they never
call that box empty. A draft OpenRig can't recognise in that box may receive
the restore text.
- **Still held:** a box that reads as typed, with or without a suggestion; a
seat whose draft-aware delivery or typing guard holds OpenRig's input; and the
step after a `/compact` whose result was lost. To take over a held restore by
hand, run `rig compact <seat> --cancel`, then send the seat a restore request
naming its newest restore map, its restore marker and its packet. A step
already being pasted can still land.
- **tmux modes:** a send or wake now leaves every tmux mode in a seat's pane,
including a chooser, customize or clock mode a person has open. If a send is
still refused for a tmux mode, the refusal names why leaving failed.
- **A scrolled pane with a stale record:** leaving the mode now goes through the
seat's address, so a stale binding naming the same reused tmux pane, from
another rig for example, no longer makes the send fail with nothing sent.
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
as the same attachment when only OpenRig's tile script changed, and moves to
the current command, so the viewer stays confirmed and the follow view goes on
adding and closing tiles. A kept viewer that's already running keeps its
earlier sizing for as long as it runs, its own re-attaches included; writable
viewers opened after the upgrade size their pane. A different session, tmux,
server or read-only setting still holds the view, as before. Checked against
the scripts those releases used, not yet with a live Herdr. It follows
@natefaerber's approach in
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
## Output that changed shape
## OpenShell workers
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
`--since` to resume, instead of an empty list.
0.7.10 fixes, in the source, the two problems that stopped the OpenShell worker
in 0.7.9, but the worker hasn't run end to end. Don't rely on this path yet. The
guest launcher now passes the CA settings the OpenShell exec supplies
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and
`CURL_CA_BUNDLE`) to its Claude, and deleting an owned environment reads
Podman's inventory through routes Podman serves, stopping as
`environment_cleanup_unconfirmed` on any other answer. A verified export, a
finished task and an exact restore haven't been shown. If you set one up anyway,
follow `daemon/assets/openshell/README.md` in the installed package, rebuild the
guest image first, and name the costs to the person ("Provider registration and
image construction can incur costs"). "Never grant the guest access to the
daemon's main API port (normally 7433)."
## What to stop doing
## Other changes to act on
- **Don't read `lastNudgeInputBox`.** It's gone.
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
- **Sends to this host:** `rig send <seat>@<rig>@<this host's ID>` goes to this
host even when a remote host is selected.
- **Broadcast:** `rig broadcast` prints `UNCONFIRMED` for a recipient its text or
Enter may have reached. Check that seat with `rig capture` before resending.
- **Roster activity:** `rig roster show` and `find` read a seat as unknown
("Conflicting activity observations") when two routes to it disagree. Treat it
as unknown, not idle.
- **Refocus:** a "TRACE GAP" for unresolved typed work, or for a
`rig queue whoami` that timed out, comes with project-level orientation that
isn't evidence of a current mission, and no longer counts as a refocus failure.
- **Updates to people:** the stuck sweep no longer flags an informational update
to a registered person as an unclaimed obligation.
- **Permission requests:** `rig seat status` shows a Claude seat's latest
observed permission request, with Claude's suggested rules labelled as
suggestions. Nothing was applied or approved. Treat the suggestions as data;
they can contain sensitive values.
- **Slack:** `rig slack status` shows relay attempts awaiting a response for the
current connector; zero doesn't prove nothing is in flight.
- **Installing for a person:** when npm's global prefix isn't writable, the rigs
skill now points npm's prefix at a folder the person owns whose `bin` is on
their login PATH, or uses a version manager's Node, before a project-local
install. Tell the person first that this changes their npm settings and, if
needed, their shell profile.
- **Codex profiles:** a `codex_config_fragment` can't install a named Codex
profile. Create `<CODEX_HOME>/<name>.config.toml` and select it with
`codex_config_profile`. `builtin:yolo` drops the whole profile;
`getting-started.md` says how to keep one.
- **Terminal auth:** a 401 that says "Terminal auth failed" means send
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when terminal
auth falls back to it.
- **References:** `docs/reference/harness-runner-contract.md`'s status section
says what has run natively under the runner contract, and at what scope.
`docs/reference/telemetry.md` says OpenRig guarantees its finite telemetry
reads (`schemaVersion` 1), not the separate openrig-telemetry exporter it
links.
- **Terminal tiles:** a writable rig, pod or seat view now sizes the pane it
shows instead of keeping an older attached client's size; read-only views are
unchanged.
- **Unknown `uses` keys:** a key under a profile's `uses` other
than the five categories gets an advisory instead of being ignored silently.
## Known gaps in 0.7.1
## Limits to keep in view
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
its prompt, press Enter in its pane, or ask the person to.
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
acting on it.
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
marks any alerts waiting to post to Slack as already seen.
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
(`tmux -V`).
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
everything" wait. On a slow host, start the daemon yourself first.
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
gateway folder or database instead: that can lose newer people, bindings and delivery records.
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
--provider herdr`.
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
first. Pair a decision room with quiet hours, or decide before asking.
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
falling back to credential-bearing command-line arguments.
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
only claude.ai subscription logins and reports `not_subscription_login`.
- **OpenShell** hasn't run end to end on 0.7.10 (above). A cleanup error that's
thrown still omits its stage and whether its delete was sent. A
same-generation reconciled launch opens no viewer, so run
`rig environment connect` yourself; non-text startup actions aren't supported
on environment seats.
- **`rig exec`:** no real adapter has run under it yet; one run per row,
detached ownership and reconciling were checked against a stand-in agent.
Concurrent runs, long or background tasks, recovery across a reboot, MCP
servers and Windows aren't covered. A Ctrl-C just before the connector is
launched isn't caught by `--wait`.
- **Draft-aware delivery to Claude:** a narrow Claude pane showing
`✔ Update installed · Restart to update` on its own line can still hold
deliveries at an empty prompt, queue wakes included; 0.7.10 doesn't recognize
that bare banner. A held message isn't replayed automatically by that path.
- **Seats handed over before 0.7.9** keep their adopted origin until a later
launched handover or managed launch records a new session. Until then
`rig seat stop`, `clean` and in-place restart refuse them, and now say why.
Don't use `rig unclaim` to get around it: that ends OpenRig's management of
the seat.
- **Permission requests in seat status** show only the latest one, held in
memory for five minutes and lost on a daemon restart.
- **Agent spec advisories** don't show for a native resume that continues a
saved conversation.
- **The other 0.7.9 limits below still apply:** open updates for a person, Slack,
SSH host status, shared asks, permit records, existing seats, Codex hooks, and
grant-service assistants not being woken. 0.7.9's limits on a restore request
held after compaction, `rig broadcast`'s `FAILED`, what has run under the
runner contract, and Codex seats' `rig: command not found` are fixed or
changed. The 0.7.8 limits below that 0.7.9
didn't fix or change, and the
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
apply.
---
## What changed in OpenRig 0.7.9
The changes from 0.7.8 to 0.7.9, published on 2026-11-06, for an install
upgrading from 0.7.8 or earlier.
### After the upgrade
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`.
- **Migrations 146 to 149** record a Pi seat's guidance file, what OpenRig last
wrote in each managed guidance block, an index for reading a rig's outbox,
and the stream intake position.
- **Tell the person that stream intake is on.** Each `rig stream emit` now opens
an intake row for the kernel queue worker (`queue.worker`), or adds a note to
the one still unclaimed, and the row wakes the queue worker like any queue row.
Earlier stream history isn't replayed. If they want 0.7.8's behaviour, with no
intake rows, run `rig config set stream.intake off`.
- **The bundled openrig-core plugin goes from 0.1.24 to 0.1.25.** Its
seat-continuity-and-handover skill changed. Managed refresh preserves replaced
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
you still need from the preserved copy.
- **Where seat guidance goes now:**
- a fresh Pi seat gets OpenRig's guidance in its own agent folder's
`AGENTS.md`, unless that file or folder is a link;
- a fresh Codex seat on Codex 0.164.0 or later gets it as launch developer
instructions, not in the repository `AGENTS.md`, when no config layer
OpenRig reads sets `developer_instructions`;
- a Codex resume, fork, restore or handover successor, an older or unknown
Codex, and a config layer that sets `developer_instructions` or can't be read
still use the repository `AGENTS.md`;
- if only the person's shell startup sets `CODEX_HOME`, OpenRig reads
`~/.codex`, so a `developer_instructions` set in their home is replaced for
that session. Declare the same absolute path as `CODEX_HOME` in the member's
or rig's `env:` to have it checked. `docs/reference/agent-startup-guide.md`
("Codex guidance at launch") is the rule;
- a managed block a person edited by hand is kept and named
(`guidance_block_edited`), not replaced. Read the warning before you change
the block.
- **Slack:** on the first start after upgrading, the connector edits the asks
that ended earlier to show they were handled, at Slack's edit rate: about an
hour for 450 asks. `rig slack status` shows pending edits.
- **OpenShell:** rebuild a guest image built for 0.7.8 with this release's
helper; an older helper rejects the new startup step, and the error names the
rebuild.
### Runner seats
Section 7 of `docs/reference/harness-runner-contract.md` is the rule for
permissions, and section 4 for activity. Read them before writing or changing a
runner.
- **Typed words:** a reply of just yes or no counts as the agent's allow or
reject. Any other words choose nothing: OpenRig's default permits the request
when the agent offered an allow option, and the read result carries the words
whole as `said`. A runner may queue them for the agent as an ordinary prompt
after the current turn, never as the answer; one that drops them still keeps
the contract. The ask now tells the person this.
- **Permit records:** each permit OpenRig decides by default (no one to ask, or
an ask closed without a choice, including a row passed to an agent seat) is
recorded once, as a stream item from the seat, naming the tool call, why no
person answered and the option given. Read them with
`rig stream list --tag runner-permission-permit` or `--source <seat>`. A record
has no destination and never changes the permit. Stream intake reads past it, so
it opens no intake row and wakes no one.
- **The working beat:** while a prompt is outstanding and the agent is alive, a
runner may post `active` / `liveness` so a long call keeps reading working.
After delivering an answer or permit, it posts `active` /
`permission_resolved`. A runner that sends no beats behaves as before. The Pi
runner and the dsh example post it.
- **Check a runner** with
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`.
It now covers the beat, and no longer falsely fails a correct runner on a busy
machine.
- **The dsh example** reads `DSH_SEAT_BIN` and `DSH_SEAT_HOME` from the member's
`env:`, and section 8 names ACP's `session/resume`.
### OpenShell workers
0.7.9 includes a repair for the OpenShell worker that wasn't usable in 0.7.8,
but the worker still doesn't work in 0.7.9. Don't rely on this path in 0.7.9.
Startup reaches the guest through a hash-checked first-input file, and nothing
is typed through the host viewer. Readiness checks the guest's own execution,
and a failed owned delete reports the stage that failed. Release testing on
0.7.9 then found two more problems: the guest's Claude couldn't reach its model
(`SELF_SIGNED_CERT_IN_CHAIN`), because the guest launcher left out the CA
settings the OpenShell exec supplies, and deleting an owned environment never
sent the delete, because its inventory step misread a "Not Found" reply. 0.7.10's
fixes for both are described above. "Never grant the guest access to the
daemon's main API port (normally 7433)."
### Assistants connected through a grant service (experimental)
Experimental, unsupported, off by default, and at the person's own risk. With
`rig mcp serve --http --grant-service <url>` and a grant service the person
supplies and runs, a connected assistant can claim, update and park the rows
addressed to its own seat, and see its team with `rig_team`, within the scope its
existing tools have. Nothing wakes it: an item stays queued until its receiver
claims it. Name the outside service, and the exposure of serving MCP over HTTP,
to the person before you set this up. `docs/reference/hosted-members.md` is the
rule.
### Other changes to act on
- **Held messages:** a seat that starts, or takes over a seat, with held
messages is told how many there are. Read them with
`rig seat held-messages <seat>`.
- **Unconfirmed sends:** `rig send --verify` now reports unconfirmed and exits 1
when it can't see what its Enter did, and `rig walk` reports unconfirmed
instead of lost. Inspect the recipient before any resend.
- **Deadlines:** to accept a re-estimate without unclaiming the work, run
`rig queue update <id> --closure-required-at <ISO>`.
- **Shared workspaces:** any registered person can settle another's ask as
themselves, and the first committed answer wins; a later answer gets 409
`qitem_already_terminal` and records nothing. A person registered with no
connector binding is asked on Steering and in the Feed only.
- **Handover:** a fresh or rebuild successor's first turn lists its startup
files, role first. A successor that started without a required startup file is
marked in the seat list.
- **Ambiguous rig names:** when an active rig name matches more than one rig,
`rig env down`, `status` and `logs` list the matching rig IDs and stop, and
`rig doctor --spec` reports conformance as unknown. Use the rig ID.
- **Seat activity:** window samples older than their bound no longer decide a
seat's activity, so a seat whose pane can't be read reads unknown rather than
its last verdict. Treat unknown as unknown, not idle.
- **Launch options:** `specs.builtins_visible: false` hides built-in specs
(`rig specs ls --include-builtins` still lists them); `plugin_channels` gives
one Claude member its own `--channels`, untested with a real channel plugin.
- **Files:** `rig down` removes OpenRig's own additions from a working folder
once no live seat uses them. `rig export` with no `-o` writes
`rig-export.yaml` beside an existing `rig.yaml`. `rig workspace validate` exits
1 for malformed optional frontmatter.
- **Kernels:** a new default kernel starts in `<workspace.root>/kernel`.
Existing kernels stay where they are. `docs/reference/agent-startup-guide.md`
explains the optional move; a fresh launch there starts a new native
conversation, so ask the person before moving.
- **PATH:** if preflight warns that a Codex seat's login shell can't find `rig`,
follow its fix. `getting-started.md` explains the `~/.profile` line and how to
check it with `bash -lc 'command -v node rig'`.
### Limits to keep in view
- **OpenShell:** the worker doesn't work in 0.7.9 (above). A same-generation
reconciled launch opens no viewer, so run `rig environment connect` yourself;
non-text startup actions aren't supported on environment seats.
- **Open updates for a person** read a 1,001-row window, so a page can come back
short, marked `truncated`.
- **Slack:** a private channel needs the documented route, which isn't tested,
and direct messages aren't supported. The channel and people reads haven't
been run against a live Slack workspace. Redaction of the bot token from a
transport error can still leave a malformed token fragment shorter than six
characters, so review an error before sharing it.
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
isn't proof the connection is broken. `rig host ls` reads the ssh config with
`ssh -G`, which runs any `Match exec` command in it.
- **Shared asks:** a click's option written before its answer row can stand
beside or inside the closer's answer without the first clicker's authorship.
- **Permit records** last as long as stream retention keeps them. A permit a
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
an ask opened before 0.7.9, a record may omit the option's kind.
- **Existing seats** pick up Codex launch instructions and the single
openrig-core skill load at their next fresh launch. A `CLAUDE.md` or
`AGENTS.md` that already grew a line per launch isn't trimmed.
- **Codex hooks after an upgrade:** Codex sessions kept running across the
upgrade show "Hook failed — plugin contents changed; hook approval required"
until relaunched.
- **0.7.8's limits** on the dsh `env:` names, conformance false failures, typed
words, the README's operator address, a symlinked rebuild, the rebuild receipt,
a re-parked click and unrecorded default permits are fixed or changed. The
other 0.7.8 limits below, and the
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
apply.
---
## What changed in OpenRig 0.7.8
The changes from 0.7.7 to 0.7.8, published on 2026-11-05, for an install
upgrading from 0.7.7 or earlier.
### After the upgrade
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`.
- **Migrations 142 to 145** store a Claude seat's MCP opt-out, seat activity
grants, OpenShell environment bindings and a seat's chosen permission person.
- **The bundled openrig-core plugin goes from 0.1.23 to 0.1.24.** One skill line
changed. Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from the preserved copy.
### Runner seats: a person answers permission requests
Section 7 of `docs/reference/harness-runner-contract.md` is the rule. Read it
before writing or changing a runner.
- **Who is asked:**
- **the default person** (`rig gateway default`, or the only registered
person) gets an ordinary ask in the Feed and on Slack;
- **a chosen person,** after
`rig seat set-permissions-to <seat> --to <person> --reason "…"`. `--clear`
returns to the default;
- **no one:** when no one can be asked, the request is "Permitted at once,
with the agent's own allow option";
- **refusal** is opt-in, with `--deny`. `rig seat status` shows the choice.
- **What a person's answer does:** a click gives that option, a plain yes gives
the allow, and a plain no gives the reject. "A person's no is never a grant."
An unanswered request stays pending until the agent's turn ends.
- **The runner's protocol:**
- `POST /api/activity/permission-requests` submits the request;
- `GET …/<askId>?waitSeconds=25` reads the answer (25 at most);
- `POST …/<askId>/withdraw` withdraws it at the turn's end.
Deliver an answer once, only to the live request.
- **Check a runner** with
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`
from the installed package. It needs Node and tmux, and no daemon, model or
network.
- **The worked example** is the dsh runner under
`daemon/docs/reference/community-harness-examples/dsh/`.
- **A seat activity grant** lets a runner outside the host's hook setup report
activity for one seat and launch, without the install-wide hook token.
### OpenShell workers
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
**In our release testing the worker never became usable:** after the sandbox was
created and the native process started, `rig environment connect` failed when its
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
0.7.8; the later repairs are described above.
**What it needs:**
- NVIDIA OpenShell 0.1.6;
- a Linux image;
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
credentials aren't supported.
"Provider registration and image construction can incur costs", so name them to
the person before you start. "Never grant the guest access to the daemon's main
API port (normally 7433)."
### Other changes to act on
- **Mission following:** `rig terminal open mission:<id> --follow --provider herdr`
follows a mission's current owners, with the same Herdr path as slices.
- **Launch choices:**
- `claude_mcp: none` on a Claude member launches it without MCP servers;
- `rig config set terminal.tmux_server isolated` gives this OpenRig home its
own persistent tmux server.
- **Hosts:** `rig host edit` and `rig host remove` correct or retire a host
entry. Use them instead of editing `hosts.yaml` by hand.
- **Another user's daemon:** when `rig doctor` warns that the port is held by
another OS user's daemon, follow its fix (`rig config set daemon.port <free-port>`),
or set `OPENRIG_URL` to share that daemon on purpose.
- **tmux control mode:** with `OPENRIG_TMUX_CONTROL=1`, OpenRig starts a control
client only on tmux 3.7c or newer, and uses spawned clients otherwise.
- **Read-only tmux clients:** a submit key refused because of one goes through
the paste route.
- **Slack:** a typed reply to a decision parked on its person is recorded as the
answer. Read the person's words; don't treat the close as approval.
- **The TUI:** agent pages now show health findings and recently finished items,
and an agent spec's "not loaded in this view" means it wasn't read, not that
nothing runs it.
### Limits to keep in view
- ~~**No harness has run end to end under the runner contract yet.** The dsh
example and the conformance check were checked against stand-ins.~~ *Corrected
after release:* 0.7.8's release testing ran a real dsh 0.2.1-rc.2 through the
shipped dsh example runner for a person's allow-once and reject-once, and
through an outside runner, dsh-seat 0.3.0, for launch, queue work, multiline
input, activity, cancel and exact resume. The conformance check itself runs
against a stand-in agent.
- **The answer owner is a registered person,** and a colleague's answer is
followed on this host only. A request handed to another host waits as needs
input until the turn ends.
- **Permits and answers aren't recorded** beyond the runner's pane and the ask.
If the agent offers only `allow_always`, a permit makes its harness remember a
permission nobody chose.
- **Coming as follow-ups:** recording permits made when no one can be asked, and
passing typed words that aren't a plain yes or no to the agent.
- **The dsh example keeps one hold per seat:** two asks in one turn can leave one
waiting after the hold is released.
- **An OpenShell worker's activity reads unknown,** sampled from the pane, never
idle.
- **The [0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits still apply.**
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.10",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7262,7 +7262,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.10",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7300,7 +7300,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.10",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7342,7 +7342,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.10",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.10",
"private": true,
"license": "Apache-2.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.10",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
+53 -9
View File
@@ -1,7 +1,7 @@
import { spawn } from "node:child_process";
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer } from "./json-rpc-stdio.js";
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer, type JsonRpcErrorObject } from "./json-rpc-stdio.js";
import { findRunProcesses, RUN_MARKER } from "./run-processes.js";
// `rig acp run`: one queue row's task to one ACP agent turn, with the outcome back on that row. OpenRig carries the
@@ -48,10 +48,22 @@ export interface AcpRunOptions {
detachRequested?: Promise<void>;
/** How long to wait for the agent to exit after its stdin closes (default 5 s). */
exitWaitMs?: number;
/** A harness's own evidence that a `session/load` error means that session doesn't exist. Without it, or when it
* doesn't match, the load error reads `failed` with the agent's code and message: no code means missing by itself. */
missingSession?: (error: JsonRpcErrorObject, sessionId: string) => boolean;
/** Called once with what the stop found: the agent's exit and whether any process carrying the run's marker remains. */
onSettled?: (report: string) => void;
}
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown";
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string }
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown" | "missing";
/** What the agent itself reported; OpenRig measures none of it. */
export interface AcpUsage {
/** The prompt response's `usage` (an unstable ACP field), as the agent counted it. */
tokens?: { total?: number; input?: number; output?: number; thought?: number; cachedRead?: number; cachedWrite?: number };
/** The last `usage_update` cost: the agent's own cumulative figure for the whole session, in its unit. */
cost?: { amount: number; currency: string };
}
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string; usage?: AcpUsage }
/** Agent text that reports a failed turn while still ending it `end_turn` (OpenClaw #178057; its #44294 is open). */
export const INTERRUPTION_MARKERS = ["[OpenClaw interruption]"];
@@ -87,6 +99,7 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
let sessionId: string | undefined;
let text = "";
let usage: { used?: number; size?: number; cost?: unknown } | undefined;
const reported: AcpUsage = {};
let toolCalls = 0;
let lastTool = "";
let lastProgressNote = Date.now();
@@ -104,6 +117,8 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
progress(`\n[tool] ${lastTool}\n`);
} else if (update.sessionUpdate === "usage_update") {
usage = { used: update.used as number, size: update.size as number, cost: update.cost };
const cost = update.cost as { amount?: unknown; currency?: unknown } | null | undefined;
if (typeof cost?.amount === "number" && typeof cost.currency === "string") reported.cost = { amount: cost.amount, currency: cost.currency };
}
if (Date.now() - lastProgressNote >= 60_000) {
lastProgressNote = Date.now();
@@ -153,10 +168,11 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
const record = async (outcome: AcpOutcome): Promise<AcpOutcome> => {
const shown = outcome.text.length > NOTE_TEXT_LIMIT ? `${outcome.text.slice(0, NOTE_TEXT_LIMIT)}\n… (truncated; the full transcript is in ${options.logPath})` : outcome.text;
const withUsage: AcpOutcome = { ...outcome, usage: { ...reported } };
await queue.update(row.qitemId, {
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.`,
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.\n${describeUsage(withUsage.usage)}`,
});
return outcome;
return withUsage;
};
try {
@@ -166,7 +182,12 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
const loads = init.agentCapabilities?.loadSession === true;
if (options.loadSession) {
if (!loads) return await record({ kind: "failed", summary: `the agent doesn't advertise loadSession, so session ${options.loadSession} wasn't loaded and no new session was started`, text: "" });
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
try {
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
} catch (error) {
if (!(error instanceof JsonRpcRequestError) || !options.missingSession?.(error.error, options.loadSession)) throw error;
return await record({ kind: "missing", text: "", summary: `session ${options.loadSession} doesn't exist for this agent (session/load returned ${describeError(error.error)}); no new session was started` });
}
sessionId = options.loadSession;
} else {
sessionId = (await peer.request("session/new", { cwd: options.cwd, mcpServers: [] }) as { sessionId: string }).sessionId;
@@ -190,13 +211,18 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
void options.detachRequested?.then(() => { void peer.end(0); reject(new DetachedBeforeTurnEnd()); });
});
detached.catch(() => {});
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string };
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string; usage?: Record<string, unknown> };
turnOver = true;
const tokens = response.usage;
if (tokens && typeof tokens === "object") {
const count = (key: string) => (typeof tokens[key] === "number" ? tokens[key] as number : undefined);
reported.tokens = { total: count("totalTokens"), input: count("inputTokens"), output: count("outputTokens"), thought: count("thoughtTokens"), cachedRead: count("cachedReadTokens"), cachedWrite: count("cachedWriteTokens") };
}
return await record(classify(response.stopReason, text, usage, cancelRequestedAt, sessionId));
} catch (error) {
turnOver = true;
if (error instanceof JsonRpcRequestError) {
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${error.error.code} ${error.error.message}${error.error.data === undefined ? "" : ` ${JSON.stringify(error.error.data)}`}` });
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${describeError(error.error)}` });
}
if (error instanceof DetachedBeforeTurnEnd) {
return await record({ kind: "unknown", sessionId, text, summary: "detached before the turn ended; the agent may still be working" });
@@ -212,7 +238,9 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
const settled = left === undefined ? "unknown (the process table couldn't be read)"
: left.length === 0 ? "settled: no process carrying this run's marker remains"
: `incomplete: ${left.length} process(es) carrying this run's marker still run (${left.map((p) => p.pid).join(", ")}); stop them with the runtime's own stop, or rig acp stop ${options.runId} -- <its stop command>`;
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}` }).catch(() => {});
const report = `agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}`;
options.onSettled?.(report);
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: ${report}` }).catch(() => {});
peer.release(); // a still-running agent keeps running, but holds nothing that keeps this process alive
}
}
@@ -231,6 +259,22 @@ export function classify(stopReason: string | undefined, text: string, usage: {
return { kind: "completed", sessionId, text, summary: "the agent ended its turn (end_turn)" };
}
const describeError = (error: JsonRpcErrorObject) => `${error.code} ${error.message}${error.data === undefined ? "" : ` ${JSON.stringify(error.data)}`}`;
/** Usage and cost as the agent reported them, with where each came from; a figure it didn't report reads unknown. */
export function describeUsage(usage: AcpUsage | undefined): string {
const t = usage?.tokens;
const parts = t ? [["in", t.input], ["out", t.output], ["thought", t.thought], ["cache read", t.cachedRead], ["cache write", t.cachedWrite]]
.filter(([, n]) => n !== undefined).map(([label, n]) => `${n} ${label}`) : [];
const tokens = t?.total !== undefined || parts.length > 0
? `${t?.total !== undefined ? `${t.total} tokens` : "tokens"}${parts.length ? ` (${parts.join(", ")})` : ""}, from the agent's prompt response`
: "tokens unknown (the agent's prompt response reported none)";
const cost = usage?.cost
? `cost ${usage.cost.amount} ${usage.cost.currency}, the agent's own cumulative figure for the session (usage_update)`
: "cost unknown (the agent reported none)";
return `Usage as the agent reported it: ${tokens}; ${cost}.`;
}
/** An owner answers with the offered option's id; its kind (allow_once …) works when only one option has it. */
export function chooseOption(offered: PermissionOption[], answer: string | null | undefined): PermissionOption | undefined {
const value = answer?.trim();
+4 -3
View File
@@ -9,9 +9,10 @@ export const RUN_MARKER = "OPENRIG_ACP_RUN";
/** Only the pid: on macOS the listing carries the environment, which may hold secrets, so nothing else leaves here. */
export interface RunProcess { pid: number }
/** The caller's processes carrying this run's marker. Linux reads /proc; elsewhere `ps e` shows own environments. */
export async function findRunProcesses(runId: string): Promise<RunProcess[]> {
const marker = `${RUN_MARKER}=${runId}`;
/** The caller's processes carrying this run's marker (`rig exec` names its connector's own). Linux reads /proc;
* elsewhere `ps e` shows own environments. */
export async function findRunProcesses(runId: string, markerName = RUN_MARKER): Promise<RunProcess[]> {
const marker = `${markerName}=${runId}`;
if (process.platform === "linux") {
const found: RunProcess[] = [];
for (const entry of await readdir("/proc")) {
+13
View File
@@ -62,3 +62,16 @@ export function writeTextAtomically(path: string, content: string, label = "file
if (owned) try { unlinkSync(temporary); } catch { /* Renamed or already removed. */ }
}
}
/** Create a file, failing with EEXIST rather than replacing anything already at the path, even a dangling link. */
export function writeNewText(path: string, content: string): void {
const fd = openSync(path, "wx", 0o666);
try {
writeFileSync(fd, content, "utf-8");
} catch (error) {
try { unlinkSync(path); } catch { /* Report the write failure. */ }
throw error;
} finally {
closeSync(fd);
}
}
+2 -2
View File
@@ -12,8 +12,8 @@ import { findRunProcesses } from "../acp/run-processes.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const runsDir = () => join(getOpenRigHome(), "acp", "runs");
const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
export const runsDir = () => join(getOpenRigHome(), "acp", "runs");
export const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
interface RunFile { runId: string; qitemId: string; pid: number; processStart?: string; agent: string[]; startedAt: string; endedAt?: string }
+3 -1
View File
@@ -45,7 +45,7 @@ export function agentCommand(depsOverride?: AgentDeps): Command {
const client = deps.clientFactory(getDaemonUrl(status));
const res = await client.postText<{ valid?: boolean; errors?: string[] }>("/api/agents/validate", yaml);
const res = await client.postText<{ valid?: boolean; errors?: string[]; advisories?: string[] }>("/api/agents/validate", yaml);
if (opts.json) {
console.log(JSON.stringify(res.data));
@@ -65,6 +65,8 @@ export function agentCommand(depsOverride?: AgentDeps): Command {
}
const data = res.data;
// Advisories are printed whether or not the spec is valid; they never make it invalid.
for (const advisory of data.advisories ?? []) console.error(`⚠ agent spec advisory: ${advisory}`);
if (data.valid) {
const name = yamlScalar(yaml, "name") ?? "unknown";
const version = yamlScalar(yaml, "version") ?? "unknown";
+6 -2
View File
@@ -222,12 +222,16 @@ async function runCrossHostBroadcast(
* local route does, so per-target honesty prints identically. A partial
* fan-out sets a non-zero exit either way. */
function renderBroadcastResults(data: Record<string, unknown>): void {
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; managedCompaction?: { notice?: unknown } }>) ?? [];
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; inputPhase?: string; sent?: boolean; managedCompaction?: { notice?: unknown } }>) ?? [];
for (const r of results) {
if (r.ok) {
console.log(`${r.sessionName}: sent`);
} else if (r.inputPhase === "attempted" || r.inputPhase === "written" || r.sent === true) {
// The daemon records this recipient's receipt as indeterminate (#2507): its text or Enter may have reached it.
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}UNCONFIRMED — ${r.error ?? "unknown error"}; the text may have reached it, so check with rig capture before resending`);
} else {
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
// A target that resolved to no sessions has no name to report.
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`);
}
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
}
+8
View File
@@ -69,6 +69,7 @@ Keys:
topology.root the topology tree root (instance altitude at its top; default $OPENRIG_HOME/topology)
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
context.system_world default System World, replacement manifest path, or disabled
specs.builtins_visible show built-ins in spec inventory (default true)
skills.root versioned skill catalog (default $OPENRIG_HOME/skills)
skills.router_line.enabled the person's yes to the seat-skills line (one line plus OpenRig's markers) in AGENTS.md / CLAUDE.md (default false)
onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on)
@@ -103,6 +104,13 @@ Keys:
retention.* enabled, transitions_days, watchdog_days,
watchdog_keep_per_job, batch_size
launch.non_interruptive accept harness first-launch warnings at full bypass for new rigs (default off)
launch.codex_login_shell defer to Codex's own login-shell setting in managed Codex seats (default off)
Off adds allow_login_shell=false: tool commands inherit the seat's environment, so plain rig
works wherever OpenRig is installed. Inherited setup isn't re-run per command: setup only in
login or rc files (including a tmux default-command that isn't a login shell) and shell
functions or aliases may be absent. On omits that override; it doesn't force login shells,
and a login file that sets PATH outright can then hide rig. Applies at each seat's next
launch; a restart in place keeps the running choice.
terminal.inherit_tmux_environment inherit stored tmux global values for new agents and successors (default off)
POSIX launches otherwise start from daemon + seat environment, preserving shell startup.
Turn on for custom setups that intentionally rely on tmux globals. Running agents are unchanged.
+1
View File
@@ -194,6 +194,7 @@ async function installPackFromUrl(
// Re-validate the on-disk staged pack, then publish atomically.
validateContextPackManifestForInstall(join(staging, "manifest.yaml"));
assertTreeHasNoSymlinks(staging);
mkdirSync(dirname(targetDir), { recursive: true });
renameSync(staging, targetDir);
return { targetDir, installName };
} catch (err) {
+67 -15
View File
@@ -6,6 +6,7 @@ import { execSync } from "node:child_process";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { DaemonClient, ForeignLocalDaemonError, formatDaemonHostForUrl } from "../client.js";
import { ConfigStore } from "../config-store.js";
import { fetchWithTimeout } from "../fetch-with-timeout.js";
import { buildWritableHomeCheck } from "../system-preflight.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
@@ -45,7 +46,7 @@ export interface DoctorDeps {
/** The caller's effective user id, compared with the one the port's daemon reports. Absent: this process's own;
* null: this platform has none. */
callerUid?: number | null;
/** How long the port's /healthz body may take to arrive (default 2000 ms); after that its owner is unknown. */
/** How long each part of /healthz and the cmux status response may take (default 2000 ms). */
ownerReportTimeoutMs?: number;
/**
* Build B — path to a rig spec to check against the RUNNING rig of the same name (`--spec`).
@@ -61,6 +62,7 @@ export interface DoctorDeps {
}
const DEFAULT_PORT = 7433;
const DOCTOR_HTTP_TIMEOUT_MS = 2000;
const CMUX_SOCKET_CONTROL_FIX = 'In cmux Settings > Automation, select "Automation mode" for Socket Control Mode, or set automation.socketControlMode to "automation" in ~/.config/cmux/cmux.json (older versions use ~/.config/cmux/settings.json; current cmux reads that legacy file only for keys absent from cmux.json). cmux reloads saved settings automatically; rerun `rig doctor` to re-probe without restarting the daemon.';
/** The port's OpenRig daemon, judged by the OS user it reports. /healthz's pid and euid are the daemon's own report,
@@ -301,6 +303,15 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
const daemonPort = config.daemon.port ?? DEFAULT_PORT;
const daemonBase = `http://${formatDaemonHostForUrl(daemonHost)}:${daemonPort}`;
const fetchFn = deps.fetch ?? globalThis.fetch;
const healthHeaders = async (controller: AbortController) => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
fetchFn(`${daemonBase}/healthz`, { signal: controller.signal }),
new Promise<null>(resolve => { timer = setTimeout(() => { resolve(null); controller.abort(); }, deps.ownerReportTimeoutMs ?? 2000); }),
]);
} finally { clearTimeout(timer); }
};
const portCheck = deps.checkPort(daemonPort, daemonHost).then(async (available): Promise<DoctorCheck> => {
if (available) {
return { name: "port", status: "pass", message: `Port ${daemonHost}:${daemonPort} available.` };
@@ -308,7 +319,13 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
// Port in use — check if it's an OpenRig daemon via healthz, and whose.
const owner = new AbortController();
try {
const res = await fetchFn(`${daemonBase}/healthz`, { signal: owner.signal });
const res = await healthHeaders(owner);
if (!res) return {
name: "port", status: "warn",
message: `Port ${daemonHost}:${daemonPort} is in use by an unknown listener.`,
reason: "Its /healthz response headers did not arrive in time, so doctor cannot identify it.",
fix: `Check the listener on port ${daemonPort} or configure a free port for the daemon.`,
};
if (res.ok) return await listenerOwnerCheck(`${daemonHost}:${daemonPort}`, daemonBase, res, () => owner.abort(), deps);
} catch { /* not our daemon */ }
finally { owner.abort(); }
@@ -325,19 +342,27 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
const asyncChecks: Promise<DoctorCheck>[] = [portCheck];
if (shellCmuxPassed) {
const daemonCmuxCheck = (async (): Promise<DoctorCheck> => {
const health = new AbortController();
try {
const healthRes = await fetchFn(`${daemonBase}/healthz`);
if (!healthRes.ok) {
const healthRes = await healthHeaders(health);
if (!healthRes?.ok) {
return { name: "cmux_daemon", status: "skipped", message: "Daemon healthz not ok. Skipping daemon cmux check." };
}
} catch {
return { name: "cmux_daemon", status: "skipped", message: "Daemon not reachable. Skipping daemon cmux check." };
}
} finally { health.abort(); }
try {
const cmuxRes = await fetchFn(`${daemonBase}/api/adapters/cmux/status`);
if (cmuxRes.ok && cmuxRes.json) {
const data = (await cmuxRes.json()) as { available?: boolean };
let data: { available?: boolean } = {};
const cmuxRes = await fetchWithTimeout(fetchFn as typeof fetch, `${daemonBase}/api/adapters/cmux/status`, {}, {
timeoutMs: deps.ownerReportTimeoutMs ?? DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's cmux status read timed out.",
consumeResponse: async res => {
if (res.ok && res.json) data = (await res.json()) as { available?: boolean };
else await res.body?.cancel();
},
});
if (cmuxRes.ok) {
if (data.available) {
return { name: "cmux_daemon", status: "pass", message: "Daemon cmux control available." };
}
@@ -398,6 +423,12 @@ async function buildSpecConformanceCheck(deps: DoctorDeps): Promise<DoctorCheck>
let liveIds: string[] | null;
try { liveIds = deps.fetchLiveLogicalIds ? await deps.fetchLiveLogicalIds(rigName) : null; }
catch (error) {
if (error instanceof AmbiguousLiveRigError) return {
name, status: "skipped",
message: `Multiple live rigs match '${rigName}': ${error.ids.join(", ")}; conformance unknown.`,
reason: "The spec name does not identify one live rig, so no topology was compared.",
fix: "Inspect the matching rig IDs with rig ps --json; use rig ps --nodes -A --json to inspect nodes.",
};
if (error instanceof ForeignLocalDaemonError) return { name, status: "fail", message: error.message };
throw error;
}
@@ -482,22 +513,43 @@ function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
}
}
class AmbiguousLiveRigError extends Error {
constructor(readonly ids: string[]) { super("Multiple live rigs share the spec name"); }
}
/** Raw topology reads retain their existing headers; the owner policy is shared with the client. */
export async function readLiveLogicalIds(base: string, rigName: string, ownerClient = new DaemonClient(base)): Promise<string[] | null> {
try {
await ownerClient.assertLocalOwner();
const rigsRes = await fetch(`${base}/api/rigs`);
let rigs: Array<{ id?: string; name?: string }> = [];
const rigsRes = await fetchWithTimeout(fetch, `${base}/api/rigs`, {}, {
timeoutMs: DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's live rig read timed out.",
consumeResponse: async res => {
if (res.ok) rigs = (await res.json()) as typeof rigs;
else await res.body?.cancel();
},
});
if (!rigsRes.ok) return null;
const rigs = (await rigsRes.json()) as Array<{ id?: string; name?: string }>;
const rig = rigs.find((r) => r.name === rigName);
const matches = rigs.filter((r) => r.name === rigName);
if (matches.length > 1) throw new AmbiguousLiveRigError(matches.map(r => r.id ?? "(missing id)"));
if (matches.length === 0) return null;
const rig = matches[0];
if (!rig?.id) return null;
await ownerClient.assertLocalOwner();
const nodesRes = await fetch(`${base}/api/rigs/${rig.id}/nodes`);
let nodes: Array<{ logicalId?: string | null }> = [];
const nodesRes = await fetchWithTimeout(fetch, `${base}/api/rigs/${rig.id}/nodes`, {}, {
timeoutMs: DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's live node read timed out.",
consumeResponse: async res => {
if (res.ok) nodes = (await res.json()) as typeof nodes;
else await res.body?.cancel();
},
});
if (!nodesRes.ok) return null;
const nodes = (await nodesRes.json()) as Array<{ logicalId?: string | null }>;
return nodes.map((n) => n.logicalId ?? "");
} catch (error) {
if (error instanceof ForeignLocalDaemonError) throw error;
if (error instanceof ForeignLocalDaemonError || error instanceof AmbiguousLiveRigError) throw error;
return null;
}
}
@@ -529,7 +581,7 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
const base = `http://${formatDaemonHostForUrl(cfg.daemon.host ?? "127.0.0.1")}:${cfg.daemon.port ?? DEFAULT_PORT}`;
return await readLiveLogicalIds(base, rigName);
} catch (error) {
if (error instanceof ForeignLocalDaemonError) throw error;
if (error instanceof ForeignLocalDaemonError || error instanceof AmbiguousLiveRigError) throw error;
return null;
}
},
+11
View File
@@ -88,6 +88,8 @@ interface TeardownResult {
keptSkills?: string[];
/** Managed guidance blocks a person edited, kept in their file as `<file>: <block id>`. */
keptGuidance?: string[];
/** Files in a Claude seat's cwd that keep OpenRig's additions because down couldn't safely change them, as `<file>: <why>`. */
keptFiles?: string[];
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
leftRunning?: string[];
paneOnly?: string[];
@@ -145,6 +147,11 @@ Before running it, check who is working (rig ps --nodes --rig <rig>) and that th
don't stop a team you didn't start unless asked. OpenRig snapshots the rig first (best-effort), and
rig up <rig> --existing brings it back, resuming each seat's conversation where its harness allows. With
--delete the rig record is removed too, so it can't be brought back that way.
Once no live seat uses a working folder, rig down also takes back what OpenRig added there: its
guidance blocks, and in a Claude seat's folder the status line, activity hooks and context collector.
It keeps and lists an edited skills-line block, edited team skill files, and settings or a collector
that don't match what OpenRig wrote or that another seat shares by a link. Any other guidance block
goes even if someone edited it. rig up <rig> --existing puts the guidance back.
When your resolved seat belongs to this rig, confirm before stopping it. Without a terminal,
the command warns and exits without stopping; re-run with --yes to confirm explicitly.`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -295,6 +302,10 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
console.log(`Kept ${result.keptGuidance.length} edited OpenRig block(s) in guidance files; remove them by hand if you don't want them:`);
for (const block of result.keptGuidance) console.log(` ${block}`);
}
if (result.keptFiles && result.keptFiles.length > 0) {
console.log(`Kept ${result.keptFiles.length} file(s) that still hold OpenRig's additions; check them by hand:`);
for (const file of result.keptFiles) console.log(` ${file}`);
}
if (result.leftRunning && result.leftRunning.length > 0) {
console.log(`Not stopped: ${result.leftRunning.join(", ")}. Joined with rig attach as an external CLI, which OpenRig doesn't stop or check.`);
+8 -3
View File
@@ -30,9 +30,14 @@ Examples:
throw new Error(error ?? `Failed to list rigs (HTTP ${summaries.status})`);
}
if (!Array.isArray(summaries.data)) throw new Error("The daemon returned an invalid rig summary.");
const match = summaries.data.find((r) => r.name === rigRef || r.id === rigRef);
if (!match) throw new Error(`Rig '${rigRef}' not found`);
return match.id;
const exactId = summaries.data.find((r) => r.id === rigRef);
if (exactId) return exactId.id;
const matches = summaries.data.filter((r) => r.name === rigRef);
if (matches.length > 1) {
throw new Error(`'${rigRef}' matches ${matches.length} rigs (${matches.map((r) => r.id).join(", ")}). Nothing was done; pass one of these ids instead.`);
}
if (matches.length === 0) throw new Error(`Rig '${rigRef}' not found`);
return matches[0]!.id;
}
// rig env status <rig>
+4 -2
View File
@@ -128,8 +128,10 @@ export function environmentCommand(override?: EnvironmentCommandDeps): Command {
.action(action(async (binding: string, opts: { live?: boolean }) => report(checked(await (await client()).get<Record<string, any>>(`/api/environments/${encodeURIComponent(binding)}${opts.live ? "?live=true" : ""}`)))));
cmd.command("connect").argument("<binding>").requiredOption("--generation <id>").description("Open only the bound execution's viewer; closing it does not prove native or compute stopped")
.action(action(async (id: string, opts: { generation: string }) => {
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`));
checked(await c.post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/validate`, { generation: opts.generation }));
// Launch and the guest latch already verify all projected files. Opening a viewer needs only one live
// resource lookup (the driver bounds it at 30s), not another serial validation of every startup file.
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`, { timeoutMs: 35_000 }));
if (b.generation !== opts.generation) throw new Error("The selected environment generation changed; no viewer was opened");
const args = ["--gateway", b.placement.gateway, "--workspace", b.placement.workspace, "sandbox", "connect", b.placement.name];
await new Promise<void>((resolveExit, reject) => {
const child = spawn("openshell", args, { stdio: "inherit" });
+581
View File
@@ -0,0 +1,581 @@
import { Command } from "commander";
import { spawn } from "node:child_process";
import { closeSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { delimiter, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { DaemonClient } from "../client.js";
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { describeUsage, runAcpAssignment, type AcpOutcomeKind, type AcpQueue, type AcpUsage } from "../acp/connector.js";
import type { JsonRpcErrorObject } from "../acp/json-rpc-stdio.js";
import { findRunProcesses, RUN_MARKER } from "../acp/run-processes.js";
import { daemonAcpQueue, newId, processStart, runsDir } from "./acp.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
// `rig exec`: one queue row's task as one turn on a named, version-qualified ACP harness (Claude Code or Codex through
// their maintained adapters), run by a detached connector that outlives the calling CLI. The connector owns the run,
// the run file is its registry, and the row stays the custody record: the outcome is the agent's report, and accepting
// the work stays with the row's custodian. `rig acp run` is the synchronous form and is unchanged.
/** Set only in the connector's own environment, so a run's connector can be found before it records its pid. */
export const CONNECTOR_MARKER = "OPENRIG_EXEC_CONNECTOR";
export interface Harness {
name: string;
/** The adapter package, and the version this configuration was checked with. */
package: string;
qualified: string;
bin: string;
native: { package: string; label: string; versionField?: string };
/** Environment the caller may set to choose what runs or what the agent asks; passed through untouched. */
env: string[];
reject: string;
/** The adapter's own evidence that a `session/load` error means the session doesn't exist. */
missingSession: (error: JsonRpcErrorObject, sessionId: string) => boolean;
}
/** The ACP SDK's `sessionNotFound`: -32002 with `data.sessionId` naming the session asked for. A bare -32002 isn't. */
const sessionNotFound = (error: JsonRpcErrorObject, sessionId: string) =>
error.code === -32002 && (error.data as { sessionId?: unknown } | null | undefined)?.sessionId === sessionId;
export const HARNESSES: Record<string, Harness> = {
claude: {
name: "claude", package: "@agentclientprotocol/claude-agent-acp", qualified: "0.101.0", bin: "claude-agent-acp",
native: { package: "@anthropic-ai/claude-agent-sdk", label: "Claude Code", versionField: "claudeCodeVersion" },
env: ["CLAUDE_CODE_EXECUTABLE", "ANTHROPIC_MODEL", "CLAUDE_CONFIG_DIR"],
reject: "a reject refuses that one action, and the turn continues",
missingSession: sessionNotFound,
},
codex: {
name: "codex", package: "@agentclientprotocol/codex-acp", qualified: "2.5.0", bin: "codex-acp",
native: { package: "@openai/codex", label: "Codex" },
env: ["INITIAL_AGENT_MODE", "CODEX_HOME", "CODEX_CONFIG"],
reject: "its only refusal, cancel, ends the whole turn",
// codex-acp 2.5.0 passes a failed thread resume through as -32603 carrying Codex's own text, which the adapter's
// own code reads as a missing rollout. Any other -32603 is an internal error, not a missing session.
missingSession: (error, sessionId) => sessionNotFound(error, sessionId) || (error.code === -32603
&& JSON.stringify(error.data ?? "").includes("no rollout found for thread id") && JSON.stringify(error.data).includes(sessionId)),
},
};
export interface ResolvedHarness {
harness: Harness;
/** The adapter's program as found on PATH; it runs as-is. */
command?: string;
adapterVersion?: string;
native: string;
problem?: string;
}
function readJson(file: string): Record<string, unknown> | undefined {
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>; } catch { return undefined; }
}
function onPath(bin: string, path = ""): string | undefined {
for (const dir of path.split(delimiter)) {
if (!dir) continue;
try { if (statSync(join(dir, bin)).isFile()) return join(dir, bin); } catch { /* not in this folder */ }
}
return undefined;
}
/** The installed package a file belongs to: the nearest folder above it whose package.json has that name. */
function packageRoot(file: string, name: string): string | undefined {
for (let dir = dirname(file); dir !== dirname(dir); dir = dirname(dir)) {
if (readJson(join(dir, "package.json"))?.name === name) return dir;
}
return undefined;
}
/** Node's lookup from the adapter's folder, reading package.json directly (a package's exports map may not list it). */
function dependencyManifest(from: string, name: string): Record<string, unknown> | undefined {
for (let dir = from; dir !== dirname(dir); dir = dirname(dir)) {
const manifest = readJson(join(dir, "node_modules", name, "package.json"));
if (manifest) return manifest;
}
return undefined;
}
export function resolveHarness(harness: Harness, env: NodeJS.ProcessEnv = process.env): ResolvedHarness {
const found = onPath(harness.bin, env.PATH);
if (!found) {
return { harness, native: "unknown", problem: `${harness.bin} isn't on PATH; install it with npm install --prefix <dir> ${harness.package}@${harness.qualified} and put <dir>/node_modules/.bin on PATH` };
}
const root = packageRoot(realpathSync(found), harness.package);
const version = root ? readJson(join(root, "package.json"))?.version : undefined;
const manifest = root ? dependencyManifest(root, harness.native.package) : undefined;
const field = harness.native.versionField ? manifest?.[harness.native.versionField] : undefined;
const bundled = !manifest ? `${harness.native.label}: ${harness.native.package} wasn't found beside the adapter`
: field !== undefined ? `${harness.native.label} ${String(field)}, from ${harness.native.package} ${String(manifest.version)}`
: `${harness.native.label} ${String(manifest.version)} (${harness.native.package})`;
const native = harness.name === "claude" && env.CLAUDE_CODE_EXECUTABLE
? `the Claude Code at ${env.CLAUDE_CODE_EXECUTABLE} (CLAUDE_CODE_EXECUTABLE; its version isn't read here), not ${bundled}`
: bundled;
return { harness, command: found, adapterVersion: typeof version === "string" ? version : undefined, native };
}
/** The harness's caller-set environment, by name; CODEX_CONFIG's contents aren't shown. */
function callerEnv(harness: Harness, env: NodeJS.ProcessEnv = process.env): string[] {
return harness.env.filter((name) => env[name] !== undefined).map((name) => (name === "CODEX_CONFIG" ? `${name} (set)` : `${name}=${env[name]}`));
}
export function describeHarness(resolved: ResolvedHarness): string {
const { harness } = resolved;
const installed = resolved.problem ? `not installed: ${resolved.problem}`
: `${harness.package} ${resolved.adapterVersion ?? "(version unreadable)"} at ${resolved.command}${resolved.adapterVersion === harness.qualified ? "" : `; checked with ${harness.qualified}`}`;
const set = callerEnv(harness);
return [
`${harness.name}: ${installed}`,
` runs: ${resolved.native}`,
` answers: ${harness.reject}`,
` permissions: the agent's own mode decides what it asks; rig exec adds no policy. Read here: ${harness.env.join(", ")}${set.length ? ` (set: ${set.join(", ")})` : " (none set)"}`,
].join("\n");
}
type ExecState = "requested" | "started" | "finished" | "interrupted";
/** A run's registry entry. `pid` is whichever process owns the run now: the requesting CLI until the connector records itself. */
export interface ExecRun {
runId: string;
qitemId: string;
harness: string;
agent: string[];
cwd: string;
self: string;
permissionsTo?: string;
loadSession?: string;
pollMs: number;
adapterVersion?: string;
native: string;
state: ExecState;
pid: number;
processStart?: string;
requestedAt: string;
startedAt?: string;
endedAt?: string;
outcome?: AcpOutcomeKind;
summary?: string;
usage?: AcpUsage;
settle?: string;
transcript: string;
connectorLog: string;
}
const now = () => new Date().toISOString();
const runPath = (runId: string) => join(runsDir(), `${runId}.json`);
/** A row's claims: one file per generation, `000001.json` upward, each published whole and never removed. */
const rowDir = (qitemId: string) => join(runsDir(), "rows", qitemId.replace(/[^A-Za-z0-9._-]/g, "_"));
/** A run's two transitions, each published once: `start` by the connector that runs it, or by whoever settles a run
* that never started; `end` by whichever records how a started run ended. The slots are the run's state; the record
* file is a copy written after each, for readers like `rig acp cancel`, and may lag a crash. */
const slotPath = (runId: string, slot: "start" | "end") => join(runsDir(), `${runId}.${slot}.json`);
type StartSlot = { by: "connector"; pid: number; processStart?: string; startedAt: string } | { by: "settled"; ended: Partial<ExecRun> };
type EndSlot = { by: "connector" | "settled"; ended: Partial<ExecRun> };
let drafts = 0;
/** Publishes `content` at `target` whole, and only if nothing is there: a hard link fails if the target exists. */
function publishOnce(target: string, content: unknown): boolean {
const draft = `${target}.${process.pid}.${++drafts}.tmp`;
writeFileSync(draft, `${JSON.stringify(content)}\n`);
try {
linkSync(draft, target);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
throw error;
} finally { rmSync(draft, { force: true }); }
}
export function readRun(runId: string): ExecRun | undefined {
const base = readJson(runPath(runId)) as ExecRun | undefined;
if (!base || typeof base.state !== "string") return undefined;
const start = readJson(slotPath(runId, "start")) as StartSlot | undefined;
if (start?.by === "settled") return { ...base, ...start.ended };
let run: ExecRun = start?.by === "connector"
? { ...base, state: "started", pid: start.pid, processStart: start.processStart, startedAt: start.startedAt }
: base;
const end = readJson(slotPath(runId, "end")) as EndSlot | undefined;
if (end) run = { ...run, ...end.ended };
return run;
}
function writeRun(run: ExecRun): void {
const tmp = `${runPath(run.runId)}.${process.pid}.tmp`;
writeFileSync(tmp, `${JSON.stringify(run)}\n`);
renameSync(tmp, runPath(run.runId));
}
function topClaim(qitemId: string): { generation: number; runId?: string } | undefined {
let names: string[];
try { names = readdirSync(rowDir(qitemId)); } catch { return undefined; }
const generation = Math.max(0, ...names.filter((name) => /^\d{6}\.json$/.test(name)).map((name) => Number(name.slice(0, 6))));
if (generation === 0) return undefined;
const runId = readJson(join(rowDir(qitemId), `${String(generation).padStart(6, "0")}.json`))?.runId;
return { generation, runId: typeof runId === "string" ? runId : undefined };
}
export const rowRun = (qitemId: string): string | undefined => topClaim(qitemId)?.runId;
/** Makes `runId` the row's run: when the row has none, or when its current run is exactly `previous`. The next
* generation's file is published with a hard link, which fails if it exists, so of two callers that read the same
* current run only one claims, and nothing is ever moved aside. `afterRead` lets a test act between the read and
* the publish. */
export function claimRow(qitemId: string, runId: string, previous?: string, afterRead?: () => void): boolean {
const dir = rowDir(qitemId);
mkdirSync(dir, { recursive: true });
const top = topClaim(qitemId);
if (top?.runId !== previous) return false;
afterRead?.();
return publishOnce(join(dir, `${String((top?.generation ?? 0) + 1).padStart(6, "0")}.json`), { runId, qitemId, replaces: previous ?? null });
}
type Liveness = "live" | "dead" | "unknown";
/** A started run is live while its recorded process is still the one that recorded itself. A requested run is live
* only while a process carries its connector marker: the record then names the requester, which isn't the connector
* and may itself be waiting. */
async function liveness(run: ExecRun): Promise<Liveness> {
if (run.endedAt) return "dead";
if (run.state !== "requested" && run.processStart !== undefined && processStart(run.pid) === run.processStart) return "live";
const found = await findRunProcesses(run.runId, CONNECTOR_MARKER).catch(() => undefined);
return found === undefined ? "unknown" : found.length > 0 ? "live" : "dead";
}
/** Ends `run` as `ended`, exclusively: a run that hasn't started by claiming its start slot, so its connector can no
* longer start; a started run by claiming its end slot, so its connector can no longer record an outcome over this.
* Only the winner writes the copy and the row note; a loser gets the run as whoever won left it. */
export async function settleRun(run: ExecRun, ended: Partial<ExecRun>, note?: string, queue?: () => Promise<AcpQueue | undefined>): Promise<{ won: boolean; run: ExecRun }> {
const won = run.state === "requested"
? publishOnce(slotPath(run.runId, "start"), { by: "settled", ended } satisfies StartSlot)
: publishOnce(slotPath(run.runId, "end"), { by: "settled", ended } satisfies EndSlot);
const after = readRun(run.runId) ?? run;
if (!won) return { won, run: after };
writeRun(after);
const q = note ? await queue?.().catch(() => undefined) : undefined;
await q?.update(run.qitemId, { note: note! }).catch(() => {});
return { won, run: after };
}
/** A run whose connector is gone without recording an outcome reads interrupted, outcome unknown, with one row note. */
export async function reconcileRun(run: ExecRun, queue?: () => Promise<AcpQueue | undefined>, connectorExit?: string): Promise<ExecRun> {
if (run.endedAt || (await liveness(run)) !== "dead") return run;
const current = readRun(run.runId) ?? run;
if (current.endedAt || (current.state !== run.state && (await liveness(current)) !== "dead")) return current;
const left = await findRunProcesses(run.runId, RUN_MARKER).catch(() => undefined);
const agent = left === undefined ? "whether an agent process remains is unknown (the process table couldn't be read)"
: left.length === 0 ? "no process carrying the run's marker remains"
: `${left.length} process(es) carrying the run's marker still run (${left.map((p) => p.pid).join(", ")})`;
const summary = current.state === "requested"
? `interrupted: its connector ${connectorExit ?? "isn't running"} and never recorded itself, so no turn was sent; ${agent}; its log is ${current.connectorLog}`
: `interrupted: its connector ended without recording an outcome, so the outcome is unknown; ${agent}`;
const settled = await settleRun(current, { state: "interrupted", outcome: "unknown", summary, endedAt: now() },
`rig exec run ${run.runId} ${summary}. Its transcript: ${run.transcript}`, queue);
return settled.run;
}
export function describeRun(run: ExecRun): string {
const state = run.state === "requested" ? "requested; its connector hasn't recorded itself yet"
: run.state === "started" ? `running (connector pid ${run.pid}, since ${run.startedAt})`
: run.state === "interrupted" ? run.summary ?? "interrupted; outcome unknown"
: `finished, outcome ${run.outcome}: ${run.summary}`;
return [
`${run.runId} for ${run.qitemId} on ${run.harness} (${run.agent[0]}${run.adapterVersion ? ` ${run.adapterVersion}` : ""}; runs ${run.native})`,
` ${state}`,
run.state === "finished" ? ` ${describeUsage(run.usage)}` : undefined,
run.endedAt ? " The outcome is the agent's report, not acceptance: the row stays open for its custodian to judge and close." : undefined,
run.settle ? ` After the turn: ${run.settle}` : undefined,
` transcript ${run.transcript}; connector log ${run.connectorLog}`,
].filter((line) => line !== undefined).join("\n");
}
/** Tests only: points where a test acts as a concurrent reconciler would. */
export interface ConnectorHooks { afterGuard?: () => Promise<void>; afterStart?: () => Promise<void>; beforeEnd?: () => Promise<void> }
/** The detached connector: claims the run's start, runs the turn as `rig acp run` does, then claims its end. */
export async function runExecConnector(runId: string, queue: AcpQueue | undefined, hooks: ConnectorHooks = {}): Promise<number> {
const run = readRun(runId);
if (!run) { console.error(`No rig exec run ${runId} on this machine`); return 1; }
// Only the connector `rig exec run` started for this record runs it, once: anything else would send the row's task a
// second time. The record is left untouched.
if (run.state !== "requested" || process.env[CONNECTOR_MARKER] !== runId) {
console.error(`${runId} is ${run.state}${run.state === "requested" ? ", and this process wasn't started for it" : ""}; rig exec connector runs only the run rig exec run started it for`);
return 1;
}
delete process.env[CONNECTOR_MARKER]; // the agent doesn't inherit it; this process's own environment still shows it
// Before the run names this process, so a `rig exec cancel` that reads the record can't reach a default SIGINT.
let cancel!: () => void;
let detach!: () => void;
const cancelRequested = new Promise<void>((done) => { cancel = done; });
const detachRequested = new Promise<void>((done) => { detach = done; });
let interrupts = 0;
process.on("SIGINT", () => { interrupts += 1; if (interrupts === 1) cancel(); else detach(); });
process.on("SIGHUP", () => { /* no terminal owns this process; a hangup isn't a cancel */ });
await hooks.afterGuard?.();
// Claiming the start is what lets this process send the turn. A reconciler that judged the run dead may have claimed
// it first; then the run stays settled and nothing is sent.
if (!publishOnce(slotPath(runId, "start"), { by: "connector", pid: process.pid, processStart: processStart(process.pid), startedAt: now() } satisfies StartSlot)) {
console.error(`${runId} was settled before this connector started (${readRun(runId)?.summary ?? "no summary"}); no turn is sent`);
return 1;
}
const started = readRun(runId)!;
writeRun(started);
await hooks.afterStart?.();
/** Records the end only if nothing recorded one first; a reconciler's record stands, and the row hears what happened. */
const end = async (ended: Partial<ExecRun>): Promise<void> => {
await hooks.beforeEnd?.();
if (publishOnce(slotPath(runId, "end"), { by: "connector", ended } satisfies EndSlot)) { writeRun(readRun(runId)!); return; }
const recorded = readRun(runId);
console.error(`${runId} was recorded ${recorded?.state} before this connector finished; its own outcome was ${ended.outcome}`);
await queue?.update(run.qitemId, { note: `rig exec run ${runId}: its connector finished with outcome ${ended.outcome} (${ended.summary}) after the run had been recorded ${recorded?.state}; that record stands` }).catch(() => {});
};
if (!queue) {
await end({ state: "finished", outcome: "failed", summary: "the OpenRig daemon couldn't be reached, so no turn was sent", endedAt: now() });
return 1;
}
let settle: string | undefined;
try {
const outcome = await runAcpAssignment(queue, {
qitemId: run.qitemId, agent: run.agent, cwd: run.cwd, self: run.self, permissionsTo: run.permissionsTo,
loadSession: run.loadSession, runId, logPath: run.transcript, pollMs: run.pollMs,
progress: (text) => process.stdout.write(text), cancelRequested, detachRequested,
missingSession: HARNESSES[run.harness]?.missingSession, onSettled: (report) => { settle = report; },
});
await end({ state: "finished", outcome: outcome.kind, summary: outcome.summary, usage: outcome.usage, settle, endedAt: now() });
return outcome.kind === "completed" ? 0 : 2;
} catch (error) {
await end({ state: "finished", outcome: "failed", summary: (error as Error).message, settle, endedAt: now() });
return 1;
}
}
export interface ExecDeps {
status?: StatusDeps;
/** The queue as the calling seat (default: the daemon's). */
queue?: () => Promise<AcpQueue | undefined>;
/** The detached connector's command line (default: this CLI's own entry, `exec connector <runId>`). */
connectorCommand?: (runId: string) => string[];
pollMs?: number;
startWaitMs?: number;
/** Tests only: acts between the row-claim read and its publish, as a concurrent caller would. */
beforeClaim?: () => void;
/** Tests only: runs right after the CLI has told its caller about the run, as a process descheduled there would. */
afterAnnounce?: () => void;
}
const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms));
const exitFor = (run: ExecRun) => (run.outcome === "completed" ? 0 : 2);
type Waiter = { left: boolean; leave: () => void };
export function execCommand(deps: ExecDeps = {}): Command {
const cmd = new Command("exec").description("Run a queue row's task on a named harness (Claude Code or Codex over ACP) in a connector that outlives the command, with the outcome kept on the row");
const queueFor = deps.queue ?? (async () => {
const d = deps.status ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const status = await getDaemonStatus(d.lifecycleDeps);
return daemonStatusGuard(status) ? daemonAcpQueue(d.clientFactory(getDaemonUrl(status))) : undefined;
});
const connectorCommand = deps.connectorCommand
?? ((runId: string) => [process.execPath, fileURLToPath(new URL("../index.js", import.meta.url)), "exec", "connector", runId]);
const pollMs = deps.pollMs ?? 1000;
/** The wait's own Ctrl-C, armed before the CLI tells its caller anything about the run, so a Ctrl-C from then on
* only stops the wait: armed later, one landing just after the announcement met the default and killed the CLI. The
* connector is in its own session either way. */
const armWait = (wait: boolean | undefined): Waiter | undefined => {
if (!wait) return undefined;
const waiter: Waiter = { left: false, leave: () => { waiter.left = true; } };
process.on("SIGINT", waiter.leave);
return waiter;
};
/** Polls the run file until it ends, or until the waiter leaves. */
const waitForEnd = async (runId: string, waiter: Waiter): Promise<ExecRun | "left"> => {
for (let polls = 1; ; polls++) {
const run = readRun(runId);
if (!run) throw new Error(`the record of ${runId} is gone`);
if (run.endedAt) return run;
if (waiter.left) return "left";
if (polls % 5 === 0) { const checked = await reconcileRun(run, queueFor); if (checked.endedAt) return checked; }
await sleep(pollMs);
}
};
const finish = (result: ExecRun | "left", runId: string) => {
if (result === "left") {
console.log(`\n[rig exec] stopped waiting; ${runId} keeps running. rig exec status ${runId} shows it; rig exec cancel ${runId} cancels it.`);
process.exitCode = 3;
return;
}
console.log(describeRun(result));
process.exitCode = exitFor(result);
};
/** Every path that holds the row's run ends here: a run that has ended exits by its outcome (0 completed, 2 any
* other), --wait waits for one still going (3 if the waiter leaves), and without --wait a run still going exits 0. */
const report = async (run: ExecRun, waiter: Waiter | undefined) => {
try {
if (run.endedAt) process.exitCode = exitFor(run);
else if (waiter) finish(await waitForEnd(run.runId, waiter), run.runId);
} finally { if (waiter) process.removeListener("SIGINT", waiter.leave); }
};
cmd
.command("harnesses")
.description("List the named harnesses: the adapter and native versions installed here, and how each one differs")
.option("--json", "JSON output")
.action((opts: { json?: boolean }) => {
const all = Object.values(HARNESSES).map((harness) => resolveHarness(harness));
if (opts.json) {
console.log(JSON.stringify(all.map((r) => ({
name: r.harness.name, package: r.harness.package, checkedWith: r.harness.qualified, installed: r.adapterVersion ?? null,
command: r.command ?? null, runs: r.native, answers: r.harness.reject, reads: r.harness.env, set: callerEnv(r.harness), problem: r.problem ?? null,
})), null, 2));
return;
}
console.log(all.map(describeHarness).join("\n\n"));
});
cmd
.command("run <qitemId>")
.description("Start one row's task as a single turn on a named harness, in a connector that outlives this command")
.requiredOption("--harness <name>", `The harness: ${Object.keys(HARNESSES).join(" or ")} (rig exec harnesses)`)
.option("--cwd <dir>", "The agent session's working directory", process.cwd())
.option("--permissions-to <session>", "The seat that answers the agent's permission requests (default: this seat)")
.option("--load-session <id>", "Continue an earlier session; nothing new starts when it can't be loaded")
.option("--wait", "Wait here for the outcome; leaving the wait (Ctrl-C) never cancels the run")
.option("--plan", "Show what would run, with its versions, and start nothing")
.option("--again", "Start another turn on a row whose earlier run has ended")
.option("--poll-seconds <n>", "How often the connector looks for a permission answer", "2")
.addHelpText("after", `
A connector owns the run and outlives this command: closing this terminal, or leaving --wait, doesn't stop it.
It runs one turn of the row's task on the harness's ACP adapter, as rig acp run does, and records the binding,
progress, permission requests and the outcome on the row. The outcome is the agent's report: a completed turn isn't
accepted work, so the row stays open for you to judge and close.
One run per row: running it again returns the row's run, still going or ended, instead of sending the task twice.
--again starts another turn once that run has ended. A run whose connector died without recording an outcome reads
interrupted, outcome unknown.
The agent keeps its own permission mode, and rig exec adds no policy: your environment passes through (rig exec
harnesses lists what each harness reads). A permission request becomes a row to --permissions-to.
rig exec status <runId|qitemId> state, outcome, usage and cost as the agent reported them, and what still runs
rig exec cancel <runId> asks the agent to cancel its turn; the acknowledgement isn't settlement
Guide: rig context get reference/acp-agents.md`)
.action(async (qitemId: string, opts: { harness: string; cwd: string; permissionsTo?: string; loadSession?: string; wait?: boolean; plan?: boolean; again?: boolean; pollSeconds: string }) => {
const harness = HARNESSES[opts.harness];
if (!harness) { console.error(`No harness ${opts.harness}; the named ones are ${Object.keys(HARNESSES).join(", ")}`); process.exitCode = 1; return; }
const resolved = resolveHarness(harness);
if (opts.plan) {
console.log(`${describeHarness(resolved)}\n would run: ${resolved.command ?? harness.bin} for ${qitemId} in ${resolve(opts.cwd)}${opts.loadSession ? `, continuing session ${opts.loadSession}` : ", in a new session"}; permission requests to ${opts.permissionsTo ?? "this seat"}`);
if (resolved.problem) process.exitCode = 1;
return;
}
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
if (!seat) { console.error("rig exec run acts as the seat that owns the row, but OPENRIG_SESSION_NAME isn't set"); process.exitCode = 1; return; }
if (resolved.problem) { console.error(resolved.problem); process.exitCode = 1; return; }
const queue = await queueFor();
if (!queue) { process.exitCode = 1; return; }
mkdirSync(runsDir(), { recursive: true });
const previousId = rowRun(qitemId);
const previous = previousId ? readRun(previousId) : undefined;
if (previous) {
const run = await reconcileRun(previous, async () => queue);
if (opts.again && !run.endedAt) {
console.error(`${run.runId} is still ${run.state} on ${qitemId}; cancel it (rig exec cancel ${run.runId}) or let it end before --again`);
process.exitCode = 1;
return;
}
if (!opts.again) {
const waiter = armWait(opts.wait);
console.log(`${qitemId} already has a run, so the task isn't sent twice:\n${describeRun(run)}\nOnce it has ended, --again starts another turn on this row.`);
await report(run, waiter);
return;
}
}
const row = await queue.show(qitemId).catch((error: Error) => error);
if (row instanceof Error) { console.error(`rig exec run: ${row.message}`); process.exitCode = 1; return; }
if (row.destinationSession !== seat) { console.error(`${qitemId} belongs to ${row.destinationSession}; run rig exec from that seat, or hand the row to ${seat} first`); process.exitCode = 1; return; }
if (row.state !== "pending" && row.state !== "in-progress") { console.error(`${qitemId} is ${row.state}; rig exec runs an in-progress or pending row`); process.exitCode = 1; return; }
const runId = newId("exec", 4);
const run: ExecRun = {
runId, qitemId, harness: harness.name, agent: [resolved.command!], cwd: resolve(opts.cwd), self: seat,
permissionsTo: opts.permissionsTo, loadSession: opts.loadSession, pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
adapterVersion: resolved.adapterVersion, native: resolved.native, state: "requested", pid: process.pid,
processStart: processStart(process.pid), requestedAt: now(),
transcript: join(runsDir(), `${runId}.jsonl`), connectorLog: join(runsDir(), `${runId}.connector.log`),
};
writeRun(run);
if (!claimRow(qitemId, runId, previousId, deps.beforeClaim)) {
rmSync(runPath(runId), { force: true });
const winnerId = rowRun(qitemId);
const winner = winnerId ? readRun(winnerId) : undefined;
const waiter = winner ? armWait(opts.wait) : undefined;
console.log(`${qitemId} was started by another caller as ${winnerId ?? "another run"}, so the task isn't sent twice${winner ? `:\n${describeRun(winner)}` : ""}`);
if (winner) await report(winner, waiter);
else process.exitCode = 1;
return;
}
const log = openSync(run.connectorLog, "a");
const [file, ...args] = connectorCommand(runId);
let spawnError: Error | undefined;
let connectorExit: string | undefined;
try {
const child = spawn(file!, args, { detached: true, stdio: ["ignore", log, log], env: { ...process.env, [CONNECTOR_MARKER]: runId } });
child.once("error", (error) => { spawnError = error; });
child.once("exit", (code, signal) => { connectorExit = `exited (code ${code ?? "none"}, signal ${signal ?? "none"})`; });
child.unref();
} finally { closeSync(log); }
const waiter = armWait(opts.wait);
let current: ExecRun = run;
for (const deadline = Date.now() + (deps.startWaitMs ?? 10_000); Date.now() < deadline; await sleep(50)) {
current = readRun(runId) ?? current;
if (current.state !== "requested" || spawnError || connectorExit || waiter?.left) break;
}
if (spawnError && current.state === "requested") {
current = (await settleRun(current, { state: "finished", outcome: "failed", summary: `the connector couldn't be started: ${spawnError.message}`, endedAt: now() })).run;
} else if (connectorExit && current.state === "requested") {
current = await reconcileRun(current, async () => queue, connectorExit);
}
console.log(`[rig exec] ${runId} for ${qitemId} on ${harness.name}: ${current.endedAt ? `ended, outcome ${current.outcome}: ${current.summary}` : current.state === "started" ? "started" : "requested; its connector hasn't recorded itself yet"}`);
console.log(` rig exec status ${runId} shows it; rig exec cancel ${runId} cancels it.`);
deps.afterAnnounce?.();
await report(current, waiter);
});
cmd
.command("status <runIdOrQitemId>")
.description("Show a run's state, the outcome as the agent reported it, usage and cost, and what still runs")
.option("--json", "JSON output")
.action(async (target: string, opts: { json?: boolean }) => {
const runId = readRun(target) ? target : rowRun(target);
const run = runId ? readRun(runId) : undefined;
if (!run) { console.error(`No rig exec run for ${target} on this machine`); process.exitCode = 1; return; }
const current = await reconcileRun(run, queueFor);
console.log(opts.json ? JSON.stringify(current, null, 2) : describeRun(current));
});
cmd
.command("cancel <runId>")
.description("Ask a running rig exec run's agent to cancel its turn")
.action((runId: string) => {
const run = readRun(runId);
if (!run || run.endedAt || run.state !== "started" || run.processStart === undefined || processStart(run.pid) !== run.processStart) {
console.error(`No running rig exec run ${runId}`);
process.exitCode = 1;
return;
}
process.kill(run.pid, "SIGINT");
console.log(`Cancel requested for ${runId}; its row ${run.qitemId} records what the agent acknowledges. An acknowledgement isn't settlement: rig exec status ${runId} shows the outcome and what still runs.`);
});
cmd
.command("connector <runId>", { hidden: true })
.description("The detached connector that owns a run (started by rig exec run)")
.action(async (runId: string) => { process.exitCode = await runExecConnector(runId, await queueFor()); });
return cmd;
}
+26 -5
View File
@@ -1,7 +1,7 @@
import nodePath from "node:path";
import fs from "node:fs";
import { Command } from "commander";
import { writeTextAtomically } from "../atomic-text-write.js";
import { writeNewText, writeTextAtomically } from "../atomic-text-write.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
@@ -10,6 +10,8 @@ import { rigIdForHandle } from "../rig-handle.js";
export interface ExportDeps extends StatusDeps {
writeFile: (path: string, content: string) => void;
/** Must fail with EEXIST when the path is taken. */
writeNewFile?: (path: string, content: string) => void;
exists?: (path: string) => boolean;
}
@@ -37,18 +39,35 @@ function copyCulture(file: string, root: string, outDir: string): void {
console.log(`Copied culture to ${target}`);
}
/** rig.yaml is also the name of an authored spec, and an export doesn't carry all of one (comments, a summary, startup
* it couldn't read back). So a default export takes the first free name, created so that it can't replace a file that
* appeared after the daemon was asked. Only a path the person named with -o may replace a file. */
function writeDefaultExport(write: (path: string, content: string) => void, first: string, content: string): string {
for (let attempt = 1; ; attempt++) {
const path = attempt === 1 ? first : attempt === 2 ? "rig-export.yaml" : `rig-export-${attempt - 1}.yaml`;
try {
write(path, content);
if (attempt > 1) console.warn(`${first} already exists and was left unchanged. Use -o ${first} to replace it.`);
return path;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
}
}
export function exportCommand(depsOverride?: ExportDeps): Command {
const cmd = new Command("export").description("Export a rig spec as YAML");
const getDeps = (): ExportDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
writeNewFile: writeNewText,
exists: (p) => fs.existsSync(p),
};
cmd
.argument("<rig>", "Rig name or ID to export")
.option("-o, --output <path>", "Output file path", "rig.yaml")
.option("-o, --output <path>", "Output file path, replacing any file there. Without it, an existing rig.yaml is kept and the export goes to an unused rig-export*.yaml", "rig.yaml")
.action(async (rigId: string, opts: { output: string }) => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -67,8 +86,10 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
console.error(`Export failed: ${res.data}`);
process.exitCode = 1;
} else {
deps.writeFile(opts.output, res.data);
console.log(`Exported to ${opts.output}`);
const written = cmd.getOptionValueSource("output") === "default"
? writeDefaultExport(deps.writeNewFile ?? deps.writeFile, opts.output, res.data)
: (deps.writeFile(opts.output, res.data), opts.output);
console.log(`Exported to ${written}`);
for (const line of String(res.data).split("\n")) {
if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
if (line.startsWith("# Member startup not carried for ")) console.warn(`Warning: ${line.slice(2)}`);
@@ -92,7 +113,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
const parsed = parse(res.data) as Record<string, unknown> | null;
if (parsed && typeof parsed === "object") {
if (parsed["name"] === "starter") {
console.warn("Warning: this export still installs as 'starter'. Before sharing, rename the team's name in rig.yaml; changing the bundle name alone does not rename the installed rig.");
console.warn(`Warning: this export still installs as 'starter'. Before sharing, rename the team's name in ${written}; changing the bundle name alone does not rename the installed rig.`);
}
const pods = parsed["pods"] as Array<{
id?: string;
+13 -13
View File
@@ -173,9 +173,9 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.command("add <entityId>")
.description("Add a human fragment (verb-add-only; the fragment is truth, the registry is a GENERATED projection)")
.requiredOption("--display-name <name>", "Human-readable display name")
.requiredOption(
.option(
"--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>",
"Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
"Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
)
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
@@ -188,7 +188,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.option("--policy <text>", "Readable policy for agents working with this person (text, not enforced)")
.option("--policy-file <path>", "Read the policy text from a file")
.option("--default", "Make this person the default: the one escalations, the notice digest and other unaddressed needs go to")
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
.action(async (entityId: string, opts: { displayName: string; binding?: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
// LAZY import the narrow daemon surface at invocation (dep rail 2).
const registry = await import("@openrig/daemon/gateway-human-registry");
const { addHumanFragment, parseBindingSpec } = registry as unknown as {
@@ -209,7 +209,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; }
}
const bindings: Record<string, unknown>[] = [];
for (const spec of opts.binding) {
for (const spec of opts.binding ?? []) {
const b = parseBindingSpec(spec);
if (!b.ok) { console.error(`--binding: ${b.error}`); process.exitCode = 1; return; }
bindings.push(b.binding);
@@ -284,8 +284,9 @@ Example:
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
for (const h of humans) {
const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]";
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}) delivery=${h.deliveryReadiness.state}${inbound}`);
const inbound = h.bindings.inboundResolvable || !h.bindings.primary ? "" : " [outbound-only]";
const primary = h.bindings.primary ? `primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}` : "no connector: Feed and Steering only";
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (${primary}) delivery=${h.deliveryReadiness.state}${inbound}`);
if (!h.deliveryReadiness.ready) console.log(` delivery: ${h.deliveryReadiness.reason}${h.deliveryReadiness.nextAction ? `; next: ${h.deliveryReadiness.nextAction}` : ""}`);
console.log(` ${"unknown" in h.quiet ? `quiet: unknown (${h.quiet.unknown})` : describeQuiet(h.quiet)}`);
}
@@ -731,17 +732,16 @@ Examples:
.option("--json", "Complete records as JSON, including organizations and groups")
.action(async (opts: { organization?: string; json?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
if (opts.json) {
const result = registry.participantsJsonView(undefined, opts.organization);
if (!result.ok) { console.error(`refused: ${result.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify(result));
return;
}
const view = registry.participantsView();
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
if (opts.json) {
const side = registry.readParticipantsFile();
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
warnings: view.warnings, participantFile: view.sideFile }));
return;
}
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
for (const p of shown) {
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
+35 -9
View File
@@ -24,8 +24,9 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { hostname as osHostname, userInfo } from "node:os";
import { connect } from "node:net";
import { execFile } from "node:child_process";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
import { DaemonClient } from "../client.js";
@@ -167,15 +168,35 @@ function defaultDoctorDeps(): DoctorDeps {
};
}
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
* direct dial stands for that transport. */
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
return new Promise((resolve) => {
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
if (error) return resolve(null);
const conf = new Map(stdout.split("\n").map((line) => {
const i = line.indexOf(" ");
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
}));
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
const hostname = conf.get("hostname");
const port = Number(conf.get("port"));
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
});
});
}
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
* "remote rig binary missing/old". */
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
* against the host's transport endpoint (ssh: target:22; http: the
* URL's host:port). Coarse by design (the kubectl STATUS / docker
* context ls precedent): reachable | unreachable | unknown — never a
* hang (hard timeout), never a guess (dial failure = unreachable,
* probe error/unparseable endpoint = unknown). */
* against the host's transport endpoint (ssh: the host and port ssh itself
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
* design (the kubectl STATUS / docker context ls precedent): reachable |
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
* failure = unreachable, probe error/unparseable endpoint = unknown). */
export async function probeHostStatus(
host: HostEntry,
tcpProbe: DoctorDeps["tcpProbe"],
@@ -185,8 +206,9 @@ export async function probeHostStatus(
let target: string;
let port: number;
if (host.transport === "ssh") {
target = host.target;
port = 22;
const endpoint = await resolveSshEndpoint(host, timeoutMs);
if (!endpoint) return "unknown";
({ hostname: target, port } = endpoint);
} else {
const u = new URL(host.url);
target = u.hostname.replace(/^\[|\]$/g, "");
@@ -560,6 +582,7 @@ function printHostNextSteps(host: HostEntry): void {
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
const rewritesRegistry = "\nIt rewrites ~/.openrig/hosts.yaml from the fields it knows, so comments, formatting, unknown entry keys and other top-level keys aren't kept.";
cmd
.command("add")
@@ -573,6 +596,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
.option("--bearer-file <path>", "File PATH holding the bearer token — http transport (optional pointer, never a value; at most one bearer flag)")
.option("--notes <text>", "Free-form operator note")
.option("--json", "JSON output")
.addHelpText("after", rewritesRegistry)
.action((opts: { id: string; transport: string; target?: string; user?: string; url?: string; bearerEnv?: string; bearerFile?: string; notes?: string; json?: boolean }) => {
const rawEntry: Record<string, unknown> = { id: opts.id, transport: opts.transport };
if (opts.target !== undefined) rawEntry["target"] = opts.target;
@@ -703,6 +727,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
.option("--url <url>", "Remote daemon base URL — http transport")
.option("--notes <text>", "Free-form operator note (empty string clears the value)")
.option("--json", "JSON output")
.addHelpText("after", rewritesRegistry)
.action((id: string, opts: { target?: string; user?: string; url?: string; notes?: string; json?: boolean }) => {
const changes: Record<string, unknown> = {};
if (opts.target !== undefined) changes["target"] = opts.target;
@@ -738,6 +763,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
.description("Remove a host entry (deletes only the registry entry — credential files, the learned identity and the selection stay as they are)")
.argument("<id>", "A registered host id")
.option("--json", "JSON output")
.addHelpText("after", rewritesRegistry)
.action((id: string, opts: { json?: boolean }) => {
const res = removeHostEntry(id.trim());
if (!res.ok) {
@@ -757,7 +783,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
cmd
.command("pair")
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port)")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port); that daemon must run with OPENRIG_AUTH_BEARER_TOKEN set")
.option("--id <id>", "Registry id for the new host (default: derived from the hostname)")
.option("--timeout <seconds>", "How long to wait for the target-side approval", "600")
.option("--human <address>", "Registered @external approval recipient on the target (required when several exist)")
+1 -1
View File
@@ -42,7 +42,7 @@ export function setExperiment(file: string, enabled: boolean, options: Partial<E
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
try {
const s = fs.fstatSync(fd);
if (!s.isFile() || s.nlink !== 1 || s.uid !== process.getuid?.()) throw Error("experiment config must be an owned regular file");
if (!s.isFile() || s.nlink !== 1 || (process.getuid !== undefined && s.uid !== process.getuid())) throw Error("experiment config must be an owned regular file");
fs.ftruncateSync(fd, 0); fs.writeFileSync(fd, JSON.stringify(config, null, 2) + "\n");
} finally { fs.closeSync(fd); }
return config;
+10 -5
View File
@@ -580,11 +580,16 @@ to a 0600 file holding the token>. Only the path is kept, as the seat's referenc
so the token never passes through OpenRig. First the daemon checks that the file would apply and that nothing Claude Code ranks
above the token is set (a cloud provider, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, an apiKeyHelper), and refuses with each one
and its remedy if it is. When the token applies, the launch also unsets those names for Claude's process, so the pane shell's
own exports can't outrank it. It takes a snapshot and checks that the snapshot resumes the seat's conversation. Then, holding
the seat so no other stop, launch or switch runs on it in between, it sets the reference and relaunches the seat from that
snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore resumes the same conversation in it.
Work in flight when it stopped doesn't carry over. Only a refusal before Claude was touched reads as unchanged; a relaunch that
doesn't report a resumed conversation reads unknown, with the snapshot's recovery command, and isn't retried.
own exports can't outrank it. For a running seat, it takes a snapshot and checks that the snapshot resumes the seat's
conversation. Then, holding the seat so no other stop, launch or switch runs on it in between, it sets the reference and
relaunches the seat from that snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore
resumes the same conversation in it. Work in flight when it stopped doesn't carry over. Only a refusal before Claude was
touched reads as unchanged; a relaunch that doesn't report a resumed conversation reads unknown, with the snapshot's recovery
command, and isn't retried. The relaunch uses the seat's own effort choice (rig seat set-effort) when there is one; otherwise
it asks to keep the running effort, and uses the seat's saved effort when it can't. The report's effort line says which.
A stopped seat isn't relaunched, and no snapshot is taken: the reference is saved only when the seat's next launch would
resume its conversation, and the report prints that launch (rig launch <rig> <seat> --snapshot-id <id>, or a plain rig launch
when restore's periodic carry resumes it). Otherwise it refuses and nothing changes.
Seats sharing the config dir keep their own login: Claude Code reads the token from that one process's environment and doesn't
save it. That environment is Claude's, so the tools, hooks and MCP servers it starts inherit the token, and one that prints its
environment writes it into the transcript. A token-authenticated seat can make model requests but can't use claude.ai
+13 -2
View File
@@ -99,6 +99,8 @@ interface NodeEntry {
blockedWorkCount?: number;
/** OPR.0.3.4.11 — held reason from node.held event. */
heldReason?: string | null;
/** #2456: the required startup files this occupant's handover started it without. */
startedWithoutRequired?: string[];
agentActivity?: {
state: "running" | "needs_input" | "idle" | "unknown";
reason: string;
@@ -238,6 +240,7 @@ const ALLOWED_NODE_FIELDS = new Set([
"needsInputEvidence",
"contextUsage",
"heldReason",
"startedWithoutRequired",
"storedSessionStatus",
"identityVerdict",
]);
@@ -705,6 +708,11 @@ export function formatDeclaredModel(model: string | null | undefined): string {
return m.length > 0 ? m : "not-declared";
}
/** #2456: the attention reason for an occupant started without a required startup file. */
function startedWithoutLine(files: string[]): string {
return `missing required ${files.map((file) => file.split("/").pop()).join(", ")}`;
}
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
return nodes.map((n) => {
const keepDiagnostics = n.needsAttention !== false;
@@ -768,6 +776,7 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
}
// held/attention reason (short) when present.
if (n.heldReason) compact.heldReason = n.heldReason;
if (n.startedWithoutRequired?.length) compact.startedWithoutRequired = n.startedWithoutRequired;
if (keepDiagnostics && n.latestError) {
compact.latestError = n.latestError;
}
@@ -926,7 +935,7 @@ intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachC
resumeCommand, latestError, terminalActive, hasAssignedWork,
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
agentActivity, needsAttention, arbitratedNeedsInput, needsInputEvidence,
contextUsage, heldReason, storedSessionStatus, identityVerdict.
contextUsage, heldReason, startedWithoutRequired, storedSessionStatus, identityVerdict.
--host runs on a remote host declared in ~/.openrig/hosts.yaml (no current-rig
default applied; the remote host's rigs are shown).
@@ -1358,7 +1367,8 @@ async function handleNodes(
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
for (const n of humanList as NodeEntry[]) {
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
? (n.latestError ? truncate(n.latestError, 40) : n.agentActivity?.reason ?? "—")
? (n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
: n.agentActivity?.reason ?? "—")
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
console.log(padCompactNodeRow(
n.rigName,
@@ -1395,6 +1405,7 @@ async function handleNodes(
formatContextUsage(n.contextUsage),
n.restoreOutcome,
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}`
: n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 30)
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—"),
));
}
+40 -4
View File
@@ -356,6 +356,17 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
// Priority is matched exactly where it matters (the founder view, attention ranks), so "Critical" would quietly file
// the row as ordinary work. The daemon stores what it's given, so a documented value is normalized here. Other values
// are kept as given: `high`, `normal` and `background` are in real use, and the mission view ranks some of them.
const QUEUE_PRIORITIES = ["routine", "urgent", "critical"];
function queuePriorityArg(value: string): string {
const lower = value.toLowerCase();
if (QUEUE_PRIORITIES.includes(lower)) return lower;
console.error(`Note: --priority '${value}' is outside the documented choices (${QUEUE_PRIORITIES.join(", ")}); keeping it as given.`);
return value;
}
/** #2276: the daemon's checks of a new row's human-facing fields (missing summary or evidence for a person,
* notification, questions, reply-to). The queue repository raises them before the row's first write, inside the
* create's transaction. A remote host's refusal arrives as remote_queue_write_failed instead, so it isn't one of these. */
@@ -639,6 +650,18 @@ async function printQueueItemResult(
// IMPL-SPEC §2.3-2.4.
const SHOW_BODY_PREVIEW_MAX_CODEPOINTS = 512;
// A misspelled state ("in_progress") would otherwise read as an empty queue, not as a mistake: the list route matches
// names as given. Each name is checked and trimmed the way the route trims it. This warns rather than refuses, so the
// CLI's copy of the list doesn't stop it working with a newer daemon that knows more states.
const QUEUE_STATE_NAMES = ["pending", "in-progress", "done", "blocked", "failed", "denied", "canceled", "handed-off"];
function warnIfUnknownStates(value?: string): void {
const names = (value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
const unknown = names.filter((name) => !QUEUE_STATE_NAMES.includes(name));
if (unknown.length === 0) return;
const effect = unknown.length === names.length ? " Nothing is filed under these names, so an empty result doesn't mean an empty queue." : "";
console.error(`Warning: unrecognized --state '${unknown.join("', '")}'. Supported states: ${QUEUE_STATE_NAMES.join(", ")}.${effect}`);
}
// Commander option parser. InvalidArgumentError (like positiveIntArg/enumArg) is what Commander
// renders as its usual "option ... argument ... is invalid" error; a plain Error would escape parse.
const WAKE_DURATION_FORMAT = "must be a positive integer with an optional s, m or h suffix (for example 90s, 15m, 168h)";
@@ -680,6 +703,12 @@ export function previewBody(
};
}
// Mark the cut inside the body so readers that discard metadata or stderr can request the complete brief.
// Counts use code points, like the preview.
export function previewCutMarker(qitemId: string, shownCodePoints: number, totalCodePoints: number): string {
return `…[cut at ${shownCodePoints} of ${totalCodePoints} chars; rig queue show ${shellQuote(qitemId)} --full]`;
}
function isRecordWithStringBody(v: unknown): v is Record<string, unknown> & { body: string } {
return (
typeof v === "object" &&
@@ -911,7 +940,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
.option("--gate <role>", "OPR.0.4.3.16: mark this as a gate qitem; translated to a gate:<role> tag (role e.g. guard | spec-review | pm-lead | qa | human). The idle-gate watchdog reads this predicate. Composes with --tags.")
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
.option("--priority <priority>", "Priority: routine | urgent | critical", queuePriorityArg, "routine")
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
@@ -1211,6 +1240,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--reopen", "Explicitly acknowledge a deliberate terminal-to-active repair; requires --state and --note")
.option("--closure-reason <reason>", "Required for state=done. --state canceled takes no reason, except 'superseded' (with --closure-target = the successor), which records a supersession, distinct from an abandoned cancel")
.option("--closure-target <target>", "Required for handed_off_to, escalation, and superseded; blocked_on park records use state=blocked with --blocked-on")
.option("--closure-required-at <iso>", "Accept a revised closure deadline on an in-progress item; keeps the claim and records the old/new time and accepting actor")
.option("--resolution <text>", "The outcome, stored on the row and shown by rig queue show. Needs --state done, canceled, denied, failed or handed-off, and non-blank text. Stored when the update closes an open row, after anything the row already has (a fallback line, an earlier outcome); a row that is already closed keeps what it has")
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
@@ -1226,6 +1256,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
reopen?: boolean;
closureReason?: string;
closureTarget?: string;
closureRequiredAt?: string;
resolution?: string;
blockedOn?: string;
wakeWatchdog?: string;
@@ -1262,6 +1293,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
reopen: opts.reopen,
closureReason: opts.closureReason,
closureTarget: opts.closureTarget,
...(opts.closureRequiredAt !== undefined ? { closureRequiredAt: opts.closureRequiredAt } : {}),
resolution: opts.resolution,
blockedOn: opts.blockedOn,
wakeWatchdogId: opts.wakeWatchdog,
@@ -1385,7 +1417,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem")
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
@@ -1489,7 +1521,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem")
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
@@ -1652,7 +1684,10 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
...(closingTransition === undefined ? [] : [omittedReadField("closingTransition", closingTransition)]),
];
const view = readView(item, fullCommand, omitted);
const transformed = { ...record, body: preview, bodyBytes, bodyTruncated, readView: view };
const shownBody = bodyTruncated
? `${preview}${previewCutMarker(qitemId, Array.from(preview).length, Array.from(item.body).length)}`
: preview;
const transformed = { ...record, body: shownBody, bodyBytes, bodyTruncated, readView: view };
// Slice 15 P2: with output.compact on, the empty fields are left out and counted.
const compact = compactOutputEnabled() ? compactShow(transformed) : null;
printResult(json, compact ? compact.item : transformed, res.status);
@@ -1772,6 +1807,7 @@ Examples:
}
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
warnIfUnknownStates(opts.state);
if (opts.state) params.set("state", opts.state);
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
if (opts.limit) params.set("limit", String(opts.limit));
+14 -2
View File
@@ -60,7 +60,19 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
const node = identities.size === 1 ? matches[0] : undefined;
const sessionStatus = node ? text(node.sessionStatus) : null;
const current = sessionStatus === "running";
const activity = current && object(node?.agentActivity) ? node.agentActivity : null;
const activities = matches.map(n => {
const activity = current && object(n.agentActivity) ? n.agentActivity : null;
return { state: text(activity?.state) ?? "unknown", reason: text(activity?.reason), sampledAt: text(activity?.sampledAt) };
});
const agreed = node && new Set(activities.map(a => JSON.stringify([a.state, a.reason]))).size === 1;
const times = activities.map(a => a.sampledAt);
const sameTime = new Set(times).size === 1;
const comparable = times.every((t): t is string => t !== null && Number.isFinite(Date.parse(t)));
// A later timestamp is meaningful only when every route supplied a comparable time.
const sampledAt = sameTime ? times[0] ?? null : comparable
? [...times].sort((a, b) => Date.parse(b!) - Date.parse(a!) || b!.localeCompare(a!))[0] ?? null : null;
const activity = agreed ? { ...activities[0]!, sampledAt }
: { state: "unknown", reason: matches.length > 1 ? "Conflicting activity observations" : null, sampledAt: null };
const observations = matches.map(n => modelObservation(n.modelObservation, current));
const observation = node && new Set(observations.map(o => JSON.stringify(o))).size === 1 ? observations[0]!
: unknownModelObservation(matches.length ? "conflicting-observations" : "host-or-seat-unavailable");
@@ -75,7 +87,7 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
observedModel: observation.model,
modelObservation: observation,
sessionStatus,
agentActivity: { state: text(activity?.state) ?? "unknown", reason: text(activity?.reason), sampledAt: text(activity?.sampledAt) },
agentActivity: activity,
// A hosted member's last call to this host's MCP server, for a roster row on this host only: another host's member,
// or one whose host can't be told, is unknown (null), never this host's record for a same-named seat. Its last ring
// is in rig_team.
+36 -2
View File
@@ -213,7 +213,7 @@ function buildSliceLsCommand(): Command {
const cmd = new Command("ls")
.description("List slices in a mission (or across all missions)")
.option("--mission <name>", "Restrict to a single mission")
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
.option("--state <state>", "Filter: active (in flight, blocked included) | shipped (delivered in place: shipped or done) | closed (in closed/, or superseded, retired or closed in place) | all", "active")
.option("--json", "Machine-readable output")
.action(async (opts, command) => {
const out = makeStdout();
@@ -727,12 +727,13 @@ function buildMissionLsCommand(): Command {
id: m.id,
path: m.absPath,
activeSliceCount: m.activeSliceCount,
shippedSliceCount: m.shippedSliceCount,
closedSliceCount: m.closedSliceCount,
}));
emit(out, { ok: true, count: rows.length, missions: rows }, json,
rows.length === 0
? ["(no missions)"]
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} shipped=${r.shippedSliceCount} closed=${r.closedSliceCount}`),
);
} catch (err) {
fail(err, json, out);
@@ -764,6 +765,7 @@ function buildMissionShowCommand(): Command {
id: mission.id,
path: mission.absPath,
activeSliceCount: mission.activeSliceCount,
shippedSliceCount: mission.shippedSliceCount,
closedSliceCount: mission.closedSliceCount,
frontmatter: mission.frontmatter,
trust,
@@ -778,6 +780,7 @@ function buildMissionShowCommand(): Command {
out.write(` id: ${mission.id ?? "—"}\n`);
out.write(formatTrustLine(trust));
out.write(` active slices: ${mission.activeSliceCount}\n`);
out.write(` shipped slices: ${mission.shippedSliceCount}\n`);
out.write(` closed slices: ${mission.closedSliceCount}\n`);
out.write(` path: ${mission.absPath}\n`);
if (readme) {
@@ -1100,6 +1103,7 @@ function buildAuditCommand(): Command {
frontmatter: {},
id: null,
activeSliceCount: 0,
shippedSliceCount: 0,
closedSliceCount: 0,
};
const graph = buildMissionDependencyGraph(auditMission);
@@ -1232,6 +1236,8 @@ function buildAuditCommand(): Command {
for (const sr of sliceResults) {
const shadow = shadowedNodeFileFinding(path.join(slicesDir, sr.name), "slice");
if (shadow) sr.result.findings.push(shadow);
const shared = sharedSliceNameFinding(missionsRoot, missionName, sr.name);
if (shared) sr.result.findings.push(shared);
}
const allFindings = [
@@ -2152,6 +2158,34 @@ function shadowedNodeFileFinding(dir: string, level: "mission" | "slice"): {
};
}
/**
* Advisory-only: another mission has a slice folder of the same name. The slice list and the routes that
* find a slice by name keep one slice per name, so the others are hidden there with no sign they exist.
* Twin of the daemon's finding of the same name.
*/
function sharedSliceNameFinding(missionsRoot: string, missionName: string, sliceName: string): {
kind: "slice_name_shared"; severity: "low"; path: string; message: string; remediation: string;
} | null {
let others: string[];
try {
others = fs.readdirSync(missionsRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== missionName
&& fs.statSync(path.join(missionsRoot, e.name, "slices", sliceName), { throwIfNoEntry: false })?.isDirectory() === true)
.map((e) => e.name)
.sort();
} catch {
return null;
}
if (others.length === 0) return null;
return {
kind: "slice_name_shared",
severity: "low",
path: path.join(missionsRoot, missionName, "slices", sliceName),
message: `Mission ${others.join(", ")} also ${others.length === 1 ? "has" : "have"} a slice named "${sliceName}". The slice list (GET /api/slices) and the routes that find a slice by name keep one slice per name, so all but one of these slices are hidden there.`,
remediation: "Rename one of the slice folders so each slice name is unique across missions. Advisory only — nothing is blocked.",
};
}
export function scopeCommand(): Command {
const cmd = new Command("scope")
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
+69 -7
View File
@@ -21,6 +21,10 @@ async function defaultReadStdin(): Promise<string> {
}
interface SeatStatusResponse {
permission_stop?: { state: "unavailable"; command: "unavailable"; origin: "unknown" }
| { state: "request_observed" | "completed"; command: string; origin: "harness";
source: "PermissionRequest"; observed_at: string; actor: "main" | "subagent"; pending: "unknown" | false;
suggested_rules?: { state: "missing" | "present" | "unavailable"; entries: unknown[]; unsupported: boolean; size_cut: boolean } };
seat_ref: string;
rig_id: string;
rig_name: string;
@@ -159,19 +163,31 @@ interface SeatHandoverMutationResult {
currentStatus: SeatHandoverPlan["currentStatus"];
handoverAt: string;
eventSeq: number;
/** What the successor was told on taking the seat, such as its held messages. */
warnings?: string[];
/** OPR.0.5.5.5 — per-source execution record (wire mirror of the daemon shape). */
sourceOutcome?:
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
/** The restore packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
restorePacket?: { status: "unconfirmed"; reason: string };
/** #2456: the required startup files the successor started without, where a start would have failed. */
startedWithoutRequired?: string[];
sideEffects: {
departingSessionKilled: false;
/** Absent from an older daemon, whose receipt reported both side effects as a fixed false. */
departingProcess?: "ended-graceful" | "ended-forced" | "not-touched";
startupContextDelivered: boolean;
provenanceRecordWritten: false;
provenanceRecordWritten: boolean;
};
}
const DEPARTING_PROCESS_LINES = {
"ended-graceful": "The previous occupant's process exited on TERM; its tmux session was kept, and the successor runs in the same pane.",
"ended-forced": "The previous occupant's process didn't exit on TERM and was killed; its tmux session was kept, and the successor runs in the same pane.",
"not-touched": "The previous occupant's process wasn't touched: the successor was an existing session.",
} as const;
interface SeatSwitchClientResponse {
seat_ref: string;
session: string;
@@ -196,6 +212,13 @@ const LEVEL_WORDS: Record<string, string> = {
"sends-only": "Deliberate sends from people and agents type; OpenRig's own notices are held.",
};
// JSON escapes C0; also escape C1, format/separator and default-ignorable characters.
// Escaping the serialized representation leaves JSON.parse's rule strings unchanged.
function terminalJson(value: unknown, space?: number): string {
return JSON.stringify(value, null, space).replace(/[\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu,
character => character.split("").map(unit => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`).join(""));
}
/** Show configured seat state, including selected account references, without claiming the running login is verified. */
function printHuman(status: SeatStatusResponse): void {
console.log(`Seat ${status.seat_ref}`);
@@ -231,6 +254,24 @@ function printHuman(status: SeatStatusResponse): void {
else console.log("Hold pauses all automatic input, including writing lifecycle. Changing mode does not replay held messages.");
}
console.log(`Session: ${display(status.session_status, "unknown")}`);
const stop = status.permission_stop;
if (stop && stop.state !== "unavailable") {
console.log(`Latest permission observation at ${stop.observed_at}: ${stop.command}; harness permission request (${stop.source}, ${stop.actor})`);
console.log(stop.state === "completed" ? " Displayed actor's tool batch completed; this observation is no longer pending."
: " Displayed actor's pending status: unknown (an observation does not establish a waiting prompt).");
console.log(" Other actors' pending state: unknown (only the latest observation is shown).");
console.log(" Claude's suggested rules: read-only evidence; not applied or approved, effectiveness unknown.");
console.log(" These are neither the exact command nor a complete list of dialog options.");
const rules = stop.suggested_rules;
if (!rules || rules.state === "unavailable") console.log(" Suggested-rule evidence: unavailable.");
else if (rules.state === "missing") console.log(" Suggested-rule field: missing from the observed hook.");
else {
if (!rules.entries.length) console.log(" Supported suggested-rule entries: empty.");
for (const entry of rules.entries) console.log(` ${terminalJson(entry)}`);
}
if (rules?.unsupported) console.log(" Unsupported or malformed suggested-rule evidence was withheld.");
if (rules?.size_cut) console.log(" Suggested-rule evidence is incomplete because of size bounds.");
}
if (status.compaction) console.log(`Managed compaction (${status.compaction.stage}): ${status.compaction.detail}`);
const reason = sessionReason(status.session_reason);
if (reason) console.log(`Reason: ${reason}`);
@@ -315,7 +356,11 @@ function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
}
function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log(`Seat handover complete: ${result.seat.ref}`);
// #2456: a successor that started without a required startup file isn't a plain success; a start would have failed.
const withoutRequired = result.startedWithoutRequired ?? [];
console.log(`Seat handover complete${withoutRequired.length > 0 ? ", degraded" : ""}: ${result.seat.ref}`);
for (const file of withoutRequired) console.log(`The successor started without required file ${file}.`);
if (withoutRequired.length > 0) console.log("rig ps --nodes shows the seat as needing attention until it's next launched.");
console.log(`Rig: ${result.seat.rigName}`);
console.log(`Logical ID: ${result.seat.logicalId}`);
console.log(`Source: ${result.source.mode}${result.source.ref ? `:${result.source.ref}` : ""}`);
@@ -325,6 +370,14 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log(`Current occupant: ${result.currentOccupant}`);
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
console.log("Seat binding and inventory provenance were updated.");
for (const warning of result.warnings ?? []) console.warn(`Warning: ${warning}`);
// An older daemon reports both side effects as a fixed false: say nothing rather than misreport them.
if (result.sideEffects.departingProcess) {
console.log(result.sideEffects.provenanceRecordWritten
? "The handover's lineage record (occupant tenure) was written."
: "The handover's lineage record (occupant tenure) was NOT written; check the daemon log, which names the first such failure since the daemon started. The seat is already bound to the successor, so don't run the handover again.");
console.log(DEPARTING_PROCESS_LINES[result.sideEffects.departingProcess]);
}
// OPR.0.5.5.5 — the per-source execution record: what actually carried context.
if (result.sourceOutcome?.mode === "fork") {
console.log(`Native fork of ${result.sourceOutcome.forkedFrom}: the successor carries the incumbent conversation from its first byte.`);
@@ -349,18 +402,18 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
// live successor agent.
console.log("The captured startup context (restore packet) was delivered to the successor.");
}
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
} else if (result.restorePacket?.status === "unconfirmed") {
// The packet was pasted and Enter pressed: the successor may already be working on it, so don't send it again.
console.log(`The captured startup context (restore packet) was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`);
console.log("Check the successor's screen before sending the context again; it may already be working on it.");
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
} else if (result.source.mode === "fork") {
console.log("No packet delivery: fork context rides the native conversation itself.");
} else {
// discovered handover: the operator-prepared successor is already live, so no
// separate context delivery is performed.
console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
console.log("OpenRig delivered no startup context and carried no conversation: the discovered successor was already running.");
}
}
@@ -445,6 +498,11 @@ when the current input contains a draft or cannot be read. It returns the exact
reason and held message ID immediately, without automatic retry.
Off (--enabled false) permits new automatic sends.
This works from an ordinary terminal as well as from a seat's shell. From a
managed seat's shell (OPENRIG_SESSION_NAME set), the change is recorded under
that seat. From a terminal with no seat identity, it applies the same way and is
recorded as "unattributed (no seat identity)".
Between off (always interrupt) and hold (full quiet):
openrig-urgent OpenRig's own messages (watchdogs, scheduled wakes, notices) and
anything marked urgent type; other agents' messages are held.
@@ -521,7 +579,7 @@ Examples:
const res = await client.get<SeatStatusResponse | SeatStatusError>(`/api/seat/status/${encodeURIComponent(seat)}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
console.log(terminalJson(res.data, 2));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
@@ -998,6 +1056,8 @@ effort, Codex profile and native permission choice are cleared, not mapped; two
processes run until cutover; messages sent to the seat during the switch are
held, not typed, and need sending again afterwards.
Codex and Claude Code, single-pane seats only. --dry-run lists the details.
A seat that has never started has no pane to switch: declare the other runtime for it in its rig spec and bring the team
up again from that spec. That's a new generation of the seat, without this seat's edges, settings or queued work.
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
It uses a fresh successor and clears an account-home token reference only after confirmation. Do not combine it with
--account-home or a new token reference. Anthropic's global active/default profile is not isolated by CLAUDE_CONFIG_DIR.
@@ -1368,7 +1428,9 @@ Examples:
.description("Stop exactly one LIVE managed seat (kills only that seat's tmux session; audited)")
.addHelpText("after", `
Siblings are untouched. A dead seat is refused (use rig seat clean); an adopted
session is refused (use rig unclaim). Stopping ends that agent's session and any
session is refused (use rig unclaim). A seat handed over (fresh, fork or rebuild)
before OpenRig 0.7.9 still reads adopted and is refused too, until its next launched
handover or managed launch; the refusal says so. Stopping ends that agent's session and any
work in progress: check it first (rig capture <seat>) and stop only a seat you
own or were asked to stop. rig seat launch <seat> --fresh --reason <text> starts
a fresh occupant there; it does not resume the stopped conversation.
+50 -27
View File
@@ -1,3 +1,4 @@
import { isHumanSeatSessionRef } from "../session-name.js";
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient, DaemonConnectionError, terminalAuthHeaders } from "../client.js";
@@ -90,6 +91,9 @@ export function wrapSendBody(
// renders EXACTLY AS RECEIVED — bare member@rig locally (copy-paste reply hint), and a
// cross-host caller constructs the origin triple at the forwarding boundary before wrapping.
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
// Only reply classification uses the bare address; From and the destination retain the origin host.
const senderParts = senderLabel.split("@");
const personAddress = senderParts.length === 3 && senderParts[2] ? senderParts.slice(0, 2).join("@") : senderLabel;
const header = [`From: ${senderLabel}`, renderToLine(recipient, meta?.scope)];
if (meta?.stampISO) {
// GHOST-STAGE (g): the sender's occupant generation rides the Sent: line as a short suffix
@@ -102,7 +106,9 @@ export function wrapSendBody(
}
const reply = senderLabel.endsWith("@external")
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
: `↩ Reply: rig send ${senderLabel} "..."`;
: isHumanSeatSessionRef(personAddress)
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
: `↩ Reply: rig send ${senderLabel} "..."`;
return [...header, "---", body, "---", reply].join("\n");
}
@@ -236,7 +242,7 @@ export function sendCommand(depsOverride?: SendDeps): Command {
.option("--to <sessions>", "Multi-recipient: comma-list or repeated (--to a,b or --to a --to b)", collectSessions, [] as string[])
.option("--pod <name>", "Send to every seat in a pod (fan-out, per-recipient results)")
.option("--rig <name>", "Send to every seat in a rig (fan-out, per-recipient results)")
.option("--verify", "Verify pane only delivery by checking content after send")
.option("--verify", "Verify pane only delivery by checking content after send. Exit 1 can mean unconfirmed, not failed: the message may have arrived, so inspect with rig capture before any resend")
.option("--force", "Back-compat no-op: a mid-task/busy pane already sends-with-advisory by default; --force never bypasses the interactive-prompt/permission guard")
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending. If it's still busy, or its activity is unknown, when the wait ends, nothing is sent (exit 1) and nothing is kept to send later; put separate work in a queue row (rig queue create)")
.option("--raw", "Send exact text/keystrokes without the From/To messaging envelope (still guarded against interactive prompts)")
@@ -385,10 +391,10 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
}
const isFanOut = fanModes.length === 1;
// Slice-03 Atom 6b: --context is single-seat LOCAL in v1 (fan-out and
// cross-host --context are follow-ons). Reject loudly rather than silently
// dropping the requested context.
if (opts.context && (isFanOut || opts.host)) {
// --context is single-seat LOCAL in v1. Reject fan-out here; the
// cross-host check follows target resolution so a self-qualified target
// can override a persisted remote selection.
if (opts.context && isFanOut) {
console.error("--context is supported on a single-seat LOCAL send in v1 (not with --to/--pod/--rig or --host).");
process.exitCode = 1;
return;
@@ -461,14 +467,10 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
session = targetResolution.target;
const crossHostHint = targetResolution.hint;
if (targetResolution.warning) console.error(targetResolution.warning);
opts.host = explicitHost ?? targetResolution.sugarHost ?? opts.host;
opts.host = targetResolution.localHost ? undefined : explicitHost ?? targetResolution.sugarHost ?? opts.host;
// Atom 6b QA fix (root cause): re-reject --context on the cross-host path
// AFTER the agent@rig@host sugar host folds into opts.host. The early guard
// runs before resolveCrossHostTarget, so it cannot see the sugar host — the
// hole that let a sugar-form --context reach the remote argv (shipping a
// literal null with no message, or silently dropping the context with one).
// --context is single-seat LOCAL in v1; it is never handed to a remote send.
// Reject --context after resolving the target, before a remote send can
// silently drop the context or forward an empty message.
if (opts.context && opts.host) {
console.error("--context is supported on a LOCAL send in v1 (not with --host or an agent@rig@host cross-host target).");
process.exitCode = 1;
@@ -565,7 +567,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
const envelope = !effect
? res.data
: effectUnresolved(effect)
? { ...res.data, verified: false, outcome: "staged-not-consumed", effectCheck: effect }
? { ...res.data, verified: false, outcome: effectSubmissionUnconfirmed(effect) ? "submission-unconfirmed" : "staged-not-consumed", effectCheck: effect }
: { ...res.data, effectCheck: effect };
console.log(JSON.stringify(envelope));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
@@ -632,7 +634,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
* recheck, then stops. Pure classification: no printing here.
*/
type EffectCheck =
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused"; detail?: string }
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused" | "submit-unconfirmed"; detail?: string }
| { checked: true; state: "no-staged-residual" }
| { checked: false; why: string };
@@ -683,16 +685,26 @@ async function classifyDeliveryEffect(
const probe = await detectStagedAtPrompt(client, session, identity);
if (probe.state === "unchecked") return { checked: false, why: probe.why };
if (probe.state === "not-staged") return { checked: true, state: "no-staged-residual" };
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session,
submitOnly: true,
expectedStagedText: identity.expectedStagedText,
expectedStagedLineCount: identity.expectedLines,
}, transportRequestOptions(waitForIdleMs));
let enter: { status: number; data: Record<string, unknown> };
try {
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session,
submitOnly: true,
expectedStagedText: identity.expectedStagedText,
expectedStagedLineCount: identity.expectedLines,
}, transportRequestOptions(waitForIdleMs));
} catch (err) {
return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: (err as Error).message };
}
if (enter.status >= 400) {
return { checked: true, state: "staged", remedy: "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
const phase = enter.data?.["inputPhase"];
const reason = enter.data?.["reason"];
const unconfirmed = phase === "attempted" || phase === "written"
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
return { checked: true, state: "staged", remedy: unconfirmed ? "submit-unconfirmed" : "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
}
const recheck = await detectStagedAtPrompt(client, session, identity);
if (recheck.state === "unchecked") return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: recheck.why };
return recheck.state === "staged"
? { checked: true, state: "staged", remedy: "submitted-still-staged" }
: { checked: true, state: "staged", remedy: "submitted-cleared" };
@@ -705,13 +717,17 @@ function effectHumanLines(effect: EffectCheck, session: string): string[] {
return [`Note: the pane-effect check could not run (${effect.why}); the verdict above is transport-level only.`];
}
if (effect.state === "no-staged-residual") return [];
if (effect.remedy === "submit-unconfirmed") return [
`Delivery: submission unconfirmed — the text was staged before one guarded Enter attempt, but the result could not be confirmed (${effect.detail}). Inspect with: rig capture ${session}`,
];
if (effect.remedy === "submit-refused") return [
`Delivery: unconfirmed — the guarded Enter was refused (${effect.detail}); the earlier staged observation may have changed. Inspect with: rig capture ${session}`,
];
const out = ["Delivery: staged, not consumed (checked: post-send pane capture; observed: the sent text is still at the prompt — typed, never submitted)"];
if (effect.remedy === "submitted-cleared") {
out.push("Remedy: one guarded Enter submitted — the staged text left the prompt.");
} else if (effect.remedy === "submitted-still-staged") {
out.push(`Remedy: one guarded Enter submitted, but the text is STILL at the prompt — not consumed; stopping here (one submit is the contract). Inspect with: rig capture ${session}`);
} else {
out.push(`Remedy: the single guarded Enter (submit path) was refused (${effect.detail}); stopping here — one submit is the contract. Inspect with: rig capture ${session}`);
}
return out;
}
@@ -721,6 +737,10 @@ function effectUnresolved(effect: EffectCheck | undefined): boolean {
return !!effect && effect.checked && effect.state === "staged" && effect.remedy !== "submitted-cleared";
}
function effectSubmissionUnconfirmed(effect: EffectCheck | undefined): boolean {
return !!effect && effect.checked && effect.state === "staged" && (effect.remedy === "submit-unconfirmed" || effect.remedy === "submit-refused");
}
/**
* S3 (OPR.0.5.4.6) — the staged-at-prompt detector, walk's staged-evidence
* primitive generalized to plain sends. POSITIVE evidence only: text rendered
@@ -754,7 +774,7 @@ async function detectStagedAtPrompt(
for (let i = lines.length - 1; i >= 0; i--) {
if (PROMPT_LINE_PATTERN.test(lines[i]!)) { lastPrompt = i; break; }
}
if (lastPrompt === -1) return { state: "not-staged" };
if (lastPrompt === -1) return { state: "unchecked", why: "no input prompt was visible in the capture" };
const inputRegionRaw = lines.slice(lastPrompt).join("\n").replace(PROMPT_LINE_PATTERN, "");
// IDENTITY FIRST (round-2 F2, desk-binding): the literal-residual match uses
// the daemon submit-precheck's own normalization (strip ALL whitespace,
@@ -1083,6 +1103,7 @@ async function runFanOutSend(params: {
}
const effectBySeat = new Map((effects ?? []).map((e) => [e.sessionName, e.effect]));
const unresolvedCount = (effects ?? []).filter((e) => effectUnresolved(e.effect)).length;
const unconfirmedCount = (effects ?? []).filter((e) => effectSubmissionUnconfirmed(e.effect)).length;
if (opts.json) {
// Round-2 F1: ONE verdict per recipient — a staged-unresolved recipient's
@@ -1090,7 +1111,7 @@ async function runFanOutSend(params: {
const rawResults = (res.data["results"] as Array<{ sessionName: string; ok: boolean; outcome?: string }> | undefined) ?? [];
const encodedResults = rawResults.map((r) =>
effectUnresolved(effectBySeat.get(r.sessionName))
? { ...r, ok: false, verified: false, outcome: "staged-not-consumed", error: "staged, not consumed (pane effect); the one guarded submit did not clear it" }
? { ...r, ok: false, verified: false, outcome: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "submission-unconfirmed" : "staged-not-consumed", error: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "delivery unconfirmed; inspect the recipient after the guarded submit attempt" : "staged, not consumed (pane effect); the one guarded submit did not clear it" }
: r
);
// Round-3 (r2 row 00fb3a68): the machine-readable AGGREGATE derives from
@@ -1133,6 +1154,8 @@ async function runFanOutSend(params: {
if (ec && ec.checked && ec.state === "staged") {
if (ec.remedy === "submitted-cleared") {
console.log(`${r.sessionName}: sent — staged at the prompt, cleared by one guarded Enter (consumed)`);
} else if (effectSubmissionUnconfirmed(ec)) {
console.log(`${r.sessionName}: ${effectHumanLines(ec, r.sessionName)[0]}`);
} else {
console.log(`${r.sessionName}: staged, not consumed — ${effectHumanLines(ec, r.sessionName).slice(-1)[0]}`);
}
@@ -1145,7 +1168,7 @@ async function runFanOutSend(params: {
}
// The delivered count never includes a staged-unresolved recipient.
const deliveredCount = Math.max(0, ((data["sent"] as number) ?? 0) - unresolvedCount);
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount > 0 ? `, ${unresolvedCount} staged-not-consumed` : ""}`);
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount - unconfirmedCount > 0 ? `, ${unresolvedCount - unconfirmedCount} staged-not-consumed` : ""}${unconfirmedCount > 0 ? `, ${unconfirmedCount} submission-unconfirmed` : ""}`);
// S2 (OPR.0.5.4.3): surface additive advisories (e.g. the unknown-sender
// sign-it notice) — an env-less operator must see it, not "sent" lines alone.
const fanoutAdvisory = data["warning"] as string | undefined;
+7 -1
View File
@@ -251,7 +251,8 @@ export function slackCommand(deps: SlackDeps = {}): Command {
log(`Daemon: ${observation.state}${observation.reason ? ` (${observation.reason})` : ""}`);
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
inboundRouting?: { fallback: string; destination: string };
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string;
settlement?: { pending: number; lastFailure: string | null };
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string; relayAttemptsInFlight?: number;
inbound?: InboundDeliveryStatus & { state?: string; generation?: number; lastEventAt?: string };
recovery?: { state?: string; reason?: string; lastScanAt?: string; acceptedThisProcess?: number; deadLetteredThisProcess?: number;
coverage?: { coverageStart: string; coveredThrough: string; pending?: { upper: string; nextLatest: string }; nextRetryAt?: number } | null;
@@ -261,6 +262,10 @@ export function slackCommand(deps: SlackDeps = {}): Command {
backoff?: Record<string, number>; incomplete?: Record<string, number> };
limits?: string[] } } | undefined;
if (connector) {
if (connector.settlement) {
log(` Ask edits: ${connector.settlement.pending} queued for reconciliation`);
if (connector.settlement.lastFailure) log(` Last ask-edit failure: ${connector.settlement.lastFailure}`);
}
if (connector.inboundRouting?.fallback === "local") log(` Configured routing fallback: local to ${connector.inboundRouting.destination} (after forwarding, thread and channel routes)`);
log(` Socket: ${connector.inbound?.state ?? "unknown"}; generation ${connector.inbound?.generation ?? "unknown"}; last event ${connector.inbound?.lastEventAt ?? "unknown"}`);
for (const line of socketDeliveryLines(connector.inbound)) log(` ${line}`);
@@ -286,6 +291,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
+ `${reasons(threads.backoff) ? `; backing off: ${reasons(threads.backoff)}` : ""}${reasons(threads.incomplete) ? `; incomplete: ${reasons(threads.incomplete)}` : ""}`);
}
log(` Recovery counts since connector start: accepted ${recovery?.acceptedThisProcess ?? "unknown"}; dead-lettered ${recovery?.deadLetteredThisProcess ?? "unknown"} (custody, not delivery)`);
if (connector.relayAttemptsInFlight !== undefined) log(` Relay attempts awaiting response: ${connector.relayAttemptsInFlight} (this process, point-in-time; zero does not prove global quiescence)`);
if (connector.deadLetterBacklogState !== undefined) {
const scope = "inbound messages, reactions and click answers";
if (connector.deadLetterBacklogState === "unknown") {
+21 -13
View File
@@ -157,21 +157,25 @@ export async function resolveLibrarySpec(
nameOrId: string,
opts?: { kind?: string },
): Promise<LibraryEntry> {
const res = await client.get<LibraryEntry[]>(
opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library",
);
const rawEntries = res.data ?? [];
const entries =
const url = opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
const filterKind = (entries: LibraryEntry[]) =>
opts?.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
// Try exact ID match first
? entries.filter((entry) => entry.kind === opts.kind)
: entries;
const visibleEntries = filterKind(res.data ?? []);
// An exact ID always wins, including when a visible name matches a hidden ID.
const visibleId = visibleEntries.find((entry) => entry.id === nameOrId);
if (visibleId) return visibleId;
const separator = opts?.kind !== undefined ? "&" : "?";
const all = await client.get<LibraryEntry[]>(`${url}${separator}include_builtins=true`);
const entries = filterKind(all.data ?? []);
const byId = entries.find((e) => e.id === nameOrId);
if (byId) return byId;
// Try name match
const byName = entries.filter((e) => e.name === nameOrId);
// Visible names take precedence over hidden built-ins with the same name.
const visibleNames = visibleEntries.filter((e) => e.name === nameOrId);
const byName = visibleNames.length ? visibleNames : entries.filter((e) => e.name === nameOrId);
if (byName.length === 1) return byName[0]!;
if (byName.length > 1) {
@@ -230,12 +234,16 @@ Examples:
cmd.command("ls")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
.option("--include-builtins", "Include built-ins hidden by the visibility setting")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
.action(async (opts: { kind?: string; json?: boolean; includeBuiltins?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const url = opts.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const params = new URLSearchParams();
if (opts.kind !== undefined) params.set("kind", opts.kind);
if (opts.includeBuiltins) params.set("include_builtins", "true");
const url = `/api/specs/library${params.size ? `?${params}` : ""}`;
const res = await client.get<LibraryEntry[]>(url);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const rawEntries = res.data ?? [];
+6 -1
View File
@@ -104,7 +104,7 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
.description("Append a stream item")
.requiredOption("--source <session>", "Source session (e.g. dev-build@starter)")
.requiredOption("--body <text>", "Stream item body")
.option("--hint-destination <session>", "Hint at intended destination seat")
.option("--hint-destination <session>", "Hint at intended destination seat (the queue worker routes by this hint)")
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
.option("--hint-tags <tags>", "Comma-separated hint tags")
@@ -112,6 +112,11 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
.option("--interrupt", "Mark item as interrupting")
.option("--id <streamItemId>", "Idempotent stream_item_id (skip if not provided)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Stream intake is on by default: the seat named by stream.intake (queue.worker unless set)
gets an intake row for each item, which wakes it, or a note on its intake row still pending.
--hint-urgency critical or --interrupt makes that row urgent. The reply names only the stream item.
To keep emits passive: rig config set stream.intake off`)
.action(async (opts: {
source: string;
body: string;
@@ -11,7 +11,7 @@ interface AgentLibraryEntry {
/** Resolve the runtime's terminal seat or the shipped general-purpose agent. */
export async function resolveDefaultAgentRef(client: DaemonClient, runtime: string): Promise<string> {
if (runtime === "terminal") return "builtin:terminal";
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent");
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent&include_builtins=true");
const entry = res.data?.find(
(candidate) =>
candidate.kind === "agent"
+26 -10
View File
@@ -273,7 +273,7 @@ initial record is explicitly reported as unverified delivery.`)
let verdict = await pollConsumed();
if (verdict === "generation-rolled") {
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); the walk cannot continue into a different generation.");
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); delivery is unconfirmed. Inspect the recipient before any resend; the walk cannot continue into a different generation.");
return;
}
if (verdict === "timeout") {
@@ -289,23 +289,39 @@ initial record is explicitly reported as unverified delivery.`)
/\[Pasted text #\d+ \+\d+ lines\]/.test(pane)
);
if (stagedEvidence) {
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session: seat,
submitOnly: true,
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
expectedStagedLineCount: piece.content.split("\n").length,
}, { headers: terminalAuthHeaders() });
let enter: { status: number; data: Record<string, unknown> };
try {
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session: seat,
submitOnly: true,
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
expectedStagedLineCount: piece.content.split("\n").length,
}, { headers: terminalAuthHeaders() });
} catch (error) {
failPiece(i, piece.label, `the guarded submit result is unconfirmed (${(error as Error).message}); inspect the recipient before any resend.`);
return;
}
if (enter.status >= 400) {
failPiece(i, piece.label, `typed but not consumed; the single submit retry was refused (${(enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`}).`);
const phase = enter.data?.["inputPhase"], reason = enter.data?.["reason"];
const unconfirmed = phase === "attempted" || phase === "written"
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
const detail = (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`;
failPiece(i, piece.label, unconfirmed
? `the guarded submit result is unconfirmed (${detail}); inspect the recipient before any resend.`
: `the single guarded submit was refused (${detail}); the earlier staged observation may have changed. Inspect the recipient before any resend.`);
return;
}
verdict = await pollConsumed();
if (verdict === "generation-rolled") {
failPiece(i, piece.label, "the seat's generation rolled after the guarded submit; delivery is unconfirmed. Inspect the recipient before any resend.");
return;
}
if (verdict !== "consumed") {
failPiece(i, piece.label, `typed and staged, but not consumed even after the single submit retry — the piece never entered the seat's conversation record. Not re-sending (one retry is the contract).`);
failPiece(i, piece.label, "no matching conversation record was observed within the verification window after the single submit retry; delivery is unconfirmed. Inspect the recipient before any resend.");
return;
}
} else {
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"} but the piece is neither consumed in the generation record nor staged in the pane — delivery lost; consumption verification fails this walk closed.`);
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"}, but no matching record or staged input was observed within the verification window. Delivery and consumption are unconfirmed; inspect the recipient before any resend.`);
return;
}
}
+34 -5
View File
@@ -13,7 +13,8 @@ import {
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
export interface RiggedConfig {
launch: { nonInterruptive: boolean };
specs: { builtinsVisible: boolean };
launch: { nonInterruptive: boolean; codexLoginShell: boolean };
daemon: { port: number; host: string; dbConflict?: "warn" | "stop" };
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
// OPR.0.4.6.MH1 FR-4 — the own-host display name (default "localhost").
@@ -229,6 +230,10 @@ export interface RiggedConfig {
wakeSwapGraceSeconds: number;
wakeHumanRung: string;
};
// Issue #1856 — stream intake setting
stream: {
intake: string;
};
}
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
@@ -262,7 +267,7 @@ const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigP
);
const DEFAULTS = {
launch: { nonInterruptive: false },
launch: { nonInterruptive: false, codexLoginShell: false },
daemon: { port: 7433, host: "127.0.0.1", dbConflict: "warn" },
// OPR.0.4.6.MH1 FR-1 — "local" ≡ no remote selection (LOCAL_HOST_ID).
// FR-4 — own-host display name; default "localhost" (PRD-named).
@@ -283,6 +288,7 @@ const DEFAULTS = {
topology: { root: getDefaultOpenRigPath("topology") },
// OPR.0.5.9.5 Wave B — canonical addressable context library.
context: { root: getDefaultOpenRigPath("context"), systemWorld: "default" },
specs: { builtinsVisible: true },
skills: { root: getDefaultOpenRigPath("skills"), routerLine: { enabled: false } },
onboarding: { defaultPack: { enabled: true } },
health: { contextPressure: { warningPercent: 95, criticalPercent: 99 } },
@@ -388,6 +394,10 @@ const DEFAULTS = {
consumers: "",
templateDir: "",
},
// Issue #1856 — stream intake default queue.worker; read live by the stream intake coordinator.
stream: {
intake: "queue.worker",
},
// OPR.0.4.6.FS-1 W2 — retention defaults (twin of daemon getDefaultValue).
retention: {
enabled: true,
@@ -454,11 +464,13 @@ export const VALID_KEYS = [
// OPR.0.5.9.5 Wave B — canonical context library; old key is refused.
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
// OPR.0.7.0.13 (router): the person's recorded yes to OpenRig's one-line seat-skills bootstrap in AGENTS.md / CLAUDE.md.
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
"launch.non_interruptive",
"launch.codex_login_shell",
"health.context_pressure.warning_percent",
"health.context_pressure.critical_percent",
"files.allowlist",
@@ -537,6 +549,7 @@ export const VALID_KEYS = [
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
"retention.stream_days",
"retention.stream_max_items",
"stream.intake",
"retention.batch_size",
// S04 — pickup-receipt stall threshold; lockstep with the daemon settings-store twin.
"queue.pickup_stall_threshold_minutes",
@@ -581,10 +594,12 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" },
"context.root": { primary: "OPENRIG_CONTEXT_ROOT" },
"context.system_world": { primary: "OPENRIG_CONTEXT_SYSTEM_WORLD" },
"specs.builtins_visible": { primary: "OPENRIG_SPECS_BUILTINS_VISIBLE" },
"skills.root": { primary: "OPENRIG_SKILLS_ROOT" },
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
"launch.non_interruptive": { primary: "OPENRIG_LAUNCH_NON_INTERRUPTIVE" },
"launch.codex_login_shell": { primary: "OPENRIG_LAUNCH_CODEX_LOGIN_SHELL" },
"health.context_pressure.warning_percent": { primary: "OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT" },
"health.context_pressure.critical_percent": { primary: "OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT" },
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
@@ -652,6 +667,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
"stream.intake": { primary: "OPENRIG_STREAM_INTAKE" },
"retention.batch_size": { primary: "OPENRIG_RETENTION_BATCH_SIZE" },
"queue.pickup_stall_threshold_minutes": { primary: "OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES" },
"queue.stuck_sweep_interval_seconds": { primary: "OPENRIG_QUEUE_STUCK_SWEEP_INTERVAL_SECONDS" },
@@ -688,10 +704,12 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"topology.root": ["topology", "root"],
"context.root": ["context", "root"],
"context.system_world": ["context", "systemWorld"],
"specs.builtins_visible": ["specs", "builtinsVisible"],
"skills.root": ["skills", "root"],
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
"launch.non_interruptive": ["launch", "nonInterruptive"],
"launch.codex_login_shell": ["launch", "codexLoginShell"],
"health.context_pressure.warning_percent": ["health", "contextPressure", "warningPercent"],
"health.context_pressure.critical_percent": ["health", "contextPressure", "criticalPercent"],
"files.allowlist": ["files", "allowlist"],
@@ -750,6 +768,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
"retention.stream_days": ["retention", "streamDays"],
"retention.stream_max_items": ["retention", "streamMaxItems"],
"stream.intake": ["stream", "intake"],
"retention.batch_size": ["retention", "batchSize"],
"queue.pickup_stall_threshold_minutes": ["queue", "pickupStallThresholdMinutes"],
"queue.stuck_sweep_interval_seconds": ["queue", "stuckSweepIntervalSeconds"],
@@ -1113,6 +1132,12 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
}
},
"stream.intake": (raw) => {
const val = (raw ?? "").trim();
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
}
},
"retention.batch_size": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
@@ -1259,6 +1284,7 @@ export class ConfigStore {
root: v("context.root") as string,
systemWorld: v("context.system_world") as string,
},
specs: { builtinsVisible: v("specs.builtins_visible") as boolean },
skills: {
root: v("skills.root") as string,
routerLine: { enabled: v("skills.router_line.enabled") as boolean },
@@ -1306,7 +1332,7 @@ export class ConfigStore {
auditLog: v("feed.subscriptions.audit_log") as boolean,
},
},
launch: { nonInterruptive: v("launch.non_interruptive") as boolean },
launch: { nonInterruptive: v("launch.non_interruptive") as boolean, codexLoginShell: v("launch.codex_login_shell") as boolean },
runtime: {
readinessTimeoutSeconds: v("runtime.readiness_timeout_seconds") as number,
codex: {
@@ -1385,6 +1411,9 @@ export class ConfigStore {
wakeSwapGraceSeconds: v("queue.wake_swap_grace_seconds") as number,
wakeHumanRung: v("queue.wake_human_rung") as string,
},
stream: {
intake: v("stream.intake") as string,
},
};
}
@@ -1525,7 +1554,7 @@ export class ConfigStore {
const critical = getNestedValue(fileConfig, KEY_TO_PATH["health.context_pressure.critical_percent"])
?? getDefaultValue("health.context_pressure.critical_percent", workspaceRoot);
if ((warning as number) >= (critical as number)) {
throw new Error(`Invalid context-pressure policy: warning (${warning}) must be less than critical (${critical})`);
throw new Error(`Invalid context-pressure policy for the config-file pair: warning (${warning}) must be less than critical (${critical}). Unset file values use defaults; environment overrides apply when each process resolves configuration and are not part of this check.`);
}
}
mkdirSync(dirname(this.configPath), { recursive: true });
@@ -1577,7 +1606,7 @@ export class ConfigStore {
const critical = getNestedValue(fileConfig, KEY_TO_PATH["health.context_pressure.critical_percent"])
?? getDefaultValue("health.context_pressure.critical_percent", workspaceRoot);
if ((warning as number) >= (critical as number)) {
throw new Error(`Invalid context-pressure policy: warning (${warning}) must be less than critical (${critical})`);
throw new Error(`Invalid context-pressure policy for the config-file pair: warning (${warning}) must be less than critical (${critical}). Unset file values use defaults; environment overrides apply when each process resolves configuration and are not part of this check.`);
}
}
pruneEmptyAncestors(fileConfig, parentParts);
+3 -1
View File
@@ -36,6 +36,8 @@ export interface CrossHostTargetResolution {
target: string;
/** The sugar-derived host id (registered suffix), if any. */
sugarHost: string | undefined;
/** The qualifier explicitly names this host, overriding a persisted remote selection. */
localHost?: boolean;
/**
* Loud-failure hint when the target was 3-part-SHAPED but the suffix
* matched no registered host. Callers append it to failure surfaces for
@@ -102,7 +104,7 @@ export function resolveCrossHostTarget(
};
}
// Routes home: strip the self-suffix, no cross-host sugarHost (local send).
return { ok: true, target: base, sugarHost: undefined, hint: undefined };
return { ok: true, target: base, sugarHost: undefined, localHost: true, hint: undefined };
}
const loader = registryLoader ?? loadHostRegistry;
+3
View File
@@ -34,6 +34,7 @@ import type { GatewayCommandDeps } from "./commands/gateway.js";
import { parkedCommand } from "./commands/parked.js";
import { mcpCommand } from "./commands/mcp.js";
import { acpCommand } from "./commands/acp.js";
import { execCommand, type ExecDeps } from "./commands/exec.js";
import { agentCommand, type AgentDeps } from "./commands/agent.js";
import { rigCommand, type RigDeps } from "./commands/rig.js";
import { transcriptCommand } from "./commands/transcript.js";
@@ -120,6 +121,7 @@ export interface ProgramDeps {
psDeps?: StatusDeps;
mcpDeps?: StatusDeps;
acpDeps?: StatusDeps;
execDeps?: ExecDeps;
agentDeps?: AgentDeps;
rigDeps?: RigDeps;
transcriptDeps?: StatusDeps;
@@ -206,6 +208,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(rosterCommand());
program.addCommand(mcpCommand(depsOverride?.mcpDeps));
program.addCommand(acpCommand(depsOverride?.acpDeps));
program.addCommand(execCommand(depsOverride?.execDeps));
program.addCommand(agentCommand(depsOverride?.agentDeps));
program.addCommand(rigCommand(depsOverride?.rigDeps));
program.addCommand(transcriptCommand(depsOverride?.transcriptDeps));
+1
View File
@@ -42,6 +42,7 @@ export const COMMAND_LOADERS: ReadonlyMap<string, Load> = new Map<string, Load>(
["roster", async () => (await import("./commands/roster.js")).rosterCommand()],
["mcp", async () => (await import("./commands/mcp.js")).mcpCommand()],
["acp", async () => (await import("./commands/acp.js")).acpCommand()],
["exec", async () => (await import("./commands/exec.js")).execCommand()],
["agent", async () => (await import("./commands/agent.js")).agentCommand()],
["spec", async () => (await import("./commands/rig.js")).rigCommand()],
["transcript", async () => (await import("./commands/transcript.js")).transcriptCommand()],
+36 -7
View File
@@ -62,6 +62,31 @@ function bodyStartIndex(lines: string[]): number {
return 0; // unterminated frontmatter — treat as no frontmatter
}
/** Keep source indices while hiding code examples from the progress grammar. */
function progressLines(lines: string[]): string[] {
let fence: { marker: string; length: number } | null = null;
const start = bodyStartIndex(lines);
return lines.map((line, i) => {
if (i < start) return "";
const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (match) {
const marker = match[1]![0]!;
const length = match[1]!.length;
const tail = match[2]!;
if (fence) {
if (marker === fence.marker && length >= fence.length && /^[ \t\r]*$/.test(tail)) {
fence = null;
return line; // A new row may be inserted after the closing fence.
}
} else if (marker !== "`" || !tail.includes("`")) {
fence = { marker, length };
}
return "";
}
return fence ? "" : line;
});
}
export interface ProgressEditResult {
content: string;
changed: boolean;
@@ -92,32 +117,35 @@ export function addProgressRow(
const newRow = `- [${statusIndicator(opts.status)}] ${text}`;
const lines = content.split("\n");
const start = bodyStartIndex(lines);
const visible = progressLines(lines);
// Locate the target section heading.
let sectionIdx = -1;
for (let i = start; i < lines.length; i++) {
const h = lines[i]!.match(HEADING_RE);
const h = visible[i]!.match(HEADING_RE);
if (h && h[1] === "##" && h[2]!.trim() === section) { sectionIdx = i; break; }
}
if (sectionIdx === -1) {
// Create the section, appended after the last existing content.
let end = lines.length;
while (end > start && lines[end - 1]!.trim() === "") end--;
while (end > start && visible[end - 1]!.trim() === "") end--;
const head = lines.slice(0, end);
const rebuilt = [...head, "", `## ${section}`, "", newRow, ""].join("\n");
const suffix = lines.slice(end);
const rebuilt = [...head, "", `## ${section}`, "", newRow, "",
...(suffix.some(line => line.trim() !== "") ? suffix : [])].join("\n");
return { content: rebuilt, changed: true };
}
// Section block = [sectionIdx+1, nextHeadingOrEOF).
let blockEnd = lines.length;
for (let i = sectionIdx + 1; i < lines.length; i++) {
if (HEADING_RE.test(lines[i]!)) { blockEnd = i; break; }
if (HEADING_RE.test(visible[i]!)) { blockEnd = i; break; }
}
// Idempotency / conflict scan within the section.
for (let i = sectionIdx + 1; i < blockEnd; i++) {
const cb = lines[i]!.match(ROW_RE);
const cb = visible[i]!.match(ROW_RE);
if (cb && cb[3]!.trim() === text) {
const current = indicatorToStatus(cb[2]!);
if (current === opts.status) return { content, changed: false };
@@ -132,7 +160,7 @@ export function addProgressRow(
// Insert after the last non-blank line in the section block.
let insertAt = sectionIdx + 1;
for (let i = sectionIdx + 1; i < blockEnd; i++) {
if (lines[i]!.trim() !== "") insertAt = i + 1;
if (visible[i]!.trim() !== "") insertAt = i + 1;
}
lines.splice(insertAt, 0, newRow);
return { content: lines.join("\n"), changed: true };
@@ -155,10 +183,11 @@ export function setProgressRow(
const text = opts.text.trim();
const lines = content.split("\n");
const start = bodyStartIndex(lines);
const visible = progressLines(lines);
const matches: number[] = [];
for (let i = start; i < lines.length; i++) {
const cb = lines[i]!.match(ROW_RE);
const cb = visible[i]!.match(ROW_RE);
if (cb && cb[3]!.trim() === text) matches.push(i);
}
+4 -1
View File
@@ -31,7 +31,10 @@ export type FindingKind =
// SPEC.md compatibility — a node carrying BOTH authored files. Advisory by
// construction (low severity never flips the exit code): SPEC.md wins, and the
// shadowed README.md is a state to notice, never a failure to gate on.
| "shadowed_node_file";
| "shadowed_node_file"
// Another mission has a slice folder of the same name, which the slice list and the name-only routes
// hide. Advisory (low severity): renaming is the person's call.
| "slice_name_shared";
export interface AuditFinding {
kind: FindingKind;
+36 -52
View File
@@ -362,31 +362,23 @@ export function listMissions(missionsRoot: string): MissionInfo[] {
const hasReadme = readmePath !== null;
if (!hasReadme) continue;
const frontmatter = readFrontmatter(readmePath);
const slicesDir = path.join(absPath, "slices");
const closedDir = path.join(absPath, "closed");
const activeSliceCount = countSliceDirs(slicesDir);
const closedSliceCount = countSliceDirs(closedDir);
const id = pickIdFromFrontmatter(frontmatter);
out.push({
name: entry.name,
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
id,
activeSliceCount,
closedSliceCount,
});
out.push(withSliceCounts({ name: entry.name, absPath, readmePath, frontmatter, id }));
}
// Stable order: by mission name.
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function countSliceDirs(dir: string): number {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
.length;
/** The mission with each of its slices counted once, by its standing (the same one `slice ls` filters on). */
function withSliceCounts(base: Omit<MissionInfo, "activeSliceCount" | "shippedSliceCount" | "closedSliceCount">): MissionInfo {
const mission: MissionInfo = { ...base, activeSliceCount: 0, shippedSliceCount: 0, closedSliceCount: 0 };
for (const { standing } of slicesWithStanding(mission)) {
if (standing === "active") mission.activeSliceCount++;
else if (standing === "shipped") mission.shippedSliceCount++;
else mission.closedSliceCount++;
}
return mission;
}
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
@@ -425,15 +417,7 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
const readmePath = resolveNodeFile(absPath);
const frontmatter = readmePath ? readFrontmatter(readmePath) : {};
return {
name: path.basename(absPath),
absPath,
readmePath,
frontmatter,
id: pickIdFromFrontmatter(frontmatter),
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
};
return withSliceCounts({ name: path.basename(absPath), absPath, readmePath, frontmatter, id: pickIdFromFrontmatter(frontmatter) });
}
// ---------------------------------------------------------------------
@@ -444,35 +428,35 @@ export function listSlices(
mission: MissionInfo,
state: SliceState,
): SliceInfo[] {
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
if (state === "active" || state === "shipped" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
}
if (state === "closed" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
}
const out: SliceInfo[] = [];
for (const { root } of dirs) {
return slicesWithStanding(mission)
.filter(({ standing }) => state === "all" || standing === state)
.map(({ slice }) => slice);
}
/** Where a slice stands. In closed/, it's closed. In place: shipped (or done) is delivered; a closed or superseded
* status, or a superseded or retired stage, is an exit, so closed; anything else, blocked work included, is active. */
export function sliceStanding(slice: SliceInfo, inClosedFolder: boolean): Exclude<SliceState, "all"> {
if (inClosedFolder) return "closed";
const status = (slice.status ?? "").toLowerCase();
const stage = typeof slice.frontmatter.stage === "string" ? slice.frontmatter.stage.toLowerCase() : "";
if (status === "done" || status.startsWith("shipped")) return "shipped";
if (status.startsWith("closed") || status === "superseded" || stage === "superseded" || stage === "retired") return "closed";
return "active";
}
/** Every slice in the mission (slices/ and closed/), in order, each with its standing. */
function slicesWithStanding(mission: MissionInfo): Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> {
const out: Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> = [];
for (const [folder, inClosedFolder] of [["slices", false], ["closed", true]] as const) {
const root = path.join(mission.absPath, folder);
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const m = SLICE_DIRNAME_RE.exec(entry.name);
if (!m) continue;
const sliceInfo = buildSliceInfo(mission, root, entry.name);
out.push(sliceInfo);
if (!entry.isDirectory() || !SLICE_DIRNAME_RE.test(entry.name)) continue;
const slice = buildSliceInfo(mission, root, entry.name);
out.push({ slice, standing: sliceStanding(slice, inClosedFolder) });
}
}
out.sort((a, b) => (a.nn ?? Number.MAX_SAFE_INTEGER) - (b.nn ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name));
if (state === "active") {
// active = not in closed/, and not already delivered in-place.
return out.filter((s) => {
const st = (s.status ?? "").toLowerCase();
return st !== "done" && !st.startsWith("closed") && !st.startsWith("shipped");
});
}
if (state === "shipped") {
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
}
out.sort((a, b) => (a.slice.nn ?? Number.MAX_SAFE_INTEGER) - (b.slice.nn ?? Number.MAX_SAFE_INTEGER) || a.slice.name.localeCompare(b.slice.name));
return out;
}
+3
View File
@@ -30,7 +30,10 @@ export interface MissionInfo {
/** Minted dot-ID for this mission (read from frontmatter if present,
* otherwise inferred from the folder name pattern). */
id: string | null;
/** Slices in flight (blocked included); delivered in place; and closed (closed/, or an exit in place). Each slice is
* counted once, by the standing `slice ls --state` filters on. */
activeSliceCount: number;
shippedSliceCount: number;
closedSliceCount: number;
}
+51 -16
View File
@@ -336,10 +336,11 @@ function scopeFor(access: McpAccess, seatOf: () => string | undefined): Scope {
};
}
/** The grant service names an external caller's seat cus-<id>-caller@<rig>. v1 marks no seat as a caller, so an item
* from a seat of this shape is never the team's question to an external caller, and an external caller can't hand a
* task to one. The shape never grants anything. */
const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
/** The grant service names each connected assistant's seat cus-<id>-<connection>@<rig> (an existing connection's is
* cus-<id>-caller@<rig>). v1 marks no seat as a caller, so for a grant without team/list an item from a seat of this
* shape is never the team's question to it, and it can't hand a task to one. The shape only refuses; it never grants
* anything, and a grant with team/list doesn't read it. */
const CALLER_SHAPED = /^cus-[^@]+-[^@]+@[^@]+$/;
/** OpenRig's own tags that decide what a person asked about a row sees of its body: what Slack ingestion records about where
* the row came from and which parts of its body OpenRig wrote (the sender's channel, the attachments block, the body's
@@ -349,11 +350,15 @@ const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
const OPENRIG_PROVENANCE_TAG = /^(?:founder-slack|inbound|shared-channel|gate)$|^participant:|^slack-/;
/** An external grant (openrig.grant/v1): a requester, narrower than a work grant. The grant's own seat is the
* only caller. It may hand tasks to its rigs' team seats (never a caller-shaped seat, a person or another host's
* seat), read the items it sent and the team's questions to it (never team-internal or another caller's items), follow
* a handed-on item only where it came back to it, and read the artifacts of its own items under its named roots. */
* only caller. It may hand tasks to its rigs' other seats (never a person or another host's seat, and without team/list
* never a caller-shaped seat), read the items it sent and those addressed to it from those seats (never team-internal
* items or other callers' items between themselves), change only the items addressed to its own seat (with the claim and
* update pairs), follow a handed-on item only where it came back to it, and read the artifacts of its own items under
* its named roots. */
function externalScope(access: McpAccess, seatOf: () => string | undefined): Scope {
const rigs = new Set(access.rigs);
// team/list makes the caller one of its rigs' participants: it may reach another connected assistant's seat.
const participant = access.external?.objects.team?.includes("list") === true;
const caller = (value: unknown): boolean => typeof value === "string" && value !== "" && value === seatOf();
// A seat of one of its rigs on this instance: member@rig, never a person or another host's seat.
const member = (value: unknown): boolean => {
@@ -361,17 +366,20 @@ function externalScope(access: McpAccess, seatOf: () => string | undefined): Sco
const parts = value.split("@");
return parts.length === 2 && parts[0] !== "" && parts[1] !== "external" && rigs.has(parts[1]!);
};
const team = (value: unknown): boolean => member(value) && !caller(value) && !CALLER_SHAPED.test(value as string);
const team = (value: unknown): boolean => member(value) && !caller(value) && (participant || !CALLER_SHAPED.test(value as string));
const field = (row: unknown, name: "sourceSession" | "destinationSession"): unknown => ((row ?? {}) as Record<string, unknown>)[name];
const item = (row: unknown): boolean => caller(field(row, "sourceSession"))
|| (caller(field(row, "destinationSession")) && team(field(row, "sourceSession")));
return {
session: (value) => caller(value) || team(value),
item,
owner: () => false,
// Only an item addressed to its exact seat, and one it may read, is its own to change; the daemon's claim also checks
// the claimant.
owner: (row) => caller(field(row, "destinationSession")) && item(row),
rig: (name) => typeof name === "string" && rigs.has(name),
root: (name) => typeof name === "string" && access.roots.includes(name),
// A task goes to the team: never to the caller itself or to another caller-shaped seat, which v1 can't tell apart.
// A task goes to another seat of its rigs: never to the caller itself, and without team/list never to another
// caller-shaped seat, which v1 can't tell apart.
destination: team,
successor: caller,
artifact: (row) => caller(field(row, "sourceSession")),
@@ -480,7 +488,8 @@ export type McpAccess = {
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
const EXTERNAL_TOOLS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list" },
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list", claim: "rig_queue_claim", update: "rig_queue_update" },
team: { list: "rig_team" },
artifact: { read: "rig_artifact_read" },
};
@@ -564,6 +573,16 @@ const SHARED_INSTRUCTIONS = [
];
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
const EXTERNAL_INSTRUCTIONS = SHARED_INSTRUCTIONS.join("\n");
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
].join("\n");
function instructionsFor(access: McpAccess | undefined): string {
if (!access?.external) return INSTRUCTIONS;
const tools = externalTools(access.external.objects);
return tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
}
/**
* Maps a DaemonResponse to MCP tool result.
@@ -616,7 +635,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
const server = new McpServer({
name: "openrig",
version: CLI_VERSION,
}, { instructions: options.access?.external ? EXTERNAL_INSTRUCTIONS : INSTRUCTIONS });
}, { instructions: instructionsFor(options.access) });
const { access } = options;
const rostersFolder = options.rostersFolder ?? (() => nodePath.join(new ConfigStore().get("workspace.root") as string, "rosters"));
const presenceFile = options.presenceFile ?? (() => mcpPresenceFilePath());
@@ -1345,7 +1364,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
try {
const denied = await itemOutOfScope(qitemId);
if (denied) return denied;
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {}), undefined, { structured: true });
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
// An external caller's claimed item goes through the same projection as show and list.
const view = externalView(scope);
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
} catch (err) {
return caught(err);
}
@@ -1376,9 +1398,18 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
try {
const denied = await itemOutOfScope(qitemId, "owner");
if (denied) return denied;
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
// An external caller waits only on an item it names and can read: parking on a person would ask them, parking on
// another item would say whether it exists, and a park that names nothing would restore a blocker the team set.
if (access?.external && (blockedOn || state === "blocked")) {
const blocker = blockedOn?.startsWith("qitem-") ? await client.get(`/api/queue/${encodeURIComponent(blockedOn)}`) : null;
if (!blocker || blocker.status >= 400 || !scope?.item(blocker.data)) return outOfScope(`Blocker ${blockedOn ?? "(none named)"}`);
}
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
state, closureReason, closureTarget, summary, evidenceRef, blockedOn, wakeAfterSeconds, transitionNote: note, resolution, reopen,
}), undefined, { structured: true });
});
// An external caller's updated item goes through the same projection as show and list.
const view = externalView(scope);
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
} catch (err) {
return caught(err);
}
@@ -1491,6 +1522,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
},
async ({ rigId }) => {
try {
// An external caller names one of its grant's rigs, so a lookup never answers about another workspace's rig.
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`);
const rig = await rigIdFor(client, rigId);
if ("content" in rig) return rig;
const id = encodeURIComponent(rig.id);
@@ -1511,7 +1544,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
async (seat) => {
const rows = await client.get(`/api/queue/list?${new URLSearchParams({ destinationSession: seat, compact: "1", limit: String(PRESENCE_ROWS) }).toString()}`)
.then((res) => (res.status < 400 ? res.data : null), () => null);
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(rows, seat),
// An external caller sees a ring only on an item it may read, so another pair's item isn't named.
const visible = access?.external && Array.isArray(rows) ? rows.filter((row) => scope?.item(row)) : rows;
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(visible, seat),
lastWakeScope: `the latest ring among its ${PRESENCE_ROWS} most recent rows` };
});
return mapResult({ status: 200, data: { ...view, hostedMembers: hosted.members, ...(hosted.note ? { hostedMembersNote: hosted.note } : {}) } }, undefined, { structured: true });
@@ -7,6 +7,8 @@ export interface SeatLaunchAdditionsView {
agentPromptReplacesDefault?: boolean;
appendSystemPromptFile?: string;
pluginDirs: string[];
/** #2044: absent from a daemon before it, and when the seat has none. */
pluginChannels?: string[];
missing: string[];
refused: string[];
}
@@ -17,6 +19,7 @@ export function seatLaunchAdditionLines(additions: SeatLaunchAdditionsView | nul
...(additions.agent ? [`agent ${additions.agent} (--agent${additions.agentPromptReplacesDefault ? "; its prompt replaces Claude's default system prompt" : ""})`] : []),
...(additions.appendSystemPromptFile ? [`prompt ${additions.appendSystemPromptFile} (--append-system-prompt-file)`] : []),
...additions.pluginDirs.map((dir) => `plugin dir ${dir} (CLAUDE_CODE_PLUGIN_DIRS)`),
...(additions.pluginChannels ?? []).map((entry) => `channel ${entry} (--channels)`),
...additions.missing.map((file) => `missing ${file}: not passed to the launch`),
...additions.refused.map((reason) => `refused ${reason}: not passed to the launch`),
];
+34
View File
@@ -220,6 +220,40 @@ describe("rig acp run", () => {
expect(readFileSync(run.logPath, "utf8")).not.toContain("session/new");
});
it("reads a load error as missing only by the harness's own rule, and never starts a fresh session", async () => {
vi.stubEnv("FAKE_ACP_LOAD", "1");
vi.stubEnv("FAKE_ACP_MISSING", "claude");
const claudeRule = (error: { code: number; data?: unknown }, id: string) => error.code === -32002 && (error.data as { sessionId?: string })?.sessionId === id;
const q = memoryQueue("scenario:reply PONG");
const run = options({ loadSession: "sess-gone", missingSession: claudeRule });
expect(await runAcpAssignment(q.queue, run)).toMatchObject({ kind: "missing" });
expect(outcomeNote(q)).toContain('outcome missing: session sess-gone doesn\'t exist for this agent (session/load returned -32002 Resource not found {"sessionId":"sess-gone"}); no new session was started');
expect(readFileSync(run.logPath, "utf8")).not.toContain("session/new");
// The same agent error with no rule, or a rule it doesn't match, reads failed with the agent's own code and text.
vi.stubEnv("FAKE_ACP_MISSING", "codex");
for (const extra of [{}, { missingSession: claudeRule }]) {
const other = memoryQueue("scenario:reply PONG");
const outcome = await runAcpAssignment(other.queue, options({ loadSession: "sess-gone", ...extra }));
expect(outcome.kind).toBe("failed");
expect(outcome.summary).toBe('session/load returned error -32603 Internal error {"details":"no rollout found for thread id sess-gone"}');
}
});
it("puts the agent's reported usage and cost on the outcome with where each came from, and unknown when absent", async () => {
vi.stubEnv("FAKE_ACP_COST", "1");
vi.stubEnv("FAKE_ACP_PROMPT_USAGE", "1");
const q = memoryQueue("scenario:reply PONG");
const outcome = await runAcpAssignment(q.queue, options());
expect(outcome.usage).toEqual({ tokens: { total: 1500, input: 1200, output: 300, thought: undefined, cachedRead: undefined, cachedWrite: undefined }, cost: { amount: 0.00199, currency: "USD" } });
expect(outcomeNote(q)).toContain("Usage as the agent reported it: 1500 tokens (1200 in, 300 out), from the agent's prompt response; cost 0.00199 USD, the agent's own cumulative figure for the session (usage_update).");
vi.unstubAllEnvs();
const none = memoryQueue("scenario:reply PONG");
expect((await runAcpAssignment(none.queue, options())).usage).toEqual({});
expect(outcomeNote(none)).toContain("Usage as the agent reported it: tokens unknown (the agent's prompt response reported none); cost unknown (the agent reported none).");
});
it("reports a process the agent launched that outlives the connection, without stopping it", async () => {
const q = memoryQueue("scenario:reply PONG");
const run = options();
+15 -1
View File
@@ -70,7 +70,11 @@ function createMockDaemon() {
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
req.on("end", () => {
if (body.includes("INVALID")) {
if (body.includes("ADVISED")) {
res.writeHead(200, { "Content-Type": "application/json" });
const invalid = body.includes("ADVISED-INVALID");
res.end(JSON.stringify({ valid: !invalid, errors: invalid ? ["name is required"] : [], advisories: ['defaults.effort: non-string value "3" ignored; effort must be a text value'] }));
} else if (body.includes("INVALID")) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ valid: false, errors: ["name is required", "missing runtime"] }));
} else {
@@ -128,6 +132,16 @@ describe("rig agent", () => {
expect(exitCode).toBeUndefined();
});
it("agent validate prints each advisory, valid or not, and an advisory never fails it", async () => {
for (const [content, exit] of [["name: my-agent\nversion: 1.2.0\n# ADVISED\n", undefined], ["# ADVISED-INVALID\n", 1]] as const) {
const program = new Command();
program.addCommand(agentCommand(agentDeps(content)));
const { logs, exitCode } = await captureLogs(() => program.parseAsync(["node", "rig", "agent", "validate", "agent.yaml"]));
expect(logs).toContain('⚠ agent spec advisory: defaults.effort: non-string value "3" ignored; effort must be a text value');
expect(exitCode).toBe(exit);
}
});
// T2: rig agent validate invalid spec -> exit 1, output contains error
it("agent validate invalid spec: prints errors, exitCode 1", async () => {
const deps = agentDeps("INVALID");
+18 -1
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { execFileSync, spawn } from "node:child_process";
import http from "node:http";
import { Command } from "commander";
import { writeTextAtomically } from "../src/atomic-text-write.js";
import { writeNewText, writeTextAtomically } from "../src/atomic-text-write.js";
import { exportCommand } from "../src/commands/export.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
import { allowFetchTarget, resetFetchAllowlist } from "./fetch-guard.js";
@@ -35,6 +35,23 @@ it("preserves an existing export and removes staging after a partial failed writ
expect(fs.readFileSync(target, "utf8")).toBe("name: original\n");
expect(fs.readdirSync(dir)).toEqual(["rig.yaml"]);
});
it("writeNewText creates a file but never replaces one, even a dangling link", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
const target = join(dir, "rig.yaml"), link = join(dir, "link.yaml");
writeNewText(target, "name: first\n");
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
expect(() => writeNewText(target, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
fs.symlinkSync(join(dir, "missing.yaml"), link);
expect(() => writeNewText(link, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
expect(fs.existsSync(join(dir, "missing.yaml"))).toBe(false);
});
it("writeNewText leaves nothing behind after a failed write", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
failure.active = true;
expect(() => writeNewText(join(dir, "rig.yaml"), "name: new\n")).toThrow(/no space left/);
expect(fs.readdirSync(dir)).toEqual([]);
});
it("retains export symlinks and target permissions on successful replacement", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
const target = join(dir, "saved.yaml"), link = join(dir, "rig.yaml");
@@ -46,7 +46,7 @@ describe("bootstrap source paths respect the daemon target", () => {
// The responder reads relative paths from a different root, like an already-running daemon.
server = http.createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.url === "/api/specs/library") {
if (req.url === "/api/specs/library" || req.url === "/api/specs/library?include_builtins=true") {
catalogReads++;
res.end(JSON.stringify(catalog));
return;
@@ -184,7 +184,7 @@ describe("bootstrap source paths respect the daemon target", () => {
("preserves catalog source paths (explicit URL=%s, plan=%s)", async (explicitUrl, plan) => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
await run("bootstrap", "catalog-rig", [plan ? "--plan" : "--yes"]);
expect(catalogReads).toBe(1);
expect(catalogReads).toBe(2);
expect(requests[0]?.sourceRef).toBe(catalog[0]!.sourcePath);
expect(requests[0]?.content).toBe("name: daemon\n");
});
@@ -192,7 +192,7 @@ describe("bootstrap source paths respect the daemon target", () => {
it.each([false, true])("preserves unresolved bare-name fallback (explicit URL=%s)", async (explicitUrl) => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
await run("bootstrap", "unlisted", ["--plan"]);
expect(catalogReads).toBe(1);
expect(catalogReads).toBe(2);
expect(requests[0]?.sourceRef).toBe("unlisted");
});
@@ -208,7 +208,7 @@ describe("bootstrap source paths respect the daemon target", () => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
catalog.push({ ...catalog[0]!, id: "other-id", sourcePath: path.join(callerDir, "rig.yaml") });
await run("bootstrap", "catalog-rig", ["--plan"]);
expect(catalogReads).toBe(1);
expect(catalogReads).toBe(2);
expect(requests).toEqual([]);
expect(process.exitCode).toBe(1);
});
+1 -1
View File
@@ -400,7 +400,7 @@ describe("Bootstrap CLI", () => {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk; });
req.on("end", () => {
if (url === "/api/specs/library") {
if (url === "/api/specs/library" || url === "/api/specs/library?include_builtins=true") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "lib1", name: "my-lib-rig", sourcePath: "/specs/my-lib-rig.yaml", kind: "rig", version: "0.2", sourceType: "user_file" }]));
} else if (url.includes("/api/bootstrap")) {
+30
View File
@@ -70,6 +70,19 @@ describe("Broadcast CLI", () => {
{ ok: false, sessionName: "c@notice-rig", error: "send failed", managedCompaction: { stage: "compacting", observed: "before_send", notice: "c@notice-rig was being compacted: /compact was submitted." } },
],
}));
} else if (parsed.rig === "phase-rig") {
// #2507: a recipient whose input was attempted or written is recorded indeterminate, not failed.
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
total: 5, sent: 1, failed: 4,
results: [
{ ok: true, sessionName: "a@phase-rig", inputPhase: "written", sent: true },
{ ok: false, sessionName: "b@phase-rig", inputPhase: "not_reached", sent: false, error: "target_needs_input" },
{ ok: false, sessionName: "c@phase-rig", inputPhase: "attempted", sent: false, error: "paste lost" },
{ ok: false, sessionName: "d@phase-rig", inputPhase: "written", sent: false, error: "enter unconfirmed" },
{ ok: false, sessionName: "e@phase-rig", sent: true, error: "receipt lost" },
],
}));
} else if (parsed.rig === "warn-rig") {
// S2 (OPR.0.5.4.3): an unattributed broadcast's response carries the
// sign-it notice as an additive warning; the renderer must surface it.
@@ -156,6 +169,22 @@ describe("Broadcast CLI", () => {
expect(exitCode).toBe(1);
});
it("broadcast prints UNCONFIRMED, not FAILED, for a recipient whose input was attempted or written; exit code unchanged", async () => {
const { stdout, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "broadcast", "--rig", "phase-rig", "hello"]);
});
const check = "the text may have reached it, so check with rig capture before resending";
expect(stdout).toEqual([
"a@phase-rig: sent",
"b@phase-rig: FAILED — target_needs_input",
`c@phase-rig: UNCONFIRMED — paste lost; ${check}`,
`d@phase-rig: UNCONFIRMED — enter unconfirmed; ${check}`,
`e@phase-rig: UNCONFIRMED — receipt lost; ${check}`,
"1/5 delivered",
]);
expect(exitCode).toBe(1);
});
it("broadcast --rig prints per-target summary", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "broadcast", "--rig", "my-rig", "hello"]);
@@ -326,6 +355,7 @@ describe("Broadcast CLI", () => {
const output = logs.join("\n");
expect(output).toContain("No running sessions found");
expect(output).toContain("0/0 delivered");
expect(logs).toContainEqual(expect.stringMatching(/^FAILED — No running sessions found/));
expect(exitCode).toBe(1);
});
});
@@ -0,0 +1,21 @@
import { it, expect, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ConfigStore } from "../src/config-store.js";
import { SettingsStore } from "../../daemon/src/domain/user-settings/settings-store.js";
it("launch.codex_login_shell defaults off in both stores, with file and env precedence", () => {
const home=mkdtempSync(join(tmpdir(),"codex-login-shell-")), file=join(home,"config.json");
const cli=new ConfigStore(file), daemon=new SettingsStore(file);
try {
vi.stubEnv("OPENRIG_LAUNCH_CODEX_LOGIN_SHELL",undefined);
expect(cli.resolveWithSource("launch.codex_login_shell")).toMatchObject({value:false,source:"default"});
expect(daemon.resolveOne("launch.codex_login_shell")).toMatchObject({value:false,source:"default"});
writeFileSync(file, JSON.stringify({launch:{codexLoginShell:true}}));
expect(cli.resolveWithSource("launch.codex_login_shell")).toMatchObject({value:true,source:"file"});
expect(daemon.resolveOne("launch.codex_login_shell")).toMatchObject({value:true,source:"file"});
vi.stubEnv("OPENRIG_LAUNCH_CODEX_LOGIN_SHELL","false");
expect(cli.resolveWithSource("launch.codex_login_shell")).toMatchObject({value:false,source:"env"});
expect(daemon.resolveOne("launch.codex_login_shell")).toMatchObject({value:false,source:"env"});
} finally { vi.unstubAllEnvs(); rmSync(home,{recursive:true,force:true}); }
});
@@ -138,10 +138,12 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"topology.root",
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
"launch.non_interruptive",
"launch.codex_login_shell",
"health.context_pressure.warning_percent",
"health.context_pressure.critical_percent",
"files.allowlist", "progress.scan_roots",
@@ -211,6 +213,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"retention.watchdog_keep_per_job",
"retention.stream_days", // slice 22 outcome 9: stream retention
"retention.stream_max_items",
"stream.intake",
"retention.batch_size",
"queue.pickup_stall_threshold_minutes",
// S02 — standing-stuck-sweep cadence + unclaimed-obligation age.
@@ -234,6 +237,16 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect(() => store.set("daemon.db_conflict", "always")).toThrow(/warn or stop/);
});
it("defaults stream.intake to queue.worker and validates destinations", () => {
const store = new ConfigStore(configPath);
expect(store.get("stream.intake")).toBe("queue.worker");
store.set("stream.intake", "off");
expect(store.get("stream.intake")).toBe("off");
store.set("stream.intake", "lead@kernel");
expect(store.get("stream.intake")).toBe("lead@kernel");
expect(() => store.set("stream.intake", "invalid-bare-seat")).toThrow(/Invalid value for stream.intake/);
});
it("resolves context.root from the canonical default and OPENRIG_CONTEXT_ROOT", () => {
const store = new ConfigStore(configPath);
expect(store.resolveWithSource("context.root")).toMatchObject({
@@ -376,6 +389,37 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect(store.resolve().health.contextPressure).toEqual({ warningPercent: 95, criticalPercent: 99 });
});
it("explains that saved context-pressure pair validation excludes environment overrides", () => {
const store = new ConfigStore(configPath);
process.env.OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT = "50";
expect(() => store.set("health.context_pressure.critical_percent", "80"))
.toThrow(/config-file pair: warning \(95\).*critical \(80\).*each process resolves configuration/);
expect(existsSync(configPath)).toBe(false);
store.set("health.context_pressure.critical_percent", "100");
store.set("health.context_pressure.warning_percent", "99");
const before = readFileSync(configPath, "utf8");
expect(() => store.reset("health.context_pressure.critical_percent"))
.toThrow(/config-file pair: warning \(99\).*critical \(99\).*each process resolves configuration/);
expect(readFileSync(configPath, "utf8")).toBe(before);
});
it("shows the file-pair explanation through the config command", async () => {
process.env.OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT = "50";
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const oldExitCode = process.exitCode;
try {
process.exitCode = undefined;
await configCommand(configPath, { probeDaemonRunning: async () => false })
.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "80"]);
expect(process.exitCode).toBe(1);
expect(error.mock.calls.flat().join(" ")).toContain("config-file pair: warning (95)");
expect(existsSync(configPath)).toBe(false);
} finally {
error.mockRestore();
process.exitCode = oldExitCode;
}
});
it("S01 wake ladder exposes retry cadence, cap, unconfirmed window, and swap grace defaults", () => {
const store = new ConfigStore(configPath);
expect(store.resolveWithSource("queue.wake_retry_interval_seconds" as never)).toMatchObject({
+8
View File
@@ -63,6 +63,14 @@ describe("ConfigStore", () => {
});
// Test 1
it("persists the built-in inventory preference as a validated boolean", () => {
const store = new ConfigStore(join(tmpDir, "config.json"));
expect(store.resolveOne("specs.builtins_visible").value).toBe(true);
store.set("specs.builtins_visible", "false");
expect(store.resolve().specs.builtinsVisible).toBe(false);
expect(() => store.set("specs.builtins_visible", "no")).toThrow();
});
it("resolve() returns defaults when no config file and no env", () => {
const store = new ConfigStore(join(tmpDir, "config.json"));
const config = store.resolve();
+15
View File
@@ -529,6 +529,21 @@ files:
const R4_MANIFEST = "name: url-pack\nversion: 1.0.0\ntaxonomy: world\nfiles:\n - path: SKILL.md\n role: instruction\n";
it("add <url>: creates a missing namespace for a path-like install name", async () => {
const pack = await startPackServer({ "manifest.yaml": R4_MANIFEST, "SKILL.md": "# nested\n" });
try {
await withContextRoot(async (root) => {
const { exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}manifest.yaml`, "--name", "packs/example"]);
});
expect(exitCode).toBeUndefined();
expect(readFileSync(join(root, "packs", "example", "SKILL.md"), "utf-8")).toBe("# nested\n");
});
} finally {
pack.close();
}
});
it("add <url>: installs into the OPENRIG_CONTEXT_ROOT landing zone (config-resolved, no hardcoded path)", async () => {
const pack = await startPackServer({ "manifest.yaml": R4_MANIFEST, "SKILL.md": "# hello\nbody\n" });
try {
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
describe.skipIf(process.platform === "win32")("doctor with a listener holding response headers", () => {
it("returns an unknown listener result through the built CLI", async () => {
const root = mkdtempSync(path.join(tmpdir(), "doctor-held-headers-"));
const requests: string[] = [];
const server = createServer(req => { requests.push(req.url ?? ""); });
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
try {
const port = (server.address() as { port: number }).port;
const cli = fileURLToPath(new URL("../dist/bin-wrapper.js", import.meta.url));
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(process.execPath, [cli, "doctor", "--json"], {
env: { HOME: root, OPENRIG_HOME: path.join(root, "openrig"), OPENRIG_PORT: String(port), PATH: "/usr/bin:/bin", TMPDIR: root },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", chunk => { stdout += chunk; });
child.stderr.resume();
const timer = setTimeout(() => { child.kill(); reject(new Error("doctor did not finish")); }, 30_000);
child.on("error", error => { clearTimeout(timer); reject(error); });
child.on("close", () => { clearTimeout(timer); resolve(stdout); });
});
const result = JSON.parse(output) as { checks: Array<{ name: string; status: string; message: string }> };
const check = result.checks.find(item => item.name === "port");
expect(requests).toContain("/healthz");
expect(check?.status).toBe("warn");
expect(check?.message).toContain("unknown listener");
} finally {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
rmSync(root, { recursive: true, force: true });
}
}, 40_000);
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
describe.skipIf(process.platform === "win32")("doctor --spec with duplicate live names", () => {
it("reports both IDs without fetching either topology through the built CLI", async () => {
const root = mkdtempSync(path.join(tmpdir(), "doctor-ambiguous-cli-"));
const spec = path.join(root, "rig.yaml");
writeFileSync(spec, "version: '0.2'\nname: twin\npods:\n - namespace: orch\n members:\n - name: lead\n");
const requests: string[] = [];
const server = createServer((req, res) => {
requests.push(req.url ?? "");
res.setHeader("content-type", "application/json");
if (req.url === "/api/rigs") res.end(JSON.stringify([{ id: "first", name: "twin" }, { id: "second", name: "twin" }]));
else if (req.url === "/healthz") res.end(JSON.stringify({ ok: true, pid: process.pid, euid: process.geteuid?.() }));
else { res.statusCode = 404; res.end("{}"); }
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
try {
const port = (server.address() as { port: number }).port;
const cli = fileURLToPath(new URL("../dist/bin-wrapper.js", import.meta.url));
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(process.execPath, [cli, "doctor", "--spec", spec, "--json"], {
env: { HOME: root, OPENRIG_HOME: path.join(root, "openrig"), OPENRIG_PORT: String(port), PATH: "/usr/bin:/bin", TMPDIR: root },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", chunk => { stdout += chunk; });
child.stderr.resume();
const timer = setTimeout(() => { child.kill(); reject(new Error("doctor did not finish")); }, 30_000);
child.on("error", error => { clearTimeout(timer); reject(error); });
child.on("close", () => { clearTimeout(timer); resolve(stdout); });
});
const result = JSON.parse(output) as { checks: Array<{ name: string; status: string; message: string }> };
const check = result.checks.find(item => item.name === "spec_live_conformance");
expect(check?.status).toBe("skipped");
expect(check?.message).toContain("first, second");
expect(requests.filter(url => url === "/api/rigs")).toHaveLength(1);
expect(requests.some(url => url.includes("/nodes"))).toBe(false);
} finally {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
rmSync(root, { recursive: true, force: true });
}
}, 40_000);
});
@@ -7,8 +7,8 @@
// is not given it must SKIP WITH THE REASON rather than pass — a check that cannot find its input
// and stays quiet is indistinguishable from one that looked and found nothing wrong.
import { describe, it, expect } from "vitest";
import { runDoctorChecks, type DoctorDeps } from "../src/commands/doctor.js";
import { describe, it, expect, vi } from "vitest";
import { readLiveLogicalIds, runDoctorChecks, type DoctorDeps } from "../src/commands/doctor.js";
const SPEC_3_8 = `version: "0.2"
name: v-openrig-build
@@ -52,6 +52,23 @@ async function conformanceCheck(deps: DoctorDeps) {
}
describe("rig doctor — spec vs live conformance", () => {
it("does not choose a topology when multiple rigs share the spec name", async () => {
const fetch = vi.fn(async (_url: string) => ({ ok: true, json: async () => [
{ id: "first", name: "v-openrig-build" }, { id: "second", name: "v-openrig-build" },
] }));
vi.stubGlobal("fetch", fetch);
try {
const owner = { assertLocalOwner: async () => {} };
const check = await conformanceCheck(baseDeps({ specPath: "/spec", readFile: () => SPEC_3_8,
fetchLiveLogicalIds: name => readLiveLogicalIds("http://fixture", name, owner as never) }));
expect(check!.status).toBe("skipped");
expect(check!.message).toContain("first, second");
expect(check!.fix).toContain("rig ps --json");
const urls = fetch.mock.calls.map(([url]) => url);
expect(urls.filter(url => url.endsWith("/api/rigs"))).toHaveLength(1);
expect(urls.some(url => url.includes("/nodes"))).toBe(false);
} finally { vi.unstubAllGlobals(); }
});
it("SKIPS with the reason when no --spec is given — it must not read as a pass", async () => {
const check = await conformanceCheck(baseDeps());
expect(check).toBeDefined();
+143 -6
View File
@@ -1,7 +1,8 @@
import { describe, it, expect, vi } from "vitest";
import { createProgram } from "../src/index.js";
import { Command } from "commander";
import { doctorCommand, runDoctorChecks, type DoctorDeps } from "../src/commands/doctor.js";
import { doctorCommand, runDoctorChecks, readLiveLogicalIds, type DoctorDeps } from "../src/commands/doctor.js";
import { DaemonClient } from "../src/client.js";
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
const CMUX_SETTINGS_PATH = resolveCmuxSettingsPath();
@@ -565,11 +566,11 @@ describe("runDoctorChecks", () => {
// A listener that sends an OK header and never finishes its body must not hold doctor, or setup, before anything prints.
it("settles a /healthz body that never finishes as owner unknown within its bound, and releases the request", async () => {
// The port check's request carries a signal; doctor's other /healthz reads (the cmux check) don't.
const signals: AbortSignal[] = [];
const deps = makeDeps({ checkPort: async () => false, callerUid: 1002, ownerReportTimeoutMs: 50,
fetch: async (_url: string, init?: { signal?: AbortSignal }) => {
if (init?.signal) signals.push(init.signal);
fetch: async (url: string, init?: { signal?: AbortSignal }) => {
// This owner-body cell measures the two health probes; status has its own deadline cells below.
if (init?.signal && url.endsWith("/healthz")) signals.push(init.signal);
return { ok: true, json: () => new Promise<unknown>(() => {}) };
} });
const started = Date.now();
@@ -578,8 +579,8 @@ describe("runDoctorChecks", () => {
expect(result.status).toBe("warn");
expect(result.message).toContain("OS user is unknown");
expect(result.reason).toContain("didn't finish");
expect(signals).toHaveLength(1);
expect(signals[0]!.aborted).toBe(true);
expect(signals).toHaveLength(2);
expect(signals.every(signal => signal.aborted)).toBe(true);
});
it("settles a real listener's held /healthz body the same way, through the real fetch", async () => {
@@ -599,6 +600,28 @@ describe("runDoctorChecks", () => {
await new Promise(resolve => server.close(resolve));
}
});
it("bounds a real listener that never sends health response headers", async () => {
const { createServer } = await import("node:http");
const server = createServer(() => {});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
try {
const deps = makeDeps({ checkPort: async () => false, ownerReportTimeoutMs: 100,
configStore: { resolve: () => ({ ...defaultConfig, daemon: { port, host: "127.0.0.1" } }) } as DoctorDeps["configStore"] });
delete deps.fetch;
const checks = runDoctorChecks(deps);
const result = await checks.portCheck;
expect(result.status).toBe("warn");
expect(result.message).toContain("unknown listener");
expect(result.reason).toContain("headers did not arrive");
const all = await Promise.all(checks.asyncChecks);
expect(all.find(check => check.name === "cmux_daemon")?.status).toBe("skipped");
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}
});
});
describe("rig doctor", () => {
@@ -775,3 +798,117 @@ describe("rig doctor", () => {
expect(portArgs).toContain(7433);
});
});
describe("doctor JSON HTTP deadlines", () => {
it.each(["cmux headers", "cmux body", "healthy"] as const)("returns an honest diagnostic when a real listener holds %s", async (held) => {
const { createServer } = await import("node:http");
const requests: string[] = [];
const server = createServer((req, res) => {
requests.push(req.url!);
if (req.url === "/healthz") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, pid: 4242, euid: 1002 }));
} else if (held === "healthy") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ available: true }));
} else if (held === "cmux body") {
res.writeHead(200, { "content-type": "application/json" });
res.write('{"available":');
}
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const priorExitCode = process.exitCode;
const program = new Command();
program.addCommand(doctorCommand(makeDeps({ checkPort: async () => false, callerUid: 1002,
ownerReportTimeoutMs: 100, fetch: (url, init) => globalThis.fetch(url, init),
configStore: { resolve: () => ({ ...defaultConfig, daemon: { host: "127.0.0.1", port } }) },
})));
const task = program.parseAsync(["node", "rig", "doctor", "--json"]);
let observationTimer: ReturnType<typeof setTimeout> | undefined;
try {
const outcome = await Promise.race([task.then(() => "settled"), new Promise<string>(resolve => {
observationTimer = setTimeout(() => resolve("still-waiting"), 1500);
})]);
expect(["darwin", "linux"]).toContain(process.platform);
expect(requests).toContain("/healthz");
expect(requests).toContain("/api/adapters/cmux/status");
console.info("[doctor HTTP deadline]", JSON.stringify({ platform: process.platform, held, requests, outcome }));
expect(outcome).toBe("settled");
const result = JSON.parse(String(log.mock.calls.at(-1)![0]));
const portCheck = result.checks.find((c: { name: string }) => c.name === "port");
const cmux = result.checks.find((c: { name: string }) => c.name === "cmux_daemon");
expect(portCheck.status).toBe("pass");
expect(cmux.status).toBe(held === "healthy" ? "pass" : "warn");
} finally {
clearTimeout(observationTimer);
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await Promise.allSettled([task]);
log.mockRestore(); process.exitCode = priorExitCode;
expect(server.listening).toBe(false);
}
});
});
describe("doctor --spec topology HTTP deadlines", () => {
it.each(["rig headers", "rig body", "nodes headers", "nodes body", "healthy"] as const)(
"returns unknown or measured conformance when the topology listener is %s", async held => {
const { createServer } = await import("node:http");
const requests: string[] = [];
const heldPath = held.startsWith("rig") ? "/api/rigs" : "/api/rigs/rig-id/nodes";
const server = createServer((req, res) => {
requests.push(req.url!);
if (held !== "healthy" && req.url === heldPath) {
if (held.endsWith("body")) { res.writeHead(200, { "content-type": "application/json" }); res.write("["); }
return;
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(req.url === "/healthz" ? { ok: true, pid: 4242, euid: 1002 }
: req.url === "/api/rigs" ? [{ id: "rig-id", name: "fixture" }]
: req.url === "/api/rigs/rig-id/nodes" ? [{ logicalId: "dev.worker" }] : { available: true }));
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
const base = `http://127.0.0.1:${port}`;
vi.stubEnv("OPENRIG_TRANSPORT_LOCAL_USER_GUARD", "true");
vi.stubEnv("OPENRIG_URL", ""); vi.stubEnv("RIGGED_URL", "");
const ownerProbe = vi.fn(async () => ({ state: "unknown" as const, reason: "fixture-unconfirmed" }));
const ownerClient = new DaemonClient(base, { ownerProbe });
const specPath = "/fixture/rig.yaml";
const spec = 'version: "0.2"\nname: fixture\npods:\n - id: dev\n label: Development\n members:\n - id: worker\n';
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const priorExitCode = process.exitCode;
const program = new Command().addCommand(doctorCommand(makeDeps({ callerUid: 1002,
checkPort: async () => false, specPath, readFile: path => path === specPath ? spec : null,
configStore: { resolve: () => ({ ...defaultConfig, daemon: { host: "127.0.0.1", port } }) },
fetch: (url, init) => globalThis.fetch(url, init),
fetchLiveLogicalIds: name => readLiveLogicalIds(base, name, ownerClient),
})));
const task = program.parseAsync(["node", "rig", "doctor", "--json", "--spec", specPath]);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const outcome = await Promise.race([task.then(() => "settled"), new Promise<string>(resolve => {
timer = setTimeout(() => resolve("still-waiting"), 4500);
})]);
expect(["darwin", "linux"]).toContain(process.platform);
expect(requests).toContain(held === "healthy" ? "/api/rigs/rig-id/nodes" : heldPath);
expect(ownerProbe).toHaveBeenCalled();
console.info("[doctor spec HTTP deadline]", JSON.stringify({ platform: process.platform, held, requests, outcome }));
expect(outcome).toBe("settled");
const result = JSON.parse(String(log.mock.calls.at(-1)![0]));
const check = result.checks.find((c: { name: string }) => c.name === "spec_live_conformance");
expect(check.status).toBe(held === "healthy" ? "pass" : "skipped");
if (held === "healthy") expect(ownerProbe).toHaveBeenCalledTimes(2);
} finally {
clearTimeout(timer); server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
await Promise.allSettled([task]);
log.mockRestore(); process.exitCode = priorExitCode; vi.unstubAllEnvs();
expect(server.listening).toBe(false);
}
});
});
+17
View File
@@ -344,6 +344,23 @@ describe("Down CLI", () => {
expect(logs).toContain(" /work/project/CLAUDE.md: openrig-seat-skills.md");
});
it("names each file in a Claude seat's cwd that still holds OpenRig's additions, and keeps the exit code", async () => {
const kept = "/work/project/.openrig/context-collector.cjs: not the shipped collector's bytes, so it was kept";
responseOverride = {
status: 200,
body: {
rigId: "rig-1", sessionsKilled: 1, snapshotId: null, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: [],
keptFiles: [kept],
},
};
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "rig-1"]);
});
expect(logs).toContain("Kept 1 file(s) that still hold OpenRig's additions; check them by hand:");
expect(logs).toContain(` ${kept}`);
expect(exitCode).toBeUndefined();
});
it.each([
{ rigName: "workshop", isUniqueName: false },
{},
+24
View File
@@ -47,12 +47,14 @@ describe("rig env", () => {
let probeStatus = "fresh";
let probeError: string | undefined;
let kept: string | undefined;
const requests: string[] = [];
const rigSummary = [{ id: "rig-1", name: "my-rig", nodeCount: 2 }];
beforeAll(async () => {
server = http.createServer((req, res) => {
const url = req.url ?? "";
requests.push(`${req.method} ${url}`);
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk; });
req.on("end", () => {
@@ -201,6 +203,28 @@ describe("rig env", () => {
expect(logs.join("\n")).toContain("Services stopped");
});
it.each(["status", "logs", "down"])("env %s refuses an ambiguous name before contacting services", async (action) => {
rigSummary.push({ id: "rig-2", name: "my-rig", nodeCount: 0 });
requests.length = 0;
try {
const { logs, exitCode } = await captureLogs(() => makeCmd().parseAsync(["node", "rig", "env", action, "my-rig"]).then(() => {}));
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("'my-rig' matches 2 rigs");
expect(requests.filter((request) => request.includes("/env"))).toEqual([]);
} finally { rigSummary.pop(); }
});
it("prefers an exact ID over another rig's matching name", async () => {
rigSummary.unshift({ id: "rig-2", name: "rig-1", nodeCount: 0 });
requests.length = 0;
try {
const { exitCode } = await captureLogs(() => makeCmd().parseAsync(["node", "rig", "env", "down", "rig-1"]).then(() => {}));
expect(exitCode).toBeUndefined();
expect(requests).toContain("POST /api/rigs/rig-1/env/down");
expect(requests).not.toContain("POST /api/rigs/rig-2/env/down");
} finally { rigSummary.shift(); }
});
it("env down explains an archived predecessor's retained project", async () => {
kept = "kept project shared: still used by rig successor (rig-2)";
try {
@@ -0,0 +1,33 @@
import { afterEach, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { spawn } from "node:child_process";
import { environmentCommand } from "../src/commands/environment.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
vi.mock("node:child_process", async original => ({ ...await original<typeof import("node:child_process")>(), spawn: vi.fn() }));
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); process.exitCode = undefined; });
it.each(["slow-get", "slow-validate", "changed-generation"])("the viewer bounds its live lookup without the redundant validation deadline: %s", async mode => {
vi.useFakeTimers(); const generation = mode === "changed-generation" ? "changed-generation" : "generation-one";
const paths: string[] = [];
const client = new DaemonClient("http://127.0.0.1:1", { sessionName: "worker@test", ownerProbe: async () => ({ state: "unknown" }) as never,
fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => {
paths.push(String(url)); const validation = init?.method === "POST";
if (validation && mode === "changed-generation") return new Response(JSON.stringify({ error: "The prepared environment belongs to another generation" }), { status: 409 });
if ((mode === "slow-get" && !validation) || (mode === "slow-validate" && validation)) {
await new Promise<void>((resolve, reject) => { const timer = setTimeout(resolve, 6000);
init?.signal?.addEventListener("abort", () => { clearTimeout(timer); reject(init.signal?.reason); }, { once: true }); });
}
return new Response(JSON.stringify({ generation, placement: { gateway: "gateway", workspace: "workspace", name: "worker" } }), { status: 200 });
}) as typeof fetch });
const lifecycleDeps: LifecycleDeps = { spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 7433, db: "test.sqlite", startedAt: "2026-04-20T00:00:00Z" }) : null),
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn((path: string) => path === STATE_FILE), mkdirp: vi.fn(), openForAppend: vi.fn(() => 3), isProcessAlive: vi.fn(() => true) };
vi.mocked(spawn).mockImplementation(() => { const child = new EventEmitter(); queueMicrotask(() => child.emit("exit", 0)); return child as ReturnType<typeof spawn>; });
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
const run = environmentCommand({ lifecycleDeps, clientFactory: () => client }).parseAsync(["node", "rig", "connect", "binding-one", "--generation", "generation-one"]);
await vi.advanceTimersByTimeAsync(6500); await run;
if (mode !== "changed-generation") {
expect(paths).toEqual(["http://127.0.0.1:1/api/environments/binding-one?live=true"]);
expect(errors).not.toHaveBeenCalled(); expect(spawn).toHaveBeenCalledTimes(1);
} else { expect(spawn).not.toHaveBeenCalled(); expect(errors).toHaveBeenCalledWith(expect.stringContaining("generation")); }
});
+405
View File
@@ -0,0 +1,405 @@
import { afterEach, describe, expect, it } from "vitest";
import { spawn, type ChildProcess } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { processStart } from "../src/commands/acp.js";
import { claimRow, HARNESSES, resolveHarness, rowRun, type ExecRun } from "../src/commands/exec.js";
import { readRows, writeRows } from "./fixtures/exec-file-queue.js";
// `rig exec` end to end: each `rig exec …` is its own process that exits as the CLI does, the connector it starts is
// real and detached, and the adapters are fake installed packages whose program is the fake ACP agent.
const FAKE_AGENT = fileURLToPath(new URL("./fixtures/fake-acp-agent.mjs", import.meta.url));
const RUNNER = fileURLToPath(new URL("./fixtures/exec-cli-runner.ts", import.meta.url));
const CONNECTOR_ENTRY = fileURLToPath(new URL("./fixtures/exec-connector-entry.ts", import.meta.url));
const SEAT = "builder@studio";
const cleanup: Array<() => void> = [];
afterEach(() => { for (const fn of cleanup.splice(0)) fn(); });
function install(root: string, pkg: string, version: string, bin?: string, extra: Record<string, unknown> = {}) {
const dir = join(root, "node_modules", pkg);
mkdirSync(join(dir, "dist"), { recursive: true });
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: pkg, version, ...extra }));
if (!bin) return;
const program = join(dir, "dist", "index.mjs");
writeFileSync(program, `#!/usr/bin/env node\nawait import(${JSON.stringify(pathToFileURL(FAKE_AGENT).href)});\n`);
chmodSync(program, 0o755);
mkdirSync(join(root, "node_modules", ".bin"), { recursive: true });
symlinkSync(program, join(root, "node_modules", ".bin", bin));
}
/** An OpenRig home with both adapters installed and one row owned by this seat, whose body picks the agent's scenario. */
function setup(body = "scenario:reply PONG", env: Record<string, string> = {}) {
const home = mkdtempSync(join(tmpdir(), "rig-exec-"));
cleanup.push(() => rmSync(home, { recursive: true, force: true }));
install(home, "@agentclientprotocol/claude-agent-acp", "0.101.0", "claude-agent-acp");
install(home, "@anthropic-ai/claude-agent-sdk", "0.3.318", undefined, { claudeCodeVersion: "2.1.318" });
install(home, "@agentclientprotocol/codex-acp", "2.5.0", "codex-acp");
install(home, "@openai/codex", "0.165.0");
const queueFile = join(home, "queue.json");
writeRows(queueFile, { "qitem-task": { qitemId: "qitem-task", state: "pending", destinationSession: SEAT, body, resolution: null, tags: ["mission:demo"], notes: [] } });
const runEnv: NodeJS.ProcessEnv = {
...process.env, OPENRIG_HOME: home, OPENRIG_SESSION_NAME: SEAT, EXEC_QUEUE_FILE: queueFile,
PATH: [join(home, "node_modules", ".bin"), dirname(process.execPath), process.env.PATH].join(delimiter), ...env,
};
for (const name of ["FAKE_ACP_LOAD", "FAKE_ACP_MISSING", "FAKE_ACP_COMPANION_PID_FILE", "FAKE_ACP_COST", "FAKE_ACP_PROMPT_USAGE", "FAKE_ACP_IGNORE_EOF", "EXEC_TEST_CONNECTOR", "EXEC_TEST_WINNER", "EXEC_TEST_HOOK", "EXEC_TEST_STALL_MS", "OPENRIG_EXEC_CONNECTOR"]) if (!(name in env)) delete runEnv[name];
return { home, env: runEnv, queueFile };
}
type Home = ReturnType<typeof setup>;
function start(h: Home, args: string[], options: { group?: boolean } = {}) {
const child: ChildProcess = spawn(process.execPath, ["--import", "tsx", RUNNER, ...args], { env: h.env, stdio: ["ignore", "pipe", "pipe"], detached: options.group });
let out = "";
child.stdout!.on("data", (data: Buffer) => { out += data.toString(); });
child.stderr!.on("data", (data: Buffer) => { out += data.toString(); });
const exited = new Promise<number | null>((resolve) => child.once("exit", (code) => resolve(code)));
cleanup.push(() => { if (child.exitCode === null) child.kill("SIGKILL"); });
return { child, exited, out: () => out };
}
async function rig(h: Home, args: string[]) {
const run = start(h, args);
const code = await run.exited;
return { code, out: run.out() };
}
async function until<T>(check: () => T | undefined, timeoutMs = 20_000): Promise<T> {
for (const deadline = Date.now() + timeoutMs; Date.now() < deadline; await new Promise((r) => setTimeout(r, 50))) {
const value = check();
if (value !== undefined) return value;
}
throw new Error("timed out");
}
const runFile = (h: Home, runId: string) => JSON.parse(readFileSync(join(h.home, "acp", "runs", `${runId}.json`), "utf8")) as ExecRun;
const ended = (h: Home, runId: string) => until(() => { const run = runFile(h, runId); return run.endedAt ? run : undefined; });
const notes = (h: Home) => readRows(h.queueFile)["qitem-task"]!.notes;
const runIds = (h: Home) => readdirSync(join(h.home, "acp", "runs")).filter((f) => /^exec-[^.]+\.json$/.test(f)).map((f) => f.slice(0, -5));
const startedId = (out: string) => /\[rig exec\] (exec-\S+) for qitem-task/.exec(out)?.[1];
const withEnv = (h: Home, env: Record<string, string>): Home => ({ ...h, env: { ...h.env, ...env } });
function withHome<T>(h: Home, fn: () => T): T {
const previous = process.env.OPENRIG_HOME;
process.env.OPENRIG_HOME = h.home;
try { return fn(); } finally { if (previous === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = previous; }
}
/** A run record as rig exec writes one, for the states a test needs. */
function record(h: Home, runId: string, fields: Partial<ExecRun>): string {
const runs = join(h.home, "acp", "runs");
mkdirSync(runs, { recursive: true });
const file = join(runs, `${runId}.json`);
writeFileSync(file, JSON.stringify({
runId, qitemId: "qitem-task", harness: "claude", agent: ["claude-agent-acp"], cwd: h.home, self: SEAT, pollMs: 1000, native: "x",
state: "started", pid: 1, requestedAt: "2026-11-06T00:00:00Z", transcript: join(runs, `${runId}.jsonl`), connectorLog: join(runs, `${runId}.connector.log`), ...fields,
}));
return readFileSync(file, "utf8");
}
describe("rig exec", () => {
it("finds each harness's adapter and native versions, and names the install when it's missing", () => {
const h = setup();
const claude = resolveHarness(HARNESSES.claude!, { PATH: h.env.PATH });
expect(claude).toMatchObject({ adapterVersion: "0.101.0", native: "Claude Code 2.1.318, from @anthropic-ai/claude-agent-sdk 0.3.318" });
expect(claude.command).toBe(join(h.home, "node_modules", ".bin", "claude-agent-acp"));
expect(resolveHarness(HARNESSES.claude!, { PATH: h.env.PATH, CLAUDE_CODE_EXECUTABLE: "/opt/claude" }).native)
.toBe("the Claude Code at /opt/claude (CLAUDE_CODE_EXECUTABLE; its version isn't read here), not Claude Code 2.1.318, from @anthropic-ai/claude-agent-sdk 0.3.318");
expect(resolveHarness(HARNESSES.codex!, { PATH: h.env.PATH })).toMatchObject({ adapterVersion: "2.5.0", native: "Codex 0.165.0 (@openai/codex)" });
expect(resolveHarness(HARNESSES.codex!, { PATH: "/nonexistent" }).problem)
.toBe("codex-acp isn't on PATH; install it with npm install --prefix <dir> @agentclientprotocol/[email protected] and put <dir>/node_modules/.bin on PATH");
});
it("reads a missing session only from each adapter's own evidence, never from a code alone", () => {
const { claude, codex } = HARNESSES;
const rollout = { code: -32603, message: "Internal error", data: { details: "no rollout found for thread id s1" } };
expect(claude!.missingSession({ code: -32002, message: "Resource not found", data: { sessionId: "s1" } }, "s1")).toBe(true);
expect(claude!.missingSession({ code: -32002, message: "Resource not found" }, "s1")).toBe(false);
expect(claude!.missingSession({ code: -32002, message: "Resource not found", data: { sessionId: "s2" } }, "s1")).toBe(false);
expect(claude!.missingSession(rollout, "s1")).toBe(false);
expect(codex!.missingSession(rollout, "s1")).toBe(true);
expect(codex!.missingSession(rollout, "s9")).toBe(false);
expect(codex!.missingSession({ code: -32603, message: "Internal error", data: { details: "context length exceeded" } }, "s1")).toBe(false);
expect(codex!.missingSession({ code: -32603, message: "Internal error" }, "s1")).toBe(false);
expect(codex!.missingSession({ code: -32002, message: "Resource not found", data: { sessionId: "s1" } }, "s1")).toBe(true);
});
it("--plan shows the versions and what would run, and starts nothing", async () => {
const h = setup();
const plan = await rig(h, ["run", "qitem-task", "--harness", "claude", "--plan"]);
expect(plan.code).toBe(0);
expect(plan.out).toContain(`claude: @agentclientprotocol/claude-agent-acp 0.101.0 at ${join(h.home, "node_modules", ".bin", "claude-agent-acp")}`);
expect(plan.out).toContain("runs: Claude Code 2.1.318, from @anthropic-ai/claude-agent-sdk 0.3.318");
expect(plan.out).toContain("answers: a reject refuses that one action, and the turn continues");
expect(plan.out).toContain("would run:");
expect(existsSync(join(h.home, "acp", "runs"))).toBe(false);
expect(notes(h)).toEqual([]);
}, 30_000);
it("keeps running after the calling CLI exits, and records the outcome as the agent's report", async () => {
const h = setup("scenario:slow");
const started = await rig(h, ["run", "qitem-task", "--harness", "claude"]);
expect(started.code).toBe(0);
const runId = startedId(started.out)!;
expect(started.out).toContain(`${runId} for qitem-task on claude: started`);
const live = runFile(h, runId);
expect(live.state).toBe("started");
expect(() => process.kill(live.pid, 0)).not.toThrow(); // the connector outlives the CLI that started it
const run = await ended(h, runId);
expect(run).toMatchObject({ state: "finished", outcome: "completed", summary: "the agent ended its turn (end_turn)" });
expect(run.settle).toContain("settled: no process carrying this run's marker remains");
expect(readRows(h.queueFile)["qitem-task"]!.state).toBe("in-progress"); // accepting the work stays with the custodian
expect(notes(h).join("\n")).toMatch(/executor fake-acp-agent 0\.0\.1 \(protocol 1, loadSession not advertised\), session fake-session-1 new, custodian builder@studio/);
const status = await rig(h, ["status", "qitem-task"]);
expect(status.out).toContain("finished, outcome completed: the agent ended its turn (end_turn)");
expect(status.out).toContain("The outcome is the agent's report, not acceptance: the row stays open for its custodian to judge and close.");
expect(status.out).toContain("Usage as the agent reported it: tokens unknown");
}, 40_000);
it("leaving --wait, even by a Ctrl-C to its whole process group, stops only the wait", async () => {
const h = setup("scenario:slow");
const waiter = start(h, ["run", "qitem-task", "--harness", "codex", "--wait"], { group: true });
await until(() => (/for qitem-task on codex: started/.test(waiter.out()) ? true : undefined));
process.kill(-waiter.child.pid!, "SIGINT"); // what a terminal's Ctrl-C sends: the connector is in its own session
expect(await waiter.exited).toBe(3);
expect(waiter.out()).toMatch(/stopped waiting; exec-\S+ keeps running/);
const run = await ended(h, startedId(waiter.out())!);
expect(run.outcome).toBe("completed");
expect(notes(h).join("\n")).not.toContain("cancel requested");
}, 40_000);
it("a Ctrl-C that lands just after the run is announced, while the CLI is stalled there, still stops only the wait", async () => {
const h = withEnv(setup("scenario:slow"), { EXEC_TEST_STALL_MS: "1500" });
const waiter = start(h, ["run", "qitem-task", "--harness", "codex", "--wait"], { group: true });
await until(() => (/for qitem-task on codex: started/.test(waiter.out()) ? true : undefined));
process.kill(-waiter.child.pid!, "SIGINT"); // arrives during the stall, before the CLI's next step
expect(await waiter.exited).toBe(3);
expect(waiter.out()).toMatch(/stopped waiting; exec-\S+ keeps running/);
expect((await ended(h, startedId(waiter.out())!)).outcome).toBe("completed");
}, 40_000);
it("starts a row's task once: a second start or a retry returns the same run, and --again starts another after it ends", async () => {
const h = setup();
const args = ["run", "qitem-task", "--harness", "claude"];
const [a, b] = await Promise.all([rig(h, args), rig(h, args)]);
expect(runIds(h)).toHaveLength(1);
const [runId] = runIds(h);
expect(`${a.out}\n${b.out}`).toMatch(/started by another caller|already has a run, so the task isn't sent twice/);
await ended(h, runId!);
const retry = await rig(h, args);
expect(retry.out).toContain(`qitem-task already has a run, so the task isn't sent twice:\n${runId} for qitem-task on claude`);
expect(runIds(h)).toEqual([runId]);
expect(notes(h).filter((note) => /^ACP run \S+: executor/.test(note))).toHaveLength(1);
const again = await rig(h, [...args, "--again"]);
const second = startedId(again.out)!;
expect(second).not.toBe(runId);
await ended(h, second);
expect(notes(h).filter((note) => /^ACP run \S+: executor/.test(note))).toHaveLength(2);
}, 60_000);
it("claims a row for one run only: a second claim loses, and a replacement must name the run it replaces", () => {
const h = setup();
const previous = process.env.OPENRIG_HOME;
process.env.OPENRIG_HOME = h.home;
try {
expect(claimRow("qitem-task", "exec-a")).toBe(true);
expect(claimRow("qitem-task", "exec-b")).toBe(false);
expect(rowRun("qitem-task")).toBe("exec-a");
expect(claimRow("qitem-task", "exec-c", "exec-b")).toBe(false);
expect(claimRow("qitem-task", "exec-c", "exec-a")).toBe(true);
expect(claimRow("qitem-task", "exec-d", "exec-a")).toBe(false);
expect(rowRun("qitem-task")).toBe("exec-c");
} finally {
if (previous === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = previous;
}
});
it("lets only one of two callers that read the same current run claim the row, and a fresh start never claims during a replacement", () => {
withHome(setup(), () => {
expect(claimRow("qitem-task", "exec-p")).toBe(true);
let inWindow: boolean | undefined;
// A and B both read exec-p as the row's run; B publishes inside A's window, so A's publish fails.
expect(claimRow("qitem-task", "exec-a", "exec-p", () => { inWindow = claimRow("qitem-task", "exec-b", "exec-p"); })).toBe(false);
expect(inWindow).toBe(true);
expect(rowRun("qitem-task")).toBe("exec-b");
// A fresh start inside a replacement's window still sees the row's run, so it never claims.
expect(claimRow("qitem-task", "exec-c", "exec-b", () => { inWindow = claimRow("qitem-task", "exec-x"); })).toBe(true);
expect(inWindow).toBe(false);
expect(rowRun("qitem-task")).toBe("exec-c");
// Two fresh starts on a row with no run: the one that publishes first wins.
expect(claimRow("qitem-new", "exec-1", undefined, () => { inWindow = claimRow("qitem-new", "exec-2"); })).toBe(false);
expect(inWindow).toBe(true);
expect(rowRun("qitem-new")).toBe("exec-2");
});
});
it("exits by the run's outcome on every path: an ended run returned again, a run that ended before the first look, and a losing caller", async () => {
const failed = setup("scenario:error");
const first = startedId((await rig(failed, ["run", "qitem-task", "--harness", "claude"])).out)!;
expect((await ended(failed, first)).outcome).toBe("failed");
const again = await rig(failed, ["run", "qitem-task", "--harness", "claude", "--wait"]);
expect(again.out).toContain("qitem-task already has a run, so the task isn't sent twice");
expect(again.code).toBe(2);
expect((await rig(failed, ["run", "qitem-task", "--harness", "claude"])).code).toBe(2);
const quick = await rig(withEnv(setup(), { EXEC_TEST_CONNECTOR: "instant" }), ["run", "qitem-task", "--harness", "claude"]);
expect(quick.out).toMatch(/for qitem-task on claude: ended, outcome completed/);
expect(quick.code).toBe(0);
for (const [outcome, code] of [["failed", 2], ["completed", 0]] as const) {
const lost = withEnv(setup(), { EXEC_TEST_WINNER: outcome });
const loser = await rig(lost, ["run", "qitem-task", "--harness", "claude", "--wait"]);
expect(loser.out).toContain("qitem-task was started by another caller as exec-winner, so the task isn't sent twice");
expect(loser.out).toContain(`finished, outcome ${outcome}`);
expect(loser.code).toBe(code);
expect(runIds(lost)).toEqual(["exec-winner"]); // the losing caller's own record is gone, and nothing else started
}
}, 60_000);
it("ends a run interrupted when its connector dies before recording itself, even while a waiter or requester lives", async () => {
const h = setup();
const died = await rig(withEnv(h, { EXEC_TEST_CONNECTOR: "die" }), ["run", "qitem-task", "--harness", "claude", "--wait"]);
expect(died.code).toBe(2);
expect(died.out).toContain("interrupted: its connector exited (code 3, signal none) and never recorded itself, so no turn was sent");
expect(runFile(h, startedId(died.out)!)).toMatchObject({ state: "interrupted", outcome: "unknown" });
// A requested record names its requester; a live requester with no connector is not a live run.
const other = setup();
record(other, "exec-orphan", { state: "requested", pid: process.pid, processStart: processStart(process.pid) });
const status = await rig(other, ["status", "exec-orphan"]);
expect(status.out).toContain("interrupted: its connector isn't running and never recorded itself, so no turn was sent");
}, 40_000);
it("settles a run's start and its end exclusively against a concurrent reconciler, in either order", async () => {
const connectorLog = (h: Home, runId: string) => readFileSync(runFile(h, runId).connectorLog, "utf8");
const binding = (h: Home) => notes(h).filter((note) => /^ACP run \S+: executor/.test(note));
// The reconciler first: it settles the run after the connector's guard and before its start, so no turn is sent.
const first = withEnv(setup(), { EXEC_TEST_HOOK: "after-guard" });
const a = await rig(first, ["run", "qitem-task", "--harness", "claude", "--wait"]);
const aId = startedId(a.out)!;
expect(a.code).toBe(2);
expect(runFile(first, aId)).toMatchObject({ state: "interrupted", outcome: "unknown", summary: "interrupted by a test reconciler after-guard" });
await until(() => (/no turn is sent/.test(connectorLog(first, aId)) ? true : undefined));
expect(connectorLog(first, aId)).toContain("reconciler after-guard: won");
expect(binding(first)).toEqual([]);
expect(notes(first).filter((note) => note.startsWith("test reconciler"))).toHaveLength(1);
// The connector first: the reconciler still holding the requested copy loses, and the turn completes.
const second = withEnv(setup(), { EXEC_TEST_HOOK: "after-start" });
const bId = startedId((await rig(second, ["run", "qitem-task", "--harness", "claude"])).out)!;
expect(await ended(second, bId)).toMatchObject({ state: "finished", outcome: "completed" });
expect(connectorLog(second, bId)).toContain("reconciler after-start: lost");
expect(binding(second)).toHaveLength(1);
expect(notes(second).some((note) => note.startsWith("test reconciler"))).toBe(false);
// The end: a reconciler that settles the started run before the connector records its end wins, and the
// connector's own outcome is told to the row without replacing that record.
const third = withEnv(setup(), { EXEC_TEST_HOOK: "before-end" });
const cId = startedId((await rig(third, ["run", "qitem-task", "--harness", "claude"])).out)!;
await until(() => (notes(third).some((note) => note.includes("that record stands")) ? true : undefined));
expect(runFile(third, cId)).toMatchObject({ state: "interrupted", summary: "interrupted by a test reconciler before-end" });
expect((await rig(third, ["status", cId])).out).toContain("interrupted by a test reconciler before-end");
expect(notes(third).join("\n")).toContain(`rig exec run ${cId}: its connector finished with outcome completed (the agent ended its turn (end_turn)) after the run had been recorded interrupted; that record stands`);
}, 60_000);
it("recovers a run from its slots when a crash left the copy behind", async () => {
const h = setup();
const gone = spawn(process.execPath, ["-e", ""]);
await new Promise((resolve) => gone.once("exit", resolve));
const runs = join(h.home, "acp", "runs");
// The connector claimed its start and died before writing the copy, which still says requested.
record(h, "exec-crashed", { state: "requested", pid: process.pid, processStart: processStart(process.pid) });
writeFileSync(join(runs, "exec-crashed.start.json"), JSON.stringify({ by: "connector", pid: gone.pid, processStart: "Thu Jan 1 00:00:00 2026", startedAt: "2026-11-06T00:00:01Z" }));
const status = await rig(h, ["status", "exec-crashed"]);
expect(status.out).toContain("interrupted: its connector ended without recording an outcome, so the outcome is unknown");
// The connector recorded its end and died before writing the copy, which still says started.
record(h, "exec-ended", { state: "started", pid: gone.pid, processStart: "Thu Jan 1 00:00:00 2026" });
writeFileSync(join(runs, "exec-ended.start.json"), JSON.stringify({ by: "connector", pid: gone.pid, startedAt: "2026-11-06T00:00:01Z" }));
writeFileSync(join(runs, "exec-ended.end.json"), JSON.stringify({ by: "connector", ended: { state: "finished", outcome: "completed", summary: "the agent ended its turn (end_turn)", endedAt: "2026-11-06T00:00:09Z" } }));
expect((await rig(h, ["status", "exec-ended"])).out).toContain("finished, outcome completed");
expect(notes(h).filter((note) => note.startsWith("rig exec run exec-ended"))).toEqual([]);
}, 40_000);
it("the connector entry runs only the requested run it was started for, and leaves any other record untouched", async () => {
const h = setup();
const done = record(h, "exec-done", { state: "finished", outcome: "completed", endedAt: "2026-11-06T00:00:05Z" });
const waiting = record(h, "exec-waiting", { state: "requested" });
const connector = async (runId: string, marker?: string) => {
const child = spawn(process.execPath, ["--import", "tsx", CONNECTOR_ENTRY, runId], { env: { ...h.env, ...(marker ? { OPENRIG_EXEC_CONNECTOR: marker } : {}) }, stdio: "ignore" });
return new Promise<number | null>((resolve) => child.once("exit", (code) => resolve(code)));
};
expect(await connector("exec-done", "exec-done")).toBe(1);
expect(await connector("exec-waiting")).toBe(1);
expect(await connector("exec-waiting", "exec-other")).toBe(1);
expect(readFileSync(join(h.home, "acp", "runs", "exec-done.json"), "utf8")).toBe(done);
expect(readFileSync(join(h.home, "acp", "runs", "exec-waiting.json"), "utf8")).toBe(waiting);
expect(notes(h)).toEqual([]);
}, 40_000);
it("reads a run whose connector died, or whose pid now names another process, as interrupted, with one note", async () => {
const h = setup();
const gone = spawn(process.execPath, ["-e", ""]);
await new Promise((resolve) => gone.once("exit", resolve));
const other = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
cleanup.push(() => other.kill("SIGKILL"));
const runs = join(h.home, "acp", "runs");
mkdirSync(runs, { recursive: true });
const record = (runId: string, pid: number) => writeFileSync(join(runs, `${runId}.json`), JSON.stringify({
runId, qitemId: "qitem-task", harness: "claude", agent: ["claude-agent-acp"], cwd: h.home, self: SEAT, pollMs: 1000, native: "x",
state: "started", pid, processStart: "Thu Jan 1 00:00:00 2026", requestedAt: "2026-11-06T00:00:00Z", startedAt: "2026-11-06T00:00:01Z",
transcript: join(runs, `${runId}.jsonl`), connectorLog: join(runs, `${runId}.connector.log`),
}));
record("exec-dead", gone.pid!);
record("exec-reused", other.pid!);
for (const runId of ["exec-dead", "exec-reused"]) {
const status = await rig(h, ["status", runId]);
expect(status.out).toContain("interrupted: its connector ended without recording an outcome, so the outcome is unknown; no process carrying the run's marker remains");
expect(runFile(h, runId)).toMatchObject({ state: "interrupted", outcome: "unknown" });
await rig(h, ["status", runId]);
expect(notes(h).filter((note) => note.startsWith(`rig exec run ${runId} interrupted`))).toHaveLength(1);
}
expect(other.exitCode).toBeNull();
expect(other.signalCode).toBeNull(); // the process that reused the pid was never signalled
}, 40_000);
it("cancel asks the agent to stop; the acknowledgement and what still runs are kept apart", async () => {
const h = setup("scenario:slow");
const runId = startedId((await rig(h, ["run", "qitem-task", "--harness", "claude"])).out)!;
await until(() => (notes(h).some((note) => note.includes("executor")) ? true : undefined));
const cancel = await rig(h, ["cancel", runId]);
expect(cancel.code).toBe(0);
expect(cancel.out).toContain("An acknowledgement isn't settlement");
const run = await ended(h, runId);
expect(run.outcome).toBe("cancelled");
expect(run.summary).toMatch(/^cancel requested at \S+, acknowledged by the agent at \S+ \(stopReason cancelled\); upstream spend is unknown$/);
expect(run.settle).toContain("settled: no process carrying this run's marker remains");
expect((await rig(h, ["cancel", runId])).code).toBe(1); // an ended run isn't signalled again
}, 40_000);
it("records a failed turn as failed, and a completed turn that left a process running as both", async () => {
const failed = setup("scenario:error");
const failedId = startedId((await rig(failed, ["run", "qitem-task", "--harness", "codex"])).out)!;
expect(await ended(failed, failedId)).toMatchObject({ outcome: "failed", summary: 'session/prompt returned error -32603 Internal error {"details":"context length exceeded"}' });
const pidFile = join(tmpdir(), `rig-exec-companion-${process.pid}`);
const leftBehind = setup("scenario:reply PONG", { FAKE_ACP_COMPANION_PID_FILE: pidFile });
const runId = startedId((await rig(leftBehind, ["run", "qitem-task", "--harness", "claude"])).out)!;
const run = await ended(leftBehind, runId);
const companion = Number(readFileSync(pidFile, "utf8"));
cleanup.push(() => { try { process.kill(companion, "SIGKILL"); } catch { /* gone */ } rmSync(pidFile, { force: true }); });
expect(run.outcome).toBe("completed");
expect(run.settle).toContain(`incomplete: 1 process(es) carrying this run's marker still run (${companion})`);
const status = await rig(leftBehind, ["status", runId]);
expect(status.out).toContain("finished, outcome completed");
expect(status.out).toContain(`After the turn: agent exited`);
expect(status.out).toContain(`still run (${companion})`);
}, 60_000);
it("a session the adapter says doesn't exist reads missing, and no fresh session is started", async () => {
const h = setup("scenario:reply PONG", { FAKE_ACP_LOAD: "1", FAKE_ACP_MISSING: "codex" });
const runId = startedId((await rig(h, ["run", "qitem-task", "--harness", "codex", "--load-session", "sess-gone"])).out)!;
const run = await ended(h, runId);
expect(run.outcome).toBe("missing");
expect(run.summary).toContain("session sess-gone doesn't exist for this agent (session/load returned -32603 Internal error");
expect(readFileSync(run.transcript, "utf8")).not.toContain("session/new");
}, 40_000);
});
+61
View File
@@ -262,6 +262,67 @@ describe("rig export + import", () => {
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
});
describe("export: a default export never replaces a file", () => {
const occupied = (...taken: string[]) => vi.fn((p: string) => {
if (taken.includes(p)) throw Object.assign(new Error(`EEXIST: ${p}`), { code: "EEXIST" });
});
const run = async (deps: Partial<ExportDeps>, ...args: string[]) => {
const program = new Command();
program.addCommand(exportCommand(exportDeps(deps)));
return captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1", ...args]));
};
it("the first export writes rig.yaml", async () => {
const writeNewFile = occupied();
const logs = await run({ writeNewFile });
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml"]);
expect(logs).toContain("Exported to rig.yaml");
expect(logs.join("\n")).not.toContain("left unchanged");
});
it("an occupied rig.yaml sends the export to a sibling, and says which", async () => {
const writeFile = vi.fn();
const writeNewFile = occupied("rig.yaml");
const exitCode = process.exitCode;
const logs = await run({ writeFile, writeNewFile });
expect(writeFile).not.toHaveBeenCalled();
expect(writeNewFile).toHaveBeenLastCalledWith("rig-export.yaml", expect.any(String));
expect(logs).toContain("Exported to rig-export.yaml");
expect(logs.join("\n")).toContain("rig.yaml already exists and was left unchanged");
expect(process.exitCode).toBe(exitCode);
});
it("occupied siblings are kept too", async () => {
const writeNewFile = occupied("rig.yaml", "rig-export.yaml", "rig-export-2.yaml");
const logs = await run({ writeNewFile });
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml", "rig-export.yaml", "rig-export-2.yaml", "rig-export-3.yaml"]);
expect(logs).toContain("Exported to rig-export-3.yaml");
});
it("-o replaces the file it names, including rig.yaml", async () => {
const writeFile = vi.fn();
const writeNewFile = occupied("rig.yaml");
const logs = await run({ writeFile, writeNewFile }, "-o", "rig.yaml");
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
expect(writeNewFile).not.toHaveBeenCalled();
expect(logs).toContain("Exported to rig.yaml");
});
it("a real write failure stays a failure, with no success reported", async () => {
const writeNewFile = vi.fn(() => { throw Object.assign(new Error("no space"), { code: "ENOSPC" }); });
const log = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const program = new Command();
program.addCommand(exportCommand(exportDeps({ writeNewFile })));
await expect(program.parseAsync(["node", "rig", "export", "rig-1"])).rejects.toThrow("no space");
expect(writeNewFile).toHaveBeenCalledTimes(1);
expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Exported to"));
} finally {
log.mockRestore();
}
});
});
it("export: warns when culture_file or startup files do not resolve relative to output directory", async () => {
const writeFile = vi.fn();
const exists = vi.fn((p: string) => !p.includes("missing-culture.md") && !p.includes("missing-start.md") && !p.includes("optional-start.md"));
+34
View File
@@ -0,0 +1,34 @@
// One `rig exec …` invocation in its own process, which then exits as the CLI does, over the test's file queue.
// EXEC_TEST_CONNECTOR=die starts a connector that exits before recording itself; =instant one that records a completed
// outcome before the CLI's first look. EXEC_TEST_WINNER=<outcome> has another caller claim the row, with a run that
// ended with that outcome, between this caller's claim read and its publish. EXEC_TEST_STALL_MS stalls the CLI right
// after it announces the run, as a process descheduled there would be.
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { claimRow, execCommand } from "../../src/commands/exec.js";
import { fileQueue } from "./exec-file-queue.js";
const entry = fileURLToPath(new URL("./exec-connector-entry.ts", import.meta.url));
const instant = fileURLToPath(new URL("./exec-instant-connector.ts", import.meta.url));
const queue = fileQueue(process.env.EXEC_QUEUE_FILE!);
const connector = process.env.EXEC_TEST_CONNECTOR;
const winner = process.env.EXEC_TEST_WINNER;
const stall = Number(process.env.EXEC_TEST_STALL_MS ?? 0);
await execCommand({
queue: async () => queue,
connectorCommand: (runId) => connector === "die" ? [process.execPath, "-e", "process.exit(3)"]
: [process.execPath, "--import", "tsx", connector === "instant" ? instant : entry, runId],
pollMs: 100,
startWaitMs: 20_000,
afterAnnounce: stall ? () => { for (const until = Date.now() + stall; Date.now() < until;) { /* busy: no timer or signal callback runs */ } } : undefined,
beforeClaim: winner ? () => {
const runs = join(process.env.OPENRIG_HOME!, "acp", "runs");
writeFileSync(join(runs, "exec-winner.json"), JSON.stringify({
runId: "exec-winner", qitemId: "qitem-task", harness: "claude", agent: ["claude-agent-acp"], cwd: runs, self: "builder@studio",
pollMs: 1000, native: "x", state: "finished", pid: 1, requestedAt: "2026-11-06T00:00:00Z", endedAt: "2026-11-06T00:00:05Z",
outcome: winner, summary: `the winner's run ended ${winner}`, transcript: join(runs, "exec-winner.jsonl"), connectorLog: join(runs, "exec-winner.connector.log"),
}));
claimRow("qitem-task", "exec-winner");
} : undefined,
}).parseAsync(["node", "rig", ...process.argv.slice(2)]);
Loaded 100 of 516 files, more files were not shown because too many files have changed in this diff. Show more