Compare commits

...
Author SHA1 Message Date
dev-driver f3d8bac42e docs(release): describe 0.7.7's three fixes
0.7.7 is now 0.7.6 plus #2311 (the Update installed footer), #2347 (managed
compaction keeps a typed draft) and #2341 (a Claude seat behind a non-exec
launcher can be rebuilt, and a failed handover keeps the resume target).
The notes name each fix, its limits and the reporter's credit.
2026-11-01 18:54:11 +00:00
dev-driver 72ad78e01a test(daemon): the #1077 stale-generation case uses a registered earlier occupant (#2341)
The resume-stale-generation fixture posted a generation the seat never registered, which #2341 now answers as generation_uncommitted. A real earlier occupant's generation is registered and no longer current; the fixture now mints that earlier tenure, so the case keeps testing what it meant (an earlier occupant's resume hook writes, and the conversation reads unverified) and its assertions are unchanged.
2026-11-01 18:46:40 +00:00
dev-driver ad799ebf70 fix(daemon): rebuild a Claude seat behind a non-exec launcher, and keep the resume target on a failed handover (#2341)
A #! wrapper that starts Claude without exec keeps running the frozen script as the pane's foreground leader, with Claude as its child under any name, so neither existing admission matched and a rebuild waited out readiness. Such a launch now also admits the direct child of the process running the frozen script (argv[1], or argv[2] after one interpreter option) when it names a Claude conversation; the launch's own token, a single candidate, two stable samples and the pane PID are still required. A Claude session_identity hook from an occupant generation the seat hasn't registered (a handover's successor before it commits), while the seat has a current tenure, no longer moves the seat's resume token; it answers tokenPersisted false with generation_uncommitted. An absent generation, no tenure or an unreadable ledger keep today's write; Codex is unchanged.
2026-11-01 17:24:41 +00:00
dev-driver f5cd2a34bf test(daemon): a Claude behind a non-exec launcher becomes ready, and a failed handover keeps the resume target (#2341)
Red first. A managed launch whose frozen executable is a #! wrapper that starts Claude without exec leaves the wrapper running the script as the pane's foreground leader, with Claude as its child under any name. That child, naming the launch's conversation, must be proved; a child naming another or no conversation, another script, a grandchild, a second candidate or a changing process stays refused. A Claude SessionStart from an occupant generation the seat hasn't registered (a handover's successor before it commits) must keep the seat's resume target; the registered generation, the committed successor and a post with no generation keep writing.
2026-11-01 17:24:41 +00:00
dev-guard 710001a384 test: supply composer observations to stable compaction controls 2026-11-01 15:02:53 +00:00
dev-guard 6e4530ac54 fix: preserve managed compaction drafts on the stable line 2026-11-01 14:24:38 +00:00
dev-driver d6ba98cbeb docs(release): describe 0.7.7 2026-11-01 10:58:31 +00:00
dev-driver e0dc58cd21 chore(release): prepare 0.7.7 2026-11-01 10:56:25 +00:00
Mike Schwarz 5f83c1e183 fix(daemon): read Claude's "Update installed" footer as a status line (#2311)
A Claude seat at an empty prompt reads idle after Claude installs an update, instead of unknown, so managed compaction can see it is idle and send. Claude prints "✔ Update installed · /restart to apply" (or "Restart to apply") below its mode bar, and CLAUDE_STATUS_WARNINGS didn't list that line, so the footer finder never reached the mode bar. One anchored entry now matches both spellings exactly. The #808 readiness cases run over both spellings across every mode bar, and three near misses (a suffix, no glyph, another verb) still read unknown. Source +2, tests +18/-1, no new refusal or error code.

(cherry picked from commit ff604d943d)
2026-11-01 10:55:58 +00:00
review50-r2 c00c6321e3 docs: omit redundant kernel dashboard session name from operator guidance 2026-10-29 16:27:31 +00:00
review50-r2 a962341d52 docs: name the kernel dashboard's session from rig ps in its recovery steps
Use the terminal's registered canonicalSessionName in recovery examples,
keeping capture, fresh launch without stop, and verification before retry.
2026-10-29 16:08:49 +00:00
dev-driver 7da9a48c1f docs(release): describe 0.7.6 slice following and upgrade limits
Add the changelog, installed upgrade guidance and capability map. Preserve
the selected collision guidance and document the bounded viewer repair,
exited-attach limit, startup pending result and draft author contracts.
2026-10-29 15:04:28 +00:00
Mike Schwarz 510719037d docs: draft integration author contracts (#2272)
Adds a draft entry point for people integrating a harness or an execution platform with OpenRig: `docs/reference/author-contracts.md`, with `harness-runner-contract.md` and `execution-platform-contract.md`. It separates what the current source implements from what is proposed or still open, covering launch, identity, input and activity, permission and cancellation, resume, context, placement, owned versus attached resources, shutdown, and artifact return. Existing presets, AgentSpec and RigSpec stay distinct from the proposed HarnessSpec and DeploySpec vocabulary, and no new loader or conformance guarantee is claimed. `community-harness-presets.md` links to the entry point.
2026-10-29 14:59:02 +00:00
Mike Schwarz 7ea2177018 fix: recover terminal joins refused before split dispatch (#2271)
A following terminal view no longer leaves an owner permanently unable to join when the view's own checks refuse before any pane split is dispatched. Only that fresh, positively known no-effect intent is retired, and its removal is saved before best-effort directory cleanup, so a cleanup failure cannot strand the join again. A dispatched create with an uncertain reply keeps its provenance, including when enumeration is empty. Hold notices keep the seat, observed phase and original failure across passive confirmation reads, and the seat binding is rechecked before an attach is dispatched.

An interrupted join that stopped before its attach continues through the existing ensure path. An attach that was already dispatched stays fail-closed, including a pane that returned to a shell after its viewer exited; this change does not restart it or any native agent.
2026-10-29 14:57:02 +00:00
Mike Schwarz 6eb2cb97f1 fix: keep a still-starting daemon after its readiness deadline (#2267)
On a large database, `rig daemon start` no longer kills its own daemon when startup outlasts the CLI's readiness deadline. When only the deadline has elapsed and the spawned child is still alive, the CLI leaves it running, keeps the startup reservation that excludes a second start, and exits nonzero with a finite pending message. That message names the child PID, the launch's health endpoint and the log, and it says how to restart by hand. Nothing is published as ready.

A child that exits, a native start error, an identity or listener mismatch and owned-child cleanup are handled as before. There are no new error codes or dependencies.
2026-10-29 13:49:37 +00:00
dev-driver d4d6a01df0 chore(release): prepare 0.7.6 2026-10-29 11:41:20 +00:00
Mike Schwarz 85ee9f4ec8 fix: place colliding slice aliases by indexed mission (#2261)
A slice whose folder name is reused by another mission can now be followed by its unique id. When the selected slice's name or id collides with another slice's, a queue row carrying the colliding alias is placed by its exact `mission:` tag: it counts only when it names the selected slice's own indexed mission, and rows naming the other mission are excluded. A row with the colliding alias and no mission tag, an unknown or contradictory one, or a placement that names several claimants makes the read incomplete with that row named, so existing viewers are kept rather than retired. A bare selector that matches several slices still reports `scope_ambiguous`, flat legacy slices get no invented mission, and unique aliases match exactly as before.

Release missions commonly reuse template slice names such as `01-regression-checks`, which previously made those slices impossible to follow even through their unique ids. One queue projection and the existing read bounds are unchanged.
2026-10-29 11:41:19 +00:00
Mike Schwarz b6bd4633fb Follow current slice obligation owners in terminal views (#2259)
`rig terminal open slice:<id> --follow --provider herdr` opens one terminal workspace on the current owners of a slice's open work and keeps it as the work moves. Owners are the destinations of pending, in-progress and blocked queue items carrying that slice's exact scope tag; an owner holding several items appears once with every obligation, and an owner who is unavailable stays named.

The slice is resolved from the daemon's configured slice index by its directory name or unique frontmatter id, with no substring matching or first-match: a name or alias claimed by two slices reports `scope_ambiguous`, and an unknown, unsupported or unreadable scope reports its own code. An incomplete read keeps the existing viewers instead of reading as empty; a known empty slice keeps following and repopulates when work arrives. The slice identity is read once per observed scope change (or refresh) and queue ownership is read fresh on each render. Scope views default to four tiles (task views keep eight), and departures close viewer panes only, never agents or conversations.

Previews now carry `task.kind` and `task.id` (task views keep `qitemId`) and `capabilities.ensureViewer`. Ensure for an unsupported view kind now returns HTTP 400 like open and preview. Mission views are not part of this change.
2026-10-29 02:22:42 -07:00
Mike Schwarz 6498383a4d docs: qualify the reported Codex restart refusal in 0.7.5 (#2257)
The 0.7.5 section of the changelog now says what the published 0.7.5 release notes say about #2200: the hook-refresh restart was reported to refuse one Codex seat upgraded from 0.6.x, its cause is unknown, and the inspected 0.7.4 check already accepts the reported process tree. It is not fixed in 0.7.5 and is not an established refusal of every staged-script launch. If a restart refuses, nothing is stopped: leave the seat running and report the refusal with CLI and daemon versions. A refused restart does not complete the hook refresh. Text only.
2026-10-29 01:48:52 -07:00
Mike Schwarz ad8c479ec2 docs: v0.7.5 install pins (#2256)
The install pins on main move from `v0.7.4` to `v0.7.5`, now that 0.7.5 is published: the README's `skills add` lines, its install-script lines and the sentence naming the release, plus the same install-script lines in `docs/reference/getting-started.md` and the example tag in `docs/reference/quickstart.md`. `docs/reference/whats-new.md` stays at main's note, because it names `packages/cli`'s version. Text only.
2026-10-29 01:32:15 -07:00
NIKHIL 7462f1f9f9 fix(tui): keep emoji whole when rig table cells are cut to width (#2253)
The TUI's rig agent table no longer splits an emoji when it cuts a cell to its column width, and a cut cell keeps the table's columns aligned. Before, a seat whose current summary had an emoji at the cut point printed a lone UTF-16 surrogate before the `…`.

`pad` and `padLeft` in `render.ts`, which build every rig table cell, now cut with `sliceWhole` (as #1453 and #2252 did for other cut points) and pad the result back to the column width, so a cell isn't one column short when an emoji is dropped at the cut. All-ASCII cells are unchanged.
2026-10-29 01:05:06 -07:00
Mike Schwarz 89e5f9217e fix(daemon): preserve member settings during rig expansion (#2250)
`rig expand` now keeps every member setting a rig spec accepts, including `env.CLAUDE_CONFIG_DIR`, so a new seat uses its selected config home. The HTTP route and the expansion service each rebuilt members from an incomplete field list, silently dropping `env`, Advisor, startup, launch additions and continuity settings.

The route now passes the original member to the service. The service keeps the canonical fields and translates the established camelCase aliases (including `claudeToken` to `claude_token`) before the existing rig spec validation, creation and launch path. Canonical validation stays the one place that judges member fields, so a malformed or unknown setting gets the same validation error `rig up` gives. A provided `model` or `label` must be a string; missing or empty values behave as before. Non-string `agentRef`, `codexConfigProfile` and `restorePolicy` keep their existing string fallback, and explicit effort and alias precedence are unchanged. The single-member add route already carried raw members.

The tests use a fake runtime and tmux adapter, so they check OpenRig's plumbing and stored settings, not a native login.
2026-10-29 01:01:01 -07:00
Mike Schwarz ab5a233982 docs: carry 0.7.5's release notes and capability map to main (#2254)
Main's documentation catches up with 0.7.5's release text. `CHANGELOG.md` gains the `## [0.7.5]` section above `## [0.7.4]`, and the capability map (`packages/daemon/context-packs-src/onboarding-width/public-what-you-can-do.md`, marker `capability-delta-v0.7.5`) takes the 0.7.5 text. Both are byte-identical to `release/0.7.5-successor2` at `b57c4da3`. The rest of `CHANGELOG.md` is main's, including its newer 0.7.3 corrections.

`docs/reference/whats-new.md` stays at main's note, because the packaged note must name `packages/cli`'s version, and the install pins are unchanged. Text only.
2026-10-29 00:26:47 -07:00
Mike Schwarz 6cc3626529 feat(daemon): a Claude member names its own subscription token in the spec (#2246)
A Claude member can name its own subscription token file in the rig spec, so a new Claude seat with its own config folder starts on that token from its first launch. Before, `rig provider switch --token-file` needed a running seat with a conversation to resume, so a first start took a bootstrap conversation, `set-resume-token` and then the switch.

```yaml
- id: dev
  runtime: claude-code
  env:
    CLAUDE_CONFIG_DIR: /abs/claude-dev
  claude_token: { label: dev-sub, file: /abs/tokens/dev.token }
```

When the rig is created, the label and path are stored on the seat as the same reference `rig provider switch --token-file` writes, and every launch runs the existing token step, which reads the file as Claude starts. Validation checks the label and that the path is absolute and on one line; it never reads the file. A missing, empty or unreadable file doesn't refuse the spec: the launch starts with the existing fallback notice. Only `claude-code` members and legacy flat nodes accept the field, and there's no rig-wide default.

The token step, shared with the switch, now suspends shell tracing while it reads and assigns the token. When a Claude settings file would outrank the seat's token (an `apiKeyHelper`, or an outranking settings `env` entry), managed, fresh and resumed launches warn in the switch's words. Environment names that the token step removes aren't reported, and managed settings aren't inspected. Export writes the seat's current reference, leaving out API-key references and seats that select an account home, so a later switch shows there and `--stored-login` removes it. A first-start retry keeps the seat's stored reference.

Not covered: a native first turn on the named account needs a real token, so CI doesn't run one.
2026-10-29 00:22:55 -07:00
NIKHIL 2a73f1809a fix(tui): keep emoji whole when health and config rows are cut to width (#2252)
The TUI's Health and Config views no longer split an emoji when they cut a row to fit its column. Before, an emoji at the cut point printed a lone half-character before the `…` in the Health summary, the findings table and the Config list. Both views now use the TUI's existing surrogate-safe cut, as other views already do. ASCII output is unchanged.
2026-10-28 23:41:16 -07:00
NIKHIL c67b829616 fix(cli): read rig status snapshot times as UTC (#2251)
`rig status` now reports a rig's snapshot age correctly on hosts outside UTC. Snapshot times are stored as UTC without a zone, and the age was computed as if they were local time, so it was off by the host's UTC offset: on an Asia/Kolkata host a two-minute-old snapshot showed `5h ago`, and on an America/Los_Angeles host every snapshot showed `just now` for its first seven hours. Hosts on UTC see no change.
2026-10-28 23:40:24 -07:00
Rudy Mizrahi Celekli 0820fad101 fix(ui): show completed mission labels after proof acceptance (#2249)
In the browser dashboard, a mission declared complete whose current proof is ready now shows its completion label instead of "declared complete · proof ready", which read as though accepted proof still needed review. Readiness alone completes nothing: active or paused declarations, a missing declaration, and incomplete or mixed proof keep their qualified labels, and a mission without a proof checklist reads as before. Only the dashboard's mission formatter changes; mission status, proof judgments and delivery are unchanged.

Addresses #2230. Thanks to @KM199 for the report.
2026-10-28 23:39:09 -07:00
Nash Aschenbath 3c7cfe26ca docs(reference): explain shared kernel dashboard recovery (#2213)
The getting-started troubleshooting section now explains how to bring back the shared kernel dashboard when the kernel's other agents recovered but its `operator.human` terminal didn't. It shows how to find the terminal's session with `rig ps --nodes --rig kernel --json`, how to check the real pane with `rig capture`, why a stored running row alone doesn't prove the session is alive, and how a one-seat fresh launch restores the dashboard. The kernel operator's guidance says the same, so the operator agent can do it.

It doesn't claim a cause for #2201. A retained `excluded_from_subset` may predate the latest reboot, and #2201 stays open.

Thanks to @skv-io for the field report in #2201.
2026-10-28 22:33:29 -07:00
Mike Schwarz 75602eb977 fix(daemon): stop projecting retained dead panes as running (#2248)
When a seat's agent exits normally and tmux keeps its pane as a dead pane, OpenRig now reports the seat as exited (`sessionStatus` exited, `startupStatus` attention_required) instead of running and ready. Earlier activity no longer keeps it looking busy or idle, parked reminders built from that activity stop, and the stuck-work sweep sends its recovery finding through the existing recovery floor instead of to the seat that can't receive it. The original work row is kept, and nothing restarts the seat.

The batched tmux pane read now includes tmux's own dead-pane flag. A pane counts as exited only when tmux lists it, dead, in the seat's own session (any window). A stale binding or a failed membership read stays unknown, never exited, so a live seat isn't declared dead. Stored statuses aren't rewritten, and send, launch and recovery order are unchanged.

A watchdog episode that closes now says the seat is no longer observed parked, rather than implying a native restart. Limit: recovery findings that already exist keep their recorded recipient; only new findings use the recovery floor.
2026-10-28 22:27:22 -07:00
Seán McNamara ef82979f12 fix(codex): recognize verified Node launchers during in-place restart (#2247)
`rig seat restart --keep-conversation` now recognizes Codex's npm Node launcher when Linux names its process `MainThread`. That launcher is accepted only when its observed executable is Node and its exact argv names an absolute Codex entry script (basename `codex`, or a path ending in `/bin/codex.js`). Reading exact argv keeps argument boundaries, so a supervisor whose flattened command line only looks like Codex isn't selected for the restart's signal, and a launcher path containing a space still works.

Acceptance of `node`-named launchers is unchanged from main: they need no option parsing, absolute path or exact-argv read, so existing Codex launches keep in-place restart. A restart now counts as stopped only when both the launcher and the native Codex process are gone and the shell is back in front, and KILL is sent only while the same launcher (PID and start time) is still running, so a reused PID is never killed. All other restart checks are unchanged.

This doesn't establish whether #2200 is resolved.
2026-10-28 21:57:57 -07:00
Seán McNamara d1cdfad212 feat(provider): resume Claude seats with API-key credentials (#2191)
A running Claude Code seat can switch from its subscription to an API key and back with `rig provider switch --to-account <name> --api-key-file <file>` (or `--token-file` for a subscription token), resuming its conversation in its own pane with the same home and launch settings. In-flight work is interrupted. The key is read from a private file, never from argv. Combining `--api-key-file` with an account-home selector is refused; select or clear an account home separately. Codex keeps using its saved native API-key profiles, and shared-home behaviour is unchanged.

Restore keeps a missing classic Claude conversation token only after exact native proof, preserves conflicting hook metadata, and won't resume on an API key when credential loading can't run. Classic credential-preparation errors now return structured failures. A narrow restore rejects a snapshot whose saved node no longer matches a current seat by both ID and logical ID before changing anything. A failed in-place resume keeps the pane and binding without bringing a terminated predecessor back as running.

Three test-stability fixes are included: tmux teardown directory races, a probe server kept alive between cases, and tmux dollar escaping in session names.

Related to #2138, after #2143.
2026-10-28 21:38:27 -07:00
Mike Schwarz 2f6263b7dc feat(slack): let reaction routing be turned off (#2241)
A person whose own script handles acknowledgements can set reactions off, so an emoji added to an ask no longer creates a row that costs the asking seat a turn. `rig slack setup --reactions route|off` saves an optional `reactions` key in `slack-connector.json` (validated on save; a missing key reads as route, so the default is unchanged), and the shared reaction attempt drops reactions when it's off, including one that was retained for retry before the switch, with the same ignored (reactions-off) disposition as a fresh one. Slack still sends the event and the manifest keeps it, so switching back needs no app change. The setup guide says this, and that the setting reaches a running connector when it next rewires (a daemon restart). No flow uses a reaction as an answer; a person answers by typing a reply or with the ask's buttons.

8 files: source (4 files, +20), tests (+35 -4) and docs (+9 -1). One save-time refusal for an invalid reactions value; no new error code.
2026-10-28 21:01:10 -07:00
Mike Schwarz 0fcb7040e1 fix(slack): record where Slack actually placed a reply (#2240)
When Slack posts a reply to a deleted thread as a new top-level message, OpenRig now records it as that conversation's new thread. A person's reply to it reaches the agent that posted it, and the agent's `--reply-to` answer lands in the same thread. The old root's mapping is closed, so later posts don't reuse a deleted thread. A reply Slack threaded where it was asked to is recorded as before, and an answer without the posted message keeps the requested placement.

For a `--reply-to` update that Slack posted top-level, `rig queue create --verify` now reports it as not threaded, with the reason `thread-missing`; retries still use the original choice. A later part of a long message that Slack posts top-level, because the first part's root was deleted between parts, opens a root of its own, so a reply or reaction to it reaches the agent.

Not changed: an inbound row's source footer still omits `thread_ts`, and a delivery that reconciles an earlier ambiguous post still records the requested placement. If writing the placement note fails, `--verify` can still read the update as threaded.

Thanks to @KM199 for the trace, the offline reproduction and the acceptance cases.
2026-10-28 20:25:19 -07:00
Seán McNamara e61faf5d80 feat(provider): switch seats to isolated account homes (#2143)
A running Claude Code or Codex seat can move to an existing account home with `rig provider switch --account-home <dir>`, or `rig seat set-runtime --account-home` when it also changes runtime. A successor confirms its handover before the selected home and settings are committed, and a refusal before cutover leaves the seat as it was. The seat keeps its address, edges, queue items and claims. An account-home switch starts a fresh conversation with a handover. `--clear-account-home` returns the seat to its configured member, rig, daemon or native defaults; it doesn't restore earlier credentials.

The selection is stored as a path only, in a new nullable `nodes.account_home` column (migration `141_node_account_home`); credential values stay in that home. Reading a Claude settings file for credential conflicts no longer blocks on a FIFO or parses a file over 1 MiB: either reads as unreadable.

Related to #2138. Conversation-preserving API-key switching follows in #2191.
2026-10-28 20:21:30 -07:00
Mike Schwarz 9039c86ad9 fix(slack): a button answer resumes a decision parked on the person it asked (#2245)
When an ask is parked on the same person it asks, that person's button answer in Slack is now recorded and resumes the ask, as a typed thread reply already did. Before, the click was received, then refused as if the decision were closed, the answer wasn't stored, the asking seat wasn't woken, and the person was told "This decision is no longer waiting for answers", which wasn't true.

A click on a parked ask is accepted only when the park names a person, the clicker is the ask's own destination and not a different registered person, and the click comes after the current human-required post. The check is the typed reply's existing current-question lookup, now shared rather than copied, and a complete answer set resumes through the same resolve step, once, with one answer to the asking seat; a redelivered click changes nothing. A click that's still refused for a parked ask now says the decision is parked right now and isn't taking this answer. Parks on someone else or on another item, closed decisions and stale questions are refused as before, and the typed reply path is unchanged.

4 files: source (3 daemon files, +32 -10) and tests (+68). No new refusal, error code or setting.
2026-10-28 19:17:47 -07:00
Mike Schwarz 9924040ba4 feat(daemon): a community harness can take the seat's declared model (#2243)
A model declared for a seat, in the rig spec or with `rig seat set-model`, can reach a community harness whose launcher opts in, so it doesn't have to be set twice. OpenRig says which model it asked for and whether it passed it, and doesn't claim which model actually runs.

A preset may set `passes_model: true` (a new optional boolean, false by default). Its launcher then gets the seat's declared model as `OPENRIG_MODEL`, on new and resumed launches, in the same launch command as the other `OPENRIG_` variables; with no declared model the variable is cleared. Passed means sent: the launch result marks the model's dispatch confirmed only when the launch command's send succeeded, a later failure of the launch doesn't undo that, and startup records `node.startup_model_passed` only then. Typing that began and then failed leaves the dispatch uncertain, not recorded as passed and not typed again; a launch that failed before typing passes nothing. The effective model stays unknown: no model reader is added, and there's no default, substitute or fallback model. Presets that don't opt in, plain presets, built-in runtimes and seats with no declared model are unchanged.

The example launchers map it for a new session only and don't apply it on a resume, where the model a resumed session runs stays unknown (for Cursor it hasn't been checked). Cursor's adds `--model`. Hermes's writes a model line only into an absent or empty seat `config.yaml`; a seat with its own config keeps it byte-for-byte, and the launcher says the model wasn't applied. Both presets ship with `passes_model: false` until checked with the real harness.

The requested / passed / effective split comes from @pdurlej's input on #1597.

15 files: source (5 daemon files, +43 -13), tests (+163 -5) and docs and example launchers (+57 -2). One parse refusal for a non-boolean passes_model; no migration, new error code or setting beyond the preset field.
2026-10-28 18:16:21 -07:00
Mike Schwarz e3d9ce488d docs(changelog): match two 0.7.3 lines to the published release notes (#2244)
Two lines in the 0.7.3 section of CHANGELOG.md now say what the published v0.7.3 release notes say. A Codex seat's permission settings were enforced natively in our test with Codex 0.164.0, including after a managed restore and a restart, and other Codex versions weren't tested; the line no longer says enforcement was unproven. The credit for #1679 drops "the issue's main report is still open", since that issue was closed on 2026-10-21.

1 file, CHANGELOG.md, +6 -6. Text only; no release, package or behavior change.
2026-10-28 17:15:19 -07:00
Mike Schwarz fc50fb8d71 fix(daemon): retain protected Codex messages before an unobservable paste (#2242)
On a Codex seat whose typing guard is draft-aware or openrig-urgent, protected delivery no longer pastes a message into a composer where it can't then confirm and submit it, which left input that later work could collide with. Before the paste, under the target lease and after buffer preparation, it checks that the complete formatted message will be visible. If that can't be confirmed, the message is retained and not sent (`draft_input_unknown`). The estimate and the complete-text ownership reader share Codex's native textarea width (62 columns in a 65-column pane).

A logical line strictly shorter than the native width is charged exactly one row; any other line gets a conservative bound over the whole line, covering native break points and the insertion cursor row, rather than re-implementing native Unicode line breaking. So a message with a line that wraps can be retained where main would have delivered it: at common four-tile sizes, roughly a one-paragraph body over about 115 characters at 80x22, 192 at 80x24 or 526 at 120x30 (examples, not limits). A paste over 1,000 code points is also retained, because Codex 0.164 collapses it to a label that protected ownership doesn't recognize. Recovery is a larger pane or a shorter message; turning the seat's typing guard off turns draft protection off and isn't a recovery. Retained isn't delivered, and nothing is replayed, cleared, force-sent or submitted with an unconditional Enter. The default (guard off) and the full staged-text ownership and human-input checks are unchanged.

5 files: source (4 daemon files, +51 -4) and tests (+217). No new refusal, error code or setting.
2026-10-28 15:58:35 -07:00
Mike Schwarz 5a690c9c8b feat(daemon): runner presets report their seat activity (#2239)
A community harness preset can declare `runner: true` when its launcher hosts the agent and reports the seat's turn boundaries, as OpenRig's own Pi runner does. That seat's activity (working, idle, needs input) then follows the runner's reports instead of reading unknown from a quiet screen. Plain presets and the built-in runtimes are unchanged.

`runner: true` needs `launch_result: true`. Each harness launch records the declaration on its session in a new nullable `sessions.agent_runner` column (migration 141), so a later preset edit doesn't change a running seat, a no-launch continuation keeps the recorded declaration, and a session recorded before this reads as plain. A runner-declared session's lifecycle hooks decide its activity, and screen sampling counts only as evidence of work; the hook route's first declaration and the reconcile pass read the same recorded fact, and reconcile re-declares when a same-name occupant switches between a plain and a runner launch. A runner's `Stop/<preset name>_exited` reads unknown, never idle, while Pi, OMP, plain and legacy sessions keep today's mapping. A runner launch whose record never arrives, or whose record folder can't be made, needs attention instead of reading ready. Docs: a "Runner presets" section in `docs/reference/community-harness-presets.md`.

No new endpoint, token or permission: a runner posts to the existing activity hook route with the existing local token. Send readiness and pane input parsing are unchanged.

16 files: source (11 daemon files, +122 -38, one migration), tests (+328 -3) and docs (+40 -2).
2026-10-28 15:32:36 -07:00
176 changed files with 6953 additions and 830 deletions

No files matched your search

+451 -6
View File
@@ -8,6 +8,451 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.7]
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
idle prompt under Claude's "Update installed" line is recognized again, managed
compaction no longer types into a message you haven't sent, and a Claude seat
launched through a wrapper script can be rebuilt again.
### Before you upgrade
- **Restart the daemon after installing** so the fixes take effect:
```bash
npm install -g @openrig/cli@0.7.7
rig daemon stop && rig daemon start
```
There are no database migrations. The bundled openrig-core plugin stays at
0.1.23 with unchanged files.
### Fixed
- **A Claude seat under the "Update installed" line reads idle again.** After
Claude installs an update, it prints "✔ Update installed · /restart to apply"
(or "Restart to apply") below its mode bar. OpenRig didn't recognize that line,
so a seat at an empty prompt read unknown instead of idle, and managed
compaction waited. Both spellings are now recognized; lines that only look
similar still read unknown. ([#2311](https://github.com/mvschwarz/openrig/pull/2311))
- **Managed compaction no longer types into a draft.** With the optional typing
guard off (the default), compaction's preparation prompt could be pasted onto
a message someone had typed but not sent. Now the preparation prompt,
`/compact` and the restore prompts check the input box first: typed or
unreadable input holds the step with a reason. A held restore prompt goes ahead
on its own once the box is empty; a manual `rig compact` says why it stopped,
to be retried once the box is empty. An empty box or Claude's own suggestion
goes ahead.
([#2347](https://github.com/mvschwarz/openrig/pull/2347))
- **A Claude seat launched through a wrapper script can be rebuilt again.** On
0.7.6, `rig seat handover --source rebuild` on a seat whose `claude` is a `#!`
script that starts the real Claude without `exec` stopped after 30 seconds with
"Waiting to confirm the new Claude process in this pane", though the new Claude
was running. The readiness check now also accepts the script's own direct child
when it names this launch's conversation; every other check is unchanged.
([#2341](https://github.com/mvschwarz/openrig/issues/2341), [#2354](https://github.com/mvschwarz/openrig/pull/2354))
- **A failed handover no longer moves the seat's resume target** to the new,
unprimed conversation while the seat keeps its previous occupant.
([#2354](https://github.com/mvschwarz/openrig/pull/2354))
### Known limits
- **Other reasons managed compaction can wait are unchanged.**
- **The draft check reads the input box just before pasting.** Typing that starts
in the same moment can still be appended to.
- **A Claude seat whose current occupant generation was never registered,** such as
an adopted Claude carrying another generation, or a managed launch whose tenure
record failed to write, keeps its first conversation token, but later hooks don't
move it.
- **The 0.7.6 known limits below still apply.**
### Thanks
- @oneandonlydean for the report and the evidence in #2341.
## [0.7.6]
OpenRig 0.7.6 lets one terminal workspace follow the current owners of a slice's
open work through handoffs and completions. It also keeps more seat settings
through expansion and restore, reports retained dead panes honestly, and repairs
queue and Slack delivery paths.
### Before you upgrade
- **Restart the daemon after installing** so the new code and database updates
take effect:
```bash
npm install -g @openrig/cli@0.7.6
rig daemon stop && rig daemon start
```
Two migrations add the selected account-home path to nodes and the runner
declaration to sessions. Both have prefix 141; their distinct full filenames
identify them. ([#2143](https://github.com/mvschwarz/openrig/pull/2143), [#2239](https://github.com/mvschwarz/openrig/pull/2239))
- **The bundled openrig-core plugin advances from 0.1.22 to 0.1.23.** Claude's
activity hook clears permission marks after a tool batch. Managed refresh
preserves replaced files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`;
reapply local edits you still need from that copy. ([#2209](https://github.com/mvschwarz/openrig/pull/2209))
### Follow the work in a slice
- **Open `rig terminal open slice:<name-or-id> --follow --provider herdr`.**
Membership comes from pending, in-progress and blocked queue rows tagged with
that exact slice. Owners appear once with all their obligations listed;
unavailable owners and tile overflow stay named.
Handoffs retain the association unless their tags are replaced. New slice
views default to four automatic tiles; `--max-tiles` changes that. Known empty
slices stay following; incomplete reads retain the previous viewers. Pins,
drafts, unknown input and focus retain departed viewers, and closing a viewer
never stops its agent. Slice following targets the Linux Herdr 0.9.5 /
protocol 22 path. Mission following is not included.
([#2259](https://github.com/mvschwarz/openrig/pull/2259))
- **Named Herdr sessions use their configured endpoint throughout viewer and
follow operations.** The TUI's terminal action opens the agent, pod or rig
currently in view. ([#2205](https://github.com/mvschwarz/openrig/pull/2205), [#2232](https://github.com/mvschwarz/openrig/pull/2232))
- **Client-visible HTTP change:** `/api/terminal/viewer/ensure` now returns 400
for `view_required` and `unknown_provider`, where it returned 200 with an
error body. Clients should continue reading the body code. Missing slice
identities return 404; ambiguous or unavailable identities return 409.
([#2259](https://github.com/mvschwarz/openrig/pull/2259))
- **When a slice's folder name repeats across missions, tag its work with the
slice's unique ID** (`rig queue create --slice <id>`) or add
`--mission <mission>`. A row tagged only with the shared name cannot be placed,
so the view reads incomplete until it is.
([#2261](https://github.com/mvschwarz/openrig/pull/2261))
- **A join refused before a pane split can join on a later refresh.** Hold
notices retain the original seat, phase and reason. A created viewer whose
attach has not yet run can continue without replacing its pane.
([#2271](https://github.com/mvschwarz/openrig/pull/2271))
### Seats, accounts and harnesses
- **Expansion keeps the member settings accepted by `rig up`,** including env,
effort and startup settings. Restores retain the model saved with
`rig seat set-model`. ([#2250](https://github.com/mvschwarz/openrig/pull/2250), [#2227](https://github.com/mvschwarz/openrig/pull/2227), [#2228](https://github.com/mvschwarz/openrig/pull/2228))
- **Move a running Codex or Claude Code seat to an existing account home** with
`rig provider switch --seat <seat> --to-account <name> --account-home <dir>`;
preview with `--dry-run`. This starts a fresh conversation with a handover,
retaining the seat's address and work. `--clear-account-home` returns to the
configured defaults. ([#2143](https://github.com/mvschwarz/openrig/pull/2143))
- **Claude subscription/API-key switching has a same-conversation path:**
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`;
return with `--to-account plan --token-file <abs>`. In-flight work is
interrupted. The API-key path remains unqualified; verify the resulting
account and conversation. A new Claude member can instead name a prepared
subscription token in its spec with `claude_token: {label: name, file: /abs/token}`.
([#2191](https://github.com/mvschwarz/openrig/pull/2191), [#2246](https://github.com/mvschwarz/openrig/pull/2246))
- **Relaunch retains the selected seat and its observed executable.** Codex
identity reads preserve OS argument boundaries, and in-place restart can
recognize a verified Node launcher named `MainThread`. This does not establish
that the reported #2200 restart refusal is fixed. ([#2196](https://github.com/mvschwarz/openrig/pull/2196), [#2212](https://github.com/mvschwarz/openrig/pull/2212), [#2247](https://github.com/mvschwarz/openrig/pull/2247))
- **Managed seats inherit the daemon's SSH agent socket** for git over SSH;
check the daemon's agent with `ssh-add -l`. ([#2222](https://github.com/mvschwarz/openrig/pull/2222))
- **Community presets can report turns and accept a declared model.**
`runner: true` with `launch_result: true` records runner activity per launch.
`passes_model: true` passes the requested model as `OPENRIG_MODEL`; it does not
observe the effective model. The Cursor and Hermes examples leave this off
pending native qualification. These are opt-in preset fields, not a completed
integration with every harness. ([#2239](https://github.com/mvschwarz/openrig/pull/2239), [#2243](https://github.com/mvschwarz/openrig/pull/2243))
- **Draft integration author contracts** describe harness, runner and platform
duties, distinguishing implemented interfaces from proposals. Start at
[the author-contract entry point](docs/reference/author-contracts.md).
([#2272](https://github.com/mvschwarz/openrig/pull/2272))
### Delivery and Slack
- **Original queue wakes retry after a selection prompt clears.** Retries identify
OpenRig as the sender, workflow entry keeps its caller, and prompt-blocked
alerts remain routable when the original sender is on another host.
([#2235](https://github.com/mvschwarz/openrig/pull/2235), [#2229](https://github.com/mvschwarz/openrig/pull/2229), [#2237](https://github.com/mvschwarz/openrig/pull/2237))
- **Work sent to a terminal-only seat gets a recovery finding for a person.**
Cross-host `rig queue create --verify` reads the destination's receipt;
unreadable receipts remain unconfirmed without replaying the create.
([#2192](https://github.com/mvschwarz/openrig/pull/2192), [#2193](https://github.com/mvschwarz/openrig/pull/2193))
- **Slack button answers resume decisions parked on the person asked.** Replies
Slack places outside a deleted thread are recorded at their actual location,
so the next reply reaches the right conversation. ([#2245](https://github.com/mvschwarz/openrig/pull/2245), [#2240](https://github.com/mvschwarz/openrig/pull/2240))
- **Use `rig slack setup --reactions off` to stop acknowledgement emoji waking
agents.** The default remains `route`, and regenerating the app manifest
preserves the choice. Alert selection filters eligible notifications without
dropping older eligible asks behind an arbitrary row cap. ([#2241](https://github.com/mvschwarz/openrig/pull/2241), [#2183](https://github.com/mvschwarz/openrig/pull/2183))
### Status, inventory and help
- **A readiness deadline no longer ends a still-starting daemon.** The CLI
returns a nonzero pending result naming the child PID, launch endpoint and
log. It leaves the child running and retains the startup reservation, without
declaring readiness or allowing a duplicate start. Follow that pending
guidance before recovery. ([#2267](https://github.com/mvschwarz/openrig/pull/2267))
- **A verified exited process in a retained pane reads `pane_exited`,** instead
of running and ready. Inventory avoids discarded graph loads, scopes startup
history to the requested rig's nodes, and reuses node identity for activity
reads. These changes make no universal latency claim. ([#2248](https://github.com/mvschwarz/openrig/pull/2248), [#2225](https://github.com/mvschwarz/openrig/pull/2225),
[#2226](https://github.com/mvschwarz/openrig/pull/2226), [#2236](https://github.com/mvschwarz/openrig/pull/2236))
- **Completed mission labels reflect accepted proof**, without making proof
readiness complete a mission. Snapshot ages use UTC correctly; emoji stay
whole when TUI health, config and rig-table cells are shortened. ([#2249](https://github.com/mvschwarz/openrig/pull/2249),
[#2251](https://github.com/mvschwarz/openrig/pull/2251), [#2252](https://github.com/mvschwarz/openrig/pull/2252), [#2253](https://github.com/mvschwarz/openrig/pull/2253))
- **Smaller command fixes:** chatroom tmux rig names stay literal; a trailing
slash in `OPENRIG_URL` still identifies the same daemon; a trailing slash in
the missions root no longer hides a slice's mission. `rig host doctor` names
the entry replies actually reach. ([#2218](https://github.com/mvschwarz/openrig/pull/2218), [#2220](https://github.com/mvschwarz/openrig/pull/2220), [#2221](https://github.com/mvschwarz/openrig/pull/2221), [#2214](https://github.com/mvschwarz/openrig/pull/2214))
- **Help and contributor checks:** edge help explains deferred escalation
targets; section addressing ignores HTML comments; the guide covers shared
kernel-dashboard recovery. Install pins, help links and earlier release text
are corrected. Scenario fixtures honor send verification, execute tmux
directly, and reject unknown step verbs at schema load. ([#2204](https://github.com/mvschwarz/openrig/pull/2204), [#2211](https://github.com/mvschwarz/openrig/pull/2211),
[#2213](https://github.com/mvschwarz/openrig/pull/2213), [#2238](https://github.com/mvschwarz/openrig/pull/2238), [#2244](https://github.com/mvschwarz/openrig/pull/2244), [#2254](https://github.com/mvschwarz/openrig/pull/2254), [#2256](https://github.com/mvschwarz/openrig/pull/2256), [#2257](https://github.com/mvschwarz/openrig/pull/2257), [#2215](https://github.com/mvschwarz/openrig/pull/2215), [#2216](https://github.com/mvschwarz/openrig/pull/2216),
[#2217](https://github.com/mvschwarz/openrig/pull/2217))
### Known limits
- **The Codex hook-refresh refusal in [#2200](https://github.com/mvschwarz/openrig/issues/2200)
remains unresolved.** If `rig seat restart <seat> --keep-conversation` refuses,
nothing is stopped: leave the seat running and report the refusal with CLI
and daemon versions. A refused restart has not refreshed the hooks.
- **A viewer whose attach already ran and then exited back to a shell is not
restarted automatically.** The whole slice view can hold until that tile is
resolved. No tested workaround is offered for that case.
- **Following protects uncertain input by retaining tiles.** A larger workspace
or explicit `--max-tiles` can help with overflow. Protected Codex delivery can
still retain messages it cannot confirm before paste; retained is not
delivered. Disabling its typing guard removes protection and is not recovery.
- **Internet-facing hosted members remain experimental, unsupported, off by
default and at your own risk.** Slice following does not require them.
### Credits
Thanks to @seanmcn, @DeryFerd, @Aschenbath, @Vaishnavi220506, @bradvin,
@jay23606, @rudycelekli and @Nikhi00718 for merged contributions, and to
@akushonkamen, @jcmurrayii, @KM199 and @pdurlej for contributed changes, reports
and reproductions carried into this release.
## [0.7.5]
OpenRig 0.7.5 adds `rig terminal open qitem:<id> --follow`: keep one terminal
workspace as a task's current owners join and leave. Joining viewers do not
request focus; departures remove viewers, not the agents or conversations.
Follow mode is scoped to Linux Herdr 0.9.5 / protocol 22. The exercised journey
used shell stand-ins; real-agent follow qualification and active render cost
are not established by that result. Changes since 0.7.4:
### Before you upgrade
- **Restart the daemon after installing** to run the new code and database
update. Migration 126 adds deferred `escalates_to` target addresses to the
edges table, preserving existing edges. Previously inert escalation edges
now affect recovery for seats without a delegation parent.
```bash
npm install -g @openrig/cli@0.7.5
rig daemon stop && rig daemon start
```
- **The bundled openrig-core plugin goes from 0.1.21 to 0.1.22.** Its
`queue-handoff` skill teaches agents to park waits on CI or a long job with
a continuation and wake. As before, managed refresh preserves replaced
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply any local
edits you still need from that copy. No setting is added or removed.
- **A Codex hook-refresh refusal remains unresolved.** The earlier remedy
`rig seat restart <seat> --keep-conversation --reason "refresh hooks"`
(preview with `--plan`) was reported to refuse one Codex seat upgraded from
0.6.x. Its cause is unknown: the inspected 0.7.4 check already accepts the
reported process tree. [#2200](https://github.com/mvschwarz/openrig/issues/2200)
is not fixed in 0.7.5; this is not an established refusal of all staged-script
launches. A refused restart does not complete the hook refresh. If it
refuses, nothing is stopped: leave the seat running and report the refusal
with CLI and daemon versions. If Codex asks for hook review, review the
hooks you intend to enable.
### Follow one task in one workspace
- **Follow the current owners of a queue item:**
`rig terminal open qitem:<id> --follow --provider herdr`. The view derives
membership from the task's open rows and explicitly linked successors.
Joining seats get verified viewer tiles in the same workspace. A complete
empty task drains removable tiles and keeps the workspace; after retained
tiles are gone, following stops. Run `--follow` again to resume it.
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
- **Keep control of the view:** `--pin`, `--unpin`, `--suppress`,
`--unsuppress`, `--suppression-ms` and `--max-tiles` adjust presentation.
`--no-follow` leaves the layout in place. The default is eight tiles,
four seconds of departure grace and twelve more seconds for a focused
leaver. Pins can exceed the cap; input-retained leavers do not consume a
joining seat's slot. See [terminal sessions](docs/reference/terminal-sessions.md).
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
- **Uncertain input keeps its tile.** Typed drafts and unreadable composers
retain departed viewers, including Pi, terminal and community harness
seats whose input cannot be confirmed. Unpinning does not override that.
There is one delayed input recheck, then queue/session events or an explicit
follow command can reconsider it. Failed or incomplete reads retain the
previous view with an explanation. Activity hooks do not trigger renders.
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
- **Protected Codex delivery no longer strands a message it can't submit.** On a
Codex seat whose typing guard is draft-aware or openrig-urgent, a message
whose complete text can't be confirmed visible before paste is retained, not
sent, instead of being pasted where it can't be submitted. The default (guard
off) is unchanged. Use a larger pane or a shorter message; turning the typing
guard off turns draft protection off and is not a recovery. Pastes over 1,000
code points are retained. Wrapped lines use a conservative bound, so most
wrapped wakes in common four-tile views can be retained even when they fit
natively; approximate standard-envelope examples are one-paragraph bodies over
about 115 characters at 80×22, 192 at 80×24, or 526 at 120×30 (examples, not
limits). A later resize is not atomic, and layouts with Codex's image-enabled
ambient pet are unqualified.
([#2242](https://github.com/mvschwarz/openrig/pull/2242))
- **Codex 0.164's two-row composer footer is recognized.** A departed Codex
agent's empty composer no longer reads as unconfirmed input that keeps its
tile. Delivery's draft-aware and openrig-urgent typing guards use the same
reader, so an empty Codex 0.164 composer there can now receive typed
delivery. ([#2206](https://github.com/mvschwarz/openrig/pull/2206))
- **Clients can ensure a missing viewer without relaying out existing panes**
through `POST /api/terminal/viewer/ensure`. It rechecks task membership and
existing viewer identity before creating or reusing a tile. Preview names
`not-provisioned`, `verified` or `unconfirmed`; membership gives no input
authority. This path has the same Linux Herdr 0.9.5 / protocol 22 scope.
([#2133](https://github.com/mvschwarz/openrig/pull/2133))
- **An unconfirmed pane no longer counts as opened.** CLI and TUI output name
it as degraded with `pane_unconfirmed`, distinguish it from a skipped seat,
and omit its restart-command report. An all-unconfirmed open exits nonzero.
Inspect before retrying; this is not proof that nothing opened.
([#2173](https://github.com/mvschwarz/openrig/pull/2173))
### Fixes by what you see
- **Slow successful sends have time to answer.** Local and HTTP `rig send`
wait 60 seconds, plus an explicit idle wait, instead of five seconds locally
or thirty over HTTP. A timeout still means unconfirmed. The advice now says
that a busy recipient may hold input off-screen: missing pane text does not
prove non-delivery, so reconcile before another send or duplicate queue item.
No automatic resend is added. This does not diagnose the daemon's latency.
([#2198](https://github.com/mvschwarz/openrig/pull/2198))
- **A wake that was unroutable before its seat had a pane is retried once the
binding exists,** through the row's existing wake intent. The outbox does
not send that same intent again after it is settled as delivered.
([#2170](https://github.com/mvschwarz/openrig/pull/2170))
- **A parentless seat's recovery can follow its declared `escalates_to`
address,** including another rig on the same daemon. A delegation parent
still takes precedence. Unavailable or ambiguous targets use the explained
fallback; an already-open recovery keeps its recorded owner. This is one
escalation hop, not cross-host routing.
([#1772](https://github.com/mvschwarz/openrig/pull/1772))
- **A stuck finding needs evidence to close.** Delivery, pickup or explicit
custody can resolve it; a changed reporting threshold, an unreadable
registry or a failed wake becoming unconfirmed cannot silently clear it.
([#2153](https://github.com/mvschwarz/openrig/pull/2153))
- **Fresh Claude handovers wait for the current launch.** Retained scrollback
or an echoed shell command cannot make a new process look ready. The context
packet waits for the matching process and Claude's complete drawn input frame.
([#2159](https://github.com/mvschwarz/openrig/pull/2159))
- **Runtime send refusals return HTTP 409 rather than 500.** A stale recorded
Claude pane names `rig reconcile-session <seat>` as the repair; nothing is
typed on that refusal. ([#2157](https://github.com/mvschwarz/openrig/pull/2157))
- **`rig down` keeps a rig down after a rolled-back start.** It records the
explicit stop without rewriting the superseded session's history. The
`rig start --last` help now explains which rigs return and how an explicit
stop keeps them out; the kernel is not selected by `--last`.
([#2166](https://github.com/mvschwarz/openrig/pull/2166))
- **Status and configuration describe the selected rig.** `rig ps` reports
`periodicSnapshotActive` only for rigs the scheduler actually snapshots
([#2168](https://github.com/mvschwarz/openrig/pull/2168)); the web Overview
reads the rig's current exported configuration instead of a same-named
library template ([#2188](https://github.com/mvschwarz/openrig/pull/2188)).
- **Host labels and next steps are clearer.** The TUI shows the configured
local host name while retaining its stable identity
([#2162](https://github.com/mvschwarz/openrig/pull/2162)). `rig host add` and
`pair` explain how to opt an HTTP host into remote Feed attention; registration
leaves that setting unchanged ([#2179](https://github.com/mvschwarz/openrig/pull/2179)).
- **Small CLI corrections:** `attach --self` reads tmux's rewritten separators
([#2178](https://github.com/mvschwarz/openrig/pull/2178)); transcript and
capture line counts use their normalized values on local and remote paths
([#2164](https://github.com/mvschwarz/openrig/pull/2164)); stdio MCP warns on
out-of-range daemon ports while continuing to serve, without substituting a
port ([#2160](https://github.com/mvschwarz/openrig/pull/2160)).
- **Notification adapters release unread HTTP response bodies,** without
changing their send result or waiting for cleanup. No performance improvement
is claimed here. ([#2172](https://github.com/mvschwarz/openrig/pull/2172))
- **Scope audits agree on slice folders numbered 100 and above.** The guide
also explains that moving a slice reopens its proof judgments; a clean
structural audit is not a proof judgment.
([#2194](https://github.com/mvschwarz/openrig/pull/2194))
### Presets, hosted members and guides
- **The `pi-tui` example runs Pi's own terminal UI as a community harness.**
It gives each seat its own Pi agent folder and passes startup input to Pi.
One Pi 1.4.0 / OpenRig 0.7.4 run showed a parked wake returning to the same
session and an unattended handoff to a second seat. It did not prove Pi
chooses that coordination by itself. Resume is not declared. Use it
unattended: a wake can submit a person's half-typed draft, and unrecognized
dialogs can receive the send. Busy-turn wakes and other Pi versions remain
unqualified. ([#2182](https://github.com/mvschwarz/openrig/pull/2182),
[#2184](https://github.com/mvschwarz/openrig/pull/2184))
- **Interactive Pi setup is explicit:** copy the coordination skills and use
`delivery_hint: send_text` for a startup file that must reach a preset's
first input. A plain terminal seat does not receive queue wakes. Setting its
typing guard to `off` can type notices into a shell; keep the default for a
seat that can return to one. These are documented limits, not new guards.
([#2174](https://github.com/mvschwarz/openrig/pull/2174),
[#2180](https://github.com/mvschwarz/openrig/pull/2180),
[#2181](https://github.com/mvschwarz/openrig/pull/2181),
[#2185](https://github.com/mvschwarz/openrig/pull/2185))
- **Hosted-member guidance names its real reach.** A member without a wake
trigger checks work when its owner next starts a session; a listed tool is
not a grant, and completed work closes with `no-follow-on`. Hosted members
exposed over the internet remain **experimental, unsupported, off by default
and at your own risk**. The guide explains narrow grants and token revocation;
this terminal-follow release requires no internet exposure.
([#2187](https://github.com/mvschwarz/openrig/pull/2187),
[#2189](https://github.com/mvschwarz/openrig/pull/2189))
- **Publishing a team bundle:** add and declare a README after `rig export`,
then run `rig bundle check` at the revision being submitted
([#2176](https://github.com/mvschwarz/openrig/pull/2176)). Queue guidance
teaches parking long waits with a continuation and wake
([#2167](https://github.com/mvschwarz/openrig/pull/2167)).
- **Contributor maintenance:** the test guide corrects installed-scenario
counts and cases ([#2165](https://github.com/mvschwarz/openrig/pull/2165));
the compaction receipt fixture shares one clock, with no product change
([#2169](https://github.com/mvschwarz/openrig/pull/2169)). Published release
notes, the capability map and roadmap records were carried forward
([#2161](https://github.com/mvschwarz/openrig/pull/2161),
[#2175](https://github.com/mvschwarz/openrig/pull/2175),
[#2197](https://github.com/mvschwarz/openrig/pull/2197)).
### Limits carried forward
- **Follow mode is Linux Herdr 0.9.5 / protocol 22 only.** Other platforms,
providers and selector kinds retain one-shot open. Its exercised journey
used shell stand-ins, not native agents. Active render cost is unmeasured.
The first workspace on an empty provider may gain focus, and a viewer can
resize the agent's terminal; `ignore-size` is not resize isolation.
- **Unreadable composers retain departed tiles.** This includes input the
inspector cannot read on Pi, terminal or community harness seats; unpinning
alone does not establish that closing is safe.
- **Escalation across rigs is same-daemon only.** Upgrade followed by snapshot
restore of deferred escalation targets has not been qualified.
- **A webhook 2xx is acceptance of the ring, not pickup.** A hosted member's
activity remains unknown between calls. Principals record attribution, not
authentication. See [hosted members](docs/reference/hosted-members.md).
- **Reviewed-history resume remains a client operation through startup review,**
not a CLI command. Kernel restore evidence lasts one daemon process.
- **OpenClaw interrupted local turns are not recovered; Hermes has no declared
resume or measured idle gateway exit.** `rig down` stops a community harness's pane; survival of independently spawned
children remains untested. Inspect actual processes before relying on their exit.
- **The reported Codex restart refusal in #2200 remains unresolved,** as do
the other 0.7.4 limits unless a change above names them fixed: Codex permission qualification
only on 0.164.0, earlier removed hook approvals, SSH tiles after a Herdr
restart, unqualified native cmux, Slack-only shared channels and the existing
browser-sizing and old Slack-thread limits. Isolated account-home switching
is not included.
### Credits
Thanks to @icey-zzz (#1772), @Aschenbath (#2153, #2159, #2162, #2169, #2179),
@DeryFerd (#2164, #2165), @Nikhi00718 (#2172) and @dajiaohuang (#2188).
Thanks also to @rudycelekli for the contribution from #1416 completed in #2160;
to @AdamWhitehurst (#1544, #2049), @kevinhsykes (#2151), @aanestesia (#605),
@todie (#1419) and @tgrundtvig (#21), whose reports led to fixes here; and to
the other people whose reports and reviews led to these fixes.
## [0.7.4]
OpenRig 0.7.4 lets an assistant you already use work as a member of your team.
@@ -667,10 +1112,11 @@ from the commit tagged `v0.7.3`. The changes since 0.7.2:
current TUI sends it (a request without a seat identity); an older TUI's
answer on someone else's ask is refused.
([#2006](https://github.com/mvschwarz/openrig/pull/2006))
- **A Codex seat's permission settings are passed as launch flags; that Codex
enforces them was not proven natively.** Going back to an older OpenRig keeps
them unread (see Before you upgrade). The Codex trust fixes stop new duplicate
hook groups but don't remove existing ones.
- **A Codex seat's permission settings were enforced natively in our test with
Codex 0.164.0,** including after a managed restore and a restart. Other Codex
versions weren't tested. Going back to an older OpenRig keeps them unread (see
Before you upgrade). The Codex trust fixes stop new duplicate hook groups but
don't remove existing ones.
([#2048](https://github.com/mvschwarz/openrig/pull/2048),
[#2035](https://github.com/mvschwarz/openrig/pull/2035))
- **The cmux path wasn't tried in a real cmux**, and a remote host's rigs are
@@ -735,8 +1181,7 @@ reported [#1653](https://github.com/mvschwarz/openrig/issues/1653) (worked on in
reported [#980](https://github.com/mvschwarz/openrig/issues/980) (addressed by
[#2081](https://github.com/mvschwarz/openrig/pull/2081)); @rakoort, whose
suggestion in [#1679](https://github.com/mvschwarz/openrig/issues/1679) became
[#2086](https://github.com/mvschwarz/openrig/pull/2086) (the issue's main
report is still open); @george661, whose
[#2086](https://github.com/mvschwarz/openrig/pull/2086); @george661, whose
[#1710](https://github.com/mvschwarz/openrig/issues/1710) led to
[#2093](https://github.com/mvschwarz/openrig/pull/2093).
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.7/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.7/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.4` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.7` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/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.
+5 -5
View File
@@ -25,7 +25,7 @@ below `rig` (including groups and the hidden `restore apply` command), and
**1,304 explicitly registered option objects**, including the root version
option. Aliases do not add command objects; short/long spellings of one
option do not add option objects. Commander-generated help is additional.
These are counts for that source snapshot, not a claim about a deployed release.
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts.
## Reading this reference
@@ -485,7 +485,7 @@ do not establish connectivity to a live peer.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig slack` | — | — |
| `rig slack setup` | — | `--channel <id>`<br>`--inbound-destination <session>`<br>`--minimum-level-that-posts <level>`<br>`--minimum-level-that-interrupts <level>`<br>`--source-label <label>`<br>`--secrets-env-file <path>`<br>`--required-scopes <csv>`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig slack setup` | — | `--channel <id>`<br>`--inbound-destination <session>`<br>`--minimum-level-that-posts <level>`<br>`--minimum-level-that-interrupts <level>`<br>`--source-label <label>`<br>`--secrets-env-file <path>`<br>`--required-scopes <csv>`<br>`--reactions <mode>`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig slack status` | — | `--json` |
| `rig slack manifest` | — | `--url`<br>`--json` |
| `rig slack verify` | — | `--json` |
@@ -794,8 +794,8 @@ Only Claude and Codex tenures can be woken.
| `rig provider accounts` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider bindings` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider signals` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--stored-login`<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--stored-login`<br>`--force-unsafe`<br>`--json` |
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--api-key-file <path>`<br>`--stored-login`<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--api-key-file <path>`<br>`--stored-login`<br>`--force-unsafe`<br>`--clear-account-home`<br>`--account-home <path>`<br>`--reason <text>`<br>`--model <id>`<br>`--codex-config-profile <name>`<br>`--dry-run`<br>`--json` |
| `rig provider moves` | — | `--seat <seat>`<br>`--limit <n>`<br>`--json` |
| `rig provider moves report` | — | `--file <path>` **required**<br>`--to <seat>` **required**<br>`--follows <qitemId>`<br>`--evidence-ref <ref>`<br>`--json` |
@@ -986,7 +986,7 @@ for input and receipt examples, rollback, and snapshot behavior.
| `rig seat set-effort <seat>` | — | `--effort <level>`<br>`--clear`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat effort <seat>` | — | `--json` |
| `rig seat set-model <seat>` | — | `--model <id>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-runtime <seat>` | — | `--runtime <runtime>`<br>`--reason <text>`<br>`--model <id>`<br>`--effort <level>`<br>`--codex-config-profile <name>`<br>`--permission-mode <mode>`<br>`--confirm-timeout <seconds>`<br>`--dry-run`<br>`--confirm <switch-id>`<br>`--note <text>`<br>`--recover`<br>`--json` |
| `rig seat set-runtime <seat>` | — | `--runtime <runtime>`<br>`--reason <text>`<br>`--model <id>`<br>`--effort <level>`<br>`--codex-config-profile <name>`<br>`--clear-account-home`<br>`--account-home <path>`<br>`--token-file <path>`<br>`--account-label <label>`<br>`--permission-mode <mode>`<br>`--confirm-timeout <seconds>`<br>`--dry-run`<br>`--confirm <switch-id>`<br>`--note <text>`<br>`--recover`<br>`--json` |
| `rig seat set-cwd <seat>` | — | `--cwd <directory>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat launch <seat>` | — | `--fresh` **required**<br>`--reason <text>`<br>`--plan`<br>`--stop`<br>`--operator <address>`<br>`--json` |
| `rig seat restart <seat>` | — | `--keep-conversation` **required**<br>`--reason <text>`<br>`--plan`<br>`--verify`<br>`--expect-account <text>`<br>`--operator <address>`<br>`--host <id>`<br>`--json` |
+74
View File
@@ -0,0 +1,74 @@
# Integration author contracts — DRAFT for 0.7.6
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
1. **[Harness and runner](harness-runner-contract.md):** launch the agent, report its conversation identity and activity, deliver input, handle permission requests and cancellation, and reopen the requested conversation.
2. **[Execution and platform](execution-platform-contract.md):** choose placement, deliver files and credential references, distinguish owned from attached resources, preserve native state, return artifacts and reconcile shutdown.
These are draft author responsibilities. Some use implemented OpenRig interfaces; others describe a proposed integration whose code remains with its author. Including the guide in release documentation does not make every described capability available.
## Candidate and support status
This draft targets the **unsealed 0.7.6 candidate**. Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. Later release repairs and version text are still being assembled; no final tag or package publication is asserted here. References below use the immutable base instead of a moving branch.
| Area | Status at this baseline | 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` | Implemented candidate source | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
| `passes_model: true` | Implemented candidate source | 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 refusal-first permission recipe | Proposed author implementation | No end-to-end dsh integration is established. 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. |
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
## How the pieces compose
```mermaid
flowchart TD
R["RigSpec: members, relationships, work arrangement"] --> A["AgentSpec: role, context and startup declarations"]
R --> H["Built-in runtime or community preset"]
H --> N["Harness / runner: native conversation and control"]
H --> P["Author platform integration: placement and resources"]
P --> N
P --> F["Workspace, retained state and returned artifacts"]
R --> S["Optional application services"]
```
**RigSpec remains the composer.** AgentSpec describes reusable agent intent; the runtime or preset selects how the native agent starts. An integration can surround that launch with an environment-specific wrapper. A bundled application's database or Compose service is software the team operates; it is distinct from the environment in which an agent executes.
The terminal provider, native harness protocol and network connection also have different jobs. A terminal provider carries input and output; a runner understands native turn boundaries; a granted MCP or other connection exposes coordination operations. An HTTPS connection does not by itself provision compute, awaken a suspended application or establish native resume. Each integration states which of those effects it implements.
| Owner | Responsibility |
|---|---|
| OpenRig Core | Team and work identity, queue records, existing launcher contracts, activity interpretation and declared configuration. |
| Harness/runner author | Native launch and protocol, exact native reference, prompt framing, truthful activity, permission/cancel behavior and native resume. |
| Platform integration author | Placement binding, destination projection, resource ownership, state transfer, artifact delivery and resource reconciliation. |
| Operator / credential owner | Selected environments, grants, provider credentials and spending authority. |
The queue records who owns work. It does not prove that an old provider process has stopped, that a new runtime consumed its history, or that guest bytes reached the consumer. Those observations belong in the launch, resume and export receipts alongside the work record. This division reuses the queue rather than creating another task system.
## Start from an existing example
For a terminal harness, begin with the 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 structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
For a contained worker, the [platform composition](execution-platform-contract.md#a-minimal-composition-using-existing-surfaces) pairs an ordinary coordinator with a worker preset. It uses existing member and preset fields. The wrapper must prepare or attach to the selected environment explicitly; declaring a runtime does not provision it. Its illustrated member list is a fragment, and its launcher is a skeleton, not a ready-to-run provider package.
A rig bundle can carry declarations, roles and supporting scripts. Do not confuse those files with hydrated image layers, installed binaries, registered presets, credentials or live sessions. State what the recipient must supply.
## Lifetime and recovery
**Persistent** and **Non-persistent** describe execution lifetime. A non-persistent invocation has an explicit completion condition tied to its assigned work. **Durable** describes interruption recovery and is independent of that lifetime. An agent can run briefly and still resume its retained conversation later.
Retaining a workspace and reconstruction material can restore useful competence without restoring the same native conversation. Label that as reconstruction. Native resume additionally requires the correct harness-qualified reference and supported native state. The platform chapter's measured example restored copied history into recreated containers on the same host; it does not establish new-host migration or RAM restoration.
## Questions contributors can help resolve
- At a pinned dsh version, which advertised method reopens a session, and can a useful first case run without permission requests?
- Which additional harness would justify a shared protocol runner rather than a separate wrapper?
- Which provider effects can use the existing projection, native-reference and lifecycle dispatch, and which need a new maintained interface?
- What exact state and ownership must travel when compute is recreated or moved?
These questions remain part of the draft. An unanswered contributor thread does not prevent authors from using the implemented interfaces or reviewing the proposed responsibilities. Compatibility claims should name versions, observed behavior and unsupported operations; they should not imply a support guarantee from a manifest alone.
@@ -11,3 +11,4 @@ launch_result: true
resume: true
startup_assisted: false
first_input: true
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
@@ -34,6 +34,12 @@ chat_store() {
printf '%s/chats/%s/%s/store.db' "$CURSOR_CONFIG_DIR" "$sum" "$1"
}
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model) goes to a new chat only. A resumed chat isn't
# passed one: whether Cursor's --resume keeps the chat's earlier model or takes a new one hasn't been checked.
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
set -- --model "$OPENRIG_MODEL" "$@"
fi
# Preserve the file's exact bytes, including trailing newlines. A read error stops before the harness starts.
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
@@ -10,3 +10,4 @@ limits:
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
@@ -24,6 +24,21 @@ if ! grep -qs OPENRIG_SESSION_NAME "$HERMES_HOME/config.yaml"; then
echo "openrig-hermes-launch: $HERMES_HOME/config.yaml doesn't pass OPENRIG_SESSION_NAME to Hermes's tools, so rig run from Hermes won't act as this seat; see config.yaml.example" >&2
fi
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model): Hermes takes it only from config.yaml, and
# Hermes v0.22.4 fixes a session's launch settings when it creates it. So it's written for a new session only, and only
# into a config.yaml that is absent or empty: a seat's own config is left byte-for-byte as it is, whatever it says. On a
# resume this launcher applies no OpenRig model.
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
case "$OPENRIG_MODEL" in
''|*[!A-Za-z0-9._:/@+-]*) echo "openrig-hermes-launch: not writing unusual model id \"$OPENRIG_MODEL\" into $HERMES_HOME/config.yaml" >&2 ;;
*) if [ -s "$HERMES_HOME/config.yaml" ]; then
echo "openrig-hermes-launch: the seat has its own $HERMES_HOME/config.yaml; OPENRIG_MODEL was not applied; set the model there" >&2
else
printf 'model: "%s"\n' "$OPENRIG_MODEL" >> "$HERMES_HOME/config.yaml"
fi ;;
esac
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
exec "$hermes" chat -c "$OPENRIG_RESUME_REF"
fi
+78 -3
View File
@@ -4,6 +4,9 @@ A community harness is a coding harness an outside contributor maintains. You in
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
harnesses, and it reads their activity only from what the screen shows.
For what an integration author owns end to end, including a runner's duties and running a harness somewhere else, see
the draft [integration author contracts](author-contracts.md).
## What a community harness gets
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
@@ -15,14 +18,14 @@ it never reads an unsupported capability as idle, working, ready or verified.
| Delivery | yes | `rig send` and queue wakes paste the text into the pane and press Enter, as for any seat. A pane that's back at a shell is refused (`target_runtime_unverified`) and nothing is typed. OpenRig doesn't confirm the harness took the message; `rig send --verify` checks the pane afterwards. |
| Identity | when declared | With `launch_result`, the conversation is shown as reported by its launcher; with `names_conversation`, as named by OpenRig. Neither is observed by OpenRig. Without either, the identity is unavailable. |
| Resume | when declared | `resume: true` with one of the two above (see [Reopening a conversation](#reopening-a-conversation-resume-true)). Without it, a resume says `Resume is unsupported: community harness "<name>" is launch-only`, and a restore stops and asks. |
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. |
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. |
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
configuration.
configuration, or let a launcher that opts in take the seat's declared model (`passes_model`, below).
**Check that the harness answers on its own first.** Before writing a preset, start the harness in a terminal outside OpenRig, with the
same configuration and credentials your launcher will give it, and send it one prompt. Use the folder your launcher gives the seat, not
@@ -48,6 +51,8 @@ launch_result: true # optional: the launcher reports the conversatio
resume: true # optional, needs one of those two; declare it only once your harness's resume is checked
startup_assisted: true # optional: startup submission needs a person's check (below)
first_input: true # optional: launcher takes the first turn from OPENRIG_FIRST_INPUT (below)
# runner: true # optional, needs launch_result: the launcher reports the seat's activity (below)
# passes_model: true # optional: the launcher takes the seat's declared model from OPENRIG_MODEL (below)
```
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
@@ -144,6 +149,45 @@ For a reported conversation, OpenRig also says so plainly when:
- **the saved conversation isn't this harness's own** (a former runtime's id, say): it isn't passed to the launcher. A restore stops and asks, and elsewhere the resume says it isn't possible;
- **the record's folder can't be made:** the launcher couldn't be told what to reopen, so nothing starts.
## Runner presets (`runner: true`)
A plain preset's seat reads its activity from the screen. A **runner** preset is for a launcher that hosts the agent itself
and reports what it's doing, as OpenRig's own Pi runner does. The seat's activity then follows those reports.
- **It needs `launch_result: true`.** A runner reports the conversation it opened in its launch record, so `runner: true`
without `launch_result` is refused.
- **The declaration is fixed at launch.** Each launch, a restore's included, records on the session what the preset
declares at that moment. Editing the preset changes the next launch, not a running seat. A session recorded before this
field existed reads as plain.
- **No record means attention.** When a runner's launch record doesn't arrive within the wait, or its folder can't be
made, the seat needs attention instead of reading ready. A plain `launch_result` preset still starts, with its identity
unavailable.
- **Input is as for any seat.** A send pastes the text and presses Enter, so a runner should submit everything up to that
Enter as one prompt.
**What the runner posts.** The runner posts each event to `POST /api/activity/hooks` with the local activity hook token,
one at a time and in order, without making the agent wait. It finds the address and token in `OPENRIG_URL` (or
`OPENRIG_PORT`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else in `$OPENRIG_HOME/activity-endpoint.json`. Every post names:
- the seat, as `sessionName` and `nodeId`;
- `runtime`, the preset's name;
- `generation`, from `OPENRIG_OCCUPANT_GENERATION`;
- an increasing `sequence`, and `occurredAt`;
- `hookEvent` and `subtype`, as in the table.
| The runner posts | When | The seat reads |
|---|---|---|
| `UserPromptSubmit` | it gave the agent a prompt | working |
| `active` or `PreToolUse` | the agent is working | working |
| `Notification` / `permission_prompt` | the agent asked for a permission | needs input |
| `Stop` / the turn's stop reason | the turn ended | idle |
| `Stop` / `<preset name>_exited` | the agent's process ended | unknown, never idle |
- **The screen still counts,** but only as evidence of work: on its own it never reads the seat idle.
- **The generation attributes a post; it isn't authentication.** A post with an earlier occupant's generation doesn't
count for the live seat. The hook token is one local token, so any process that holds it can post for any seat.
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
[harness and runner contract](harness-runner-contract.md).
## An example: Hermes
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
@@ -242,7 +286,7 @@ on it, not Pi choosing by itself to park or hand work on.
Copy both and maintain them as your own.
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted` or `first_input` is refused by an older
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner` or `passes_model` is refused by an older
OpenRig that doesn't know the field.
## Pass the first startup input at launch
@@ -255,6 +299,37 @@ OpenRig records `node.startup_first_input_passed` with the path and `submission:
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
## Pass the seat's declared model at launch
A format-1 preset may set `passes_model: true` (a boolean, default false). Then a model declared for the seat (in the rig
spec, or set later with `rig seat set-model`) reaches its launcher as `OPENRIG_MODEL`, on new and resumed launches alike. A seat that declares no model gets none,
and an inherited `OPENRIG_MODEL` is cleared. The launcher maps it into its harness's own setting; Core knows no harness's
model flag or config key.
OpenRig keeps three things apart:
- **requested:** the declared model, as `rig ps --nodes` shows it ("configured", or "not-declared");
- **passed:** the launcher was handed the model. OpenRig records `node.startup_model_passed` with the model and whether the
launch was a resume only when the launch command's send succeeded, and a later failure of the launch doesn't undo that.
It never says passed from the declaration alone or for a launch that failed before anything was typed. When typing
began and the send then failed, the model's dispatch is uncertain: it isn't recorded as passed, and it isn't retyped;
- **effective:** the model that actually runs. OpenRig doesn't observe it for a community harness, so it reads UNKNOWN.
Passing a model never makes OpenRig pick a default, substitute another model, or retry with one. A model the harness
refuses is the harness's own error.
The examples apply the request for a new session only; on a resume, the example launcher doesn't apply it, and which
model a resumed session runs stays unknown unless the harness's own evidence says otherwise:
- **Cursor:** `--model "$OPENRIG_MODEL"` for a new chat. What a resumed chat runs hasn't been checked: the launcher leaving
out `--model` doesn't show that it keeps its earlier model.
- **Hermes:** Hermes takes its model only from the seat's `config.yaml`, so for a new session the launcher writes
`model: "<id>"` there only when that file is absent or empty. A seat that has its own `config.yaml` keeps it
byte-for-byte, and the launcher says the model wasn't applied: set it in that file. A seat set up from
`config.yaml.example` already has a config, so set its model there. Hermes v0.22.4 fixes a session's
launch settings when it creates it (its own source), so a resumed Hermes session runs with what it was created with.
Both example presets ship with `passes_model: false` until that mapping has been checked with the real harness. Presets
without the field and built-in runtimes are unchanged, and older OpenRig versions reject it as unknown.
## Interactive Pi in a terminal seat: the coordination skills
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
@@ -0,0 +1,138 @@
# Execution and platform integration contract — draft
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. Inclusion of this draft in 0.7.6 documentation 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.
## Keep the identities and responsibilities separate
RigSpec composes members, their AgentSpecs/profiles, runtimes, working directories and relationships. AgentSpec supplies reusable role, startup and context declarations. A built-in runtime or registered community preset supplies a launch path. An environment integration supplies placement and provider-specific resource handling around that path. Existing declarations do not automatically provision a remote filesystem, credentials or compute.
| Identity or handle | Meaning and responsibility |
|---|---|
| Stable seat and work identity | The member's canonical address/node and owned work records. OpenRig retains this identity across executions; an environment IP or display name does not replace it. |
| Native session identity | The harness's conversation token/UUID, qualified by harness and resume-reference type. The harness owns its format and resume semantics. |
| Environment binding | The integration's durable record linking placement, immutable provider resource IDs, native-state location, projection and ownership. It is separate from the native session token. |
| Execution generation | A particular launch/process instance, with observable process/start identity when available. A new process can resume an old conversation. |
| Viewer/client | A terminal attachment or connection to an execution. Closing it need not stop either the native process or compute. |
An application service bundled with a rig is a separate responsibility. An artifact browser or database in `RigSpec.services` serves the team's application; its presence does not place an agent inside that service. Bundling service definitions also does not bundle image bytes, native binaries, authenticated homes or live runtime state.
## A minimal composition using existing surfaces
The following is a **fragment of one pod** (`pods[].members`), using existing RigSpec fields, not a complete standalone RigSpec:
```yaml
pods:
- id: dev
label: Development
members:
- id: coordinator
agent_ref: local:agents/coordinator
profile: default
runtime: claude-code
- id: worker
agent_ref: local:agents/worker
profile: default
runtime: example-contained-claude
```
The worker runtime names an author-installed community preset. This preset shape uses existing fields exercised on the 0.7.4 baseline:
```yaml
format: 1
name: example-contained-claude
maintainer: Integration author
command: ./scripts/launch-contained-worker
launch_result: true
resume: true
startup_assisted: true
limits:
- Requires an explicitly prepared environment binding and guest projection.
- Native resume is qualified only for the documented retained-state recipe.
- Viewer closure does not implement turn cancellation or resource deletion.
```
This is an integration skeleton, not an installed or qualified preset. The author must supply the executable, resolve its path against the actual launch working directory, register the preset in the existing OpenRig home, and prepare the member's host working directory. The launcher implements the preset's declared launch-result and typed-resume behavior; the runner chapter defines those mechanics. `startup_assisted` requires a startup-submission check; it does not hold back submission or establish successful context delivery or ingestion. A member's model setting is not automatically honored by a custom launcher: its resolved native model/effort must be explicit in that launcher's supported configuration. With `passes_model: true`, Core passes the declared model through `OPENRIG_MODEL`; the launcher must map and apply it. The effective model remains unknown to Core.
For a contained worker, the launcher may resolve a private binding, create or attach to the selected resource, project explicit inputs, then connect to the native process inside it. In the measured recipe, OpenRig ran on the host, the plain coordinator used the built-in native runtime, and the worker used a private community launcher. The wrapper performed projection, provider-reference delivery, resume validation and artifact export explicitly. Those effects were not supplied by the preset declarations themselves. The integration must not assume host guidance/skills, hook paths or environment variables automatically reach a community runtime's guest.
A downstream author can implement this composition today around a qualified provider API and native harness. It remains an author-maintained integration until maintained platform support is separately implemented and documented. Validate against the target OpenRig schema and effective launch configuration; a parsed field or successful process start alone does not establish its effect.
## Placement and launch receipts
Before mutation, resolve the selected host/provider, workspace, image/native versions, reachability, credential references, resource request and ownership. Record existing resources and the selected resource scope. Host headroom includes supervisors, gateway and other team members; a workload memory request is not proof of their combined cap. Report requested, admitted and observed resources separately. Do not infer enforced CPU/memory or isolation policy from accepted flags.
Persist a launch intent before a create operation. Use an attempt/retry identity where the provider supports it; otherwise reconcile the original operation before another create. A public binding receipt carries only nonsecret values. If the harness's native resume reference works like a bearer credential (anyone holding it can reopen the conversation), keep it in secret-held receipt data, never in the public receipt. A useful public receipt identifies:
- canonical seat, harness version and native resume-reference type;
- provider/host/workspace, immutable environment ID and execution generation;
- image/policy versions, requested resources and observed readiness;
- inner working directory, private native-state location and projection manifest;
- a public-safe native reference (never a bearer-like resume token), creator or attached-consumer disposition, and exact owned resource handles;
- lifecycle operations supported, completion observations, pending effects and errors.
A resource name or IP can be reused. Reconcile immutable ID, workspace and any available generation/fingerprint before acting. A lost response is **unconfirmed**, not success or absence. Record provider failures without exposing credentials. Readiness should identify what is ready: provider reachable, filesystem projected, native process started, team identity authenticated, or task completed.
## Project files and credential references deliberately
Stage only declared inputs: role/startup material, selected skills, current work context, schemas and required native settings. Preserve unmanaged files and report conflicts. Review every path-bearing setting for its destination: host-only hook scripts, interpreters, status collectors and loopback URLs may not exist or be reachable in the guest. Missing activity or permission transport must remain unsupported rather than being inferred from the base harness name.
Distinguish three observations: host staging, destination delivery and native ingestion. A destination manifest can record relative path, byte count and hash; a useful task using the current context revision/nonce can show consumption. Neither observation substitutes for the other. Current working input may change between tasks while an earlier artifact stays immutable.
Retained native history is a different data class from ordinary project context. Preserve its exact bytes through the harness's supported resume path; do not edit records or synthesize a conversation. Preserve a complete checkpoint prefix with byte length/hash if the harness may append after the checkpoint. Record which native-state files are required and which remain unavailable. Never substitute a fresh conversation seeded with a report while calling it native resume.
Keep secret values out of RigSpec, preset YAML, bindings, argv and public receipts. The provider/credential owner supplies scoped references or native-supported credential helpers and controls revocation. A successful login, provider registration or model-list request is not evidence of usable inference credit or an authorized spend envelope. Do not infer subscription portability from a provider name or copy an authenticated owner home into a guest.
Authenticate guest coordination through the actual granted member route. A shared source IP is not a seat identity. Record the grant-bound principal and scope separately from resource placement; distinct principals do not imply per-seat row ACLs or isolation between co-located secrets.
## Own only the effects assigned to the integration
Record **created** versus **attached** for each launch receipt. The creator may stop/delete the exact resource it created under the selected policy. An attached consumer normally closes only its own client and any separately identified process it owns; it must preserve the external environment, native session, workspace and shared services. Attachment must not silently transfer provider custody. An explicit owner-authorized transfer is a separate recorded action.
Describe these effects independently:
| Operation | Required distinction |
|---|---|
| Cancel a turn | Ask the runner to stop current work and observe its result. Unsupported cancellation must not be replaced silently by killing compute. |
| Close a viewer | Release that client/connection; report whether native execution continues or is unknown. |
| Stop native execution | End the identified native process and observe its exit. This is separate from task success. |
| Stop compute | Release the documented provider allocation; identify retained storage and actual provider/process state. |
| Delete an owned environment | Remove the exact creator-owned resource and reconcile final absence, including provider-managed dependents. |
Use graceful shutdown first where supported, then only explicitly owned, identified handles for stronger termination. Never equate pane disappearance with process or resource release. Persistent/non-persistent lifetime describes when execution should stop; durability describes recovery. An explicit non-persistent completion condition must name its invocation/work scope. An unrelated queue closure or an empty queue is not permission to terminate a member, and no new lifecycle YAML syntax is specified here.
## Resume exactly, or return an honest disposition
A resume request identifies the seat, harness-qualified native reference, prior binding, expected environment boundary and retained state. Validate those before launching the native harness. Reject mismatched reference types, missing/changed history, wrong immutable resources, unavailable required files and duplicate active executions. Never use “latest” as a replacement for an exact requested conversation or silently start fresh on failure.
Separate the outcomes: same native conversation resumed; new conversation rebuilt from artifacts; fresh launch; unsupported; refused; or unconfirmed. Environment continuity has its own outcome: same retained environment, recreated compute with copied state, different host, or unknown. A matching UUID alone does not show that native history was consumed.
If a provider stops exposing file transfer after canonical-process exit, capture artifacts/history and publish any retained resume descriptor **while transfer is still available**. Verify destination bytes before shutdown. After completion, use host/provider observations instead of assuming guest exec still works. For recreated compute, explicitly transfer the retained checkpoint and required artifact into the successor environment and validate them before publishing its resume descriptor. A new environment ID is expected and must be recorded as a successor, not disguised as the old environment.
The measured private recipe demonstrated this latter boundary: the original sandbox was deleted; copied native history and task bytes were restored into new containers on the same physical host; the native harness launched with `--resume` and the retained UUID. Its first new reply recalled a private conversation-only marker before tools, with no marker in the new input and no native-history tool read. It then verified artifact 1, used changed task context, produced artifact 2 and returned it for independent coordinator verification. This establishes one bounded native-return/work result. It does **not** establish new-host migration, RAM restoration, same-container stop/start, cgroup enforcement, general portability or shipped platform support.
## Export results before teardown
A guest path/hash note means bytes exist in a guest; it is not delivery to the consumer. Identify the actor holding the provider transfer handle. In the measured recipe that actor was the host wrapper, not the guest worker.
The worker publishes its report once and records the task/path/hash on its existing child. The wrapper validates the binding, task, allowed path, file type, size and expected hash; transfers to a fresh host staging path; checks the returned bytes/schema/context; and publishes without overwriting an existing result. It records an actual consumer-accessible destination, hash, byte count and actor through the authenticated work route. The worker closes its child after the matching export receipt; the coordinator independently reads/verifies those bytes before closing its parent. An ambiguous transfer is reconciled by existing bytes/hash, not a new report or duplicate task.
Complete exports before resource deletion. If output cannot be returned, preserve the exact resource/owner and failure state. Cleanup then reconciles the exact owned workload, supervisor, volumes and provider records to the intended stopped/absent state. Keep reused engines, caches, authenticated environments and attached resources. Revoke scoped credentials/grants through their owners and record remaining state. “Delete requested” and “absence verified” are different receipts.
## Proposed generic platform seam — not implemented
The existing composition need not acquire another YAML format to describe these responsibilities. If a maintained platform seam is selected, the following **design pseudocode** names the smallest useful operations; it is not a loader or API available in this release:
```text
prepare(placement, ownership, resourceRequest, credentialRefs) -> bindingReceipt
project(binding, explicitFiles, nativeCheckpoint?) -> deliveryReceipt
launch(binding, harnessLaunch, exactResumeRequest?) -> executionReceipt
observe(binding, execution) -> observedState | unsupported | unconfirmed
export(binding, artifactRequest) -> publicationReceipt
shutdown(binding, execution, selectedEffect) -> effectReceipt
```
The provider implementation interprets its image/network/compute configuration. The runner interprets native protocol and control. Core would retain typed provenance and route the selected effects; it must not reinterpret arbitrary provider keys, leak secrets or collapse ownership into a stop boolean. Each mutating operation needs an attempt identity and reconciliation route; partial receipts remain useful after failure. Capability reporting must expose unsupported projection, cancellation, resume or cleanup rather than letting method names imply support.
The most useful author evidence follows the real work path: exact resolved placement and native launch; delivered current context; authenticated work ownership; returned artifact bytes; exact resume boundary and consumed retained state; and final resource disposition. A parser pass, launcher success, viewer reconnect or same display name leaves the corresponding effect unconfirmed. These distinctions let an author implement and diagnose an integration; they do not create a new release gate or require another runtime campaign.
+37 -3
View File
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/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
@@ -848,13 +848,47 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
| Kernel seats fail within a second of the first start, and tmux reports "server exited unexpectedly" | Check that your tmux can capture a pane, then start the failed seats again; see the tmux check below this table. |
| Shared terminal is absent | Inspect the existing kernel binding and recovery state; use standalone `rig tui` while resolving it. |
| Shared terminal is absent | Check the kernel's `operator.human` terminal separately from its agents; recover only that seat as described below. Standalone `rig tui` remains available while resolving it. |
| Viewing terminal was closed | Open the conversations again with `rig terminal open saved:kernel --window`; `rig tui --shared` is only the dashboard fallback if that window cannot open. Do not relaunch the team. |
| Daemon restarted but tmux survived | Re-read `rig status` and the existing queue; a daemon restart is not a fresh project. |
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when that start finds all of its seats lost; a kernel that was already down before the reboot stays down, and `rig start` names `rig up kernel --existing`. Check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
| Launch reports no usable snapshot | Inspect the existing rig and retained project files, then follow the same-seat recovery below. |
| Work is waiting on a prompt or decision | Read the row, transition and named prompt; preserve the obligation until the missing decision arrives. |
**When the shared kernel dashboard is missing.** The dashboard runs in the
kernel's `operator.human` terminal seat. Restoring the operator, advisor and
queue worker does not also restore an unselected terminal. Read
`rig ps --nodes --rig kernel --json` and use the terminal's registered
`canonicalSessionName` for the `operator.human` row. Replace `<dashboard-session>`
in the commands below with that name. Check the actual pane with
`rig capture "<dashboard-session>"`: a persisted running row alone does not prove
that its tmux session is alive.
A daemon or transport error is not proof that the terminal is absent.
- If only the viewing window closed and the TUI is still running, reopen it
with `rig terminal open saved:kernel --window`.
- If the TUI exited but its terminal shell remains, run `rig tui` once in that
existing pane. A fresh seat is unnecessary.
- If the terminal session itself is absent, start only that existing seat:
```sh
rig seat launch "<dashboard-session>" --fresh --reason "recover missing shared kernel dashboard"
```
The terminal has no agent conversation to resume. Its fresh-start action
runs `rig tui` after the shell is ready; sibling agent sessions stay
unchanged. This command intentionally omits `--stop`, so it refuses to
replace a live occupant. Check the result before repeating it, then run
`rig capture "<dashboard-session>"` to confirm the TUI and
`rig ps --nodes --rig kernel --full` to check the seats. Once the dashboard
is running, open its view with `rig terminal open saved:kernel --window`.
Opening `saved:kernel` places a view of existing sessions; it cannot create a
missing terminal. Whole-kernel recovery is for the all-seats-lost case in the
table, and `rig up kernel --existing` refuses a kernel with live sessions.
A retained `excluded_from_subset` reason may come from an earlier subset
restore; inspect its event before attributing it to the latest reboot.
**When the kernel's seats all fail at the first start.** A report on Oracle Linux 10
([#980](https://github.com/mvschwarz/openrig/issues/980)) traced this to the distribution's packaged tmux, whose
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. `rig setup` (its tmux
+242
View File
@@ -0,0 +1,242 @@
# Harness and runner contract (DRAFT for 0.7.6)
> **Status.** This is a draft for contributors.
> - **In the 0.7.6 candidate, not yet released:** 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.
> - **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.
>
> Candidate scope 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.
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
the same harness can run in many places, and one place can host many harnesses.
## 1. Plain preset or runner preset?
| | Plain community preset | Runner preset (`runner: true`) |
|---|---|---|
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) |
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input |
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt |
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably |
**If your harness works well as a terminal app, a plain preset is enough. Use a runner only when you can report state
honestly.** A runner that guesses is worse than a quiet screen read as unknown.
**Vocabulary.** "Preset", "runner preset", `launch_result`, `resume`, `first_input` and `passes_model` are existing
terms at the candidate base. Broader names you may see in design discussion, such as *HarnessSpec*, are **proposals**, not file
formats or loaders that exist.
## 2. Declare it: what the preset says, fixed at launch
```yaml
# $OPENRIG_HOME/community-harnesses/dsh.yaml
format: 1
name: dsh
command: dsh-seat # your runner; it starts the agent itself
maintainer: <you>
runner: true # in the 0.7.6 candidate
launch_result: true # required with runner
resume: true # only after you've checked a real reopen and a refused missing one
limits:
- permission requests are refused (first version)
- a prompt whose whole text is /abort can't be sent
- fork unsupported
```
- **`runner: true` requires `launch_result: true`,** and can't be combined with `names_conversation`.
- **The declaration is fixed at launch.** Each launch, a restore's included, records what the preset declares at that
moment. Editing the preset changes the next launch, not a running seat.
- **A preset without `runner` behaves exactly as today,** and so does a session recorded before the field existed.
- **The other fields keep their existing meanings:**
- `first_input: true` passes the seat's first startup turn in a file named by `OPENRIG_FIRST_INPUT`;
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`.
## 3. Identity: report the conversation once, in the launch record
- **When:** after the agent has opened or reopened its conversation (for ACP: after `session/new`, `session/load` or
`session/resume`).
- **How:** write the attempt's launch record to the path in `OPENRIG_LAUNCH_RESULT`, with the attempt from
`OPENRIG_LAUNCH_ATTEMPT`. The record is either the conversation:
```json
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "nativeRef": "<conversation id>", "producer": "dsh-seat 0.3.0; dsh 0.2.1-rc.2; ACP 1"}
```
or the agent's own word that it doesn't exist:
```json
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "error": "missing", "producer": "dsh-seat 0.3.0"}
```
- **`producer` is free text** for your versions. There are no structured version or capability fields.
- **The launch record is the only identity channel.** Don't report identity on the activity endpoint.
- **No record means attention.** For a runner preset:
- **a new launch** with no usable record within OpenRig's wait leaves the seat needing attention, not ready. So does a
new launch where OpenRig can't create the record's folder: the runner still starts, without `OPENRIG_LAUNCH_RESULT`;
- **on a resume,** a folder OpenRig can't create means nothing starts, because the launcher can't be told what to
reopen. When the folder exists but no usable record arrives, the resume command has already been sent, and the
resume reads unconfirmed (section 8).
- **OpenRig shows this identity as launcher-reported.** It doesn't observe the conversation itself.
## 4. Activity: report turn boundaries
Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer <token>`.
- **Finding the base and token:** take them from `OPENRIG_URL` (or `OPENRIG_PORT`, with `OPENRIG_HOST` defaulting to
`127.0.0.1`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else from `$OPENRIG_HOME/activity-endpoint.json`.
- **Posting:** post one at a time, in order, with a short timeout. Never make the agent wait on a post.
```json
{"sessionName": "dev-builder@my-rig", "nodeId": "<node id>", "runtime": "dsh",
"generation": "<OPENRIG_OCCUPANT_GENERATION>", "sequence": 42, "occurredAt": "2026-10-29T12:00:00Z",
"hookEvent": "UserPromptSubmit", "subtype": "accepted", "turnId": "7"}
```
| When | `hookEvent` / `subtype` | The seat reads |
|---|---|---|
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
| The agent process ended | `Stop` / `<preset name>_exited` | unknown, **never idle** |
| Your runner or the agent hit an error | `Notification` / `runtime_error` | recorded, but not used as activity: the prior activity evidence keeps its usual lifetime. It doesn't itself request or clear attention |
- **Liveness:** state `livenessSeconds` (a whole number of seconds from 1 to 3600; any other value is ignored) on every
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
non-exit `Stop` or a beat while holding permission attention.
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
- **"Accepted" means your runner submitted its Nth prompt.** It isn't the agent's own acknowledgement, and it isn't
tied to a particular `rig send`.
- **The generation attributes a post; it isn't authentication.**
- A post carrying an earlier occupant's generation never counts for the live seat.
- The hook token is one local token, so any process holding it can post for any seat.
## 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.
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
- **What arrives:** `rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
is **one block**, newlines included. Send it to the agent as **one** prompt (for ACP, one `session/prompt`).
- **Never split a block,** and never act on lines inside it.
- **Control input:** the only control is a block that is exactly `/abort`, or Ctrl-C typed outside a paste.
- Ordinary sends and reminders arrive inside a From/To envelope, so they never equal `/abort`, whatever lines they
contain.
- **A message that arrives mid-turn** waits for the next turn. Report it as queued, never as steering the current turn.
- **Limits to keep and to list under `limits`:**
- a prompt whose whole text is `/abort` can't be sent as a prompt;
- a Ctrl-C byte inside a paste also aborts;
- without a TTY, input is read line by line;
- this isn't a binary-safe channel.
## 6. Cancel: requested isn't the same as done
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
and record when you sent it.
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
Then post `Stop` / `cancelled`.
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
## 7. Permissions: refuse every request (first version)
- **Refuse at once:** answer every permission request immediately with a refusal (for ACP,
`{"outcome": {"outcome": "cancelled"}}`).
- **Make it visible:** show what was asked in the pane, and post `Notification` / `permission_prompt`.
- **Hold that attention** as your last post through the end of the turn, so the seat reads needs input. Don't post the
turn's `Stop` or idle beats while holding it.
- **It clears** when your next prompt is accepted, or when an operator clears it
(`rig seat clear-attention <session> --reason "…"`).
- **Never leave a request pending.**
- **Not in the first version:**
- answering from the seat's permission setting;
- answers typed into the pane;
- routing an approval to another seat.
- So a job that needs an edit or a command approved can't finish under this version. Agents that can run without
asking are the best first case.
- **Unknown requests from the agent:** answer with JSON-RPC `-32601` and carry on.
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
- On a resume, OpenRig passes the conversation to reopen in `OPENRIG_RESUME_REF`. Use the method your agent
advertises: ACP's `session/load` (capability `loadSession`), or your agent's own reopen method, named with its
capability and pinned version in your `limits`.
| What happened | Write in the launch record | What OpenRig does |
|---|---|---|
| The same conversation reopened | the same `nativeRef` | resumed |
| The agent says it doesn't exist | `error: "missing"` | reports it missing; nothing new starts |
| Anything else: a timeout, an error, or the working folder no longer matches the conversation's | no record, or an error other than `missing` | **unconfirmed**: the seat keeps its conversation and needs attention |
| A different conversation opened | the other id | a mismatch; OpenRig doesn't switch to it |
| OpenRig couldn't create the record's folder | (nothing to write) | nothing starts |
- **Never start a new conversation on a resume.**
- A conversation saved under another harness's runtime is never handed to your launcher.
## 9. Context, model and shutdown: whose job is what
| Responsibility | OpenRig does | Your preset or runner does |
|---|---|---|
| **First startup turn** | Writes it to a private file and passes `OPENRIG_FIRST_INPUT` (with `first_input: true`) | Gives those exact bytes to the agent through its own first-prompt route |
| **Guidance, skills, startup files** | Doesn't place them for community harnesses; a launch warns that they weren't delivered | Puts whatever the agent needs where *your* harness reads it, or says in `limits` that it isn't supported |
| **Model** | Passes the seat's declared model as `OPENRIG_MODEL` (with `passes_model: true`); never substitutes one | Maps it to the harness's own setting. OpenRig shows the effective model as unknown |
| **Placing the agent somewhere else** (a sandbox, another machine) | See the [execution platform chapter](execution-platform-contract.md) | Keeps the runner independent of where it runs |
| **Shutdown** | `rig seat stop` and `rig down` end the seat's tmux session at once: no graceful stop handshake reaches the runner, its pane just closes. OpenRig ends nothing outside that session, and does no external provider cleanup | **(Proposed)** When the runner's pane closes (a hangup) or it's asked to stop, it attempts where possible to cancel any running turn and end the agent process it started, and lists in `limits` anything that may keep running. Cleanup of external compute needs the platform owner to reconcile it |
## 10. What counts as evidence
- **Keep three things separate:**
- **`rig send --verify`** checks the pane before and after a send. It's pane evidence, not proof the agent received
or finished anything. Mirror each accepted prompt in your pane (for example `you ▸ …`); OpenRig doesn't yet promise
that `--verify` recognises it.
- **A turn ending isn't the work being done.** Task completion is whatever the work's own record says: a queue task
marked done, or an artifact written.
- **Your own tests** should run against a fake agent that covers each section above.
- **The useful real check** for a maintainer:
- a seat starts and reads ready only after the handshake;
- one multi-line message becomes one prompt;
- the seat reads working, then idle;
- a refused permission reads needs input;
- `/abort` ends a turn as cancelled;
- a resume reopens the same conversation, and a missing one is refused, never replaced.
That's the same promotion test the presets reference describes, applied to these declarations. Passing it is
compatibility evidence for the versions you name, not certification.
## Contributor checklist
- [ ] The preset sets `runner: true` and `launch_result: true`, and not `names_conversation`.
- [ ] After the agent opens or reopens its conversation, the runner writes the launch record, with versions in `producer`.
- [ ] Activity posts carry `sessionName`, `nodeId`, `runtime`, `generation`, `sequence`, `occurredAt`, `hookEvent` and
`subtype`. They're sent in order and never block the agent.
- [ ] One submitted message becomes exactly one prompt. Only a bare `/abort` block, or Ctrl-C outside a paste, is control.
- [ ] Cancel records "requested" when sent, and "cancelled" only on the agent's own stop reason.
- [ ] Every permission request is refused at once, shown, and held as needs input until the next accepted prompt.
- [ ] An agent exit posts `Stop` / `<preset name>_exited`, never a plain idle.
- [ ] A resume reopens the exact conversation. It reports `missing` only on the agent's word, and otherwise leaves it
unconfirmed.
- [ ] Unknown agent requests get `-32601`.
- [ ] `limits` names the input limits, the permission limit, what keeps running after a stop, and anything not supported.
## Open questions
1. **For the dsh maintainer:** which method and capability does dsh advertise for reopening a session, at a pinned
version: `session/load` with `loadSession`, or `session/resume`?
2. **For the dsh maintainer:** can dsh's ACP profile run with tools off? A first journey that needs no permission
answers is the simplest to check.
3. **For OpenRig:** should a later version route permission requests to a chosen seat, as `rig acp run` already does
with queue tasks, instead of refusing them?
4. **For OpenRig:** once a second ACP harness exists, should one shared ACP runner live in OpenRig rather than one per
harness?
+1 -1
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.4`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.7`. 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.
+139
View File
@@ -526,6 +526,105 @@ only the stored `env:`. Seats may share a home. Live usage refresh (`rig provide
daemon's own logins; reading each seat's home would need a native reader per home. OpenRig doesn't copy logins or conversation history between homes, so a conversation saved under a seat's earlier home
doesn't resume after the seat moves to another one. The `openrig-user` skill has the setup steps.
**A seat's own subscription token.** A Claude member can start on a prepared subscription token from its first launch,
with no conversation to switch first. Name the token file beside its config dir:
```yaml
- id: dev
runtime: claude-code
env:
CLAUDE_CONFIG_DIR: /abs/claude-dev
claude_token: { label: dev-sub, file: /abs/tokens/dev.token }
```
The label and the absolute path are stored on the seat when the rig is created, as `rig provider switch --seat <seat>
--to-account <label> --token-file <path>` stores them. Its launches run the same token step, which reads the file as
Claude starts; a launch path that can't run it (a pane shell other than a POSIX one, for example) starts without it and
warns. The file's contents never pass through OpenRig: validation doesn't read it, and they don't reach the spec, the
seat's record or the launch command. A missing, empty or unreadable file doesn't refuse the spec; the seat starts on its
config dir's stored login (or a token the daemon gives every seat) and says so. A launch also warns when a setting Claude
Code ranks above the token would be used instead: an `apiKeyHelper`, or an outranking `env` entry in the user, project
or local settings. Managed settings aren't inspected. `rig export` writes the seat's current reference, except for a seat
with an account home or an API-key selection (select those again after import), so a later switch shows there, and `rig provider switch --stored-login` removes it. Only
`claude-code` members and legacy flat nodes accept it, and there's no rig-level default.
**Switching to an isolated account home.** A running Codex or Claude Code seat can change its native account home
while staying in its rig:
```sh
rig provider switch --seat dev-impl@my-rig --to-account api-account \
--account-home /home/me/accounts/codex-api --model gpt-5.5 --reason "Use the API budget" --dry-run
rig seat set-runtime dev-impl@my-rig --runtime claude-code \
--account-home /home/me/accounts/claude-api --model claude-sonnet-4-6 --reason "Codex limit"
```
The directory must already exist and contain the runtime's login or API settings. You choose the account and budget;
OpenRig does not choose or create credentials. `--account-home` sets `CODEX_HOME` for Codex or `CLAUDE_CONFIG_DIR` for
Claude Code before the successor starts.
This switch starts a fresh conversation with a handover, even when the runtime stays the same. The existing
subscription-login switch preserves the conversation. Once the successor confirms, OpenRig commits its home, runtime,
model, profile and permission selection together. The seat keeps its address, edges, queue rows and claims. If OpenRig
refuses the switch before cutover, the predecessor's configuration stays unchanged.
For a Claude plan authenticated with `setup-token`, supply the token file with the home:
```sh
rig provider switch --seat dev-impl@my-rig --to-account claude-plan \
--account-home /home/me/accounts/claude-plan --token-file /home/me/credentials/claude-plan-token \
--reason "The plan reset"
# Across runtimes, use set-runtime with --account-home, --token-file and --account-label.
```
To return to the seat's configured login:
```sh
rig provider switch --seat dev-impl@my-rig --clear-account-home --reason "The plan reset" --dry-run
rig provider switch --seat dev-impl@my-rig --clear-account-home --reason "The plan reset"
# Across runtimes: rig seat set-runtime dev-impl@my-rig --runtime claude-code --clear-account-home --reason "Return to configured login"
```
Clearing the selection also starts a successor with a fresh conversation and a handover. OpenRig removes the saved
account-home override and its token reference only after the successor confirms. The successor starts with the member's
or rig's configured home, falling back to the daemon's selection and then the native default, as an ordinary launch does.
If there was no account-home override, OpenRig preserves any unrelated legacy per-seat token. Clear uses the current
configuration; it does not remember a prior login or restore old credentials. Use clear on its own, without
`--to-account`, `--stored-login`, `--account-home` or a new token reference.
**Anthropic profile limitation.** An active or default profile in Anthropic's configuration directory can override a
stored Claude login. `CLAUDE_CONFIG_DIR` does not isolate that directory, and OpenRig does not change `ANTHROPIC_CONFIG_DIR`
or rewrite global profiles. Check or deselect the profile before using a stored-login account home, and verify the
running account privately.
An isolated Claude launch removes `ANTHROPIC_PROFILE`, `ANTHROPIC_FEDERATION_RULE_ID` and `ANTHROPIC_ORGANIZATION_ID`
from its inherited environment. It also refuses profile selectors in the selected home's or project's settings `env`
blocks. An explicit subscription token takes precedence over these selectors, including in the legacy
subscription-token switch.
OpenRig receives only the token's label and file path. The launch reads the token; if the required file is unusable, no agent
starts. Selecting an API home without `--token-file` clears an older seat token reference when the selection commits.
The home's API helper can stay in its user settings. For a selected plan token, OpenRig refuses a home whose API helper
or credential environment would take precedence over that token. Both modes refuse project credential overrides.
OpenRig leaves those settings unchanged.
The selected home persists across managed launches and restores. These launches exclude inherited Claude/OpenAI
credentials and routing variables so credentials from the daemon or shell cannot override the selected home. OpenRig
refuses to launch if it cannot read the saved selection or the shell cannot enforce isolation. Launches without a home
selection and existing subscription-login switches keep their behavior. A Claude seat using account-home mode must
use that mode for later account changes too.
User-managed provider settings can still affect authentication. After a real tool turn, check which account and billing
identity the runtime used. Do not print tokens or the process environment. The configured home alone cannot verify the
running account.
Before downgrading to a version without account-home switching, stop the managed seats. Select their accounts using
that version's supported setup before resuming. Older versions ignore the saved selection and may launch with inherited
or default credentials. Rolling back the binary alone does not preserve the selected billing account.
OpenRig leaves the imported team file unchanged. Exporting and reimporting a team does not preserve account-home
isolation, so configure it explicitly for each new rig. If a switch is interrupted, inspect it with `rig seat status`
and follow the printed recovery command. Wait until the outcome is known before starting another switch.
**Do not put secrets in `env:` or `--env`.** The values are not secret, and nothing here encrypts or hides them (see #306):
- Spec values are stored in OpenRig's database and in rig snapshots, and `rig export` and bundles copy them, so a secret put there by mistake travels with the rig.
@@ -534,6 +633,45 @@ doesn't resume after the seat moves to another one. The `openrig-user` skill has
Use `env:` for settings only. Credentials belong in the provider's own login or a secret store the seat reads at run time.
### Switching credentials while keeping the conversation
`rig provider switch` resumes a running seat in its existing home and pane. For Claude Code,
select an API-key file, a subscription-token file, or the home's stored login:
```sh
rig provider precheck --seat dev-a@my-rig --to-account api-budget --api-key-file /secure/claude-api-key
rig provider switch --seat dev-a@my-rig --to-account api-budget --api-key-file /secure/claude-api-key
rig provider switch --seat dev-a@my-rig --to-account plan --token-file /secure/claude-plan-token
rig provider switch --seat dev-a@my-rig --stored-login
```
The API-key file must be private (0600 or 0400), nonempty, and at most 8192 bytes, containing
one key with optional surrounding whitespace. Only the reference is stored. The child reads the
key at launch; key bytes never enter command arguments, snapshots or switch reports. Classic
launches require a supported POSIX pane shell. Missing/invalid API-key files prevent launch.
Settings that could override the key cause a refusal; OpenRig does not edit those settings.
Native managed settings and a signed-in Claude gateway cannot be ruled out; Claude may require API-key approval before its
first turn. Check `/status` after a real tool turn to confirm the authentication method.
For Codex, a saved native API-key login is selected with the existing `--to-account <name>`
path, exactly like a saved subscription login. Prepare it using native `codex login --with-api-key`
with the key on stdin, then `rig auth save --codex-home <home> <name>`; never put a key in argv.
Use the seat's existing home. Preparing a login replaces that home's active credentials, so do it
before running seats there, or prepare/import a native auth profile separately. Switches preserve
an outgoing credential copy and report peers sharing the home. A native keyring or administrator
policy can override `auth.json`; the report remains unverified until native account verification.
Select or clear an account home in a separate operation: `--api-key-file` cannot be combined with
`--account-home` or `--clear-account-home`. A credential switch inside an already selected home
keeps that home.
Both paths retain the conversation and launch settings, interrupt in-flight work, and report
uncertain restarts without retrying. They do not move account homes or start a fresh conversation.
The caller chooses subscription-first order and configures/enforces the API budget; this command
neither selects accounts automatically nor imposes a spending limit. Downgrade only after switching
API-key Claude seats back to subscription credentials: older OpenRig versions cannot read the new
credential kind correctly.
### Choosing the Claude instruction file
OpenRig writes its instructions for Claude Code members into managed blocks in
@@ -597,6 +735,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
| `codex_permissions` | mapping | no | — | Codex-only sandbox and approval envelope, emitted as explicit flags on every managed launch and resume; see [per-member Codex permissions](#per-member-codex-permissions). |
| `claude_token` | mapping | no | — | Claude-only `{ label, file }`: the seat's own subscription token file, by absolute path, used from its first launch; see "A seat's own subscription token" under [launch environment](#launch-environment). |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
| `cwd` | string | yes | — | Working directory for the agent. A relative path resolves against the rig root (the directory containing the rig spec). If that path exists as written, it is used. Otherwise a leading `~` or `~/` expands to the home directory (`~user` does not) and `$VAR` / `${VAR}` expand from the daemon host's environment; a relative result still resolves against the rig root. A reference to an unset variable stays as written, so the cwd is that literal path and works only if it exists. Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. A cwd inside the OpenRig installation fails preflight unless `--cwd` is given. Editing this declaration does not update an existing seat; use [`rig seat set-cwd`](agent-startup-guide.md#change-an-existing-seats-working-directory) to save its future launch directory. |
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
+8
View File
@@ -109,6 +109,14 @@ reaction on any other message, including a person's reply in the thread, is igno
`reaction_added` event: add both under **OAuth & Permissions** and **Event Subscriptions**, then
reinstall the app to the workspace.
To have reactions ignored instead, run `rig slack setup --reactions off`. The agent that asked
never hears of a reaction, including one still waiting to be retried, and a person answers an ask by
typing a reply in its thread. Slack still sends the event and the manifest keeps it, so switching
back with `--reactions route` needs no change to the app. Like any saved change, the setting reaches
a running connector when it next rewires, which a daemon restart does (`rig daemon stop`, then
`rig daemon start`). See [One channel per rig or seat](#one-channel-per-rig-or-seat-optional) for
why disabling and re-enabling the connector isn't the same.
The manifest also turns on **Interactivity**, so the human can answer a decision's structured
questions by clicking a button (`rig queue create --human-questions-file`). In Socket Mode the
clicks arrive over the same socket, so no request URL is needed. An app created from an older
+87 -1
View File
@@ -184,7 +184,7 @@ GET /api/terminal/preview?view=qitem:<id>&provider=herdr
POST /api/terminal/open {"view":"qitem:<id>","provider":"herdr","expectedPlan":"<preview planId>"}
```
Preview and open include `task: {qitemId, name, complete, issues, members}`. Each member
Preview and open include `task: {kind:"qitem", id, qitemId, name, complete, issues, members}`. Each member
names its structured `{hostId, nodeId}` key (null when unresolved), seat,
contributing `obligations`, `availability` and `viewerStatus`. Stopped, missing,
external and unresolved remote owners remain visible. Remote queues are not
@@ -306,3 +306,89 @@ when other clients establish the agent window's dimensions. It is **not resize
isolation**: opening a tile can change the agent's terminal size when it is the
only sizing client. Observers of a Herdr terminal do not themselves resize it;
a controlling browser can. No cross-version resize guarantee is implied.
## Following a slice's current work
Use `rig terminal open slice:<name-or-id> --follow --provider herdr` to keep
one workspace following the current destination owners of that slice's active
queue rows. `pending`, `in-progress` and `blocked` rows count. A member can own
several rows; `task.members[].obligations` preserves every row and state.
A slice resolves within the daemon's configured slice index, by exact directory
name or unique frontmatter `id`. Tags must name it exactly: `rig queue create
--slice <name-or-id> ...` associates a row. Handoff inherits those tags unless
replaced; merely linking an untagged child does not associate it. A mission-only
tag does not put a row in every slice. No project is inferred from a tag or the
caller's working directory. A bare name or ID claimed by multiple slices is
`scope_ambiguous`; no first-match scope is selected. Selecting a unique key can
still follow a slice whose other alias is reused: a row carrying that colliding
alias must have exactly one `mission:` tag naming exactly one indexed claimant.
Rows placed in another mission are excluded. Missing, contradictory or otherwise
unplaceable mission/alias tags make the snapshot incomplete, retaining previous
viewers. A unique alias on its own retains exact matching. Flat legacy slices
without an indexed parent mission cannot disambiguate a colliding alias.
Slice keys are case-sensitive and can include Unicode, plus signs and internal
spaces. They cannot be empty or contain slash, backslash, control characters,
or leading/trailing whitespace. Pass the literal selector in JSON or a quoted
CLI argument; encode it once as a GET query value. Do not double-decode it.
Slice previews and open results use `task: {kind:"slice", id, name, complete,
issues, members}`. `id` is the unique frontmatter id, or the directory name when
no id is present. There is no synthetic `qitemId`. Preserve the preview's `view`
for the ensure request. All successful previews, including qitem previews,
include `capabilities: {ensureViewer:boolean}`. True means that the resolved
view/provider implements ensure; the existing member, completeness, owner and
`expectedPlan` checks still apply. A missing capability/task on an older daemon
is unsupported scoped membership, not an empty team.
New slice workspaces default to four automatic tiles; `--max-tiles` overrides
that. Qitem views retain their eight-tile default. The full membership and
obligation list remains available when tiles exceed capacity. Pins, drafts,
unknown input and focus retention still protect departed viewers. Closing a
viewer never stops its agent. Opening a tile can change the agent's dimensions.
A known empty slice remains enabled in the same workspace; later associated
work can join it. An incomplete queue read (`task.complete:false` plus `issues`)
retains previous viewers. A missing scope is `scope_not_found`, duplicate scope
identity is `scope_ambiguous`, unreadable/capped index data is `scope_unavailable`,
and an unavailable resolver is `scope_unsupported`. None is authoritative empty.
Ensure of an incomplete snapshot returns `viewer_unconfirmed`; changed plans
return `preview_changed` before provisioning. Mission following is a separate
increment; the existing mission display projection is not authoritative follow
or ensure capability.
Ordinary ownership changes follow queue/session/node events. Node and directory
changes under the primary configured index use the existing filesystem watcher,
with a coalesced scope invalidation event. There is no repeating membership
poll. Structural edits under additional legacy roots, or a filesystem where
watching is unavailable, require `rig`'s existing slice refresh endpoint
`POST /api/slices/refresh` or reopening the view. Missing or unreadable sources
keep prior viewers; they never certify that the work disappeared.
Scope identity reads are bounded to 4,096 directory entries, 16 KiB per selected
node-header read, and 8 MiB of source bytes per index generation. The byte budget
counts the bounded read, including text after short frontmatter. An index that
exceeds these limits is too large for this read; use a smaller configured index
or a qitem view. This is separate from unreadable or malformed sources.
Queue membership reads are bounded to 50,000 active rows plus one lookahead,
16 KiB of encoded tags per row and 8 MiB of aggregate encoded tags. They remain
fresh on each render. Identities are reused within a scope-source generation
only while the primary-root watcher is live and existing additional roots are
covered by that root. A source event or explicit refresh invalidates them;
errors discard the cache. Unavailable watching or an existing unwatched legacy
root uses fresh bounded identity reads instead. Missing legacy roots are checked
on each call so an appearing root cannot reuse an older cached identity set.
Error bodies remain authoritative. `scope_unsupported` is HTTP 400,
`scope_not_found` is 404, and `scope_ambiguous`/`scope_unavailable` are 409.
An incomplete queue snapshot is a successful preview with `task.complete:false`;
ensure returns `viewer_unconfirmed` without a provider effect. Unsupported
ensure view kinds now return `view_required` (400), where they previously returned
200 with an error body. Unsupported follow view kinds
retain `follow_view_required` in a 200 `ok:false` body, as before. A changed
preview remains `preview_changed` (409). During automatic refresh a missing or
ambiguous slice retains the existing tiles and names the source problem in the
explanation strip; refresh or reopen it under the current unambiguous key.
+152 -178
View File
@@ -1,192 +1,166 @@
# What changed in OpenRig 0.7.1
# What changed in OpenRig 0.7.7
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.7. OpenRig
0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats. If you
upgraded from 0.7.5 or earlier, also read "What changed in OpenRig 0.7.6" below.
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 (`rig daemon stop`, then `rig daemon start`) to load the new
code. There are no database migrations.
- The bundled openrig-core plugin stays at 0.1.23 with unchanged files, so there
is no managed plugin refresh.
## New things you can do
## The fixes
- **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.
- A Claude seat at an empty prompt reads idle again when Claude prints
"✔ Update installed · /restart to apply" (or "Restart to apply") below its mode
bar after installing an update. Before, that line hid the mode bar, so the seat
read unknown and managed compaction waited for an idle prompt it could not see.
Both spellings are recognized; lines that only look similar still read unknown.
([#2311](https://github.com/mvschwarz/openrig/pull/2311))
- Managed compaction checks the input box before it pastes its preparation
prompt, `/compact` or a restore prompt, even with the typing guard off. Typed,
unreadable or changed input holds that step. A manual `rig compact` then says it
stopped to preserve the input box; read `rig compact <seat> --state` and retry
once the box is empty. A held restore prompt waits and goes ahead on its own once
the box is empty. An empty box or a high-confidence suggestion goes ahead.
Ordinary `rig send` and the typing-guard defaults are unchanged.
([#2347](https://github.com/mvschwarz/openrig/pull/2347))
- A Claude seat whose `claude` is a `#!` wrapper that starts the real Claude without
`exec` can be rebuilt again (`rig seat handover --source rebuild`): readiness also
accepts the wrapper's direct child when it names this launch's conversation token,
and refuses every other process as before. A rebuild that fails no longer leaves
the seat's resume target on the new, unprimed conversation.
([#2341](https://github.com/mvschwarz/openrig/issues/2341), [#2354](https://github.com/mvschwarz/openrig/pull/2354))
## Behaviour that changed
## Limits to keep in view
- **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.
- Other reasons managed compaction can wait are unchanged.
- The input-box check is a read just before the paste, not a lock: typing that
starts in the same moment can still be appended to.
- If a seat's current occupant generation was never registered (an adopted Claude
with another generation, or a managed launch whose tenure record failed to write),
hooks don't move its resume token after the first one.
- The 0.7.6 limits below still apply.
## Output that changed shape
---
- **`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.
## What changed in OpenRig 0.7.6
## What to stop doing
The changes from 0.7.5 to 0.7.6, for an install upgrading from 0.7.5 or earlier.
- **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.
### After the upgrade
## Known gaps in 0.7.1
- Restart the daemon (`rig daemon stop`, then `rig daemon start`) to load the new
code. Two database migrations record account-home selection and whether a
community harness launch declared a runner. Both use prefix 141, with distinct
full filenames.
- The bundled openrig-core plugin goes from 0.1.22 to 0.1.23. Its Claude activity
hook clears permission marks after tool batches. Managed refresh preserves
replaced files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply
local edits you still need from the preserved copy.
- **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`.
### Follow a slice's work in one workspace
Use `rig terminal open slice:<name-or-id> --follow --provider herdr`. Membership
comes from pending, in-progress and blocked queue rows tagged with the exact
slice. Associate a row with `rig queue create --slice <name-or-id>`; a handoff
inherits its tags unless replaced. A mission-only tag does not associate every
slice. One owner appears once, with all its obligations listed.
When a slice's folder name repeats across missions, tag its work with the slice's
unique ID (`rig queue create --slice <id>`) or add `--mission <mission>`. A row
tagged only with the shared name cannot be placed, so the view reads incomplete
until it is.
New slice views default to four automatic tiles (`--max-tiles` overrides it);
qitem views keep eight. A known empty slice stays following and can repopulate.
An incomplete read keeps the prior viewers. Missing or ambiguous scope identities
are errors, never an empty team. Pins, drafts, unknown input and focus retain
former viewers. Closing a viewer does not stop its agent.
An owner's join refused before any pane split can join on a later refresh;
hold notices keep the original seat, phase and reason. A created viewer whose
attach has not yet run can continue without replacing its pane. A viewer whose
attach already ran and then exited back to a shell is not restarted
automatically, so the whole slice view can hold until that tile is resolved.
No tested workaround is offered for that case.
Slice following targets the Linux Herdr 0.9.5 / protocol 22 path. Mission
following is not included. See
`reference/terminal-sessions.md` for controls, bounds and refresh behavior.
Configured Herdr session endpoints now carry through viewer/follow operations.
The TUI's terminal action opens the agent, pod or rig currently being viewed.
### Changes agents should act on
- **A readiness deadline does not end a still-starting daemon.** A nonzero
pending result names the child PID, launch endpoint and log. The child stays
running and the startup reservation stays in place; readiness is not
confirmed. Follow the returned pending guidance before attempting recovery.
- **Read exited state honestly.** A verified exited process in a retained pane
reads `pane_exited`, instead of running and ready. Its outstanding work uses
the existing recovery-finding route. Unknown observations still mean unknown.
- **Use the same member settings when expanding.** `rig expand` now carries raw
members to the canonical rig validator, keeping env, effort and startup
settings. Restores keep the model saved with `rig seat set-model`.
- **Account-home moves start a fresh conversation with a handover.**
`rig provider switch --seat <seat> --to-account <name> --account-home <dir>`
selects an existing home; preview with `--dry-run`. `--clear-account-home`
returns to configured defaults, not historical credentials. Check the moved
seat's account and conversation.
- **Claude API-key switching resumes the same conversation but interrupts work.**
Use `rig provider switch --seat <seat> --to-account api --api-key-file <abs>`;
return with `--to-account plan --token-file <abs>`. The API-key path remains
unqualified. Do not combine it with account-home selectors. Credential files
stay private; budgets remain the caller's responsibility.
- **A new Claude member can name its subscription token in the spec:**
`claude_token: {label: name, file: /abs/token}`, beside its own
`env.CLAUDE_CONFIG_DIR`. This stores a file reference, not the token. Validation
does not read the file; a missing or unreadable token uses the existing launch
fallback notice, so verify the account actually used.
- **Git over SSH uses the daemon's SSH agent** in managed seats. Make sure that
agent has the intended key (`ssh-add -l`).
- **Slack button answers resume decisions parked on the person asked.** Set
`rig slack setup --reactions off` if acknowledgement emoji should not wake
agents. The default remains `route`; manifest regeneration preserves it.
Replies posted outside a deleted Slack thread are tracked where Slack put them.
- **Queue delivery keeps its original intent.** A wake refused at a selection
prompt retries after it clears. Cross-host `queue create --verify` reads the
destination's receipt. An unreadable receipt stays unconfirmed; do not replay
a create just because verification could not establish delivery.
- **Preset authors can opt into `runner: true` and `passes_model: true`.** The
runner also requires `launch_result: true` and reports activity through the
existing hook route. Model input arrives as `OPENRIG_MODEL`. Passed means the
launch command was sent, not that the harness applied that model; the observed
model remains unknown. Cursor and Hermes examples leave model passing off
pending native qualification.
Start with the draft [integration author contracts](author-contracts.md) for
harness, runner and platform duties, with implemented interfaces distinguished from proposals.
- **Update viewer API handling.** `/api/terminal/viewer/ensure` returns 400 for
`view_required` and `unknown_provider` where it previously returned 200 with
an error body. Slice not-found is 404; ambiguous/unavailable is 409. Continue
reading the body code. Unsupported follow kinds keep `follow_view_required`
in a 200 `ok:false` response.
### Limits to keep in view
- The reported [Codex hook-refresh refusal (#2200)](https://github.com/mvschwarz/openrig/issues/2200)
remains unresolved. A verified Node-launcher fix is included, but that does
not establish that this report is fixed. If
`rig seat restart <seat> --keep-conversation` refuses, nothing is stopped:
leave the seat running and report the refusal with CLI and daemon versions.
A refused restart has not refreshed hooks; review any Codex hook prompt.
- Retained messages are not delivered. Protected Codex pastes and unreadable
composers can still retain work; inspect the explanation, use a larger pane
or shorten the message. Turning the typing guard off removes protection.
- Inventory now avoids redundant reads, but no general speed or stability
improvement is claimed without comparable measurements.
- Internet-facing hosted members remain experimental, unsupported, off by
default and at your own risk. Slice following does not require them.
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.7",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7346,7 +7346,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.7",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7384,7 +7384,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.7",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7426,7 +7426,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.7",
"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.7",
"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.7",
"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": [
+122 -12
View File
@@ -1,3 +1,4 @@
import { accountHomePlanMatches } from "../lib/account-home-switch.js";
import fs from "node:fs";
import { Command } from "commander";
import { DaemonClient, DaemonTimeoutError } from "../client.js";
@@ -22,7 +23,7 @@ export const USAGE_ACROSS_HOSTS_REFRESH_TIMEOUT_MS = 75_000;
type Peer = { logicalId: string; sessionName: string | null; home?: "same" | "different" | "unknown"; effect: string; step?: string };
type SeatView = { home?: string; activity?: string; peers: Peer[] };
type SwitchReport = { home?: string; account: string; tokenFile?: string; conversation?: string | null; loginChanged: boolean; undo: string | null; peers: Peer[];
type SwitchReport = { credentialKind?: "api-key"; home?: string; account: string; tokenFile?: string; conversation?: string | null; loginChanged: boolean; undo: string | null; peers: Peer[];
restart?: { recoveryCommand?: string; remainingStep?: string }; next?: string; warnings?: string[]; tokenApplied?: boolean };
// A switch waits as long as `rig seat restart` does: the seat's in-place restart runs inside it.
@@ -38,23 +39,25 @@ function printSeat(seat: SeatView): void {
}
}
type AccountOpts = { toAccount?: string; tokenFile?: string; storedLogin?: boolean };
type AccountOpts = { toAccount?: string; tokenFile?: string; apiKeyFile?: string; storedLogin?: boolean };
/** The account a switch names: a Codex login's saved name, a Claude token's label with its file, or a Claude seat's stored
* login. Null when neither --to-account nor --stored-login was given. */
function targetAccount(opts: AccountOpts): { toAccount: string; tokenFile?: string | null } | null {
function targetAccount(opts: AccountOpts): { toAccount: string; tokenFile?: string | null; apiKeyFile?: string } | null {
if (opts.apiKeyFile !== undefined && (opts.tokenFile !== undefined || opts.storedLogin)) return null;
if (opts.storedLogin) return { toAccount: "stored-login", tokenFile: null };
if (!opts.toAccount) return null;
return { toAccount: opts.toAccount, ...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile } : {}) };
return { toAccount: opts.toAccount, ...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile } : {}), ...(opts.apiKeyFile !== undefined ? { apiKeyFile: opts.apiKeyFile } : {}) };
}
function printClaude(claude: { label: string; tokenFile: string | null; checked: string[]; notChecked: string[]; instead?: string }): void {
console.log(`Claude token: ${claude.tokenFile ? `"${claude.label}", read from ${claude.tokenFile} when Claude starts` : "none (the config dir's stored login)"}`);
function printClaude(claude: { credentialKind?: "api-key"; label: string; tokenFile: string | null; checked: string[]; notChecked: string[]; instead?: string }): void {
console.log(`Claude ${claude.credentialKind === "api-key" ? "API key" : "token"}: ${claude.tokenFile ? `"${claude.label}", read from ${claude.tokenFile} when Claude starts` : "none (the config dir's stored login)"}`);
if (claude.instead) console.log(`instead of the stored login, the seat would use ${claude.instead}`);
if (claude.checked.length) console.log(`checked for anything that outranks it: ${claude.checked.join("; ")}`);
if (claude.notChecked.length) console.log(`not checked (the seat's /status shows what it uses): ${claude.notChecked.join("; ")}`);
}
/** Build provider views and account switches; an isolated-home switch first requires the daemon to acknowledge its plan. */
export function providerCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("provider").description(
"Provider accounts and usage signals, and moving one Codex seat to another login saved in its Codex home",
@@ -510,6 +513,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
.requiredOption("--seat <s>", "The seat session")
.option("--to-account <a>", "Codex: the login's saved name in the seat's Codex home (rig auth list --codex-home <home>). Claude: your label for the token")
.option("--token-file <path>", "Claude: the absolute path of a 0600 file holding the seat's own subscription token; it's read only as Claude starts")
.option("--api-key-file <path>", "Claude: a file containing its API key; resume the same conversation and settings")
.option("--stored-login", "Claude: move the seat back to its config dir's stored login")
.option("--json", "Output as parseable JSON")
.action(async (opts: AccountOpts & { seat: string; json?: boolean }) => {
@@ -525,7 +529,8 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
return;
}
const qs = new URLSearchParams({ seat: opts.seat, toAccount: target.toAccount,
...(target.tokenFile === null ? { storedLogin: "1" } : target.tokenFile !== undefined ? { tokenFile: target.tokenFile } : {}) });
...(target.tokenFile === null ? { storedLogin: "1" } : target.tokenFile !== undefined ? { tokenFile: target.tokenFile } : {}),
...(target.apiKeyFile !== undefined ? { apiKeyFile: target.apiKeyFile } : {}) });
const res = await client.get<{ safe: boolean; reasons?: string[]; message?: string; seat?: SeatView;
claude?: Parameters<typeof printClaude>[0] }>(`/api/provider/precheck?${qs.toString()}`);
if (handleHttpError(res, "precheck")) return;
@@ -541,12 +546,19 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
cmd
.command("switch")
.description("Move one seat to another subscription login, keeping its conversation: a Codex seat to a login saved in its Codex home (Linux only), a Claude seat to its own token file")
.description("Move one seat to subscription or API credentials, keeping its conversation: a Codex seat to a login saved in its Codex home (Linux only), a Claude seat to its own token file")
.requiredOption("--seat <s>", "The seat session")
.option("--to-account <a>", "Codex: the login's saved name in the seat's Codex home (rig auth list --codex-home <home>). Claude: your label for the token")
.option("--token-file <path>", "Claude: the absolute path of a 0600 file holding the seat's own subscription token; it's read only as Claude starts")
.option("--api-key-file <path>", "Claude: a file containing its API key; resume the same conversation and settings")
.option("--stored-login", "Claude: move the seat back to its config dir's stored login")
.option("--force-unsafe", "Kept for scripts; a seat's checks can't be overridden, since each means nothing safe to restart or no such login")
.option("--clear-account-home", "Return to member/rig/daemon/native login selection through a fresh successor")
.option("--account-home <path>", "Move to an isolated native account home with a fresh successor and handover; no shared login is changed")
.option("--reason <text>", "Required with --account-home or --clear-account-home")
.option("--model <id>", "With --account-home or --clear-account-home, the successor's model")
.option("--codex-config-profile <name>", "With --account-home or --clear-account-home, the successor's Codex profile")
.option("--dry-run", "With --account-home or --clear-account-home, show the plan without changing anything")
.option("--json", "Output as parseable JSON")
.addHelpText("after", `
The daemon plans the seat's in-place restart first (rig seat restart --plan), so a refusal changes nothing. It then saves the
@@ -578,9 +590,28 @@ save it. That environment is Claude's, so the tools, hooks and MCP servers it st
environment writes it into the transcript. A token-authenticated seat can make model requests but can't use claude.ai
connectors or Remote Control; its local MCP servers still work. --stored-login moves it back to the config dir's stored login;
a CLAUDE_CODE_OAUTH_TOKEN the seat still receives outranks that login, and the report says which token it will use. Claude's
/status shows token auth with no account email, so the label is yours, not an observed account.`)
.action(async (opts: AccountOpts & { seat: string; forceUnsafe?: boolean; json?: boolean }) => {
const target = targetAccount(opts);
/status shows token auth with no account email, so the label is yours, not an observed account.
For Claude API billing use --to-account <label> --api-key-file <absolute path to a 0600 file>. The key reaches only the
resumed child's environment, never command arguments. API credentials fail closed if their file is unavailable. Existing
model, permissions, config home and conversation are retained; in-flight work is interrupted. Claude may require native
API-key approval before the first turn. OpenRig does not enforce API budgets or choose when to fall back.
Codex can select a saved API-key auth profile with --to-account just like a subscription profile. Its shared-home effects
and native credential-store requirements are unchanged. No fresh account home or conversation is created.
Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately.
--clear-account-home returns to the configured member/rig/daemon/native home through a fresh successor with a handover.
Use it without --to-account, --stored-login, --account-home or --token-file. It removes an account-home token reference
only after confirmation; it does not restore historical login bytes. Anthropic active/default profiles live outside
CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before relying on a stored login.`)
.action(async (opts: AccountOpts & { seat: string; forceUnsafe?: boolean; json?: boolean; accountHome?: string; clearAccountHome?: boolean; reason?: string; model?: string; codexConfigProfile?: string; dryRun?: boolean }) => {
if (opts.apiKeyFile !== undefined && (opts.accountHome !== undefined || opts.clearAccountHome)) {
console.error("Use --api-key-file for an in-place credential switch; select or clear an account home in a separate switch.");
process.exitCode = 1; return;
}
if (opts.clearAccountHome && (opts.accountHome !== undefined || opts.tokenFile !== undefined || opts.toAccount !== undefined || opts.storedLogin)) {
console.error("Use --clear-account-home alone, without --account-home, --token-file, --to-account or --stored-login.");
process.exitCode = 1; return;
}
const target = opts.clearAccountHome ? { toAccount: "default" } : targetAccount(opts);
if (!target) {
console.error("Name the account: --to-account <name> (with --token-file <path> for a Claude seat), or --stored-login for a Claude seat.");
process.exitCode = 1;
@@ -591,6 +622,85 @@ a CLAUDE_CODE_OAUTH_TOKEN the seat still receives outranks that login, and the r
process.exitCode = 1;
return;
}
if (opts.accountHome !== undefined || opts.clearAccountHome) {
if (!opts.reason?.trim() || opts.storedLogin) {
console.error(opts.clearAccountHome ? "Pass --reason with --clear-account-home."
: "With --account-home pass --reason and --to-account, without --stored-login. A Claude subscription may also name --token-file.");
process.exitCode = 1;
return;
}
let preview: { status: number; data: Record<string, unknown> };
try {
const status = await client.get<{ runtime?: string }>(`/api/seat/status/${encodeURIComponent(opts.seat)}`);
const runtime = status.data.runtime;
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code")) {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The seat runtime could not be read; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
preview = await client.post<Record<string, unknown>>(`/api/seat/set-runtime/${encodeURIComponent(opts.seat)}`, {
runtime, ...(opts.clearAccountHome ? { clearAccountHome: true } : { accountHome: opts.accountHome }), reason: opts.reason, dryRun: true,
...(opts.model !== undefined ? { model: opts.model } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile, accountLabel: opts.toAccount } : {}),
}, { timeoutMs: 30_000 });
} catch {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The read-only account-home preflight failed; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
const token = opts.tokenFile ? { label: opts.toAccount!, tokenFile: opts.tokenFile } : undefined;
if (!accountHomePlanMatches(preview.data, opts.clearAccountHome ? null : opts.accountHome!, token)) {
const refusal = { ok: false, code: "account_home_switch_unavailable", message: preview.data["message"]
?? "The daemon did not acknowledge the selected account home and token. No switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
if (opts.dryRun) {
if (opts.json) console.log(JSON.stringify(preview.data));
else {
console.log(`Account-home plan for ${opts.seat}: ${opts.accountHome ?? "configured defaults"}. Nothing changed.`);
const settings = (preview.data["settings"] ?? {}) as Record<string, { value: string | null; source: string }>;
console.log(`Settings on ${String(preview.data["to"])}:`);
for (const [name, setting] of Object.entries(settings)) console.log(` ${name}: ${setting.value ?? "none"} (${setting.source})`);
const permission = preview.data["permission"] as { mode: string; source: string } | undefined;
if (permission) console.log(` permissions: ${permission.mode} (${permission.source})`);
const dropped = (preview.data["dropped"] as Array<{ setting: string; value: string }> | undefined) ?? [];
if (dropped.length) console.log(`Cleared from ${String(preview.data["from"])}: ${dropped.map(choice => `${choice.setting} ${choice.value}`).join("; ")}.`);
console.log("Costs:");
for (const cost of (preview.data["costs"] as string[] | undefined) ?? []) console.log(` - ${cost}`);
}
return;
}
let result;
try {
result = await client.post<Record<string, unknown>>("/api/provider/switch", {
seat: opts.seat, ...(opts.clearAccountHome ? { clearAccountHome: true } : { ...target, accountHome: opts.accountHome }), reason: opts.reason, dryRun: false,
...(opts.model !== undefined ? { model: opts.model } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
}, { timeoutMs: 300_000 });
} catch (error) {
if (!(error instanceof DaemonTimeoutError)) throw error;
result = { status: 202, data: { ok: false, code: "runtime_switch_outcome_unknown",
message: `The daemon may still be switching. Inspect rig seat status ${opts.seat}; do not retry the switch.` } };
}
if (opts.json) console.log(JSON.stringify(result.data));
else if (result.data["recoveryPending"] === true) {
console.error(`Recovery is still pending for ${opts.seat}; the account-home switch is not complete.`);
if (typeof result.data["message"] === "string") console.error(result.data["message"]);
if (typeof result.data["guidance"] === "string") console.error(result.data["guidance"]);
for (const warning of (result.data["warnings"] as string[] | undefined) ?? []) console.error(`Warning: ${warning}`);
}
else if (result.data["ok"] === true) console.log(`Account-home switch for ${opts.seat}: ${opts.accountHome ?? "configured defaults"}. A fresh conversation carries the handover; the account is not verified until a real tool turn.`);
else console.error(result.data["message"] ?? "The account-home switch did not complete.");
process.exitCode = result.status >= 400 || result.data["ok"] !== true || result.data["recoveryPending"] === true ? 1 : 0;
return;
}
if (opts.dryRun || opts.reason || opts.model || opts.codexConfigProfile) {
console.error("--reason, --model, --codex-config-profile and --dry-run require --account-home or --clear-account-home.");
process.exitCode = 1;
return;
}
type SwitchResponse = { status: number; data: { outcome?: string; reasons?: string[]; message?: string; seat?: SwitchReport } };
let res: SwitchResponse;
const startedAt = new Date().toISOString();
@@ -622,7 +732,7 @@ a CLAUDE_CODE_OAUTH_TOKEN the seat still receives outranks that login, and the r
console.log("undo restores saved credential bytes only; sign-in is unverified. Saved credentials may expire, be revoked, or be superseded by refresh elsewhere. Restart the affected seat and use the existing account verification; fresh sign-in may be required.");
}
} else if (seat) {
console.log(`Claude token: ${seat.tokenFile ? `"${seat.account}", read from ${seat.tokenFile}` : "the config dir's stored login"}${seat.loginChanged
console.log(`Claude ${seat.credentialKind === "api-key" ? "API key" : "token"}: ${seat.tokenFile ? `"${seat.account}", read from ${seat.tokenFile}` : "the config dir's stored login"}${seat.loginChanged
? seat.tokenApplied === false ? " (reference saved; the token didn't take effect)" : " (set)" : ""}`);
for (const warning of seat.warnings ?? []) console.log(`warning: ${warning}`);
if (seat.conversation) console.log(`conversation: ${seat.conversation}`);
+54 -11
View File
@@ -1,3 +1,4 @@
import { accountHomePlanMatches } from "../lib/account-home-switch.js";
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { DaemonClient, DaemonTimeoutError, terminalAuthHeaders } from "../client.js";
@@ -65,10 +66,10 @@ interface SeatStatusResponse {
runtime_home?: {
variable: string;
home: string | null;
source: "member" | "rig" | "daemon" | "native_default";
source: "member" | "rig" | "daemon" | "native_default" | "account-switch";
ignored?: { source: string; value: string };
};
claude_token?: { label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
claude_token?: { kind?: "api-key"; label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
session_reason?: string | null;
startup_status: string | null;
occupant_lifecycle: string;
@@ -193,6 +194,7 @@ const LEVEL_WORDS: Record<string, string> = {
"sends-only": "Deliberate sends from people and agents type; OpenRig's own notices are held.",
};
/** Show configured seat state, including selected account references, without claiming the running login is verified. */
function printHuman(status: SeatStatusResponse): void {
console.log(`Seat ${status.seat_ref}`);
console.log(`Rig: ${status.rig_name}`);
@@ -202,13 +204,14 @@ function printHuman(status: SeatStatusResponse): void {
if (status.runtime_home) {
const h = status.runtime_home;
const from = h.source === "member" || h.source === "rig" ? `the seat's own, from its ${h.source} env`
: h.source === "account-switch" ? "the seat's selected account home"
: h.source === "daemon" ? "the daemon's" : "the native default";
console.log(`Runtime home (${h.variable}) for launches: ${display(h.home)} (${from}; configured, not proof of the login a running session uses)`);
if (h.ignored) console.log(` Ignored: its ${h.ignored.source} env sets ${h.variable}=${h.ignored.value}, which isn't an absolute path`);
}
if (status.claude_token) {
const t = status.claude_token;
console.log(`Claude subscription token: ${t.label}, read from ${t.token_file} at each launch (${t.file === "readable" ? "readable now" : "won't apply at the next launch"})`);
console.log(`Claude ${t.kind === "api-key" ? "API key" : "subscription token"}: ${t.label}, read from ${t.token_file} at each launch (${t.file === "readable" ? "readable now" : "won't apply at the next launch"})`);
if (t.notice) console.log(` ${t.notice}`);
}
if (status.typingGuard) {
@@ -378,6 +381,7 @@ function printSeatError(error: SeatStatusError, fallback: string): void {
}
}
/** Build seat lifecycle commands, requiring acknowledged plans before isolated account-home changes. */
export function seatCommand(depsOverride?: SeatDeps & { readStdin?: () => Promise<string>; host?: RunOnHostDeps }): Command {
const cmd = new Command("seat")
.description("Inspect OpenRig seat observability state");
@@ -919,6 +923,10 @@ resume/successor launch reads it at call time. Examples:
.option("--model <id>", "Target model; otherwise the seat's agent spec for the target, else the target's default")
.option("--effort <level>", "Target effort, the target runtime's own level (not translated)")
.option("--codex-config-profile <name>", "Codex configuration profile for a Codex successor")
.option("--clear-account-home", "Return to member/rig/daemon/native login selection through a fresh successor")
.option("--account-home <path>", "Isolated target CODEX_HOME or CLAUDE_CONFIG_DIR; also permits a fresh successor on the same runtime")
.option("--token-file <path>", "With a Claude account home, its required subscription token reference")
.option("--account-label <label>", "With --token-file, the selected account's label")
.option("--permission-mode <mode>", "Target native permission choice; default: inherit the seat's member and rig policy")
.option("--confirm-timeout <seconds>", "How long the successor has to confirm the handover (default 180)")
.option("--dry-run", "Show the plan (settings, what is cleared, costs) without changing anything")
@@ -941,6 +949,9 @@ 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.
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
It uses a fresh successor and clears an account-home token reference only after confirmation. Do not combine it with
--account-home or a new token reference. Anthropic's global active/default profile is not isolated by CLAUDE_CONFIG_DIR.
A seat can switch itself (its CLI may end when it retires; the daemon finishes).
rig seat set-runtime dev-impl@my-rig --runtime claude-code --reason "Codex limit" --dry-run
@@ -948,7 +959,11 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
rig seat status dev-impl@my-rig # an open or interrupted switch shows here
rig seat set-runtime dev-impl@my-rig --recover`)
.action(async (seat: string, opts: { runtime?: string; reason?: string; model?: string; effort?: string; codexConfigProfile?: string;
permissionMode?: string; confirmTimeout?: string; dryRun?: boolean; confirm?: string; note?: string; recover?: boolean; json?: boolean }) => {
permissionMode?: string; accountHome?: string; clearAccountHome?: boolean; tokenFile?: string; accountLabel?: string; confirmTimeout?: string; dryRun?: boolean; confirm?: string; note?: string; recover?: boolean; json?: boolean }) => {
if (opts.clearAccountHome && (opts.accountHome !== undefined || opts.tokenFile !== undefined || opts.accountLabel !== undefined || opts.runtime === undefined)) {
console.error("Use --clear-account-home with --runtime and without --account-home, --token-file or --account-label.");
process.exitCode = 1; return;
}
const modes = [opts.confirm !== undefined, opts.recover === true, opts.runtime !== undefined].filter(Boolean).length;
if (modes !== 1) {
console.error("Pass exactly one of --runtime <runtime> (with --reason), --confirm <switch-id> (with --note), or --recover.");
@@ -980,19 +995,42 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
const client = deps.clientFactory(getDaemonUrl(daemon));
const base = `/api/seat/set-runtime/${encodeURIComponent(seat)}`;
let res;
let switchRequested = false;
try {
res = opts.confirm !== undefined
? await client.post<Record<string, unknown>>(`${base}/confirm`, { switchId: opts.confirm, note: opts.note })
: opts.recover
? await client.post<Record<string, unknown>>(`${base}/recover`, {}, { timeoutMs: 60_000 })
: await client.post<Record<string, unknown>>(base, {
runtime: opts.runtime, reason: opts.reason, dryRun: opts.dryRun === true,
...(opts.model !== undefined ? { model: opts.model } : {}), ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
...(opts.permissionMode !== undefined ? { permissionMode: opts.permissionMode } : {}),
...(confirmTimeoutSeconds !== undefined ? { confirmTimeoutSeconds } : {}),
}, { timeoutMs: opts.dryRun ? 30_000 : ((confirmTimeoutSeconds ?? 180) + 120) * 1000 });
: await (async () => {
const body = {
runtime: opts.runtime, reason: opts.reason, dryRun: opts.dryRun === true,
...(opts.model !== undefined ? { model: opts.model } : {}), ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
...(opts.codexConfigProfile !== undefined ? { codexConfigProfile: opts.codexConfigProfile } : {}),
...(opts.permissionMode !== undefined ? { permissionMode: opts.permissionMode } : {}),
...(opts.accountHome !== undefined ? { accountHome: opts.accountHome } : {}),
...(opts.clearAccountHome ? { clearAccountHome: true } : {}),
...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile, accountLabel: opts.accountLabel ?? "selected-account" } : {}),
...(confirmTimeoutSeconds !== undefined ? { confirmTimeoutSeconds } : {}),
};
if (opts.accountHome !== undefined || opts.clearAccountHome) {
const preview = await client.post<Record<string, unknown>>(base, { ...body, dryRun: true }, { timeoutMs: 30_000 });
const token = opts.tokenFile ? { label: opts.accountLabel ?? "selected-account", tokenFile: opts.tokenFile } : undefined;
if (!accountHomePlanMatches(preview.data, opts.clearAccountHome ? null : opts.accountHome!, token)) {
return { status: 409, data: { ok: false, code: "account_home_switch_unavailable",
message: preview.data["message"] ?? "The daemon did not acknowledge the selected account home and token. No switch was requested." } };
}
if (opts.dryRun) return preview;
}
switchRequested = true;
return client.post<Record<string, unknown>>(base, body,
{ timeoutMs: opts.dryRun ? 30_000 : ((confirmTimeoutSeconds ?? 180) + 120) * 1000 });
})();
} catch (err) {
if ((opts.accountHome !== undefined || opts.clearAccountHome) && !switchRequested) {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The read-only account-home preflight failed; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
}
if (!(err instanceof DaemonTimeoutError) || opts.dryRun) throw err;
const unknown = { ok: false as const, code: "runtime_switch_outcome_unknown", status: "unknown",
message: "The CLI stopped waiting; the daemon may still be switching the seat.",
@@ -1026,7 +1064,12 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
const label = (source: string) => source === "explicit" ? "as given" : source === "agent_spec" ? "from the seat's agent spec" : "the runtime's default";
const settings = (data["settings"] ?? {}) as Record<string, { value: string | null; source: string }>;
if (data["dryRun"]) console.log(`Runtime switch plan for ${name}: ${String(data["from"])} -> ${String(data["to"])}. Nothing changes until you run it without --dry-run.`);
else if (data["recoveryPending"] === true) console.log(`Recovery is still pending for ${name} (switch ${String(data["switchId"])}); the runtime switch is not complete.`);
else console.log(`Switched ${name} from ${String(data["from"])} to ${String(data["to"])} (switch ${String(data["switchId"])}).`);
if (data["accountHome"] === null) console.log("Selected account home: configured defaults (member/rig, then daemon, then native)");
else if (data["accountHome"]) console.log(`Selected account home: ${String(data["accountHome"])}`);
const accountToken = data["accountToken"] as { label: string; tokenFile: string } | undefined;
if (accountToken) console.log(`Claude token reference: ${accountToken.label}, read from ${accountToken.tokenFile} at launch`);
console.log(`Settings on ${String(data["to"])}:`);
for (const [key, title] of [["model", "model"], ["effort", "effort"], ["advisor", "advisor"], ["codexConfigProfile", "Codex profile"]] as const) {
const setting = settings[key];
+2
View File
@@ -186,6 +186,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
.option("--source-label <label>", "label shown in the posted message footer (where the queue lives)")
.option("--secrets-env-file <path>", "path to the 0600 env file with SLACK_BOT_TOKEN / SLACK_APP_TOKEN")
.option("--required-scopes <csv>", "comma-separated bot scopes to require at verify time")
.option("--reactions <mode>", "what a person's emoji reaction on an OpenRig post does: route (default) makes a row for the seat that asked; off ignores reactions, so an acknowledging emoji costs no agent turn")
.option("--reason <reason>", "Reason recorded with the configuration change", "configure human delivery")
.option("--actor <actor>", "Named operator when outside a managed seat")
.action(async (opts) => {
@@ -200,6 +201,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
sourceLabel: opts.sourceLabel ?? cur.sourceLabel,
secretsEnvFile: opts.secretsEnvFile ?? cur.secretsEnvFile,
requiredScopes: opts.requiredScopes ? String(opts.requiredScopes).split(",").map((s: string) => s.trim()).filter(Boolean) : cur.requiredScopes,
...(opts.reactions !== undefined ? { reactions: opts.reactions } : {}),
};
const { runChannelOperation, channelStateDigest } = await import("@openrig/daemon/gateway-slack");
const result = await runChannelOperation({
+3 -1
View File
@@ -20,7 +20,9 @@ export interface StatusDeps {
function formatSnapshotAge(snapshotAt: string | null): string {
if (!snapshotAt) return "none";
const now = Date.now();
const then = new Date(snapshotAt).getTime();
// Snapshot times are SQLite `datetime('now')` stamps: UTC written without a zone, which Date reads as local time.
const then = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(snapshotAt)
? `${snapshotAt.replace(" ", "T")}Z` : snapshotAt);
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 1) return "just now";
+1 -1
View File
@@ -132,7 +132,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
.option("--session <name>", "Herdr session on the daemon's host; --session default selects Herdr's default session")
.option("--window", "Open a new desktop terminal tab/window (the default when --provider is omitted)")
.option("--expected-plan <id>", "Open only if the view still matches this preview")
.option("--follow", "Keep one qitem:<id> workspace following current obligation owners (Linux Herdr 0.9.5/protocol 22)")
.option("--follow", "Keep one qitem:<id> or slice:<id-or-name> workspace following current obligation owners (Linux Herdr 0.9.5/protocol 22)")
.option("--no-follow", "Stop following and keep the existing workspace and tiles as a fixed layout")
.option("--max-tiles <count>", "Following tile limit, 1–256; explicitly raise it to show more members", Number)
.option("--pin <seats...>", "Retain these following tiles after they leave the task")
+7
View File
@@ -842,6 +842,13 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
if (unverified) {
throw new StartupChildPendingError(`Daemon child ${pid} responds at ${healthzUrl}, but listener verification is incomplete: ${lastProbe} Child left running; startup reservation retained. Inspect 'rig daemon status', daemon-start.lock and daemon.log before recovery.`);
}
// Expiring the readiness budget is not evidence that this owned child failed.
// Keep launch exclusion until an operator can inspect a slow pre-bind startup.
assertChild();
if (deps.isProcessAlive(pid!)) {
assertChild();
throw new StartupChildPendingError(`Daemon startup is pending after the readiness deadline (child PID ${pid}). Readiness is not confirmed; child left running and startup reservation retained. Last probe: ${lastProbe}. Launch endpoint: ${healthzUrl}. Inspect that URL and ${LOG_FILE}; this check does not publish readiness, adopt the child or release the reservation. To restart manually, stop child PID ${pid} and wait for exit; inspect ${path.join(OPENRIG_DIR, "daemon-start.lock")}, and only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'. 'rig daemon stop' cannot target this unpublished child, and another start remains blocked by the reservation.`);
}
throw new Error(`${summarizeDaemonStartFailure(healthzUrl, deps.readFile(resolveLifecycleFile(deps, "daemon.log")))}. Last probe: ${lastProbe}`);
}
assertChild();
@@ -0,0 +1,7 @@
/** An older daemon can ignore new fields. A read-only plan must acknowledge the exact selection before a mutation. */
export function accountHomePlanMatches(plan: Record<string, unknown>, home: string | null,
token?: { label: string; tokenFile: string }): boolean {
if (plan["ok"] !== true || plan["dryRun"] !== true || plan["accountHome"] !== home) return false;
const selected = plan["accountToken"] as { label?: unknown; tokenFile?: unknown } | null | undefined;
return token ? selected?.label === token.label && selected?.tokenFile === token.tokenFile : selected == null;
}
@@ -41,7 +41,7 @@ describe("chatroom watch --tmux arguments", () => {
const TMUX_SOCKET = `openrig-chatroom-test-${process.pid}`;
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
const names = ["my-rig", "my rig", "it's", 'say "hi"', "$(echo pwned)", "`id`", "a;b", "a$HOME", "-rig",
const names = ["my-rig", "my rig", "it's", 'say "hi"', "$(echo pwned)", "`id`", "a;b", "-rig",
"a#b", "a##b", "a#{b}", "a#[fg=red]b", "a##[b", "a;", ";"];
describe.runIf(tmuxAvailable)("chatroom watch --tmux against a real tmux server (private socket)", () => {
@@ -72,14 +72,18 @@ describe.runIf(tmuxAvailable)("chatroom watch --tmux against a real tmux server
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", rig]), { timeout: 5000 });
});
// Whether tmux changes a `.` depends on its version (3.5a writes `_`; the tmux CI runs keeps the `.`), so the CLI
// prints the name tmux reports, which is always the session tmux has.
it("reports the name tmux used for a rig name with a ., whichever form its version keeps", async () => {
const out = path.join(dir, "argv-dot.json");
const reported = start("release-0.7", out);
expect(["[email protected]", "chatroom@release-0_7"]).toContain(reported);
// tmux versions differ in how they normalize dots and literal dollars in session names.
// The reported name must address the real session, while the watcher still gets the original rig name.
it.each([
["release-0.7", ["[email protected]", "chatroom@release-0_7"]],
["a$HOME", ["chatroom@a$HOME", "chatroom@a\\$HOME"]],
] as const)("reports the name tmux used for %s", async (rig, expected) => {
const out = path.join(dir, `argv-normalized-${rig}.json`);
const reported = start(rig, out);
expect(expected).toContain(reported);
expect(execFileSync("tmux", ["-L", TMUX_SOCKET, "list-sessions", "-F", "#{session_name}"], { encoding: "utf8" }).split("\n"))
.toContain(reported);
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", "release-0.7"]), { timeout: 5000 });
expect(spawnSync("tmux", ["-L", TMUX_SOCKET, "has-session", "-t", `=${reported}:`]).status).toBe(0);
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", rig]), { timeout: 5000 });
});
});
@@ -1202,6 +1202,7 @@ describe("Daemon Lifecycle", () => {
it("start: surfaces native module runtime mismatch inline when healthz never comes up", async () => {
vi.useFakeTimers();
const deps = mockDeps({
isProcessAlive: vi.fn(() => false), // Crash diagnostics require observed process absence.
fetch: vi.fn(async () => { throw new Error("connection refused"); }),
readFile: vi.fn((p: string) => {
if (p === LOG_FILE) {
@@ -1232,6 +1233,7 @@ describe("Daemon Lifecycle", () => {
it("start: surfaces the recent daemon log line when startup crashes for another reason", async () => {
vi.useFakeTimers();
const deps = mockDeps({
isProcessAlive: vi.fn(() => false), // Crash diagnostics require observed process absence.
fetch: vi.fn(async () => { throw new Error("connection refused"); }),
readFile: vi.fn((p: string) => {
if (p === LOG_FILE) {
@@ -4,7 +4,7 @@ import type { ChildProcess } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { startDaemon, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { startDaemon, getDaemonStatus, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { acquireDaemonStartLock } from "../src/daemon-start-lock.js";
afterEach(() => vi.useRealTimers());
@@ -67,6 +67,8 @@ describe("child-bound startup", () => {
? { ok: true, json: async () => ({ ...f.body, pid: 888 }) } : fetch(url);
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/identity mismatch.*127.0.0.2/);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(f.lock.release).toHaveBeenCalledWith(false);
});
it("publishes the verified child, naming the unverified listener, when only a secondary listener stays uncertain", async () => {
vi.useFakeTimers();
@@ -114,7 +116,7 @@ describe("child-bound startup", () => {
expect((outcome as Error).message).toMatch(/identity mismatch.*observed 888/);
expect(f.files.has(STATE_FILE)).toBe(false);
});
it("gives up on a never-answering endpoint within one readiness deadline", async () => {
it("returns pending within one readiness deadline without stopping its still-starting child", async () => {
vi.useFakeTimers();
const f = fixture(); const fetch = f.deps.fetch;
f.deps.fetch = async (url) => (await fetch(url), new Promise(() => {}));
@@ -122,7 +124,11 @@ describe("child-bound startup", () => {
expect((outcome as Error).message).toMatch(/Last probe: .*timed out|Last probe: .*healthz/);
expect(elapsed).toBeLessThanOrEqual(20_000);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.lock.release).toHaveBeenCalledWith(true);
expect((outcome as Error).message).toMatch(/pending.*12345/);
expect((outcome as Error).message).toContain("Launch endpoint: http://127.0.0.1:19873/healthz");
expect((outcome as Error).message).toContain("daemon.log");
});
// Synchronous work crossing the deadline is modelled by a clock jump inside one fetch, before
// any timer runs. Post-spawn fetches: 0 primary read, 1 primary listener check, 2 secondary check.
@@ -190,6 +196,8 @@ describe("child-bound startup", () => {
};
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/exited/);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.lock.release).toHaveBeenCalledWith(false);
});
it("handles an asynchronous native spawn error without a PID", async () => {
const f = fixture();
@@ -280,3 +288,52 @@ describe("local startup reservation", () => {
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
});
it.each(["missing", "stale"] as const)("keeps a slow child discoverable with %s old state and excludes a duplicate launch", async (oldState) => {
vi.useFakeTimers();
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-pending-start-"));
const f = fixture(), fetch = f.deps.fetch;
const previousState = oldState === "stale"
? JSON.stringify({ pid: 999, host: "127.0.0.2", port: 19874, db: "old.sqlite", startedAt: "old" })
: undefined;
if (previousState) f.files.set(STATE_FILE, previousState);
f.deps.isProcessAlive = pid => pid === f.child.pid && f.child.exitCode === null;
let ready = false;
f.deps.acquireStartLock = () => acquireDaemonStartLock(home);
f.deps.fetch = async url => {
const response = await fetch(url);
if (!ready) throw Object.assign(new Error("still initializing"), { code: "ECONNREFUSED" });
return response;
};
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${opts.port}`);
vi.stubEnv("RIGGED_URL", "");
try {
const began = Date.now(), result = startDaemon(opts, f.deps).catch((error: Error) => error);
await vi.runAllTimersAsync();
const outcome = await result;
expect(outcome).toBeInstanceOf(Error);
expect((outcome as Error).message).toMatch(/pending.*12345/);
const message = (outcome as Error).message;
expect(message).toContain("Launch endpoint: http://127.0.0.1:19873/healthz");
expect(message).not.toContain("127.0.0.2:19874");
expect(message).toContain("this check does not publish readiness, adopt the child or release the reservation");
expect(message).toContain("stop child PID 12345 and wait for exit");
expect(message).toContain(path.join(path.dirname(STATE_FILE), "daemon-start.lock"));
expect(message).toContain("only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'");
expect(message).toContain("'rig daemon stop' cannot target this unpublished child");
expect(Date.now() - began).toBeLessThanOrEqual(20_000);
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.child.exitCode).toBeNull();
expect(f.files.get(STATE_FILE)).toBe(previousState);
expect(fs.readFileSync(path.join(home, "daemon-start.lock"), "utf8")).toContain('"childPid":12345');
ready = true;
await expect(getDaemonStatus(f.deps)).resolves.toMatchObject({ state: "running", healthy: true, port: opts.port });
expect(f.files.get(STATE_FILE)).toBe(previousState); // A status probe never retroactively publishes start readiness.
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/already reserved/);
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllEnvs();
fs.rmSync(home, { recursive: true, force: true });
}
});
+120
View File
@@ -82,6 +82,112 @@ describe("rig provider CLI grammar (daemon-backed)", () => {
asOf: "2026-08-03T12:00:00.000Z",
};
it.each([{ selector: ["--account-home", "/accounts/selected"] }, { selector: ["--clear-account-home"] }])("does not silently ignore an API-key selector combined with $selector", async ({ selector }) => {
const before = seen.length;
const result = await run(["switch", "--seat", "dev-a@rig", ...(selector.includes("--clear-account-home") ? [] : ["--to-account", "api"]), "--api-key-file", "/synthetic/key",
...selector, "--reason", "Integration test"], runningDeps(port));
expect(result.exitCode).toBe(1);
expect(seen.length).toBe(before);
});
it("switches to a selected account home without sending credential values", async () => {
const result = await run(["switch", "--seat", "dev-a@rig", "--to-account", "api-account", "--account-home", "/accounts/api",
"--reason", "Plan limit", "--model", "gpt-5.5", "--json"], runningDeps(port));
expect(result.exitCode).toBeFalsy();
expect(JSON.parse(seen.at(-1)!.body)).toEqual({ seat: "dev-a@rig", toAccount: "api-account", accountHome: "/accounts/api",
reason: "Plan limit", model: "gpt-5.5", dryRun: false });
expect(JSON.parse(result.logs.at(-1)!)).toMatchObject({ ok: true });
});
it("returns to configured defaults through an acknowledged read-only clear plan", async () => {
const before = seen.length;
const args = ["switch", "--seat", "dev-a@rig", "--clear-account-home", "--reason", "Plan reset"];
const dry = await run([...args, "--dry-run", "--json"], runningDeps(port));
expect(dry.exitCode).toBeFalsy();
expect(JSON.parse(dry.logs[0]!)).toMatchObject({ accountHome: null, dryRun: true });
expect(seen.slice(before).some(r => r.url === "/api/provider/switch")).toBe(false);
const result = await run(args, runningDeps(port));
expect(result.exitCode).toBeFalsy();
expect(JSON.parse(seen.at(-1)!.body)).toEqual({ seat: "dev-a@rig", clearAccountHome: true, reason: "Plan reset", dryRun: false });
});
it("refuses a clear that an older daemon does not explicitly acknowledge", async () => {
const before = seen.length;
const result = await run(["switch", "--seat", "old-daemon@rig", "--clear-account-home", "--reason", "Plan reset", "--json"], runningDeps(port));
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.logs[0]!)).toMatchObject({ code: "account_home_switch_unavailable" });
expect(seen.slice(before).some(r => r.url === "/api/provider/switch")).toBe(false);
});
it.each([{ selectors: ["--stored-login"] }, { selectors: ["--to-account", "other"] }, { selectors: ["--token-file", "/tokens/new"] }, { selectors: ["--account-home", "/accounts/new"] }])("refuses conflicting clear selectors $selectors before requesting a switch", async ({ selectors }) => {
const before = seen.length;
const result = await run(["switch", "--seat", "dev-a@rig", "--clear-account-home", "--reason", "Plan reset", ...selectors], runningDeps(port));
expect(result.exitCode).toBe(1);
expect(seen.length).toBe(before);
});
it("a clear without its reason names the required flag without asking for a conflicting account selector", async () => {
const before = seen.length;
const result = await run(["switch", "--seat", "dev-a@rig", "--clear-account-home"], runningDeps(port));
expect(result.exitCode).toBe(1);
expect(result.logs).toContain("Pass --reason with --clear-account-home.");
expect(seen.length).toBe(before);
});
it("requires a reason before starting an account-home switch", async () => {
const before = seen.length;
const result = await run(["switch", "--seat", "dev-a@rig", "--to-account", "api-account", "--account-home", "/accounts/api"], runningDeps(port));
expect(result.exitCode).toBe(1);
expect(seen.length).toBe(before);
});
it("reports pending account-home recovery with its instruction instead of success", async () => {
const result = await run(["switch", "--seat", "dev-a@rig", "--to-account", "recovery-pending", "--account-home", "/accounts/api",
"--reason", "Plan limit"], runningDeps(port));
expect(result.exitCode).toBe(1);
expect(result.logs.join("\n")).toContain("Recovery is still pending");
expect(result.logs.join("\n")).toContain("rig seat set-runtime dev-a@rig --recover");
expect(result.logs.join("\n")).not.toContain("A fresh conversation carries the handover");
});
it("prints refusal recovery instructions even without warnings", async () => {
const result = await run(["switch", "--seat", "dev-a@rig", "--to-account", "recovery-refused", "--account-home", "/accounts/api",
"--reason", "Plan limit"], runningDeps(port));
expect(result.exitCode).toBe(1);
expect(result.logs).toContain("The staged successor could not be retired.");
expect(result.logs).toContain("Run rig seat set-runtime dev-a@rig --recover.");
expect(result.logs.join("\n")).not.toContain("A fresh conversation carries the handover");
});
it("shows account-home dry-run settings, cleared choices and costs without requesting a switch", async () => {
const before = seen.length;
const args = ["switch", "--seat", "dev-a@rig", "--to-account", "api-account", "--account-home", "/accounts/api",
"--reason", "Plan limit", "--dry-run"];
const result = await run(args, runningDeps(port));
const output = result.logs.join("\n");
expect(output).toContain("Nothing changed.");
expect(output).toContain("model: gpt-5.5 (explicit)");
expect(output).toContain("effort: none (native_default)");
expect(output).toContain("Cleared from codex: Codex profile old-profile.");
expect(output).toContain("A fresh conversation carries only the handover.");
expect(result.exitCode).toBeFalsy();
expect(seen.slice(before).some(request => request.url === "/api/provider/switch")).toBe(false);
const json = JSON.parse((await run([...args, "--json"], runningDeps(port))).logs[0]!);
expect(json).toMatchObject({ dryRun: true, settings: { model: { value: "gpt-5.5", source: "explicit" } },
dropped: [{ setting: "Codex profile", value: "old-profile" }], costs: ["A fresh conversation carries only the handover."] });
});
it("sends only an API-key file reference through the existing switch and precheck endpoints", async () => {
const args = ["--seat", "dev-a@rig", "--to-account", "cdx-a", "--api-key-file", "/synthetic/api-key", "--json"];
await run(["switch", ...args], runningDeps(port));
expect(JSON.parse(seen.at(-1)!.body)).toEqual({ seat: "dev-a@rig", toAccount: "cdx-a", apiKeyFile: "/synthetic/api-key", forceUnsafe: false });
await run(["precheck", ...args], runningDeps(port));
expect(new URL(seen.at(-1)!.url, "http://localhost").searchParams.get("apiKeyFile")).toBe("/synthetic/api-key");
const before = seen.length;
expect((await run(["switch", ...args, "--stored-login"], runningDeps(port))).exitCode).toBe(1);
expect(seen.length).toBe(before);
});
beforeAll(async () => {
server = http.createServer(async (req, res) => {
let body = "";
@@ -89,6 +195,15 @@ describe("rig provider CLI grammar (daemon-backed)", () => {
seen.push({ url: req.url ?? "", method: req.method ?? "", body });
const send = (code: number, obj: unknown) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); };
const path = (req.url ?? "").split("?")[0];
if (path?.startsWith("/api/seat/status/")) return send(200, { runtime: "codex" });
if (path?.startsWith("/api/seat/set-runtime/")) {
const request = JSON.parse(body);
if (path.includes("old-daemon")) return send(200, { ok: true, dryRun: true });
return send(200, { ok: true, dryRun: true, accountHome: request.clearAccountHome ? null : request.accountHome, from: "codex", to: "codex",
settings: { model: { value: "gpt-5.5", source: "explicit" }, effort: { value: null, source: "native_default" } },
dropped: [{ setting: "Codex profile", value: "old-profile" }], costs: ["A fresh conversation carries only the handover."],
...(request.tokenFile ? { accountToken: { label: request.accountLabel, tokenFile: request.tokenFile } } : {}) });
}
if (path === "/api/provider/status") return send(200, FOUR_BLOCK);
if (path === "/api/provider/accounts") {
if ((req.url ?? "").includes("provider=bad")) return send(404, { error: "unknown provider: bad" });
@@ -99,6 +214,11 @@ describe("rig provider CLI grammar (daemon-backed)", () => {
if (path === "/api/provider/precheck") return send(200, { safe: false, reasons: ["target_needs_reauth"] });
if (path === "/api/provider/switch") {
if (body.includes("bad")) return send(400, { error: "invalid target account" });
if (JSON.parse(body).toAccount === "recovery-refused") return send(409, { ok: false, recoveryPending: true,
message: "The staged successor could not be retired.", guidance: "Run rig seat set-runtime dev-a@rig --recover." });
if (JSON.parse(body).toAccount === "recovery-pending") return send(200, { ok: true, changed: true,
recoveryPending: true, warnings: ["Run rig seat set-runtime dev-a@rig --recover before retrying."] });
if (JSON.parse(body).accountHome || JSON.parse(body).clearAccountHome) return send(200, { ok: true, changed: true, recoveryPending: false });
return send(200, { outcome: "succeeded" });
}
return send(500, { error: "not found" });
@@ -67,6 +67,46 @@ const plan = {
describe("rig seat set-runtime", () => {
afterEach(() => vi.restoreAllMocks());
it("an older daemon cannot silently ignore the chosen home and execute a switch", async () => {
const { calls, exitCode } = await run({ status: 200, data: plan }, ["dev.impl@my-rig", "--runtime", "claude-code",
"--account-home", "/accounts/claude-api", "--reason", "Plan limit", "--json"]);
expect(exitCode).toBe(1);
expect(calls).toHaveLength(1);
expect(calls[0]!.body).toMatchObject({ dryRun: true });
});
it("selects the successor's account home in the same runtime-switch request", async () => {
const { calls, exitCode } = await run({ status: 200, data: { ...plan, accountHome: "/accounts/claude-api" } }, ["dev.impl@my-rig", "--runtime", "claude-code",
"--account-home", "/accounts/claude-api", "--reason", "Use API budget", "--json", "--dry-run"]);
expect(calls[0]!.body).toMatchObject({ runtime: "claude-code", accountHome: "/accounts/claude-api", reason: "Use API budget", dryRun: true });
expect(exitCode).toBeFalsy();
});
it("requires an explicit null-home acknowledgement before clearing the account selection", async () => {
const args = ["dev.impl@my-rig", "--runtime", "claude-code", "--clear-account-home", "--reason", "Plan reset", "--json", "--dry-run"];
const old = await run({ status: 200, data: plan }, args);
expect(old.exitCode).toBe(1);
expect(old.calls).toHaveLength(1);
const accepted = await run({ status: 200, data: { ...plan, accountHome: null } }, args);
expect(accepted.exitCode).toBeFalsy();
expect(accepted.calls[0]!.body).toMatchObject({ clearAccountHome: true, dryRun: true });
expect(JSON.parse(accepted.logs[0]!)).toMatchObject({ accountHome: null });
});
it("a human dry run identifies the selected account home and token reference without reading credentials", async () => {
const selected = { ...plan, accountHome: "/accounts/claude-plan",
accountToken: { label: "work-plan", tokenFile: "/tokens/work-plan" } };
const { calls, logs, exitCode } = await run({ status: 200, data: selected }, ["dev.impl@my-rig", "--runtime", "claude-code",
"--account-home", "/accounts/claude-plan", "--token-file", "/tokens/work-plan", "--account-label", "work-plan",
"--reason", "Use plan", "--dry-run"]);
expect(logs).toContain("Selected account home: /accounts/claude-plan");
expect(logs).toContain("Claude token reference: work-plan, read from /tokens/work-plan at launch");
expect(logs.join("\n")).toContain("Nothing changes until you run it without --dry-run.");
expect(calls).toHaveLength(1);
expect(calls[0]!.body).toMatchObject({ dryRun: true });
expect(exitCode).toBeFalsy();
});
it("a dry run shows the target's settings and where each comes from, what is cleared, and the costs", async () => {
const { calls, logs, exitCode } = await run({ status: 200, data: plan }, ["dev.impl@my-rig", "--runtime", "claude-code", "--reason", "Codex limit", "--dry-run"]);
expect(calls[0]).toMatchObject({ path: "/api/seat/set-runtime/dev.impl%40my-rig", body: { runtime: "claude-code", reason: "Codex limit", dryRun: true } });
@@ -91,6 +131,20 @@ describe("rig seat set-runtime", () => {
expect(exitCode).toBeFalsy();
});
it("reports pending recovery without presenting the runtime switch as complete", async () => {
const pending = { ...plan, dryRun: false, switchId: "01SW", recoveryPending: true,
warnings: ["Run rig seat set-runtime dev.impl@my-rig --recover before trying again."] };
const args = ["dev.impl@my-rig", "--runtime", "claude-code", "--reason", "Plan limit"];
const { logs, errors, exitCode } = await run({ status: 200, data: pending }, args);
expect(logs.join("\n")).not.toContain("Switched dev.impl@my-rig");
expect(logs.join("\n")).toContain("Recovery is still pending");
expect(errors.join("\n")).toContain("rig seat set-runtime dev.impl@my-rig --recover");
expect(exitCode).toBe(1);
const json = await run({ status: 200, data: pending }, [...args, "--json"]);
expect(JSON.parse(json.logs.join("\n"))).toEqual(pending);
expect(json.exitCode).toBe(1);
});
it("a switch that didn't happen says why and what's unchanged, and exits 1", async () => {
const refusal = { ok: false, code: "runtime_switch_failed", committed: false,
message: "The claude-code successor didn't confirm the handover within 180 s, so it isn't shown able to carry on.",
+9
View File
@@ -94,6 +94,15 @@ const STATUS = {
};
describe("seat typing guard commands", () => {
it("identifies the account home selected by a switch rather than the native default", async () => {
const deps = makeDeps({ status: 200, data: { ...STATUS, runtime_home: {
variable: "CODEX_HOME", home: "/accounts/api", source: "account-switch",
} } }, []);
const result = await captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", "seat", "status", "dev-impl@seat-rig"]).then(() => undefined));
expect(result.logs.join("\n")).toContain("/accounts/api (the seat's selected account home;");
expect(result.logs.join("\n")).not.toContain("the native default");
});
it("prints the daemon's managed restore delivery failure", async () => {
const detail = "OpenRig owes its turn boundary. Last delivery: target_activity_unknown; no_activity_signal.";
const deps = makeDeps({ status: 200, data: { ...STATUS, compaction: { stage: "compacting", detail } } }, []);
+41
View File
@@ -142,6 +142,47 @@ describe("rig status", () => {
});
});
// Snapshot times come from SQLite datetime('now'): UTC with no zone marker. The age must not shift by the host's offset.
describe.each(["UTC", "Asia/Kolkata", "America/Los_Angeles"])("snapshot age under TZ=%s", (timezone) => {
let srv: ReturnType<typeof createDaemonServer>;
let port: number;
let previousTimezone: string | undefined;
beforeAll(async () => {
previousTimezone = process.env.TZ;
process.env.TZ = timezone;
srv = createDaemonServer(
[{ id: "r1", name: "alpha", nodeCount: 1, latestSnapshotAt: "2026-03-24 01:00:00", latestSnapshotId: "snap-1" }],
{ available: true },
);
port = await srv.listen();
});
afterAll(async () => {
vi.useRealTimers();
await srv.close();
if (previousTimezone === undefined) delete process.env.TZ; else process.env.TZ = previousTimezone;
});
it("reports a snapshot taken two minutes ago as 2m ago", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-03-24T01:02:00Z"));
const deps: StatusDeps = {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => (p === STATE_FILE ? JSON.stringify(runningState(port)) : null)),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
const program = new Command();
program.addCommand(statusCommand(deps));
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "status"]));
expect(logs.join("\n")).toContain("alpha 1 node(s) snapshot: 2m ago");
});
});
// OPR.0.3.3.04.2 (AC-2): state clarity - daemon port, kernel readiness
// (distinct from daemon health), effective workspace root (default vs override).
describe("AC-2 state clarity (OPR.0.3.3.04.2)", () => {
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.21",
"version": "0.1.23",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.21",
"version": "0.1.23",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
@@ -11,7 +11,7 @@ not know exists.** So the point of what follows is not skill. It is recognition:
lands, something rhymes, and you go check instead of building.
Ninety top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.4`). This marker describes the pack's teaching, not publication or live
`capability-delta-v0.7.6`). This marker describes the pack's teaching, not publication or live
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
What changed in the installed version, and what to do about it, is one read away:
`rig context get reference/whats-new.md`.
@@ -34,6 +34,8 @@ one command away. Your memory of the fleet is a claim about the past.
filters to running and **hides detached, exited and attention states** — which are exactly the
cells you were scanning for. A seat whose tmux session is confirmed gone reads `detached`
(activity reason `session_missing`), and `rig ps --nodes` says to restore or relaunch it.
A verified exited process in a retained pane reads `pane_exited`, instead of running and ready;
outstanding work uses the existing recovery-finding route. An unknown observation is not an exit.
`rig ps --nodes` also shows the model each seat was last seen using (`modelObservation`, with its
source and time), or unknown; it never shows the configured model in its place. A Claude seat at
Claude Code's MCP-server approval menu reads as needing input (`mcp_gate`), a Codex seat at its
@@ -211,14 +213,33 @@ unseen — which makes messages the one delivery channel that never gets skipped
type. Only if the window cannot open, `rig tui --shared` is the dashboard-only fallback; explain
the failure. In the window's `--json` output, `reusedWorkspace` with no new tiles means an
existing view was reused, which is success. `--provider herdr` or `--provider cmux` opens the
view in a provider that is already running, without opening a window; each such open adds a new
workspace, so to come back to a view, focus its open space.
view in a provider that is already running, without opening a window. Ordinary opens add a new
workspace, so to come back to a fixed view, focus its open space; task follow mode reuses one.
**`qitem:<id>` as the view** previews or opens the current local owners of that task: its
pending, in-progress and blocked rows through explicitly linked successors, without closed
predecessors or past senders. It's a read-only membership view: stopped, missing, external and
unresolved owners are named, and incomplete lineage is reported rather than cut short. It doesn't
follow the task as it moves, and an explicit open still has the provider's usual workspace
effects. Preview and open responses carry `viewDescriptor` (`kind`, `name`).
unresolved owners are named, and incomplete lineage is reported rather than cut short. Ordinary
open is one-shot. Add **`--follow --provider herdr`** to keep one workspace as the task's owners
join and leave (Linux Herdr 0.9.5 / protocol 22 only). Joining viewers do not request focus;
departures close viewers, not agents or conversations. Typed drafts and unreadable composers
retain departed tiles, including unconfirmed Pi, terminal and community-harness input.
`--pin`, `--suppress` and `--max-tiles` adjust the view; `--no-follow` keeps the fixed layout.
A complete empty task drains removable tiles and stops following once none remain; an explicit
`--follow` resumes that workspace. `rig terminal views --json` shows saved active selectors.
The exercised journey used shell stand-ins, not native agents, and active render cost remains
unmeasured. See `reference/terminal-sessions.md` for controls and limits. Preview and open
responses carry `viewDescriptor` (`kind`, `name`); membership grants no input authority.
**`rig terminal open slice:<name-or-id> --follow --provider herdr`** follows owners of the
slice's exact-tagged pending, in-progress and blocked rows, with every obligation listed.
Associate rows with `rig queue create --slice <name-or-id>`; handoffs inherit tags unless replaced.
New slice views default to four automatic tiles (`--max-tiles` overrides it). Known empty slices
stay following; incomplete reads keep prior viewers, and draft/focus protection still applies.
A join refused before a pane split can join on a later refresh, with the original hold reason retained.
An attach that ran and then exited to a shell is not restarted automatically; the whole slice view
can hold until that tile is resolved. No tested workaround is offered for that case.
Slice following targets the Linux Herdr 0.9.5 / protocol 22 path. Mission following is not included.
An unconfirmed pane is reported as `pane_unconfirmed`, not counted as opened. Inspect before
retrying: an unconfirmed result does not prove that nothing opened.
Add `--session <name>` to select a Herdr session for that request; `--session default`
explicitly selects its default session. `rig terminal status --session <name>` inspects
the same endpoint. Omitting the flag keeps the daemon's configured endpoint. Herdr tiles come
@@ -252,8 +273,10 @@ unseen — which makes messages the one delivery channel that never gets skipped
beats a rig match; the rest use the default channel). After a change, invite the app to each
channel, run `rig slack verify`, and restart the daemon (`rig daemon stop`, then `start`).
Disabling and enabling Slack instead marks alerts still waiting as already posted, so they're
never posted. An emoji reaction on any message posted for an ask reaches the asking seat
never posted. By default, an emoji reaction on a message posted for an ask reaches the asking seat
as a task tagged `human-reaction`: a signal to interpret, not an answer, and the ask stays open.
`rig slack setup --reactions off` disables that routing; the default is `route`, and regenerating
the app manifest preserves the setting.
It needs the `reactions:read` scope and the `reaction_added` event (reinstall the app);
`rig slack verify` warns when the scope is missing. A long ask is posted in numbered thread parts; one that
still can't be posted comes back to the asking seat as a `slack-undeliverable` task. Replies
@@ -507,6 +530,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
`append_system_prompt_file`, `plugin_dirs` and `advisor` (an Advisor model). They're copied into
that seat's own storage, never the project, so one seat in a shared repository can have its own
persona, prompt or plugins. A bundle's before-install view lists them.
- **A new Claude member can name a prepared subscription token** with
`claude_token: {label: name, file: /abs/token}` beside its `env.CLAUDE_CONFIG_DIR`; the seat stores
the file reference and reads it at launch. A missing/unreadable token uses the existing fallback
notice, so verify the account actually used.
- **`rig community-harness list`**: list installed community-harness presets and declared limits
(not tested by OpenRig). A preset can declare how its app reports the conversation each launch
made (`launch_result`), reopens one on resume (`resume`) or lets OpenRig name it
@@ -514,7 +541,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
that's gone reads `missing`, never quietly replaced. A preset's `first_input: true` hands the
harness its first startup text through a private file named by `OPENRIG_FIRST_INPUT`. Examples for
Cursor, Hermes and OpenClaw are in `docs/reference/community-harness-examples/`; OpenClaw's local
and Gateway-client examples each resume a named conversation.
and Gateway-client examples each resume a named conversation. A preset can declare `runner: true`
(with `launch_result: true`) so activity follows its runner reports, recorded per launch.
`passes_model: true` sends the declared model as `OPENRIG_MODEL`; it does not establish the
effective model. Cursor and Hermes examples leave model passing off pending native qualification.
`docs/reference/community-harness-presets.md` is the contract: what a community harness gets from
OpenRig and what it doesn't (no turn-cancel verb, no approvals reaching OpenRig), how to write
one, and how promotion is judged. Whether processes a harness starts outside its pane survive
@@ -1213,6 +1243,13 @@ your circumstances is configuration, and the ones that are not, another agent ca
never the token, and `--stored-login` moves it back. These seat account and runtime moves were
tested with stubs, not real accounts, before 0.7.2: check a moved seat's account and conversation
yourself after the first move.
- **Move to an existing account home** with `rig provider switch --seat <seat> --to-account <name>
--account-home <dir>` (preview with `--dry-run`). It starts a fresh conversation with a handover
and retains the seat's address and work. `--clear-account-home` returns to configured defaults.
- **Claude subscription/API-key switching resumes the same conversation and interrupts work:**
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`; return with
`--to-account plan --token-file <abs>`. The API-key path remains unqualified; verify the resulting
account and conversation. Do not combine API-key and account-home selectors.
- **`rig provider status --refresh --provider claude --claude-token-file <absolute path>
--credential-label <label>`** — read a labelled Claude token's usage on demand. Each refresh spends
one model turn; status and the TUI show only the cached reading, and the label is yours, not an
@@ -1319,6 +1356,10 @@ your circumstances is configuration, and the ones that are not, another agent ca
with secret names, never values): OpenRig then POSTs a pointer to the row when work lands, and a
2xx records only that the ring was accepted, not that anything woke. When work waits for a
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
**Internet-facing hosted members are experimental, unsupported, off by default and at your
own risk.** Local assistants can connect locally. Internet access needs your own TLS tunnel or
proxy and a narrowly scoped, revocable token; see `reference/hosted-members.md`. Task terminal
following does not require internet exposure.
- **`rig skill loadout --runtime <claude-code|codex>`** — inspect the exact catalog revision,
selectors, target, and current/missing/shadowed/conflicting state for one working directory.
`--apply` writes only the managed ownership set, is idempotent, and refuses local edits or
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.7",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -27,8 +27,17 @@ fallback. Explain the window failure and help with the chosen fallback.
The shared dashboard is the kernel's `operator.human` terminal, an ordinary TUI,
not an agent or human-message inbox. Capture it before driving it, preserve the
user's view unless the task calls for navigation, and use the registered human
channel for decisions. If the TUI has exited, its shell remains; run `rig tui`
there once.
channel for decisions. If the TUI has exited but its shell remains, run
`rig tui` there once. If the terminal session itself is absent, inspect
`rig ps --nodes --rig kernel --json` for its registered `canonicalSessionName`
and recover only that seat with `rig seat launch <session> --fresh --reason
"recover missing shared kernel dashboard"`.
Its fresh-start action runs `rig tui`; confirm that with `rig capture <session>`
before opening `saved:kernel`. Keep healthy agent sessions running and omit
`--stop`. Restoring selected agent seats does not restore an unselected
`operator.human`, and opening a view cannot create its missing session. See
"Incomplete setup and restart" in `$OPENRIG_HOME/reference/getting-started.md`
for the window, TUI and missing-terminal cases.
When a person asks you to watch something or remind them, in any words, read
`$OPENRIG_HOME/reference/keeping-a-watch.md` first and follow it.
@@ -259,6 +259,14 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource; freshInPlace?: boolean; beforeNativeDispatch?: () => boolean },
): Promise<HarnessLaunchResult> {
binding = { ...binding };
if (binding.accountHome !== undefined && (!this.claudeManagedLaunch || !this.seatLaunchEnvironment)) {
return { ok: false, error: "Isolated account-home launch support is unavailable; no agent started." };
}
const accountHome = binding.accountHome !== undefined ? binding.accountHome : this.claudeManagedLaunch?.accountHome?.(binding.nodeId);
if (accountHome) {
try { this.claudeManagedLaunch!.assertAccountHome(binding.nodeId, accountHome, binding.accountHome ? binding.accountToken ?? null : undefined); }
catch (error) { return { ok: false, error: (error as Error).message }; }
}
const claudeCommand = binding.claudeExecutable ? `exec ${shellQuote(binding.claudeExecutable)}` : "claude";
this.autoLaunches.delete(binding.nodeId);
opts = { ...opts, ...(opts.forkSource ? { forkSource: { ...opts.forkSource } } : {}) };
@@ -280,14 +288,22 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
managed = await this.claudeManagedLaunch!.prepare({ nodeId: binding.nodeId, cwd: binding.cwd,
...(binding.claudeExecutable ? { executable: binding.claudeExecutable } : {}),
session: binding.tmuxSession, pane: binding.tmuxPane, generation: binding.launchGeneration,
...(binding.accountHome !== undefined ? { accountHome: binding.accountHome, accountToken: binding.accountHome === null ? binding.accountToken : binding.accountToken ?? null } : {}),
...(binding.runtimeSwitchFrom ? { runtimeSwitch: { fromRuntime: binding.runtimeSwitchFrom.runtime, session: binding.runtimeSwitchFrom.session,
pane: binding.runtimeSwitchFrom.pane, generation: binding.runtimeSwitchFrom.generation } } : {}) }, binding.permissionMode);
} catch (error) { return { ok: false, error: (error as Error).message }; }
}
// A seat's own subscription token: the managed launch carries it; the classic launch gets its step in the staged
// subshell. A launch that can't apply it still starts, and says so.
const seatToken = managed ? null : this.claudeManagedLaunch?.seatTokenLaunch?.(binding.nodeId) ?? null;
// subshell. Subscription-token fallback is reported; an unusable API key refuses the launch.
let seatToken: ReturnType<ClaudeManagedLaunch["seatTokenLaunch"]> = null;
if (!managed) {
try {
seatToken = this.claudeManagedLaunch?.seatTokenLaunch?.(binding.nodeId,
binding.accountHome !== undefined ? { accountHome: binding.accountHome, ref: binding.accountHome === null ? binding.accountToken : binding.accountToken ?? null, required: !!binding.accountHome } : {}) ?? null;
} catch (error) { return { ok: false, error: (error as Error).message }; }
}
const tokenWarnings: string[] = [...(managed?.warnings ?? seatToken?.warnings ?? [])];
if (seatToken) tokenWarnings.push(...(await this.claudeManagedLaunch?.tokenPrecedenceWarnings?.(binding.nodeId, seatToken.ref) ?? []));
const session = binding.tmuxSession;
const assertSelected = opts.beforeNativeDispatch ? () => {
if (!opts.beforeNativeDispatch!()) throw new Error("Selected history changed before Claude input");
@@ -295,13 +311,15 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const beforeManagedInput = opts.beforeNativeDispatch ? () => { assertSelected?.(); managed?.assertCurrent?.(); } : managed?.assertCurrent;
const sendClassic = async (cmd: string) => {
if (!this.seatLaunchEnvironment) {
if (seatToken?.ref.kind === "api-key") return { ok: false as const, code: "api_key_unavailable", message: "This launch cannot apply the selected API key; no Claude process started." };
if (seatToken) tokenWarnings.push(this.claudeManagedLaunch?.tokenNotApplied?.(binding.nodeId, seatToken.ref, "this launch path can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "this launch path can't run the token step"));
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false as const, code: "selection_changed", message: "Selected history changed before Claude input" };
return assertSelected ? this.tmux.sendText(session, cmd, assertSelected) : this.tmux.sendText(session, cmd);
}
const staged = await this.seatLaunchEnvironment.command(session, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration,
runtime: this.runtime, ...(seatToken ? { claudeTokenStep: seatToken.step } : {}) });
runtime: this.runtime, ...(binding.accountHome !== undefined ? { accountHome: binding.accountHome } : {}), ...(seatToken ? { claudeTokenStep: seatToken.step } : {}) });
if (seatToken && !staged.includes(seatToken.step)) {
if (seatToken.ref.kind === "api-key") return { ok: false as const, code: "api_key_unavailable", message: "This pane cannot apply the selected API key; no Claude process started." };
tokenWarnings.push(this.claudeManagedLaunch?.tokenNotApplied?.(binding.nodeId, seatToken.ref, "the seat's pane shell can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "the seat's pane shell can't run the token step"));
}
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false as const, code: "selection_changed", message: "Selected history changed before Claude input" };
@@ -449,7 +467,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
}
/** Best-effort public seam for user-scope Claude bootstrap used by managed sessions. */
ensureManagedBootstrap(binding: { nodeId?: string; cwd?: string | null; tmuxSession?: string | null; permissionMode?: NodeBinding["permissionMode"] }): void {
ensureManagedBootstrap(binding: { nodeId?: string; cwd?: string | null; tmuxSession?: string | null; permissionMode?: NodeBinding["permissionMode"]; accountHome?: string | null }): void {
this.provisionManagedBootstrap(binding);
}
@@ -978,10 +996,12 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
return resolveConcreteHint(path, content, orientation);
}
private provisionManagedBootstrap(binding: { nodeId?: string; cwd?: string | null; tmuxSession?: string | null; permissionMode?: NodeBinding["permissionMode"] }): void {
private provisionManagedBootstrap(binding: { nodeId?: string; cwd?: string | null; tmuxSession?: string | null; permissionMode?: NodeBinding["permissionMode"]; accountHome?: string | null }): void {
// OPR.0.4.8.2 agnostic rip-out: provisionRigPermissions (C2) removed — OpenRig no longer
// authors any config-file permission policy. Trust/onboarding (C3/C4) are neutral plumbing, kept.
const home = this.fs.homedir ?? (typeof process !== "undefined" ? process.env.HOME : undefined);
const accountHome = binding.accountHome !== undefined ? binding.accountHome
: binding.nodeId ? this.claudeManagedLaunch?.accountHome(binding.nodeId) : undefined;
// Match launchHarness: an explicit permission mode exports the selected home to the child, so
// only that home is provisioned.
const managed = binding.permissionMode !== undefined ? this.claudeManagedLaunch : undefined;
@@ -991,17 +1011,17 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const failures: string[] = [];
if (managed) {
// The seat's own home when it declares one (member or rig `env:`), else the daemon's.
if (cwd) statePaths.push(managed.configPaths(cwd, binding.nodeId).statePath);
if (cwd) statePaths.push(managed.configPaths(cwd, binding.nodeId, accountHome).statePath);
} else {
if (home) statePaths.push(nodePath.join(home, ".claude.json"));
// A classic seat types `claude` into its pane's shell, which inherits the tmux server's
if (!accountHome && home) statePaths.push(nodePath.join(home, ".claude.json"));
// Without an isolated account home, a classic seat inherits the tmux server's
// environment, not the daemon's, so the bootstrap cannot see which home that child reads.
// When the seat's own `env:` or the daemon selects CLAUDE_CONFIG_DIR, provision that home too. This deliberately
// replaces #565's rule that classic bootstrap writes exactly one file and leaves the
// selected home untouched: a child that inherits the daemon's selection read neither.
try {
const selectedCwd = cwd ?? (binding.nodeId ? this.claudeManagedLaunch?.boundCwd(binding.nodeId) : undefined);
const selected = selectedCwd ? this.claudeManagedLaunch?.selectedStatePath(selectedCwd, binding.nodeId) : undefined;
const selected = selectedCwd ? this.claudeManagedLaunch?.selectedStatePath(selectedCwd, binding.nodeId, accountHome) : undefined;
if (selected && !statePaths.includes(selected)) statePaths.push(selected);
} catch (err) {
failures.push((err as Error).message);
@@ -103,8 +103,14 @@ export class ClaudeResumeAdapter {
} catch (error) { return { ok: false, code: "permission_selection_refused", message: (error as Error).message }; }
}
// A seat's own subscription token, as on a fresh launch (claude-code-adapter.ts).
const seatToken = managed || !nodeId ? null : this.options.claudeManagedLaunch?.seatTokenLaunch?.(nodeId) ?? null;
let seatToken: ReturnType<ClaudeManagedLaunch["seatTokenLaunch"]> = null;
if (!managed && nodeId) {
try { seatToken = this.options.claudeManagedLaunch?.seatTokenLaunch?.(nodeId) ?? null; }
catch (error) { return { ok: false, code: "api_key_unavailable", message: (error as Error).message }; }
}
const requiresApiKey = seatToken?.ref.kind === "api-key";
const tokenWarnings: string[] = [...(managed?.warnings ?? seatToken?.warnings ?? [])];
if (seatToken) tokenWarnings.push(...(await this.options.claudeManagedLaunch?.tokenPrecedenceWarnings?.(nodeId!, seatToken.ref) ?? []));
const choice = { claudePermissions, kernelAuthority, teamPermissionDefault, nonInterruptive, launchPosture: resolvedPosture, permissionMode: selectedPermissionMode };
const posture = claudePostureFlag(process.env, resolvedPosture, selectedPermissionMode);
const appliedLaunch = observeClaudePermission(posture);
@@ -121,6 +127,7 @@ export class ClaudeResumeAdapter {
const sendClassic = async () => {
if (!this.options.seatLaunchEnvironment) {
if (requiresApiKey) return { ok: false as const, code: "api_key_unavailable", message: "This launch cannot apply the selected API key; no Claude process started." };
if (seatToken) tokenWarnings.push(this.options.claudeManagedLaunch?.tokenNotApplied?.(nodeId!, seatToken.ref, "this launch path can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "this launch path can't run the token step"));
if (beforeNativeDispatch && !beforeNativeDispatch()) return { ok: false as const, code: "selection_changed", message: "Selected history changed before Claude resume input" };
return assertSelected ? this.tmux.sendText(tmuxSessionName, cmd, assertSelected) : this.tmux.sendText(tmuxSessionName, cmd);
@@ -128,6 +135,7 @@ export class ClaudeResumeAdapter {
const staged = await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: "claude-code", nodeId,
...(seatToken ? { claudeTokenStep: seatToken.step } : {}) });
if (seatToken && !staged.includes(seatToken.step)) {
if (requiresApiKey) return { ok: false as const, code: "api_key_unavailable", message: "This pane cannot apply the selected API key; no Claude process started." };
tokenWarnings.push(this.options.claudeManagedLaunch?.tokenNotApplied?.(nodeId!, seatToken.ref, "the seat's pane shell can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "the seat's pane shell can't run the token step"));
}
if (beforeNativeDispatch && !beforeNativeDispatch()) return { ok: false as const, code: "selection_changed", message: "Selected history changed before Claude resume input" };
@@ -155,7 +155,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
private resolveHomeDirByPid: ResolveHomeDirByPid;
private codexHome?: string;
/** A seat's own CODEX_HOME from its member or rig `env:`, when it declares one. */
private seatHome?: (nodeId: string) => string | undefined;
private seatHome?: (nodeId: string, ignoreAccountHome?: boolean) => string | undefined;
/** One thread-id resolver per Codex home its seats use, the daemon's included. */
private threadResolvers = new Map<string, CodexThreadIdResolver>();
private launchPath?: string;
@@ -215,7 +215,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
/** Explicit CODEX_HOME; absent preserves the legacy OS/PID home behavior. */
codexHome?: string;
/** A seat's own CODEX_HOME (an absolute path its member or rig `env:` declares). It wins over `codexHome` for that seat. */
seatHome?: (nodeId: string) => string | undefined;
seatHome?: (nodeId: string, ignoreAccountHome?: boolean) => string | undefined;
/** Match the daemon's prerequisite probe even if the pane's login shell rewrites PATH. */
launchPath?: string;
verifyProfilePreflight?: (profile: string, cwd?: string, home?: string) => Promise<CodexProfileProbeResult>;
@@ -285,28 +285,28 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
/** The home a seat's Codex uses: its own from its member or rig `env:`, else the daemon's explicit CODEX_HOME. Undefined
* keeps the legacy OS/PID home behavior. */
private homeFor(binding: { nodeId?: string } | undefined): string | undefined {
private homeFor(binding: { nodeId?: string; accountHome?: string | null } | undefined): string | undefined {
return this.ownHome(binding) ?? this.codexHome;
}
/** Only the seat's own CODEX_HOME, undefined when it declares none. Probes and readers get it as an extra argument
* only when it's set, so a seat without one calls them exactly as before. */
private ownHome(binding: { nodeId?: string } | undefined): string | undefined {
return binding?.nodeId ? this.seatHome?.(binding.nodeId) : undefined;
private ownHome(binding: { nodeId?: string; accountHome?: string | null } | undefined): string | undefined {
return binding?.accountHome ?? (binding?.nodeId ? this.seatHome?.(binding.nodeId, binding.accountHome === null) : undefined);
}
private ownHomeCommandOpt(binding: { nodeId?: string }): { codexHome?: string } {
private ownHomeCommandOpt(binding: { nodeId?: string; accountHome?: string | null }): { codexHome?: string } {
const own = this.ownHome(binding);
return own ? { codexHome: own } : {};
}
private ownHomeOpt(binding: { nodeId?: string }): { home?: string } {
private ownHomeOpt(binding: { nodeId?: string; accountHome?: string | null }): { home?: string } {
const own = this.ownHome(binding);
return own ? { home: own } : {};
}
/** A thread id read for this seat's process, in the seat's own home when it declares one. */
private readThreadIdForSeat(binding: { nodeId?: string }, pid: number, identity?: string): Promise<string | undefined> | string | undefined {
private readThreadIdForSeat(binding: { nodeId?: string; accountHome?: string | null }, pid: number, identity?: string): Promise<string | undefined> | string | undefined {
const own = this.ownHome(binding);
if (own) return this.readThreadIdByPid(pid, identity, own);
return identity === undefined ? this.readThreadIdByPid(pid) : this.readThreadIdByPid(pid, identity);
@@ -860,6 +860,9 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
binding: NodeBinding,
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource; beforeNativeDispatch?: () => boolean },
): Promise<HarnessLaunchResult> {
if (binding.accountHome !== undefined && !this.seatLaunchEnvironment) {
return { ok: false, error: "Isolated account-home launch support is unavailable; no agent started." };
}
if (!binding.tmuxSession) {
return { ok: false, error: "No tmux session bound — cannot launch Codex harness" };
}
@@ -1120,7 +1123,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
private async seatCommand(binding: NodeBinding, cmd: string): Promise<string> {
return this.seatLaunchEnvironment
? await this.seatLaunchEnvironment.command(binding.tmuxSession!, cmd, { codexCwd: binding.cwd, nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime, ...this.ownHomeCommandOpt(binding) })
? await this.seatLaunchEnvironment.command(binding.tmuxSession!, cmd, { codexCwd: binding.cwd, nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime, ...(binding.accountHome !== undefined ? { accountHome: binding.accountHome } : {}), ...this.ownHomeCommandOpt(binding) })
: this.launchCommand(cmd, this.homeFor(binding));
}
@@ -93,21 +93,34 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
} catch (error) {
return { ok: false, error: `Couldn't prepare the first input for community harness "${this.runtime}": ${(error as Error).message}; nothing was started.` };
}
const preset = found.preset.firstInput ? { ...found.preset, command: firstInput
let command = found.preset.firstInput ? (firstInput
? `/usr/bin/env OPENRIG_FIRST_INPUT=${shellQuote(firstInput.path)} ${found.preset.command}`
: `/usr/bin/env -u OPENRIG_FIRST_INPUT ${found.preset.command}` } : found.preset;
let passed = false;
: `/usr/bin/env -u OPENRIG_FIRST_INPUT ${found.preset.command}`) : found.preset.command;
// An opted-in launcher gets the seat's declared model, or none (an inherited value is cleared). Passed is what it was
// handed, not which model runs.
const model = found.preset.passesModel ? binding.model?.trim() || undefined : undefined;
if (found.preset.passesModel) command = model ? `/usr/bin/env OPENRIG_MODEL=${shellQuote(model)} ${command}` : `/usr/bin/env -u OPENRIG_MODEL ${command}`;
const preset = command === found.preset.command ? found.preset : { ...found.preset, command };
// The first input counts as passed once typing began, as before. The model's dispatch is confirmed only by a send that
// succeeded, which a later failure of the launch doesn't undo; typing that began and then failed leaves it uncertain;
// a launch that failed before typing passed nothing.
let typed = false;
let sent = false;
const handed = () => ({ ...(firstInput ? { firstInput } : {}),
...(model ? { modelInput: { model, dispatch: sent ? "confirmed" as const : "uncertain" as const } } : {}) });
try {
const result = await this.launchPreset(session, preset, resumeRef, firstInput ? () => { passed = true; } : undefined);
return { ...result, ...(passed ? { firstInput } : {}) };
const result = await this.launchPreset(session, preset, resumeRef, firstInput || model ? () => { typed = true; } : undefined,
model ? () => { sent = true; } : undefined);
return { ...result, ...(typed ? handed() : {}) };
} catch (error) {
if (!passed) throw error;
return { ok: false, firstInput, error: `Community harness "${this.runtime}" launch is uncertain: ${(error as Error).message}` };
if (!typed) throw error;
return { ok: false, ...handed(), error: `Community harness "${this.runtime}" launch is uncertain: ${(error as Error).message}` };
}
}
private async launchPreset(session: string, preset: CommunityHarnessPreset, resumeRef: string | undefined, beforeInput?: () => void): Promise<HarnessLaunchResult> {
if (preset.namesConversation) return this.launchNamed(session, preset.command, resumeRef, beforeInput);
private async launchPreset(session: string, preset: CommunityHarnessPreset, resumeRef: string | undefined, beforeInput?: () => void,
onSent?: () => void): Promise<HarnessLaunchResult> {
if (preset.namesConversation) return this.launchNamed(session, preset.command, resumeRef, beforeInput, onSent);
let attempt: LaunchAttempt | undefined;
let program = preset.command;
// The record is optional: a folder that can't be made leaves the identity unavailable, and the harness still launches.
@@ -130,6 +143,9 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
: program;
const sent = await this.deps.tmux.sendShellCommand(session, command, beforeInput, { stageIfLong: true, execInScript: true });
if (!sent.ok) return { ok: false, error: `Couldn't start community harness "${this.runtime}": ${sent.message}` };
onSent?.();
// A runner reports its conversation in this record; without one, its seat needs a person rather than reading ready.
if (!attempt && storageNote && preset.runner) return { ok: false, recovery: "attention_required", error: storageNote };
if (!attempt) return storageNote ? { ok: true, warnings: [storageNote] } : { ok: true };
const read = await waitForLaunchResult(attempt, this.runtime, { ...this.deps.launchResultWait, root: this.deps.launchResultRoot?.() ?? launchResultRoot() });
if (resumeRef) {
@@ -147,16 +163,17 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
error: `Community harness "${this.runtime}": asked to resume conversation ${resumeRef}, its launcher reports conversation ${read.nativeRef} instead. The seat keeps conversation ${resumeRef}, and OpenRig doesn't switch it to ${read.nativeRef}. Nothing was restarted.` };
}
}
return read.ok
? { ok: true, resumeToken: read.nativeRef, resumeType: `community:${this.runtime}`, resumeProvenance: "launcher" }
: { ok: true, warnings: [`community harness "${this.runtime}": its launch identity is unavailable: ${read.reason}`] };
if (read.ok) return { ok: true, resumeToken: read.nativeRef, resumeType: `community:${this.runtime}`, resumeProvenance: "launcher" };
const unavailable = `community harness "${this.runtime}": its launch identity is unavailable: ${read.reason}`;
return preset.runner ? { ok: false, recovery: "attention_required", error: unavailable } : { ok: true, warnings: [unavailable] };
}
/** OpenRig names the conversation: a new one gets a fresh name in OPENRIG_CONVERSATION, and a resume passes the saved name
* in OPENRIG_RESUME_REF. Nothing reports back, so the name is recorded as passed, not observed, and readiness says only
* that a process runs: a harness that hasn't yet found a gone conversation reads as ready. Never creating one on a
* resume is the launcher's job; OpenRig never asks for a new conversation on a resume. */
private async launchNamed(session: string, preset: string, resumeRef: string | undefined, beforeInput?: () => void): Promise<HarnessLaunchResult> {
private async launchNamed(session: string, preset: string, resumeRef: string | undefined, beforeInput?: () => void,
onSent?: () => void): Promise<HarnessLaunchResult> {
const name = resumeRef ?? conversationName(session);
const program = `/usr/bin/env ${resumeRef ? "OPENRIG_RESUME_REF" : "OPENRIG_CONVERSATION"}=${shellQuote(name)} ${preset}`;
const command = this.deps.seatLaunchEnvironment
@@ -164,6 +181,7 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
: program;
const sent = await this.deps.tmux.sendShellCommand(session, command, beforeInput, { stageIfLong: true, execInScript: true });
if (!sent.ok) return { ok: false, error: `Couldn't start community harness "${this.runtime}": ${sent.message}` };
onSent?.();
return { ok: true, resumeToken: name, resumeType: `community:${this.runtime}`, resumeProvenance: "passed" };
}
@@ -185,6 +203,12 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
return found.ok && found.preset.firstInput;
}
/** Whether this launch's preset declares an OpenRig-aware runner, recorded on the session with the launch. */
runner(): boolean {
const found = loadCommunityHarnessPreset(this.runtime, this.deps.presetsDir?.() ?? communityHarnessesDir());
return found.ok && found.preset.runner;
}
/** A process running isn't proof that the startup paste was submitted. */
startupAssisted(): boolean {
const found = loadCommunityHarnessPreset(this.runtime, this.deps.presetsDir?.() ?? communityHarnessesDir());
+18 -14
View File
@@ -633,7 +633,7 @@ export class TmuxAdapter {
}
}
async listPanes(target: string): Promise<TmuxPane[]> {
async listPanes(target: string, allWindows = false): Promise<TmuxPane[]> {
// Callers name a session (optionally with a window), or an immutable tmux
// id. Exact session matching prevents observing a prefix neighbor's pane.
// A bare leading '=' belongs to the literal session name. Only qualified
@@ -642,8 +642,8 @@ export class TmuxAdapter {
const exactTarget = /^[%$@]\d+$/.test(target) ? target
: namedTarget.includes(":") ? namedTarget : `${namedTarget}:`;
try {
const output = await this.run(["tmux", "list-panes", "-t", exactTarget, "-F", PANE_FORMAT],
`tmux list-panes -t ${shellQuote(exactTarget)} -F "${PANE_FORMAT}"`);
const output = await this.run(["tmux", "list-panes", ...(allWindows ? ["-s"] : []), "-t", exactTarget, "-F", PANE_FORMAT],
`tmux list-panes ${allWindows ? "-s " : ""}-t ${shellQuote(exactTarget)} -F "${PANE_FORMAT}"`);
return parseLines(output, parsePaneLine);
} catch (err) {
if (isNoServerError(err) || isTmuxTransportAbsentError(err)) return [];
@@ -899,7 +899,7 @@ export class TmuxAdapter {
const identity = this.freshManaged.get(target)?.nodeId ?? target;
const checkDraft = guard?.needsInputCheck(identity);
const policy = checkDraft || options?.beforePaste ? {
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity); await options?.beforePaste?.(); },
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity, false, text); await options?.beforePaste?.(); },
afterPaste: async () => { if (checkDraft) await guard!.notePasted(identity, text); },
} : undefined;
return this.sendTextUnchecked(pane, text, () => { beforeWrite(); beforeInput?.(); }, options?.bracketed, policy);
@@ -1840,20 +1840,24 @@ export class TmuxAdapter {
}
}
/** Every pane's pid and current command in ONE tmux call, keyed by pane id (%N): the values `getPanePid` /
* `getPaneCommand` read per pane. Panes without a valid pid are left out (read those per pane). Null when tmux can't
* be read at all. The command is the LAST field, so a separator inside it stays intact. */
async readAllPaneProcesses(): Promise<Map<string, { pid: number; command: string | null }> | null> {
/** Every pane's pid, explicit dead flag and current command in ONE tmux call. A retained dead pane can keep
* its former PID/command: neither proves an executor is alive. A dead pane without a valid PID is retained with
* null PID; other invalid rows fall back to per-pane reads. The command stays LAST to preserve separators. */
async readAllPaneProcesses(): Promise<Map<string, { pid: number | null; command: string | null; dead?: boolean }> | null> {
try {
const format = ["#{pane_id}", "#{pane_pid}", "#{pane_current_command}"].join(TMUX_FIELD_SEPARATOR);
const format = ["#{pane_id}", "#{pane_pid}", "#{pane_dead}", "#{pane_current_command}"].join(TMUX_FIELD_SEPARATOR);
const output = await this.run(["tmux", "list-panes", "-a", "-F", format], `tmux list-panes -a -F '${format}'`);
const out = new Map<string, { pid: number; command: string | null }>();
const out = new Map<string, { pid: number | null; command: string | null; dead?: boolean }>();
for (const line of output.split("\n")) {
const first = line.indexOf(TMUX_FIELD_SEPARATOR), second = line.indexOf(TMUX_FIELD_SEPARATOR, first + 1);
if (first <= 0 || second < 0) continue;
const id = line.slice(0, first), pid = parseInt(line.slice(first + 1, second).trim(), 10);
if (!Number.isFinite(pid) || pid <= 0) continue;
out.set(id, { pid, command: line.slice(second + 1).trim() || null });
const third = line.indexOf(TMUX_FIELD_SEPARATOR, second + 1);
if (first <= 0 || second < 0 || third < 0) continue;
const id = line.slice(0, first), rawPid = Number(line.slice(first + 1, second).trim());
const pid = Number.isInteger(rawPid) && rawPid > 0 ? rawPid : null;
const rawDead = line.slice(second + 1, third).trim();
const dead = rawDead === "1" ? true : rawDead === "0" ? false : undefined;
if (pid === null && dead !== true) continue;
out.set(id, { pid, command: line.slice(third + 1).trim() || null, ...(dead === undefined ? {} : { dead }) });
}
return out;
} catch {
+4
View File
@@ -1,3 +1,4 @@
import { nodeAccountHomeSchema } from "./migrations/141_node_account_home.js";
import { escalationTargetRefsSchema } from "./migrations/126_escalation_target_refs.js";
import { claudeTokenReadingsSchema } from "./migrations/134_claude_token_readings.js";
// The canonical ordered migration list. SINGLE SOURCE: the daemon boot path
@@ -7,6 +8,7 @@ import { claudeTokenReadingsSchema } from "./migrations/134_claude_token_reading
import { nodeClaudePermissionsSchema } from "./migrations/139_node_claude_permissions.js";
import { identityPrincipalSchema } from "./migrations/140_identity_principal.js";
import { sessionAgentRunnerSchema } from "./migrations/141_session_agent_runner.js";
import { nodeCodexPermissionsSchema } from "./migrations/137_node_codex_permissions.js";
import { coreSchema } from "./migrations/001_core_schema.js";
import { terminalPolicySchema } from "./migrations/128_terminal_policy.js";
@@ -281,4 +283,6 @@ export const ALL_MIGRATIONS: Migration[] = [
nodeClaudePermissionsSchema,
nodeCodexPermissionsSchema,
identityPrincipalSchema,
sessionAgentRunnerSchema,
nodeAccountHomeSchema,
];
@@ -0,0 +1,7 @@
import type { Migration } from "../migrate.js";
// A native account-home switch stores a path only. Credential values stay in that home.
export const nodeAccountHomeSchema: Migration = {
name: "141_node_account_home.sql",
sql: "ALTER TABLE nodes ADD COLUMN account_home TEXT;",
};
@@ -0,0 +1,12 @@
import type { Migration } from "../migrate.js";
// Whether the community app a session was launched for declared itself a runner (its preset's `runner: true`),
// recorded with that launch: 1 runner, 0 plain. Like `agent_app`, it belongs to this session row, one occupant's
// launch, so a later preset edit or a later occupant never changes it. NULL (a legacy row, a session that isn't a
// community app's, or a launch that never reached the app) keeps today's behaviour: never a runner opt-in.
export const sessionAgentRunnerSchema: Migration = {
name: "141_session_agent_runner.sql",
sql: `
ALTER TABLE sessions ADD COLUMN agent_runner INTEGER;
`,
};
@@ -111,7 +111,7 @@ export interface RungDeclaration {
* never inherits its predecessor's rung authority. */
export interface AdapterRungInventory {
adapterId: string;
runtime: "claude-code" | "codex" | "pi" | "omp" | "tmux-generic" | "community-harness";
runtime: "claude-code" | "codex" | "pi" | "omp" | "tmux-generic" | "community-harness" | "community-runner";
rungs: RungDeclaration[];
}
@@ -240,14 +240,23 @@ export const OMP_ACTIVITY_RUNG_INVENTORY: AdapterRungInventory = {
rungs: RUNNER_ACTIVITY_RUNGS,
};
/** A community app whose preset declared `runner: true` at its launch: its runner posts turn boundaries as Pi's does,
* so those hooks decide. Only a session recorded as launched for a runner gets it; any other community seat keeps
* the sampling-only inventory above. */
export const COMMUNITY_RUNNER_RUNG_INVENTORY: AdapterRungInventory = {
adapterId: "community-runner",
runtime: "community-runner",
rungs: RUNNER_ACTIVITY_RUNGS,
};
/** Resolve a runtime string to its rung inventory (the ingest auto-declaration path).
* Unknown runtimes get the generic floor — partial-coverage honesty by default. A seat launched for a community
* community harness (its session's recorded selection, still the node's runtime) gets that app's inventory. */
export function runtimeRungInventory(runtime: string | null, communityHarness?: string | null): AdapterRungInventory {
export function runtimeRungInventory(runtime: string | null, communityHarness?: string | null, communityRunner?: boolean): AdapterRungInventory {
if (runtime === "claude-code") return CLAUDE_ACTIVITY_RUNG_INVENTORY;
if (runtime === "codex") return CODEX_ACTIVITY_RUNG_INVENTORY;
if (runtime === "pi") return PI_ACTIVITY_RUNG_INVENTORY;
if (runtime === "omp") return OMP_ACTIVITY_RUNG_INVENTORY;
if (communityHarnessOf(communityHarness, runtime)) return COMMUNITY_HARNESS_RUNG_INVENTORY;
if (communityHarnessOf(communityHarness, runtime)) return communityRunner === true ? COMMUNITY_RUNNER_RUNG_INVENTORY : COMMUNITY_HARNESS_RUNG_INVENTORY;
return TMUX_GENERIC_RUNG_INVENTORY;
}
@@ -1,4 +1,4 @@
import { physicalSessionName } from "./session-registry.js";
import { physicalSessionName, sessionCommunityRunnerSelect } from "./session-registry.js";
import { isClaudeRateLimitHook } from "./provider/claude-interruption.js";
import type Database from "better-sqlite3";
import type { EventBus } from "./event-bus.js";
@@ -53,6 +53,8 @@ interface SessionLookupRow {
node_id: string;
session_name: string;
runtime: string | null;
/** 1 when this session's community app was launched as a runner; 0 or NULL otherwise. */
community_runner?: number | null;
}
interface EventPayloadRow {
@@ -114,6 +116,7 @@ export class AgentActivityStore {
// tap filter on it); semantics that depend on the runtime use the seat's.
runtime: input.runtime ?? session.runtime,
seatRuntime: session.runtime,
communityRunner: session.community_runner === 1,
hookEvent: input.hookEvent,
subtype: input.subtype ?? null,
sampledAt,
@@ -334,7 +337,7 @@ export class AgentActivityStore {
private _resolveSession(input: { sessionName?: string | null; nodeId?: string | null }): SessionLookupRow | null {
if (input.nodeId) {
const row = this.db.prepare(`
SELECT n.rig_id, n.id AS node_id, s.session_name, n.runtime
SELECT n.rig_id, n.id AS node_id, s.session_name, n.runtime, ${sessionCommunityRunnerSelect(this.db)} AS community_runner
FROM nodes n
LEFT JOIN sessions s ON s.node_id = n.id
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
@@ -347,7 +350,7 @@ export class AgentActivityStore {
if (!input.sessionName) return null;
const sessionName = physicalSessionName(this.db, input.sessionName);
const row = this.db.prepare(`
SELECT n.rig_id, n.id AS node_id, s.session_name, n.runtime
SELECT n.rig_id, n.id AS node_id, s.session_name, n.runtime, ${sessionCommunityRunnerSelect(this.db)} AS community_runner
FROM sessions s
JOIN nodes n ON n.id = s.node_id
WHERE s.session_name = ?
@@ -361,7 +364,7 @@ export class AgentActivityStore {
/** The activity a runtime hook event means, before freshness or generation checks. The one mapping behind
* ingest, the send gate and the wake ladder. `seatRuntime` is the managed seat's runtime: runtime_error is
* an OMP runner signal, and a hook claiming OMP on another runtime's seat must not escalate it. */
export function hookEventState(rawEvent: string | null | undefined, rawSubtype: string | null | undefined, seatRuntime: string | null): AgentActivity["state"] {
export function hookEventState(rawEvent: string | null | undefined, rawSubtype: string | null | undefined, seatRuntime: string | null, communityRunner = false): AgentActivity["state"] {
if (rawEvent === "UserPromptSubmit" || rawEvent === "PreToolUse" || rawEvent === "active") return "running";
if (rawEvent === "PermissionRequest") return seatRuntime === "codex" ? "unknown" : "needs_input";
if (rawEvent === "PermissionPromptConfirmed") return "needs_input";
@@ -370,7 +373,7 @@ export function hookEventState(rawEvent: string | null | undefined, rawSubtype:
return rawSubtype === "idle_prompt" ? "idle" : "unknown";
}
// The runner posts Stop when its child process exits: nothing is at a prompt, so it isn't idle.
if (rawEvent === "Stop" && isRunnerExit(rawSubtype, seatRuntime)) return "unknown";
if (rawEvent === "Stop" && isRunnerExit(rawSubtype, seatRuntime, communityRunner)) return "unknown";
if (rawEvent === "Stop" || rawEvent === "SessionEnd" || rawEvent === "stop" || rawEvent === "idle") return "idle";
return "unknown";
}
@@ -385,9 +388,12 @@ export function hookPrecedes(a: { eventAt?: string | null; sourceSequence?: numb
return !!a.sourceSequence && !!b.sourceSequence && a.sourceSequence < b.sourceSequence;
}
/** The runner's Stop for its child's process exit (`pi_exited`, `omp_exited`), on that runtime's seat. */
export function isRunnerExit(rawSubtype: string | null | undefined, seatRuntime: string | null): boolean {
return (seatRuntime === "pi" || seatRuntime === "omp") && rawSubtype === `${seatRuntime}_exited`;
/** The runner's Stop for its child's process exit (`pi_exited`, `omp_exited`), on that runtime's seat. A community app's
* `<runtime>_exited` means the same only on a session recorded as launched for a runner; any other seat keeps its
* existing reading. */
export function isRunnerExit(rawSubtype: string | null | undefined, seatRuntime: string | null, communityRunner = false): boolean {
return (seatRuntime === "pi" || seatRuntime === "omp" || (communityRunner && !!seatRuntime))
&& rawSubtype === `${seatRuntime}_exited`;
}
/** A runtime hook that leaves the agent waiting on a person: an approval, a picker or an elicitation. */
@@ -400,7 +406,8 @@ export function hookMeansNeedsInput(rawEvent: string | null | undefined, rawSubt
function latestHookState(activity: AgentActivity | null | undefined, seatRuntime: string | null): AgentActivity["state"] {
if (activity?.evidenceSource !== "runtime_hook") return "unknown";
if (activity.stale !== true) return activity.state;
return activity.reason === "stale_runtime_hook" ? hookEventState(activity.rawEvent, activity.rawSubtype, seatRuntime) : "unknown";
return activity.reason === "stale_runtime_hook"
? hookEventState(activity.rawEvent, activity.rawSubtype, seatRuntime, activity.communityRunner === true) : "unknown";
}
/** The store's latest hook still says the seat waits on a person. Nothing newer was recorded, so nothing
@@ -429,6 +436,8 @@ function normalizeHookActivity(input: {
runtime: string | null;
/** The managed seat's runtime, never a hook's claim. */
seatRuntime: string | null;
/** The session was recorded as launched for a community runner (its own launch fact, never a hook's claim). */
communityRunner?: boolean;
hookEvent: string;
subtype: string | null;
sampledAt: string;
@@ -439,7 +448,7 @@ function normalizeHookActivity(input: {
const rawSubtype = input.subtype;
const reason = normalizeReason(rawSubtype ?? rawEvent);
const runtime = input.runtime;
const state = hookEventState(rawEvent, rawSubtype, input.seatRuntime);
const state = hookEventState(rawEvent, rawSubtype, input.seatRuntime, input.communityRunner === true);
let normalizedReason = reason;
if (rawEvent === "PermissionRequest") {
normalizedReason = input.seatRuntime === "codex" ? "permission_request_pending" : "permission_request";
@@ -471,6 +480,7 @@ function normalizeHookActivity(input: {
fallback: false,
stale: false,
generation: input.generation ?? null,
...(input.communityRunner ? { communityRunner: true } : {}),
};
}
@@ -844,6 +844,7 @@ export class ClaudeCompactionEnforcer {
&& this.pendingPreCompactPrep.get(input.sessionName) === attempt;
const sendOpts = {
openrigNotice: true as const,
preserveInput: true as const,
beforeWrite: () => {
if (!isCurrent()) throw new DeliveryGuardError("compaction_cancelled", "Managed compaction was cancelled or replaced; no further input is authorized.");
},
@@ -1282,7 +1283,7 @@ export class ClaudeCompactionEnforcer {
overflowCompactInstruction: composed.instructionInCommand ? null : attempt.compactInstruction,
usedPercentage: input.usedPercentage!, thresholdPercent: policy.thresholdPercent,
preCompactInstruction: policy.preCompactInstruction, preparation: attempt, constructedAt: this.now(),
}), { openrigNotice: true, beforeWrite: () => {
}), { openrigNotice: true, preserveInput: true, beforeWrite: () => {
this.reconcilePreparations();
if (this.pendingPreCompactPrep.get(input.sessionName) !== attempt || attempt.controller.signal.aborted || attempt.status === "stopped")
throw new DeliveryGuardError("preparation_stopped", "This compaction attempt ended before preparation input.");
@@ -1348,7 +1349,7 @@ export class ClaudeCompactionEnforcer {
// deadline. (The automatic path used to send into a seat still finishing its preparation turn.)
// Idle comes from a live pane read: the newest hook can predate the rest of the preparation turn.
return this.sessionTransport.send(input.sessionName, composed.command,
{ openrigNotice: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs, attempt.deadlineAt - this.now())), readinessFromPaneOnly: true } : {}) });
{ openrigNotice: true, preserveInput: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs, attempt.deadlineAt - this.now())), readinessFromPaneOnly: true } : {}) });
};
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase">;
let guardRefused = false;
@@ -8,6 +8,9 @@ export const OUTRANKING_TOKEN_ENV = [
"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY",
] as const;
/** Profile and federation selectors outrank stored login, but not an explicit subscription token. */
export const ANTHROPIC_PROFILE_ENV = ["ANTHROPIC_PROFILE", "ANTHROPIC_FEDERATION_RULE_ID", "ANTHROPIC_ORGANIZATION_ID"] as const;
export interface CredentialConflict {
name: string;
source: string;
@@ -25,10 +28,29 @@ export interface CredentialPrecedence {
notChecked: string[];
}
/** Match the bounded profile-fingerprint read: a FIFO cannot hold the open, and only a regular file up to 1 MiB is read.
* Kept synchronous because launch assertions must finish before native input is dispatched. */
function readCredentialSettings(file: string): string {
const maxBytes = 1024 * 1024;
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
try {
const stat = fs.fstatSync(fd);
if (!stat.isFile() || stat.size > maxBytes) throw new Error("Unusable credential settings file");
const buffer = Buffer.alloc(maxBytes + 1);
let length = 0;
for (;;) {
const count = fs.readSync(fd, buffer, length, buffer.length - length, length);
if (count === 0) return buffer.subarray(0, length).toString("utf8");
length += count;
if (length > maxBytes) throw new Error("Credential settings file exceeds limit");
}
} finally { fs.closeSync(fd); }
}
/** One settings file's `apiKeyHelper` and outranking `env` entries, and with `storedLogin` its `env` block's
* `CLAUDE_CODE_OAUTH_TOKEN`. An absent file has none. */
export function settingsFileConflicts(file: string, scope: string, read: (file: string) => string = (f) => fs.readFileSync(f, "utf8"),
opts: { storedLogin?: boolean } = {}): { conflicts: CredentialConflict[]; instead?: CredentialConflict[]; checked?: string; notChecked?: string } {
export function settingsFileConflicts(file: string, scope: string, read: (file: string) => string = readCredentialSettings,
opts: { storedLogin?: boolean; apiKey?: boolean; isolatedAccountHome?: boolean; profileSelectorsOnly?: boolean } = {}): { conflicts: CredentialConflict[]; instead?: CredentialConflict[]; checked?: string; notChecked?: string } {
let text: string;
try { text = read(file); } catch (err) {
return (err as NodeJS.ErrnoException).code === "ENOENT"
@@ -39,12 +61,14 @@ export function settingsFileConflicts(file: string, scope: string, read: (file:
try { settings = JSON.parse(text) as typeof settings; } catch { return { conflicts: [], notChecked: `${scope} settings ${file} (not valid JSON)` }; }
const remedy = `remove it from ${file} (OpenRig doesn't edit settings files)`;
const conflicts: CredentialConflict[] = [];
if (settings?.apiKeyHelper !== undefined) conflicts.push({ name: "apiKeyHelper", source: `${scope} settings ${file}`, remedy });
if (!opts.profileSelectorsOnly && !opts.apiKey && settings?.apiKeyHelper !== undefined) conflicts.push({ name: "apiKeyHelper", source: `${scope} settings ${file}`, remedy });
const env = settings?.env && typeof settings.env === "object" ? settings.env as Record<string, unknown> : {};
for (const name of OUTRANKING_TOKEN_ENV) {
const names = opts.apiKey ? [...OUTRANKING_TOKEN_ENV, "ANTHROPIC_BASE_URL"] : opts.profileSelectorsOnly ? ANTHROPIC_PROFILE_ENV
: [...OUTRANKING_TOKEN_ENV, ...(opts.isolatedAccountHome ? ["ANTHROPIC_BASE_URL", ...ANTHROPIC_PROFILE_ENV] : [])];
for (const name of names) {
if (env[name] !== undefined) conflicts.push({ name, source: `the env block of ${scope} settings ${file}`, remedy });
}
const instead = opts.storedLogin && env.CLAUDE_CODE_OAUTH_TOKEN !== undefined
const instead = opts.storedLogin && !opts.profileSelectorsOnly && env.CLAUDE_CODE_OAUTH_TOKEN !== undefined
? [{ name: "CLAUDE_CODE_OAUTH_TOKEN", source: `the env block of ${scope} settings ${file}`, remedy }] : [];
return { conflicts, ...(instead.length ? { instead } : {}), checked: `${scope} settings ${file}` };
}
@@ -65,3 +89,21 @@ export function projectRootForLocalSettings(cwd: string): string | null {
if (path.dirname(dir) === dir) return null;
}
}
/** An isolated home may own an API helper, but project settings must not choose another account. A selected token also
* requires its home not to contain a helper or env that would outrank that token. Only names are reported. */
export function isolatedClaudeAccountProblem(home: string, cwd: string, selectedToken: boolean, apiKey = false): string | null {
const root = projectRootForLocalSettings(cwd) ?? cwd;
const files = [
path.join(home, "settings.json"),
...new Set([root, cwd].flatMap(dir => [path.join(dir, ".claude/settings.json"), path.join(dir, ".claude/settings.local.json")])),
];
for (const file of files) {
const check = settingsFileConflicts(file, "account or project", undefined, { storedLogin: !apiKey, isolatedAccountHome: true, apiKey,
profileSelectorsOnly: file === path.join(home, "settings.json") && !selectedToken });
if (check.conflicts.length || check.instead?.length || check.notChecked) {
return "Claude credential settings could override the selected account home or token; remove those settings or make them readable before switching. Nothing changed.";
}
}
return null;
}
@@ -13,9 +13,9 @@ import { parseClaudePermissionModes } from "./permission-drift.js";
import { validateNativePermissionSelection } from "./native-permission-selection.js";
import { seatLaunchAdditionsForLaunch } from "./seat-launch-additions.js";
import { NO_SESSION_BUS } from "./seat-runtime-dir.js";
import { effectiveRuntimeHome } from "./seat-runtime-home.js";
import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, claudeSeatTokenStep, readClaudeSeatTokenRef, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
import { OUTRANKING_TOKEN_ENV, projectRootForLocalSettings, settingsFileConflicts, type CredentialConflict, type CredentialPrecedence } from "./claude-credential-precedence.js";
import { ACCOUNT_HOME_PROVIDER_ENV, seatAccountHome, effectiveRuntimeHome } from "./seat-runtime-home.js";
import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, claudeSeatTokenOutrankedNotice, claudeSeatTokenStep, readClaudeSeatTokenRef, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
import { isolatedClaudeAccountProblem, OUTRANKING_TOKEN_ENV, projectRootForLocalSettings, settingsFileConflicts, type CredentialConflict, type CredentialPrecedence } from "./claude-credential-precedence.js";
export interface ClaudeLaunchTarget {
/** A running seat's verified executable, carried through its in-place continuation. */
@@ -26,6 +26,9 @@ export interface ClaudeLaunchTarget {
pane?: string | null;
/** A successor may have a reserved generation before its tenure is committed. */
generation?: string;
/** The staged successor's isolated account home, before it is committed. */
accountHome?: string | null;
accountToken?: ClaudeSeatTokenRef | null;
/** Slice 16: a Claude successor staged beside the seat's current occupant on another runtime (from #1214 by
* Aschenbath). The seat still records that occupant until the switch commits; it must still be bound exactly. */
runtimeSwitch?: { fromRuntime: string; session: string; pane: string; generation: string };
@@ -130,11 +133,40 @@ export class ClaudeManagedLaunch {
/** The launch step that gives the seat's Claude its own subscription token (`rig provider switch --seat`), and the
* launch's warning when the file won't apply (checked without reading it); null for a seat without a reference.
* Launches without an explicit permission mode use it too, through the seat launch environment. */
seatTokenLaunch(nodeId: string, opts: { removeOnFailure?: boolean } = {}): { ref: ClaudeSeatTokenRef; step: string; warnings: readonly string[] } | null {
const ref = readClaudeSeatTokenRef(this.db, nodeId);
seatTokenLaunch(nodeId: string, opts: { removeOnFailure?: boolean; required?: boolean; ref?: ClaudeSeatTokenRef | null; accountHome?: string | null } = {}): { ref: ClaudeSeatTokenRef; step: string; warnings: readonly string[] } | null {
const ref = opts.ref === undefined ? readClaudeSeatTokenRef(this.db, nodeId) : opts.ref;
const required = opts.required || !!this.accountHome(nodeId);
if (!ref) return null;
const problem = claudeSeatTokenFileProblem(ref);
return { ref, step: claudeSeatTokenStep(ref, shellQuote, opts), warnings: Object.freeze(problem ? [this.tokenNotApplied(nodeId, ref, problem)] : []) };
if (ref.kind === "api-key") {
if (problem) throw new Error(`Selected API-key file unavailable: ${problem}. No Claude process started.`);
const row = this.db.prepare("SELECT cwd FROM nodes WHERE id = ?").get(nodeId) as { cwd: string | null } | undefined;
if (!row?.cwd || !path.isAbsolute(row.cwd)) throw new Error("Selected API-key launch has no absolute working directory; no agent started.");
const cwd = row.cwd;
const root = projectRootForLocalSettings(cwd) ?? cwd;
for (const file of new Set([path.join(this.configPaths(cwd, nodeId, opts.accountHome).configDir, "settings.json"),
path.join(cwd, ".claude", "settings.json"), path.join(cwd, ".claude", "settings.local.json"), path.join(root, ".claude", "settings.local.json")])) {
const found = settingsFileConflicts(file, "selected", undefined, { apiKey: true });
if (found.conflicts.length || found.notChecked) throw new Error("Claude credential settings could override the selected API key or could not be checked; no Claude process started.");
}
}
return { ref, step: claudeSeatTokenStep(ref, shellQuote, { ...opts, required }), warnings: Object.freeze(problem ? [required ? "The selected account token is unusable; no agent will start."
: this.tokenNotApplied(nodeId, ref, problem)] : []) };
}
/** The switch's credential-precedence check, said as a launch warning for a seat with its own token reference, such as a
* spec's `claude_token`: what Claude Code would use ahead of it. The launch goes ahead, so it's never a refusal. Only
* the settings files count: the token step unsets the outranking names in the launch environment before Claude starts.
* An account-home seat is skipped: its home is the selection, checked when it was made. So is an API-key reference,
* which Claude Code ranks above an apiKeyHelper and whose step scrubs the outranking names itself. */
async tokenPrecedenceWarnings(nodeId: string, ref: ClaudeSeatTokenRef): Promise<string[]> {
if (ref.kind === "api-key" || this.accountHome(nodeId)) return [];
try {
const found = await this.credentialPrecedence(nodeId, { settingsOnly: true });
return found.conflicts.length ? [claudeSeatTokenOutrankedNotice(ref, found.conflicts)] : [];
} catch (error) {
return [`This seat's Claude subscription token (${ref.label}): what would take precedence over it couldn't be checked (${(error as Error).message}).`];
}
}
/** Where a launch that didn't apply the seat's token is recorded (the daemon's event log); unset, it's only reported. */
@@ -153,13 +185,14 @@ export class ClaudeManagedLaunch {
* the settings files it loads. When the token applies, the launch unsets those names for Claude's process, so a pane
* shell's own exports can't outrank it. With `storedLogin`, also a `CLAUDE_CODE_OAUTH_TOKEN` it would receive, which Claude
* uses ahead of the stored login; a pane shell's own exports can't be read then. Managed settings (a file, MDM or the
* server) aren't read; the restarted seat's /status is the evidence for those. */
async credentialPrecedence(nodeId: string, opts: { storedLogin?: boolean } = {}): Promise<CredentialPrecedence> {
* server) aren't read; the restarted seat's /status is the evidence for those. With `settingsOnly`, the launch
* environment isn't read either. */
async credentialPrecedence(nodeId: string, opts: { storedLogin?: boolean; apiKey?: boolean; settingsOnly?: boolean } = {}): Promise<CredentialPrecedence> {
const target = this.target(nodeId);
const cwd = target.cwd!;
const conflicts: CredentialConflict[] = [];
const instead: CredentialConflict[] = [];
const names = opts.storedLogin ? [...OUTRANKING_TOKEN_ENV, "CLAUDE_CODE_OAUTH_TOKEN"] : OUTRANKING_TOKEN_ENV;
const names = opts.apiKey || opts.settingsOnly ? [] : opts.storedLogin ? [...OUTRANKING_TOKEN_ENV, "CLAUDE_CODE_OAUTH_TOKEN"] : OUTRANKING_TOKEN_ENV;
const found = (name: string) => name === "CLAUDE_CODE_OAUTH_TOKEN" ? instead : conflicts;
for (const name of names) {
if (this.sessionEnv[name]) {
@@ -173,7 +206,7 @@ export class ClaudeManagedLaunch {
found(name).push({ name, source: "this seat's env: (member or rig) or a one-off launch name", remedy: `remove ${name} from this seat's env: in its rig spec` });
}
}
const checked = [opts.storedLogin ? "the launch environment"
const checked: string[] = opts.settingsOnly ? [] : [opts.storedLogin ? "the launch environment"
: "the launch environment (a pane shell's own exports are unset for Claude's process when the token applies)"];
const notChecked = ["managed settings (a managed-settings.json file, MDM or the server)",
...(opts.storedLogin ? ["the pane shell's own exports (its startup files), which a launch without an explicit permission mode keeps"] : [])];
@@ -188,6 +221,7 @@ export class ClaudeManagedLaunch {
for (const [file, scope] of files) {
const settings = settingsFileConflicts(file, scope, undefined, opts);
conflicts.push(...settings.conflicts);
if (opts.apiKey && settings.notChecked) conflicts.push({ name: "unreadable credential settings", source: `${scope} settings ${file}`, remedy: "make the settings readable and valid before switching" });
instead.push(...(settings.instead ?? []));
if (settings.checked) checked.push(settings.checked);
if (settings.notChecked) notChecked.push(settings.notChecked);
@@ -200,18 +234,32 @@ export class ClaudeManagedLaunch {
return this.target(nodeId).cwd!;
}
/** Check again at launch: settings may have changed since the switch was committed. */
assertAccountHome(nodeId: string, home: string, token?: ClaudeSeatTokenRef | null): void {
const row = this.db.prepare("SELECT cwd FROM nodes WHERE id = ?").get(nodeId) as { cwd: string | null } | undefined;
if (!row?.cwd) throw new Error("The selected account's working directory is unavailable; no agent started.");
const selected = token === undefined ? readClaudeSeatTokenRef(this.db, nodeId) : token;
const problem = isolatedClaudeAccountProblem(home, row.cwd, !!selected, selected?.kind === "api-key");
if (problem) throw new Error(problem);
}
/** The native account-home mode, which excludes inherited provider credentials. */
accountHome(nodeId: string): string | undefined {
return seatAccountHome(this.db, nodeId, "claude-code");
}
/** The config dir a seat's Claude selects: its own home from its member or rig `env:`, else the daemon's. */
private selectedConfigDir(nodeId: string | undefined): string | undefined {
return effectiveRuntimeHome(this.db, nodeId, "claude-code", this.sessionEnv.CLAUDE_CONFIG_DIR);
private selectedConfigDir(nodeId: string | undefined, ignoreAccountHome = false): string | undefined {
return effectiveRuntimeHome(this.db, nodeId, "claude-code", this.sessionEnv.CLAUDE_CONFIG_DIR, ignoreAccountHome);
}
/** Bootstrap and the launched child must use the same native selection.
* Unset config uses HOME/.claude.json; even an explicit default-looking
* directory selects <configDir>/.claude.json instead (#154/#225).
*/
configPaths(cwd: string, nodeId?: string): { configDir: string; statePath: string } {
configPaths(cwd: string, nodeId?: string, accountHome?: string | null): { configDir: string; statePath: string } {
const { HOME } = this.sessionEnv;
const CLAUDE_CONFIG_DIR = this.selectedConfigDir(nodeId);
const CLAUDE_CONFIG_DIR = accountHome ?? this.selectedConfigDir(nodeId, accountHome === null);
if (!HOME || !path.isAbsolute(HOME)) throw new Error("Claude managed launch context is unresolved: absolute HOME is required.");
const configDir = path.resolve(cwd, CLAUDE_CONFIG_DIR ?? path.join(HOME, ".claude"));
return { configDir, statePath: path.join(CLAUDE_CONFIG_DIR === undefined ? HOME : configDir, ".claude.json") };
@@ -219,18 +267,20 @@ export class ClaudeManagedLaunch {
/** The state file under the seat's own or the daemon's non-empty CLAUDE_CONFIG_DIR, or undefined when neither
* selects one. Classic bootstrap provisions it in addition to HOME/.claude.json. */
selectedStatePath(cwd: string, nodeId?: string): string | undefined {
return this.selectedConfigDir(nodeId) ? this.configPaths(cwd, nodeId).statePath : undefined;
selectedStatePath(cwd: string, nodeId?: string, accountHome?: string | null): string | undefined {
const selected = accountHome ?? this.selectedConfigDir(nodeId, accountHome === null);
// Clearing preserves even an explicitly empty baseline, just as the managed child does.
return (accountHome === null ? selected !== undefined : !!selected) ? this.configPaths(cwd, nodeId, accountHome).statePath : undefined;
}
private context(cwd: string, nodeId: string, selectedExecutable?: string) {
private context(cwd: string, nodeId: string, accountHome?: string | null, selectedExecutable?: string) {
const { PATH, HOME } = this.sessionEnv;
const CLAUDE_CONFIG_DIR = this.selectedConfigDir(nodeId);
const CLAUDE_CONFIG_DIR = accountHome ?? this.selectedConfigDir(nodeId, accountHome === null);
if (!PATH || !HOME || !path.isAbsolute(HOME)) throw new Error("Claude managed launch context is unresolved: managed PATH and absolute HOME are required.");
// Relative/empty PATH entries are interpreted at the intended seat cwd,
// including for /usr/bin/env shebangs inside the selected executable.
const search = PATH.split(path.delimiter).map(p => path.resolve(cwd, p));
const { configDir } = this.configPaths(cwd, nodeId);
const { configDir } = this.configPaths(cwd, nodeId, accountHome);
const env: Record<string, string> = { PATH: search.join(path.delimiter), HOME };
// Session storage needs a directory, but exporting the default changes
// Claude's global config selection. Preserve an unset native selection.
@@ -266,7 +316,7 @@ export class ClaudeManagedLaunch {
throw new Error("Claude managed launch context is unresolved: a bound pane and current occupant are required.");
}
// A staged switch successor: the seat's current occupant is still bound exactly, and the successor is elsewhere.
const staged = !!switching && switching.fromRuntime !== "claude-code" && switching.session === before.session
const staged = !!switching && (switching.fromRuntime !== "claude-code" || target.accountHome !== undefined) && switching.session === before.session
&& switching.pane === before.pane && switching.generation === before.generation
&& !!target.session && target.session !== before.session && !!target.pane && target.pane !== before.pane
&& !!target.generation && target.generation !== before.generation;
@@ -275,17 +325,21 @@ export class ClaudeManagedLaunch {
|| (!staged && target.pane != null && target.pane !== before.pane)) {
throw new Error("Claude managed launch target disagrees with the current binding; no input or selection changed.");
}
const context = this.context(cwd, target.nodeId, target.executable);
const accountHome = target.accountHome !== undefined ? target.accountHome : seatAccountHome(this.db, target.nodeId, "claude-code");
if (accountHome) this.assertAccountHome(target.nodeId, accountHome, target.accountHome ? target.accountToken ?? null : undefined);
const credentialKeys: readonly string[] = accountHome ? ACCOUNT_HOME_PROVIDER_ENV : [];
const context = this.context(cwd, target.nodeId, accountHome, target.executable);
// This is the existing managed session channel, not arbitrary shell variables.
// Filter out the explicit capability environment and overwrite launch identity.
const inherited = Object.keys(this.sessionEnv).filter(key => this.sessionEnv[key] !== undefined
&& !MANAGED_LAUNCH_KEYS.includes(key));
&& !MANAGED_LAUNCH_KEYS.includes(key) && !credentialKeys.includes(key));
if (inherited.some(key => !/^[A-Z_][A-Z0-9_]*$/.test(key))) throw new Error("Invalid managed environment key.");
const assertCurrent = () => {
if (accountHome) this.assertAccountHome(target.nodeId, accountHome, target.accountHome ? target.accountToken ?? null : undefined);
if (JSON.stringify(this.target(target.nodeId, switching?.fromRuntime)) !== JSON.stringify(before)
|| JSON.stringify(this.context(cwd, target.nodeId, target.executable)) !== JSON.stringify(context)
|| JSON.stringify(this.context(cwd, target.nodeId, accountHome, target.executable)) !== JSON.stringify(context)
|| JSON.stringify(Object.keys(this.sessionEnv).filter(key => this.sessionEnv[key] !== undefined
&& !["PATH", "HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"].includes(key))) !== JSON.stringify(inherited)) {
&& !["PATH", "HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"].includes(key) && !credentialKeys.includes(key))) !== JSON.stringify(inherited)) {
throw new Error("Claude managed launch context changed during capability discovery/input; retry explicitly.");
}
};
@@ -299,7 +353,7 @@ export class ClaudeManagedLaunch {
const identity: Record<string, string> = { OPENRIG_NODE_ID: target.nodeId, OPENRIG_RUNTIME: "claude-code",
...(before.session ? { OPENRIG_SESSION_NAME: before.session } : {}),
...(generation ? { OPENRIG_OCCUPANT_GENERATION: generation } : {}) };
const seatKeys = await this.seatEnvKeys(target.nodeId, before.session ?? undefined);
const seatKeys = (await this.seatEnvKeys(target.nodeId, before.session ?? undefined)).filter(key => !credentialKeys.includes(key));
const publicEnv = publicSeatEnvironment(this.sessionEnv);
const assignments = Object.entries({ ...context.env, ...publicEnv, ...identity }).map(([key, value]) => `export ${shellQuote(`${key}=${value}`)}`);
const forwarded = inherited.filter(key => !(key in identity) && !(key in publicEnv)).map(key => `export ${key}="\${${key}-}"`);
@@ -337,14 +391,16 @@ export class ClaudeManagedLaunch {
// removals are worked out, so the value never passes through OpenRig. The reduction keeps the name the step sets; when the
// file can't be read, Claude still starts, and a token the reduction would have removed is removed by the step instead.
const tokenKept = selectedKeys.includes("CLAUDE_CODE_OAUTH_TOKEN");
const token = this.seatTokenLaunch(target.nodeId, { removeOnFailure: !tokenKept });
const token = this.seatTokenLaunch(target.nodeId, { removeOnFailure: !tokenKept, accountHome: target.accountHome,
...(target.accountHome !== undefined ? { ref: target.accountHome === null ? target.accountToken : target.accountToken ?? null, required: !!target.accountHome } : {}) });
const tokenStep = token ? `${token.step} && ` : "";
const warnings = token?.warnings ?? Object.freeze([]);
const warnings = Object.freeze([...(token?.warnings ?? []), ...(token ? await this.tokenPrecedenceWarnings(target.nodeId, token.ref) : [])]);
return Object.freeze({ assertCurrent, warnings, configDir: context.configDir, executable: context.executable, executableIsScript: context.executableIsScript, command: (args: readonly string[], launchEnv: Readonly<Record<string, string>> = {}) => {
assertCurrent();
const launch = Object.entries(launchEnv).map(([key, value]) => `export ${shellQuote(`${key}=${value}`)}`);
if (token?.ref.kind === "api-key") this.seatTokenLaunch(target.nodeId, { ref: token.ref, accountHome: target.accountHome });
const launch = Object.entries(launchEnv).filter(([key]) => !credentialKeys.includes(key)).map(([key, value]) => `export ${shellQuote(`${key}=${value}`)}`);
const keep = [...selectedKeys, ...terminalKeys, "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS",
...Object.keys(launchEnv), ...(token ? ["CLAUDE_CODE_OAUTH_TOKEN"] : [])];
...Object.keys(launchEnv).filter(key => !credentialKeys.includes(key)), ...(token ? [token.ref.kind === "api-key" ? "ANTHROPIC_API_KEY" : "CLAUDE_CODE_OAUTH_TOKEN"] : [])];
// env -0 keeps multiline values out of the name parser. The existing daemon's Node reads
// that pipe with an empty environment (no pane NODE_OPTIONS); only removal names reach argv.
const removalArgs = `const data=require("node:fs").readFileSync(0,"utf8"); if(!data.endsWith("\\0"))process.exit(1); `
+59 -10
View File
@@ -1,14 +1,25 @@
import { execPath } from "node:process";
import type Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
import { OUTRANKING_TOKEN_ENV } from "./claude-credential-precedence.js";
import { OUTRANKING_TOKEN_ENV, type CredentialConflict } from "./claude-credential-precedence.js";
/** A Claude seat's own subscription credential: an operator label and an absolute token-file path. A reference only: the
// Read at launch through a private pipe, with fixed errors and a bounded non-blocking fd read.
const API_KEY_READER = `const fs=require('node:fs'); let fd; const data=Buffer.alloc(8193); try {
fd=fs.openSync(process.argv[1], fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
const st=fs.fstatSync(fd); if(!st.isFile() || ![0o400,0o600].includes(st.mode & 0o777) || st.size<1 || st.size>8192) process.exit(1);
const n=fs.readSync(fd,data,0,data.length,null); const key=data.subarray(0,n).toString('utf8').trim();
if(n>8192 || !/^[!-~]+$/.test(key)) process.exit(1); process.stdout.write(key);
} catch { process.exitCode=1; } finally { data.fill(0); if(fd!==undefined)fs.closeSync(fd); }`;
/** A Claude seat's own subscription or API credential: an operator label and an absolute token-file path. A reference only: the
* launch script reads the file when Claude starts, so the value never passes through OpenRig. The label is the
* operator's name for it, not an account the provider observed. */
export interface ClaudeSeatTokenRef {
label: string;
tokenFile: string;
/** Omitted for existing subscription-token references. */
kind?: "api-key";
}
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/;
@@ -20,13 +31,28 @@ export function claudeSeatTokenRefProblem(ref: ClaudeSeatTokenRef): string | nul
return null;
}
/** A spec's `claude_token: { label, file }` as the seat's reference; it throws, naming `where`, when it can't be one. The
* file isn't read: a missing or unreadable one starts the seat with the usual fallback notice. */
export function specClaudeTokenRef(value: unknown, where: string): ClaudeSeatTokenRef {
const fields = value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
if (!fields || typeof fields["label"] !== "string" || typeof fields["file"] !== "string"
|| Object.keys(fields).some((key) => key !== "label" && key !== "file")) {
throw new Error(`${where}: must be { label, file }, the token file's label and absolute path`);
}
const ref = { label: fields["label"] as string, tokenFile: fields["file"] as string };
const problem = claudeSeatTokenRefProblem(ref);
if (problem) throw new Error(`${where}: ${problem}`);
return ref;
}
/** The stored reference, or null when it's absent or unreadable. */
export function parseClaudeSeatTokenRef(value: unknown): ClaudeSeatTokenRef | null {
if (typeof value !== "string" || value === "") return null;
try {
const { label, tokenFile } = JSON.parse(value) as { label?: unknown; tokenFile?: unknown };
const { label, tokenFile, kind } = JSON.parse(value) as { label?: unknown; tokenFile?: unknown; kind?: unknown };
if (kind !== undefined && kind !== "api-key") return null;
if (typeof label !== "string" || typeof tokenFile !== "string") return null;
return claudeSeatTokenRefProblem({ label, tokenFile }) === null ? { label, tokenFile } : null;
return claudeSeatTokenRefProblem({ label, tokenFile }) === null ? { label, tokenFile, ...(kind === "api-key" ? { kind } : {}) } : null;
} catch {
return null;
}
@@ -46,6 +72,7 @@ export function claudeSeatTokenFileProblem(ref: ClaudeSeatTokenRef): string | nu
let stat: fs.Stats;
try { stat = fs.statSync(ref.tokenFile); } catch { return `the token file ${ref.tokenFile} doesn't exist`; }
if (!stat.isFile()) return `${ref.tokenFile} isn't a regular file`;
if (ref.kind === "api-key" && (![0o400, 0o600].includes(stat.mode & 0o777) || stat.size > 8192)) return "the API-key file must be private (0600 or 0400) and at most 8192 bytes";
if (stat.size === 0) return `the token file ${ref.tokenFile} is empty`;
try { fs.accessSync(ref.tokenFile, fs.constants.R_OK); } catch { return `the token file ${ref.tokenFile} isn't readable by the daemon's user`; }
return null;
@@ -53,6 +80,7 @@ export function claudeSeatTokenFileProblem(ref: ClaudeSeatTokenRef): string | nu
/** What a launch that couldn't apply the seat's token says, wherever it's reported. */
export function claudeSeatTokenFallbackNotice(ref: ClaudeSeatTokenRef, problem: string): string {
if (ref.kind === "api-key") return `This seat's Claude API key (${ref.label}) is unavailable: ${problem}. No Claude process will start; fix its file or switch back to a subscription.`;
return `This seat's Claude subscription token (${ref.label}) wasn't applied: ${problem}. Claude started without it, on its config dir's `
+ "stored login (or a token the daemon gives every seat), which may be another account. Fix the file, or choose another with "
+ "rig provider switch --seat.";
@@ -62,12 +90,33 @@ export function claudeSeatTokenFallbackNotice(ref: ClaudeSeatTokenRef, problem:
* names Claude Code ranks above it are unset for this process only (the pane shell's own exports included), so it's the
* token Claude uses. When the file can't be read or is empty, Claude still starts, on the config dir's stored login, and
* the pane says so; any token the launch already carried is kept unless `removeOnFailure`, and an empty one is never
* exported. */
export function claudeSeatTokenStep(ref: ClaudeSeatTokenRef, quote: (value: string) => string, opts: { removeOnFailure?: boolean } = {}): string {
const notice = claudeSeatTokenFallbackNotice(ref, `${ref.tokenFile} couldn't be read when Claude started`);
return `{ __openrig_token=$(/bin/cat -- ${quote(ref.tokenFile)} 2>/dev/null); if [ -n "$__openrig_token" ]; `
+ `then unset ${OUTRANKING_TOKEN_ENV.join(" ")}; CLAUDE_CODE_OAUTH_TOKEN=$__openrig_token; export CLAUDE_CODE_OAUTH_TOKEN; `
+ `else ${opts.removeOnFailure ? "unset CLAUDE_CODE_OAUTH_TOKEN; " : ""}printf '%s\\n' ${quote(`OpenRig: ${notice}`)} >&2; fi; unset __openrig_token; }`;
* exported. API-key references instead fail closed and scrub other auth/routing variables. A pane shell with tracing on
* (set -x) would print a subscription token as it's read and assigned, so tracing is suspended for those and restored
* afterwards. */
export function claudeSeatTokenStep(ref: ClaudeSeatTokenRef, quote: (value: string) => string, opts: { removeOnFailure?: boolean; required?: boolean } = {}): string {
if (ref.kind === "api-key") {
// A missing API key must never silently fall back to a subscription or another account.
const file = quote(ref.tokenFile);
const scrub = [...OUTRANKING_TOKEN_ENV, "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_PROFILE", "ANTHROPIC_FEDERATION_RULE_ID", "ANTHROPIC_ORGANIZATION_ID"];
return `{ set +x; unset ${scrub.join(" ")}; if [ -f ${file} ] && [ -r ${file} ]; then __openrig_token=$(/usr/bin/env -i ${quote(execPath)} -e ${quote(API_KEY_READER)} ${file} 2>/dev/null); else __openrig_token=; fi; `
+ `if [ -n "$__openrig_token" ]; then ANTHROPIC_API_KEY=$__openrig_token; export ANTHROPIC_API_KEY; unset __openrig_token; `
+ `else unset __openrig_token; printf '%s\\n' 'OpenRig: selected API-key file unavailable; no Claude process started.' >&2; exit 1; fi; }`;
}
const notice = opts.required ? "OpenRig: the selected account's token could not be read; no agent started."
: `OpenRig: ${claudeSeatTokenFallbackNotice(ref, `${ref.tokenFile} couldn't be read when Claude started`)}`;
const restore = 'if [ -n "$__openrig_x" ]; then set -x; fi; unset __openrig_x; ';
return `{ case $- in *x*) __openrig_x=1; set +x;; *) __openrig_x=;; esac; `
+ `__openrig_token=$(/bin/cat -- ${quote(ref.tokenFile)} 2>/dev/null); if [ -n "$__openrig_token" ]; `
+ `then unset ${OUTRANKING_TOKEN_ENV.join(" ")}; CLAUDE_CODE_OAUTH_TOKEN=$__openrig_token; export CLAUDE_CODE_OAUTH_TOKEN; unset __openrig_token; ${restore}`
+ `else ${opts.removeOnFailure || opts.required ? "unset CLAUDE_CODE_OAUTH_TOKEN; " : ""}printf '%s\\n' ${quote(notice)} >&2; unset __openrig_token; ${restore}${opts.required ? "false;" : ""} fi; }`;
}
/** What a launch says when a setting would make Claude Code use another credential ahead of the seat's token, in the
* switch's words. The launch goes ahead: it's a warning, not a refusal. */
export function claudeSeatTokenOutrankedNotice(ref: ClaudeSeatTokenRef, conflicts: readonly CredentialConflict[]): string {
return `This seat's Claude subscription token (${ref.label}) may not be the credential Claude uses. Claude Code would use these instead: `
+ `${conflicts.map((c) => `${c.name} (${c.source}; ${c.remedy})`).join("; ")}.`;
}
/** Whether a launch warning is the one a launch that couldn't apply the seat's token gives. */
@@ -15,7 +15,7 @@ import { BUILT_IN_RUNTIMES, isCommunityHarnessRuntime } from "./built-in-runtime
export { BUILT_IN_RUNTIMES, isCommunityHarnessRuntime };
export const COMMUNITY_HARNESS_PRESET_FORMAT = 1;
const FIELDS = ["format", "name", "command", "maintainer", "limits", "launch_result", "names_conversation", "resume", "startup_assisted", "first_input"] as const;
const FIELDS = ["format", "name", "command", "maintainer", "limits", "launch_result", "names_conversation", "resume", "startup_assisted", "first_input", "runner", "passes_model"] as const;
const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
export interface CommunityHarnessPreset {
@@ -33,6 +33,11 @@ export interface CommunityHarnessPreset {
startupAssisted: boolean;
/** The launcher takes OPENRIG_FIRST_INPUT, a private file containing the composed first turn. */
firstInput: boolean;
/** The launcher takes the seat's declared model from OPENRIG_MODEL and maps it into its harness's own setting. */
passesModel: boolean;
/** The command is an OpenRig-aware runner: it submits each pasted block as one prompt and posts the seat's turn
* boundaries as activity hooks, so its seat's activity reads from those hooks. Requires `launch_result`. */
runner: boolean;
path: string;
}
@@ -72,7 +77,12 @@ function parsePreset(name: string, path: string, text: string): CommunityHarness
if (raw.resume !== undefined && typeof raw.resume !== "boolean") return bad("resume must be true or false");
if (raw.startup_assisted !== undefined && typeof raw.startup_assisted !== "boolean") return bad("startup_assisted must be true or false");
if (raw.first_input !== undefined && typeof raw.first_input !== "boolean") return bad("first_input must be true or false");
if (raw.passes_model !== undefined && typeof raw.passes_model !== "boolean") return bad("passes_model must be true or false");
if (raw.names_conversation !== undefined && typeof raw.names_conversation !== "boolean") return bad("names_conversation must be true or false");
if (raw.runner !== undefined && typeof raw.runner !== "boolean") return bad("runner must be true or false");
if (raw.runner === true && raw.launch_result !== true) {
return bad("runner needs launch_result: true: a runner reports the conversation it opened in its launch record");
}
if (raw.launch_result === true && raw.names_conversation === true) {
return bad("launch_result and names_conversation can't both be true: either the launcher reports the conversation, or OpenRig names it");
}
@@ -81,7 +91,7 @@ function parsePreset(name: string, path: string, text: string): CommunityHarness
}
return { ok: true, preset: { name, command: raw.command as string, maintainer: raw.maintainer as string, limits: limits as string[],
launchResult: raw.launch_result === true, namesConversation: raw.names_conversation === true, resume: raw.resume === true,
startupAssisted: raw.startup_assisted === true, firstInput: raw.first_input === true, path } };
startupAssisted: raw.startup_assisted === true, firstInput: raw.first_input === true, runner: raw.runner === true, passesModel: raw.passes_model === true, path } };
}
/** The first shell construct in a preset command, or null when it's one program with its arguments. The command is
@@ -137,6 +137,8 @@ export interface ComposerInput {
state: "empty" | "text" | "unknown";
rows: string[];
columns: number;
/** Conservative visible capacity of the recognized Codex fullscreen composer. */
pasteViewport?: { columns: number; rows: number };
cursor?: ComposerSnapshot["cursor"];
unknownReason?: string;
suggestionRows?: string[];
@@ -177,6 +179,34 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
return false;
}
/** Conservative uncertainty before a protected Codex paste, never Enter authority.
* Codex 0.164 collapses >1000 code points to [Pasted Content N chars], which
* the current ownership reader cannot own. Only a strictly shorter logical
* line has an exact one-row charge. Wrapped lines use a conservative pair bound
* over all native semantic breaks, plus an insertion row; this can retain prose
* that fits natively, without recreating UAX#14 or weakening Enter ownership.
*/
export function composerPasteUncertainty(input: ComposerInput, text: string): "controls" | "long" | "viewport" | null {
const viewport = input.pasteViewport;
if (!viewport) return null;
const normalized = text.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
if (lines.some(line => /[\x00-\x1f\x7f-\x9f]/.test(line))) return "controls";
// rust-v0.164.0 chat_composer/paste_input.rs:184-192 (Unicode scalar count).
if ([...normalized].length > 1000) return "long";
if (viewport.columns < 1 || viewport.rows < 1) return "viewport";
let rows = 0;
for (const line of lines) {
const cells = [...line].reduce((sum, ch) => sum + (ch >= " " && ch <= "~" ? 1 : 2), 0);
// A native semantic fragment may move whole, even across an apparent space.
// Bound the entire line rather than assuming character or ASCII-space breaks.
// Equality is not the one-row fast path: a full line needs an insertion row.
rows += cells < viewport.columns ? 1 : Math.ceil(2 * cells / viewport.columns) + 1;
if (rows > viewport.rows) return "viewport";
}
return null;
}
/** Recognize this lease's paste immediately after pasting or before its Enter
* in the original composer. A later lease cannot acquire ownership from a label.
*/
@@ -55,8 +55,16 @@ export interface SlackConnectorConfig {
* every channel is handled here, as before. Not in DEFAULT_CONFIG, like `channelMap`.
*/
forwardChannels?: ForwardChannel[];
/**
* What a person's emoji reaction on an OpenRig post does. Absent or "route" = a row for the seat that asked, as before.
* "off" = reactions are ignored, so an acknowledging emoji costs no agent turn. Not in DEFAULT_CONFIG, like `channelMap`.
*/
reactions?: SlackReactionsMode;
}
export const SLACK_REACTIONS_MODES = ["route", "off"] as const;
export type SlackReactionsMode = typeof SLACK_REACTIONS_MODES[number];
export const DEFAULT_CONFIG: SlackConnectorConfig = {
enabled: false,
inboundDestination: "operator-agent@kernel",
@@ -82,6 +90,10 @@ function validateConfig(cfg: SlackConnectorConfig, opts: { reading?: boolean } =
validateLevel("minimumLevelThatPosts", cfg.minimumLevelThatPosts);
validateLevel("minimumLevelThatInterrupts", cfg.minimumLevelThatInterrupts);
validateChannelMap(cfg, { allowUnknownFields: opts.reading });
// A value a newer OpenRig wrote is read as the default rather than failing the connector.
if (!opts.reading && cfg.reactions !== undefined && !SLACK_REACTIONS_MODES.includes(cfg.reactions)) {
throw new Error(`reactions must be one of ${SLACK_REACTIONS_MODES.join(", ")} (got ${String(cfg.reactions)})`);
}
}
export function configPathFor(home?: string): string {
@@ -70,7 +70,7 @@ export interface SlackBlockActions {
actions?: Array<{ block_id?: string; action_id?: string; value?: string; action_ts?: string }>;
}
export type RecordHumanAnswer = (input: { qitemId: string; actorSession: string; questionId: string; optionId: string }) => RecordHumanAnswerResult;
export type RecordHumanAnswer = (input: { qitemId: string; actorSession: string; questionId: string; optionId: string; clickedTs?: string }) => RecordHumanAnswerResult;
/** What a person reads in the thread when OpenRig refuses a click or can't record it. Plain words: what happened, and
* who may answer or what to do instead. The same clicks are refused as before; they just aren't silent. */
@@ -78,6 +78,7 @@ export const CLICK_REPLIES = {
unregistered: "This click wasn't recorded: only the person this decision was sent to can answer it, and your Slack account isn't registered with this OpenRig.",
unmapped: "This click wasn't recorded: OpenRig can't find the decision this message belongs to. Reply in this thread in words instead.",
closed: "This decision is no longer waiting for answers, so this click wasn't recorded.",
parked: "This click wasn't recorded: this decision is parked right now, so it isn't taking this answer.",
onRecord: "Your answer is recorded, and this decision is no longer waiting for answers.",
someoneElse: "This click wasn't recorded: this decision was sent to someone else, and only they can answer it.",
stale: "This click wasn't recorded: the button no longer matches this decision's questions. Reply in this thread in words instead.",
@@ -93,6 +94,7 @@ export function decidedForThemReply(d: { decidedBy: string; modeName: string; ch
/** The reply for a click recordHumanAnswer didn't record, by its reason. */
export function clickNotRecordedReply(reason: string): string {
if (reason === "state-blocked") return CLICK_REPLIES.parked;
if (reason.startsWith("state-")) return CLICK_REPLIES.closed;
if (reason === "not-the-asked-human") return CLICK_REPLIES.someoneElse;
if (reason === "unknown-option" || reason === "no-questions") return CLICK_REPLIES.stale;
@@ -188,6 +190,8 @@ export interface InboundDeps {
queue: InboundQueuePort;
seen: SeenStore;
deadLetter: DeadLetterStore<SlackEvent>;
/** What a person's emoji reaction does: "route" (absent) makes a row for the seat that asked; "off" ignores it (#2233). */
reactions?: "route" | "off";
destination: string; // first-class config; default operator-agent@kernel
/** A6 v3 registration gate. Resolves ev.user -> a registered human (or refuses). Injected so
* this core stays pure/testable; the subsystem wires it via the daemon human-registry resolver. */
@@ -527,7 +531,7 @@ export class InboundRouter {
const existing = this.deps.queue.getHumanAnswer?.(qitemId, who.source);
if (existing) {
try {
const resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: humanAnswerText(existing.body) });
const resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: humanAnswerText(existing.body), messageTs: action?.action_ts });
if (resolution === "not-applicable") {
if (live) await acknowledge(CLICK_REPLIES.notClosed);
return { status: "refused", reason: "resolve-not-applicable" };
@@ -540,7 +544,7 @@ export class InboundRouter {
return { status: "handler-failed", reason: "answer-continuation-failed" };
}
}
const recorded = this.deps.recordHumanAnswer?.({ qitemId, actorSession: who.source, ...picked });
const recorded = this.deps.recordHumanAnswer?.({ qitemId, actorSession: who.source, ...picked, clickedTs: action?.action_ts });
if (!recorded || recorded.status !== "recorded") {
const reason = recorded?.reason ?? "answers-unavailable";
// A closed decision holding this person's clicked answer: typically Slack's redelivery of the click that closed
@@ -572,7 +576,8 @@ export class InboundRouter {
summary: `${who.source} via Slack: answered ${lines.length === 1 ? "1 question" : `${lines.length} questions`}`,
body: `${lines.join("\n")}\n\n---\nAnswers (question id → option id): ${JSON.stringify(recorded.answers)}\nIn reply to: ${qitemId} (its humanAnswers field holds the same)\nRouted by openrig slack-inbound (button click).`,
});
resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: lines.join("; ") });
// The click's own time, so a decision parked on this person resumes as a typed reply after its question would.
resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: lines.join("; "), messageTs: action?.action_ts });
} catch (e) {
this.deps.log?.(`answer continuation failed qitem=${qitemId}: ${(e as Error).message}`);
if (live) await acknowledge("Your answers are recorded, but handing them back failed. OpenRig will retry.");
@@ -609,6 +614,9 @@ export class InboundRouter {
}
private async attemptReaction(ev: SlackEvent): Promise<{ status: InboundDisposition; reason?: string }> {
// With reactions off, a person's emoji stays in Slack and never becomes a row (#2233). That holds on the retry too, so a
// reaction retained from before reactions were turned off is dropped as ignored.
if (this.deps.reactions === "off") return { status: "ignored", reason: "reactions-off" };
const eventId = `reaction:${ev.item?.channel ?? "-"}:${ev.item?.ts ?? "-"}:${ev.user ?? "-"}:${ev.reaction ?? "-"}:${ev.event_ts ?? "-"}`;
if (this.deps.seen.load().has(eventId)) return { status: "ignored", reason: "dup" };
if (this.inflight.has(eventId)) return { status: "ignored", reason: "inflight" };
@@ -412,7 +412,9 @@ export interface PostChatMessageInput {
}
export type PostChatMessageResult =
| { ok: true; status: number; ts: string }
/** `threadTs`: where Slack placed the message, from the posted message in its answer: the thread's parent, or null for a
* top-level message. Absent when the answer doesn't include the posted message. */
| { ok: true; status: number; ts: string; threadTs?: string | null }
| { ok: false; status: number; error?: string };
/** The longest Retry-After `postChatMessage` waits for inline. A 429 means Slack
@@ -451,5 +453,10 @@ export async function postChatMessage(
if (!ts) {
return { ok: false, status: r.status, error: "chat.postMessage returned ok without a message ts" };
}
return { ok: true, status: r.status, ts };
// #2234: Slack can accept a reply to a deleted thread root as a new top-level message, with no thread_ts.
const message = (r.json as { message?: unknown }).message;
if (!message || typeof message !== "object") return { ok: true, status: r.status, ts };
const parent = (message as { thread_ts?: unknown }).thread_ts;
const threadTs = typeof parent === "string" && parent.trim() && parent.trim() !== ts ? parent.trim() : null;
return { ok: true, status: r.status, ts, threadTs };
}
@@ -74,6 +74,9 @@ export interface SubsystemSlackDeliveryOpts {
* reaction on that message can reach them. A throw retains the
* delivery like any receipt failure; the replay reconciles the message by marker and records it again. */
onPostedPart?: (payload: OutboundPostPayload, messageTs: string, threadTs: string, channel: string) => void;
/** #2234: Slack posted a reply to `threadTs` as a new top-level message, so that thread's root is gone. The message is
* then recorded as a root through `onPostedRoot`. */
onThreadMissing?: (payload: OutboundPostPayload, threadTs: string, channel: string) => void;
/** OPR.0.5.6.14 — the transport-failure receipt hook: a failed post writes
* the row's transport-failed ledger transition (class + API error), so a
* delivery failure is as legible on the row as a success. `partlyPosted` marks an ask whose
@@ -469,10 +472,14 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
// write that throws after a successful post is a clean retained outcome
// (never an escaped throw); the replay reconciles by marker — the message
// IS in the channel — and retries the idempotent receipt without reposting.
// #2234: record where Slack actually put the message, not where it was asked to. Without the posted message in its
// answer, the requested placement stands.
const placedThreadTs = res.threadTs === undefined ? threadTs : res.threadTs ?? undefined;
try {
if (threadTs === undefined) opts.onPostedRoot?.(q, res.ts, channel);
else opts.onPostedPart?.(q, res.ts, threadTs, channel);
opts.onPosted?.(q, res.ts, threadTs);
if (threadTs !== undefined && placedThreadTs === undefined) opts.onThreadMissing?.(q, threadTs, channel);
if (placedThreadTs === undefined) opts.onPostedRoot?.(q, res.ts, channel);
else opts.onPostedPart?.(q, res.ts, placedThreadTs, channel);
opts.onPosted?.(q, res.ts, placedThreadTs);
} catch (e) {
log(`receipt write FAILED after successful post for ${q.qitemId ?? decision.decisionId}: ${(e as Error).message} — retained; replay reconciles by marker and retries the idempotent receipt`);
return { ok: false, class: "receipt-failed", detail: (e as Error).message };
@@ -498,7 +505,7 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
if (local && "skipped" in local) {
log(`ATTACHMENT skipped for ${q.qitemId ?? decision.decisionId}: ${path.basename(String(q.evidenceRef))} ${local.skipped} (text delivered; attachment missing)`);
} else if (local) {
const intoThread = threadTs ?? res.ts;
const intoThread = placedThreadTs ?? res.ts;
const up = await getUploadURLExternal(opts.botToken, local.filename, local.bytes.length, opts.fetchImpl);
if (up.ok && up.uploadUrl && up.fileId) {
const put = await uploadBytesExternal(up.uploadUrl, local.bytes, opts.fetchImpl);
@@ -772,7 +779,9 @@ export function subsystemSlackDeliver(opts: SubsystemSlackDeliveryOpts): Subsyst
resolveChannel: index === 0
? opts.resolveChannel
: (part) => firstAttemptChannel(opts, opts.attempted.load(), partId(0)) ?? opts.resolveChannel?.(part) ?? opts.channel,
onPostedRoot: index === 0 ? opts.onPostedRoot : undefined,
// #2234: a later part has the primary's thread, so this fires only when Slack posted it top-level because that root was
// deleted between parts. It then opens a root of its own for the same conversation, as a single part does.
onPostedRoot: opts.onPostedRoot,
onPosted: (_part, messageTs, threadTs) => {
if (index !== 0) return;
if (messageTs === "reconciled") throw new Error("Primary reconciliation has no Slack timestamp; multipart delivery remains incomplete.");
@@ -41,7 +41,7 @@ import { startSocketInbound, type SocketInboundHandle, type WsLike } from "./soc
import { HUMAN_ALERT_TAG, loadHumanRegistry, resolveSlackHandle } from "../human-registry.js";
import { sharedChannelAddresses, sharedChannelFor } from "../participants.js";
import { hasLiveHumanGate, isBlockerLive, type QueueRepository } from "../../queue-repository.js";
import { formatReplyToChoice, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js";
import { formatReplyToChoice, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js";
import { parseSessionName } from "../../session-name.js";
import type { FetchImpl } from "./slack-api.js";
import { ownerNotificationLevelAtLeast } from "../../queue-transition-log.js";
@@ -112,11 +112,9 @@ export function makeHumanReplyResolver(
// A park on a person by any address (a registered person's own address, as their Slack rows carry it, or a
// legacy human seat) answers only from a reply written after its current question posted.
if (source?.state === "blocked" && source.blockedOn && queueRepo.isPersonAddress(source.blockedOn)) {
const episode = queueRepo.transitionLog.listForQitem(input.qitemId)
.filter((transition) => transition.ownerNotificationKind === "human-required").at(-1);
const episode = queueRepo.currentHumanRequiredEpisode(input.qitemId);
if (!episode) return "not-applicable";
const key = `${input.qitemId}:${episode.transitionId}`;
const receipt = queueRepo.transitionLog.getOwnerNotificationReceipt(input.qitemId, key);
const { key, receipt } = episode;
// A final receipt attests delivery even when reconciliation lost its timestamp.
// Without one, ONLY a unique exact current-episode primary proves anything.
const primary = !receipt && home ? pendingPrimaryReceipt(home, input.qitemId, key) : null;
@@ -611,6 +609,20 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
log(`reply stamp failed for ${p.qitemId}: ${(e as Error).message}`);
}
},
// #2234: Slack posted a reply to this root as a new top-level message, so the root is gone. Its mapping is closed,
// so later posts don't reuse it. A closed root still routes a reply to its seat.
onThreadMissing: (p, threadTs, channel) => {
threadMap.close(threadTs, channel);
log(`thread ${threadTs} in ${channel} is gone: Slack posted the reply as a new message, so its mapping is closed`);
// A --reply-to update's receipt says where it actually went; the recorded choice stays, for retries.
if (threadReplyTo(p)) {
try {
opts.queueRepo.update({ qitemId: p.qitemId, actorSession: REPLY_TO_CHOICE_ACTOR, transitionNote: formatReplyToThreadMissing(threadTs) });
} catch (e) {
log(`reply-to placement for ${p.qitemId} not recorded: ${(e as Error).message}`);
}
}
},
onTransportFailed: recordTransportFailed,
onPosted: (p, messageTs, threadTs) => {
// v3 digest branch: transport truth first — every member receipt is
@@ -1021,6 +1033,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
seen: inboundSeen,
deadLetter: dead,
destination: cfg.inboundDestination,
reactions: cfg.reactions === "off" ? "off" : "route",
resolveSender: makeInboundSenderResolver(registry, opts.home),
// S10 — deterministic thread routing: mapped thread → exactly the mapped seat; unmapped
// or human-initiated → the configured orchestrator slot as an unrouted-signal row.
@@ -40,7 +40,7 @@ interface Deps {
piRunner: (sessionName: string) => PiOccupantRecordInput["runner"];
native: CurrentGenerationDeps;
census: ProcessCensus;
readAllPaneProcesses?: () => Promise<Map<string, { pid: number }> | null>;
readAllPaneProcesses?: () => Promise<Map<string, { pid: number | null }> | null>;
now?: () => number;
}
type InventoryNode = { nodeId: string; canonicalSessionName: string | null; runtime: string | null; sessionStatus: string | null };
@@ -185,7 +185,7 @@ export class CurrentModelReader {
} };
const prepared = await Promise.all(nodes.map(n => this.prepare(n, native)));
if (!prepared.some(p => "finish" in p)) return prepared as ModelObservation[];
let after: Map<string, { pid: number }> | null = null;
let after: Map<string, { pid: number | null }> | null = null;
try { after = await this.deps.readAllPaneProcesses(); } catch { /* UNKNOWN, never a fallback subprocess storm. */ }
return prepared.map(p => "finish" in p ? p.finish(after?.get(p.pane)?.pid ?? null, after === null ? "native-read-unavailable" : "pane-unavailable") : p);
}
@@ -550,10 +550,26 @@ function rotationProcess(row: NativeProcessRow, rotation: ClaudeResumeRotation |
export type NativeProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string; afterScript?: true };
export type CodexProcessObservation = NativeProcessObservation;
/** The process still running the frozen `#!` script: the kernel starts it as `<interpreter> [one shebang option] <script>
* args…`, so the script's path is its argv[1], or its argv[2] after a single option. */
function runsFrozenScript(row: NativeProcessRow, selectedExecutable: string): boolean {
const [, first, second] = tokens(row.command);
return first === selectedExecutable || (first?.startsWith("-") === true && second === selectedExecutable);
}
function nativeProcessCandidates(rows: NativeProcessRow[], panePid: number, runtime: NativeRuntime, selectedExecutable?: string, scriptLaunch?: boolean): NativeProcessObservation[] {
const exact = candidatesMatching(rows, panePid, runtime, row => claudeProcess(row, selectedExecutable));
if (exact.length > 0 || runtime !== "claude-code" || !selectedExecutable || scriptLaunch !== true) return exact;
return candidatesMatching(rows, panePid, runtime, claudeProcessAfterScript).map(candidate => ({ ...candidate, afterScript: true as const }));
const named = candidatesMatching(rows, panePid, runtime, claudeProcessAfterScript);
if (named.length > 0) return named.map(candidate => ({ ...candidate, afterScript: true as const }));
// #2341: a wrapper that starts Claude without `exec` keeps running the frozen script as the foreground parent, and
// Claude can run under any name. That script's direct child naming a Claude conversation is the launched runtime;
// the caller still requires the launch's own token, one candidate and a stable fingerprint.
const byPid = new Map(rows.map(row => [row.pid, row]));
return candidatesMatching(rows, panePid, runtime, row => {
const parent = byPid.get(row.ppid);
return !!parent && runsFrozenScript(parent, selectedExecutable) && claudeSessionToken(tokens(row.command).slice(1)) !== null;
}).map(candidate => ({ ...candidate, afterScript: true as const }));
}
function candidatesMatching(rows: NativeProcessRow[], panePid: number, runtime: NativeRuntime, claudeRow: (row: NativeProcessRow) => boolean): NativeProcessObservation[] {
+35 -23
View File
@@ -5,7 +5,7 @@ import type Database from "better-sqlite3";
import { decodeTime } from "ulid";
import { resolveActiveOccupantRow } from "./active-occupant.js";
import type { NodeInventoryEntry, NodeDetailEntry, NodeDetailPeer, NodeDetailEdge, NodeDetailCompactSpec, NodeRestoreOutcome, NodeOriented, NodeLifecycleState, Binding, RestoreResult, NodeRecoveryGuidance, Snapshot, WorkspaceSpec, SeatIdentityVerdict, SeatIdentityVerdictKind, AgentActivity, SeatActivity } from "./types.js";
import { identityVerdictConfirmsSessionMissing, identityVerdictDownranksRunning } from "./types.js";
import { identityVerdictConfirmsSessionMissing, identityVerdictConfirmsExecutorStopped, identityVerdictDownranksRunning } from "./types.js";
import { resolveSeatEffort } from "./seat-effort.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
import { buildOrientedMap } from "./startup-proof.js";
@@ -269,7 +269,7 @@ function deriveOccupantLifecycle(
row: InventoryRow,
identityVerdict: SeatIdentityVerdict | null,
): NodeInventoryEntry["occupantLifecycle"] {
if (row.occupant_lifecycle === "active" && identityVerdictConfirmsSessionMissing(identityVerdict)) {
if (row.occupant_lifecycle === "active" && identityVerdictConfirmsExecutorStopped(identityVerdict)) {
return "unknown";
}
if (row.occupant_lifecycle) {
@@ -551,10 +551,8 @@ function applicableVerdict(
return verdict;
}
/** Is this node's current session confirmed missing? The inventory's own rule (an identity
* verdict for the current session and binding that confirms session_missing), for readers
* outside the inventory projection. No verdict, a stale one or an unreadable store: false. */
export function nodeSessionConfirmedMissing(db: Database.Database, nodeId: string): boolean {
/** Read only identity evidence applicable to this session, binding and occupant. Unknown stays null. */
function currentIdentityVerdict(db: Database.Database, nodeId: string): SeatIdentityVerdict | null {
try {
const row = db.prepare(`
SELECT n.handover_at, s.id AS session_id, s.session_name, s.created_at AS session_created_at, b.tmux_pane AS binding_tmux_pane
@@ -563,12 +561,26 @@ export function nodeSessionConfirmedMissing(db: Database.Database, nodeId: strin
LEFT JOIN bindings b ON b.node_id = n.id
WHERE n.id = ?
`).get(nodeId) as Pick<InventoryRow, "session_id" | "session_name" | "session_created_at" | "binding_tmux_pane" | "handover_at"> | undefined;
return !!row && identityVerdictConfirmsSessionMissing(applicableVerdict(new SeatIdentityStore(db).getForNode(nodeId), row));
return row ? applicableVerdict(new SeatIdentityStore(db).getForNode(nodeId), row) : null;
} catch {
return false;
return null;
}
}
export function nodeSessionConfirmedMissing(db: Database.Database, nodeId: string): boolean {
return identityVerdictConfirmsSessionMissing(currentIdentityVerdict(db, nodeId));
}
/** Current positive stopped-executor evidence, without conflating a retained pane with a missing session. */
export function nodeExecutorStoppedReason(db: Database.Database, nodeId: string): "session_missing" | "pane_exited" | null {
const reason = currentIdentityVerdict(db, nodeId)?.reason;
return reason === "session_missing" || reason === "pane_exited" ? reason : null;
}
export function nodeExecutorConfirmedStopped(db: Database.Database, nodeId: string): boolean {
return nodeExecutorStoppedReason(db, nodeId) !== null;
}
// -- Public API --
/**
@@ -741,11 +753,14 @@ function buildInventoryEntry(
const identityVerdict = applicableVerdict(identityVerdicts.get(row.node_id) ?? null, row);
// Keep registry history intact; only an applicable, explicit absence changes
// the current status. Unavailable tmux and process mismatches are not absence.
const sessionStatus = row.session_status === "running" && identityVerdictConfirmsSessionMissing(identityVerdict)
? "detached" : row.session_status;
const paneExited = row.session_status === "running" && identityVerdict?.reason === "pane_exited";
const sessionStatus = paneExited ? "exited"
: row.session_status === "running" && identityVerdictConfirmsSessionMissing(identityVerdict) ? "detached" : row.session_status;
const startupStatus = paneExited || (sessionStatus === "running" && identityVerdictDownranksRunning(identityVerdict?.verdict))
? "attention_required" : row.startup_status as NodeInventoryEntry["startupStatus"];
const lifecycleState = deriveNodeLifecycleState({
sessionStatus,
startupStatus: row.startup_status,
startupStatus,
restoreOutcome,
nodeId: row.node_id,
usableSnapshot,
@@ -775,10 +790,7 @@ function buildInventoryEntry(
// Match the graph's current-status projection without rewriting the startup
// result. Applicable mismatch (including ambiguity) or pane_missing overrides it.
storedStartupStatus: row.startup_status as NodeInventoryEntry["startupStatus"],
startupStatus: sessionStatus === "running"
&& identityVerdictDownranksRunning(identityVerdict?.verdict)
? "attention_required"
: row.startup_status as NodeInventoryEntry["startupStatus"],
startupStatus,
restoreOutcome,
// FS-1 W1.3 S2 — O(1) lookup into the fleet-batched oriented map
// (byte-identical to the prior per-node deriveOriented; "n-a" when a node has
@@ -1251,7 +1263,7 @@ export function attachTerminalActivityAndWork(
const assignedByDest = readAssignedWorkBySession(deps.db);
const now = deps.now ?? new Date();
return entries.map((entry) => {
const sessionMissing = identityVerdictConfirmsSessionMissing(entry.identityVerdict);
const sessionMissing = identityVerdictConfirmsExecutorStopped(entry.identityVerdict);
let terminalActive: boolean | null | undefined = sessionMissing ? false : undefined;
// ARCH RULING 3a947fb1 (FR-7 additive): project the RAW lastActivityAt fact
// alongside terminalActive from the SAME observation — honest-absence ladder
@@ -1268,7 +1280,7 @@ export function attachTerminalActivityAndWork(
let activityState: NodeInventoryEntry["activityState"] = sessionMissing ? null : undefined;
// Capability-checked: a partial injected double without the S19 surface keeps the
// pre-taxonomy shape (undefined) rather than faking a null oracle answer.
const arb = seatActivity && entry.canonicalSessionName && typeof seatActivity.getSeatStateBySession === "function"
const arb = !sessionMissing && seatActivity && entry.canonicalSessionName && typeof seatActivity.getSeatStateBySession === "function"
? seatActivity.getSeatStateBySession(entry.canonicalSessionName) : null;
if (!sessionMissing && seatActivity && entry.canonicalSessionName && typeof seatActivity.getSeatStateBySession === "function") {
activityState = arb
@@ -1284,7 +1296,7 @@ export function attachTerminalActivityAndWork(
}
const work = countAssignedWorkForEntry(entry, assignedByDest);
const arbitratedNeedsInput = arbitratedNeedsInputSignal(arb);
const hook = entry.agentActivity == null && entry.canonicalSessionName
const hook = !sessionMissing && entry.agentActivity == null && entry.canonicalSessionName
? deps.agentActivity?.getLatestForNode({ nodeId: entry.nodeId, sessionName: entry.canonicalSessionName, now,
order: entry.runtime === "claude-code" ? "source" : "arrival" }) ?? null : null;
return {
@@ -1507,15 +1519,15 @@ export async function attachAgentActivity(
const sampledAt = deps.now ?? new Date();
const captureFallback = deps.captureFallback ?? false;
return Promise.all(entries.map(async (entry) => {
// A cached hook, pane sample or motion cannot revive a confirmed absent
// session. Preserve the identity observation's provenance without probing.
if (identityVerdictConfirmsSessionMissing(entry.identityVerdict)) {
// A cached hook, pane sample or motion cannot revive a confirmed absent executor.
// Preserve the applicable missing-session or exited-pane observation without probing.
if (identityVerdictConfirmsExecutorStopped(entry.identityVerdict)) {
return {
...entry,
agentActivity: {
state: "unknown",
reason: "session_missing",
evidenceSource: "tmux_session",
reason: entry.identityVerdict!.reason!,
evidenceSource: entry.identityVerdict!.evidenceSource ?? "tmux_session",
sampledAt: entry.identityVerdict!.observedAt,
evidence: entry.canonicalSessionName,
},
@@ -240,7 +240,7 @@ export function makeParkedOwnerConsumerPolicy(deps: ParkedOwnerConsumerDeps): Po
for (const qitemId of knownRows) {
try {
for (const key of openKeysOnRow(deps.rows.listTransitions(qitemId))) {
if (deps.rows.appendNote(qitemId, `${CLOSE_PREFIX} ${key} (seat resumed)`).ok && !closures.includes(key)) closures.push(key);
if (deps.rows.appendNote(qitemId, `${CLOSE_PREFIX} ${key} (no longer observed parked)`).ok && !closures.includes(key)) closures.push(key);
}
} catch { /* best-effort closure of old prompt-refusal records */ }
}
@@ -76,7 +76,7 @@ function shallowWatches(missionsRoot: string, realRoot: string, top: string, pro
* including absent nearer ones, and accepted evidence elsewhere in the project) is watched at its parent directory,
* not recursively, so sibling checkouts and build trees are never subscribed to.
*/
export function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus): ProofSourceWatch {
export function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): ProofSourceWatch {
missionsRoot = path.resolve(missionsRoot);
let projectRoot = path.dirname(missionsRoot);
try { projectRoot = resolveProjectRoot(missionsRoot) ?? projectRoot; } catch { /* Legacy roots keep the missions root's parent. */ }
@@ -106,6 +106,8 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
// failed latches for this instance: a watcher that errored is not trusted to be replaced by a later read.
let closed = false, failed = false, covered = false, readable = false;
let revision = "unavailable", timer: NodeJS.Timeout | undefined;
let scopeDirty = false;
const notifyScope = () => { try { scopeChanged?.(); } catch { /* A later event or explicit refresh retries the view read. */ } };
let wanted: Shallow = new Map();
const shallow = new Map<string, fs.FSWatcher>();
let recursive: fs.FSWatcher | undefined;
@@ -127,12 +129,13 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
else if (!wanted.get(dir)?.has(filename.toString())) return;
else stale(path.join(dir, filename.toString()));
}
if (filename == null || _event === "rename" || /(?:^|[\\/])(?:SPEC\.md|README\.md|mission\.yaml)$/.test(filename.toString())) scopeDirty = true;
schedule();
};
const onError = () => {
if (closed) return;
failed = true; covered = false; revision = "unavailable";
invalidate(); notify("unavailable");
invalidate(); notify("unavailable"); notifyScope();
};
const watchDir = (dir: string, recursiveWatch: boolean) => {
try {
@@ -176,6 +179,7 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
timer = undefined;
if (closed) return;
try {
if (scopeDirty) { scopeDirty = false; notifyScope(); }
const before = observed();
const next = evaluate();
readable = true;
@@ -208,7 +212,7 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
closed = true;
recursive?.close();
for (const watcher of shallow.values()) watcher.close();
invalidate(); notify("unavailable");
invalidate(); notify("unavailable"); notifyScope();
return { close() {}, observation: () => ({ state: "unavailable", revision }) };
}
return {
@@ -19,11 +19,14 @@ export async function checkClaudeSeat(input: ProviderPrecheckInput, seat: Claude
const refuse = (reason: PrecheckReason, message: string, claude?: ClaudeSwitchView): ClaudeCheck =>
({ ok: false, verdict: { safe: false, reasons: [reason], message, ...(claude ? { claude } : {}) } });
if (!precedence) return refuse("rebind_unsupported_for_runtime", "This daemon can't check what would outrank a Claude seat's own token, so it doesn't move Claude seats.");
if (input.tokenFile === undefined) {
if (input.apiKeyFile !== undefined && input.tokenFile !== undefined) return refuse("invalid_token_reference", "Choose --api-key-file or a subscription token/stored login, not both.");
if (input.tokenFile === undefined && input.apiKeyFile === undefined) {
return refuse("token_file_required", "A Claude seat moves to its own subscription token with --to-account <label> --token-file <absolute path to a 0600 "
+ "file holding the token>, or back to its config dir's stored login with --stored-login.");
}
const ref = input.tokenFile === null ? null : { label: input.toAccount, tokenFile: input.tokenFile };
const ref: ClaudeSeatTokenRef | null = input.apiKeyFile !== undefined
? { label: input.toAccount, tokenFile: input.apiKeyFile, kind: "api-key" }
: input.tokenFile === null ? null : { label: input.toAccount, tokenFile: input.tokenFile! };
if (ref) {
const problem = claudeSeatTokenRefProblem(ref);
if (problem) return refuse("invalid_token_reference", `${problem}.`);
@@ -33,10 +36,10 @@ export async function checkClaudeSeat(input: ProviderPrecheckInput, seat: Claude
if (!seat.running) {
return refuse("seat_not_running", `${input.seat} isn't running, so there's no conversation to resume. Launch it first: rig launch ${seat.rigName} ${seat.logicalId}.`);
}
const found = await precedence(seat.nodeId, ref ? {} : { storedLogin: true });
const found = await precedence(seat.nodeId, ref?.kind === "api-key" ? { apiKey: true } : ref ? {} : { storedLogin: true });
const instead = found.instead?.length
? `the CLAUDE_CODE_OAUTH_TOKEN from ${found.instead.map((c) => `${c.source} (${c.remedy})`).join("; ")}` : null;
const claude: ClaudeSwitchView = { label: ref?.label ?? STORED_LOGIN, tokenFile: ref?.tokenFile ?? null, checked: found.checked, notChecked: found.notChecked,
const claude: ClaudeSwitchView = { ...(ref?.kind ? { credentialKind: ref.kind } : {}), label: ref?.label ?? STORED_LOGIN, tokenFile: ref?.tokenFile ?? null, checked: found.checked, notChecked: found.notChecked,
...(instead ? { instead } : {}) };
if (found.conflicts.length) {
return refuse("credential_outranks_token", "Claude Code would use these instead, so nothing changed: "
@@ -71,7 +74,7 @@ export async function switchClaudeSeat(input: ProviderSwitchInput, relaunch: Cla
}
// Its reference was set before the error; what the seat runs isn't known.
return { outcome: "unknown", reasons: ["relaunch_threw"],
seat: { seat: input.seat, account: input.tokenFile === null ? STORED_LOGIN : input.toAccount, loginChanged: true, peers: [], undo: null },
seat: { seat: input.seat, account: input.tokenFile === null ? STORED_LOGIN : input.toAccount, ...(input.apiKeyFile ? { credentialKind: "api-key", tokenFile: input.apiKeyFile } : {}), loginChanged: true, peers: [], undo: null },
message: `${input.seat}'s relaunch stopped with an error after its process may have been ended (${message}). Check the pane with rig capture ${input.seat}. Not retried.` };
}
}
@@ -94,11 +97,11 @@ async function switchHeld(input: ProviderSwitchInput, relaunch: ClaudeSeatRelaun
}
const before = relaunch.tokenRef(nodeId);
const account = checked.ref?.label ?? STORED_LOGIN;
const report: SeatSwitchReport = { seat: input.seat, account, ...(checked.ref ? { tokenFile: checked.ref.tokenFile } : {}),
const report: SeatSwitchReport = { seat: input.seat, account, ...(checked.ref?.kind ? { credentialKind: checked.ref.kind } : {}), ...(checked.ref ? { tokenFile: checked.ref.tokenFile } : {}),
conversation: snapshot.conversation, loginChanged: false, peers: [],
// The label's characters are limited; a path can hold any but a newline, so it's quoted.
undo: before
? `rig provider switch --seat ${input.seat} --to-account ${before.label} --token-file ${shellQuote(before.tokenFile)}`
? `rig provider switch --seat ${input.seat} --to-account ${before.label} ${before.kind === "api-key" ? "--api-key-file" : "--token-file"} ${shellQuote(before.tokenFile)}`
: `rig provider switch --seat ${input.seat} --stored-login` };
touching();
relaunch.setTokenRef(nodeId, checked.ref);
@@ -114,8 +117,8 @@ async function switchHeld(input: ProviderSwitchInput, relaunch: ClaudeSeatRelaun
if (!relaunched.ok) {
const recoveryCommand = `rig launch ${rigName} ${logicalId} --snapshot-id ${snapshot.snapshotId}`;
report.restart = { outcome: relaunched.code, message: relaunched.message, recoveryCommand,
remainingStep: `If the pane (rig capture ${input.seat}) shows no running Claude, stop the seat first (rig seat stop ${input.seat} --reason "<why>"), `
+ `then run the recovery command; it resumes the conversation from snapshot ${snapshot.snapshotId} with the new reference. The way back works once the seat runs.` };
remainingStep: `Inspect the pane (rig capture ${input.seat}). If a leftover shell/session remains without a running Claude, stop it first (rig seat stop ${input.seat} --reason "<why>"). `
+ `If the session is already absent, run the recovery command directly; it resumes the conversation from snapshot ${snapshot.snapshotId} with the new reference. The way back works once the seat runs.` };
return { outcome: "unknown", reasons: [relaunched.code === "relaunch_threw" ? "relaunch_threw" : `relaunch_${relaunched.code.replace(/-/g, "_")}`], seat: report,
message: `${input.seat}'s relaunch in its pane from snapshot ${snapshot.snapshotId} didn't report a resumed conversation (${relaunched.code}: ${relaunched.message}). `
+ "Its process may have been ended. Not retried." };
@@ -123,6 +126,12 @@ async function switchHeld(input: ProviderSwitchInput, relaunch: ClaudeSeatRelaun
const fallback = relaunched.warnings.filter(isClaudeSeatTokenFallback);
report.warnings = relaunched.warnings;
report.restart = { outcome: "resumed", message: `Resumed conversation ${snapshot.conversation ?? "(not recorded in the snapshot)"} in the seat's own pane.` };
if (checked.ref?.kind === "api-key") {
report.tokenApplied = fallback.length === 0;
report.next = "After a real tool turn, check the seat's /status for API-key authentication. The label is operator-supplied; OpenRig does not verify the billing account or enforce a budget.";
return { outcome: "rebind_in_progress", seat: report,
message: `Relaunched ${input.seat} on its selected API-key file, resuming conversation ${snapshot.conversation ?? "from the snapshot"} in its own pane. Work in flight when it stopped didn't carry over.` };
}
if (checked.ref) {
report.tokenApplied = fallback.length === 0;
report.next = report.tokenApplied
@@ -242,6 +242,8 @@ export class ProviderServiceImpl implements ProviderService {
* running seat uses, and the login must be saved there. Every saved login's sign-in state reads unknown, so an
* authorized move isn't refused for that; the restart's uptake evidence settles it. */
private async seatPrecheck(input: ProviderPrecheckInput, seatRestart: SeatRestart): Promise<SeatCheck> {
if (input.apiKeyFile !== undefined) return { ok: false, verdict: { safe: false, reasons: ["invalid_token_reference"],
message: "Codex uses a saved API-key login in its existing home. Import/save that native login with rig auth, then select its name with --to-account; --api-key-file is for Claude." } };
const plan = await seatRestart.plan(input.seat);
if (!plan.ok) {
return { ok: false, verdict: { safe: false, reasons: [plan.code === "not_codex" ? "rebind_unsupported_for_runtime" : "seat_restart_refused"],
@@ -15,6 +15,8 @@ export interface ProviderPrecheckInput {
toAccount: string;
/** A Claude seat's own subscription token file (absolute); null moves it back to its config dir's stored login. */
tokenFile?: string | null;
/** Claude API key read from this file at launch; never credential bytes. */
apiKeyFile?: string;
}
export interface ProviderSwitchInput {
@@ -23,6 +25,8 @@ export interface ProviderSwitchInput {
forceUnsafe: boolean;
/** As on the precheck. */
tokenFile?: string | null;
/** Claude API key read from this file at launch; never credential bytes. */
apiKeyFile?: string;
}
/**
@@ -64,7 +68,7 @@ export interface ClaudeSeatRelaunch {
}
/** What would outrank a Claude seat's own token at its next launch (`ClaudeManagedLaunch.credentialPrecedence`). */
export type ClaudeCredentialPrecedence = (nodeId: string, opts?: { storedLogin?: boolean }) => Promise<CredentialPrecedence>;
export type ClaudeCredentialPrecedence = (nodeId: string, opts?: { storedLogin?: boolean; apiKey?: boolean }) => Promise<CredentialPrecedence>;
/** What a seat switch did: for a Codex seat, in the Codex home it runs on; for a Claude seat, to its token reference. */
export interface SeatSwitchReport {
@@ -75,6 +79,7 @@ export interface SeatSwitchReport {
account: string;
/** Claude: the token file the launch reads (a path; never its contents). */
tokenFile?: string;
credentialKind?: "api-key";
/** Claude: the conversation the relaunch resumes. */
conversation?: string | null;
/** Whether the seat's login source is now `account`. */
@@ -154,6 +154,7 @@ export interface SeatSwitchView {
/** What a Claude seat's precheck saw: the reference it would get, and what the precedence check read and couldn't read. */
export interface ClaudeSwitchView {
credentialKind?: "api-key";
label: string;
tokenFile: string | null;
checked: string[];
@@ -192,7 +193,7 @@ export interface FourBlockReadModel {
* record), else the daemon's. */
export type SeatAccountConfiguration =
| { source: "registry"; profileRef: string; recordedAt: string; profileSaved: boolean | null }
| { source: "default_login"; home: string | null; homeNote: string; homeFrom?: "member" | "rig" }
| { source: "default_login"; home: string | null; homeNote: string; homeFrom?: "member" | "rig" | "account-switch" }
| { source: "unknown"; reason: string };
export interface SeatConfiguredAccount {
@@ -205,7 +206,7 @@ export interface SeatConfiguredAccount {
registryNotes?: string[];
/** A home the seat's `env:` declares that isn't an absolute path, so it isn't used: the seat resolves as if
* it declared none. Reported, never refused. */
ignoredHome?: { from: "member" | "rig"; variable: string; value: string };
ignoredHome?: { from: "member" | "rig" | "account-switch"; variable: string; value: string };
/** Always unlinked: nothing supported links a configured account to an observed allowance. */
allowance: { linked: false; reason: string };
/** What this seat itself was observed reporting. Claude: its own status-line samples, and the seats whose
+5 -5
View File
@@ -1,6 +1,6 @@
import type Database from "better-sqlite3";
import type { NodeInventoryEntry, NodeLifecycleState, RigLifecycleState } from "./types.js";
import { identityVerdictConfirmsSessionMissing } from "./types.js";
import { identityVerdictConfirmsExecutorStopped } from "./types.js";
import {
countAssignedWorkForEntry,
seatNeedsAttention,
@@ -125,8 +125,8 @@ export function deriveRigLifecycleFromInventory(
* verdict for exactly that case; node-inventory already surfaces it here,
* applicability-gated (a stale verdict is null → fail-open).
*
* ONLY `reason === "session_missing"` — a genuinely-gone tmux session — is
* excluded. `pane_pid_gone` / `mismatch` / `tmux_unavailable` and an absent or
* Only positive `session_missing` or `pane_exited` evidence excludes an executor.
* `pane_pid_gone` / `mismatch` / `tmux_unavailable` and an absent or
* inapplicable (null) verdict all stay running: an ambiguous or non-fatal
* identity signal must never fabricate `stopped` (it down-ranks lifecycle to
* attention, a separate axis). runningCount, status, and activeCount all flow
@@ -135,7 +135,7 @@ export function deriveRigLifecycleFromInventory(
*/
export function isEffectivelyRunning(node: NodeInventoryEntry): boolean {
if (node.sessionStatus !== "running") return false;
if (identityVerdictConfirmsSessionMissing(node.identityVerdict)) return false;
if (identityVerdictConfirmsExecutorStopped(node.identityVerdict)) return false;
return true;
}
@@ -294,7 +294,7 @@ export class PsProjectionService {
// The row's served activity: a live hook, else the cached pane reading. A confirmed-missing session serves
// neither, as on the row.
const structural = this.structuralActivity && session ? this.structuralActivity.getStructuralActivity(session) : null;
const activity = identityVerdictConfirmsSessionMissing(node.identityVerdict) ? null
const activity = identityVerdictConfirmsExecutorStopped(node.identityVerdict) ? null
: hookOrPaneActivity(hook, structural, false, node.runtime)?.activity ?? hook;
if (seatNeedsAttention(node, activity, arbitratedNeedsInputSignal(arbitrated))) attentionCount++;
}
+32 -4
View File
@@ -18,7 +18,7 @@ import { isBoundTerminalSeat } from "./terminal-seat.js";
import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
import { getSelfHostId } from "./hosts/fanout-contract.js";
import { parseSessionName, isHumanSeatSessionRef } from "./session-name.js";
import { parseReplyToChoice, formatReplyToChoice, describeReplyToFallback, REPLY_TO_CHOICE_ACTOR, type ReplyToChoice } from "./reply-to-choice.js";
import { parseReplyToChoice, parseReplyToThreadMissing, formatReplyToChoice, describeReplyToFallback, REPLY_TO_CHOICE_ACTOR, type ReplyToChoice } from "./reply-to-choice.js";
import { classifyDestination, type DestinationClass } from "./gateway/destination-resolver.js";
import { join } from "node:path";
import { SettingsStore } from "./user-settings/settings-store.js";
@@ -39,6 +39,7 @@ import {
import { WatchdogJobsRepository } from "./watchdog-jobs-repository.js";
import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, retargetQueueWait, isQueueWait } from "./queue-wait-backoff.js";
import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js";
import { slackMicros } from "./gateway/slack/state-store.js";
export const QUEUE_STATES = [
"pending",
@@ -3776,12 +3777,14 @@ export class QueueRepository {
* resolve) with exactly what the seat will read. Anything else is not-applicable (a forged
* id, a stranger, a decision already resolved), never an error the socket must retry.
*/
recordHumanAnswer(input: { qitemId: string; actorSession: string; questionId: string; optionId: string; primaryUser?: boolean }): RecordHumanAnswerResult {
recordHumanAnswer(input: { qitemId: string; actorSession: string; questionId: string; optionId: string; primaryUser?: boolean;
/** The click's Slack action_ts: a decision parked on the person it asked takes the click only from after its current question posted. */
clickedTs?: string }): RecordHumanAnswerResult {
if (!this.hasHumanQuestionsColumn) return { status: "not-applicable", reason: "schema" };
return this.db.transaction(() => {
const item = this.getById(input.qitemId);
if (!item?.humanQuestions?.length) return { status: "not-applicable" as const, reason: "no-questions" };
if (item.state !== "pending") {
if (item.state !== "pending" && !this.clickAnswersPark(item, input.actorSession, input.clickedTs)) {
// Slack can redeliver the click that closed it. Only to the asked human, say whether this answer is on record.
const onRecord = item.destinationSession === input.actorSession && item.humanAnswers?.[input.questionId] === input.optionId;
const delegated = item.destinationSession === input.actorSession ? item.delegatedDecision : null;
@@ -3804,6 +3807,24 @@ export class QueueRepository {
})();
}
/** A decision parked on the person it asked takes that person's click, as their typed reply does (makeHumanReplyResolver):
* the park is on them by any of their addresses, and the click came after its current question posted. */
private clickAnswersPark(item: QueueItem, actorSession: string, clickedTs: string | undefined): boolean {
if (item.state !== "blocked" || !item.blockedOn || !this.isPersonAddress(item.blockedOn)) return false;
if (item.destinationSession !== actorSession || this.askedSomeoneElse(actorSession, item.blockedOn)) return false;
const posted = slackMicros(this.currentHumanRequiredEpisode(item.qitemId)?.receipt?.messageTs);
const clicked = slackMicros(clickedTs);
return posted !== null && clicked !== null && clicked > posted;
}
/** A park's current question: its latest human-required transition, and that notification's final post receipt, if any. */
currentHumanRequiredEpisode(qitemId: string): { key: string; receipt: ReturnType<QueueTransitionLog["getOwnerNotificationReceipt"]> } | null {
const episode = this.transitionLog.listForQitem(qitemId).filter((transition) => transition.ownerNotificationKind === "human-required").at(-1);
if (!episode) return null;
const key = `${qitemId}:${episode.transitionId}`;
return { key, receipt: this.transitionLog.getOwnerNotificationReceipt(qitemId, key) };
}
/**
* Record a decision made on the person's behalf, inside their mode's decision room, on the ask's own row. Only the
* agent that asked may record it, only on a pending decision to that person, and only while their mode has a stated
@@ -3967,7 +3988,14 @@ export class QueueRepository {
private replyToFallbackFor(qitemId: string): string | null {
const choice = this.replyToChoiceFor(qitemId);
return choice?.kind === "fallback" ? describeReplyToFallback(choice) : null;
if (choice?.kind === "fallback") return describeReplyToFallback(choice);
// #2234: the choice threaded it, but Slack found the root gone and posted it top-level. Same trust rule as the choice.
for (const t of this.transitionLog.listForQitem(qitemId).reverse()) {
if (t.actorSession !== REPLY_TO_CHOICE_ACTOR || t.identityProvenance !== null) continue;
const missing = parseReplyToThreadMissing(t.transitionNote ?? "");
if (missing) return `thread-missing (thread ${missing})`;
}
return null;
}
/** The parked query's obligation face: open rows to one destination, newest first, without the per-row
@@ -26,7 +26,7 @@ import { resolvePickupThresholdMinutes } from "./queue-pickup.js";
// legitimately wait; only an undelivered human notice is a delivery finding.
import { resolveOperatorSeat, resolveRecoveryAgentSeat, resolveSessionNodeId } from "./queue-owner.js";
import { nodeSessionConfirmedMissing } from "./node-inventory.js";
import { nodeExecutorStoppedReason } from "./node-inventory.js";
import { isBoundTerminalSeat } from "./terminal-seat.js";
import type Database from "better-sqlite3";
import { deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type QueueItem, type QueueRepository } from "./queue-repository.js";
@@ -173,8 +173,8 @@ export interface StuckSweepDeps {
* handed-off batons only, so a finding addressed to a dead seat would reach nobody.
* Injectable for tests; default derives from topology. */
resolveOrchestrator?: (session: string) => string | null;
/** Is this session's current binding confirmed missing (node inventory's session_missing
* rule)? Such a session's finding goes to a live recovery seat when one is available.
/** Legacy missing-session override. The default also consumes an applicable pane_exited fact:
* such an executor's finding uses the same live recovery floor when one is available.
* Injectable for tests; default reads the seat identity verdicts. */
sessionConfirmedMissing?: (session: string) => boolean;
/** The agent a confirmed-missing session's finding goes to first. Injectable for tests;
@@ -841,23 +841,26 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
// the stuck row's creator, skipping any that is unset, confirmed missing or this seat. When none
// qualifies, the finding stays addressed to the missing session and says so; it's never dropped.
// A live seat keeps its own.
const sessionMissing = deps.sessionConfirmedMissing ?? ((session: string) => {
const stoppedReason = (session: string) => {
const nodeId = resolveSessionNodeId(deps.db, session);
return nodeId !== null && nodeSessionConfirmedMissing(deps.db, nodeId);
});
return nodeId === null ? null : nodeExecutorStoppedReason(deps.db, nodeId);
};
const sessionMissing = deps.sessionConfirmedMissing ?? ((session: string) => stoppedReason(session) !== null);
let recoverySeats: Array<string | null> | undefined;
for (const c of candidates) {
if (!sessionMissing(c.route)) continue;
const stoppedDescription = stoppedReason(c.route) === "pane_exited"
? "has a confirmed exited pane (pane_exited)" : "is confirmed missing (session_missing)";
recoverySeats ??= [(deps.resolveRecoveryAgentSeat ?? resolveRecoveryAgentSeat)(), (deps.resolveOperatorSeat ?? resolveOperatorSeat)()];
const choices = [...recoverySeats, c.row.sourceSession];
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !sessionMissing(seat));
const floor = chosen < 0 ? null : choices[chosen]!;
if (!floor) {
c.why += `; ${c.route} is confirmed missing (session_missing) and no live recovery seat is available (operator agent, operator seat and creator are each unset, missing or this seat; an operator agent setting that's unreadable or isn't an agent's session counts as unset), so this finding stays addressed to it`;
c.why += `; ${c.route} ${stoppedDescription} and no live recovery seat is available (operator agent, operator seat and creator are each unset, missing or this seat; an operator agent setting that's unreadable or isn't an agent's session counts as unset), so this finding stays addressed to it`;
continue;
}
// Name the rule that chose it, so whoever receives the finding knows why, and which setting to change.
c.why += `; ${c.route} is confirmed missing (session_missing), so this finding goes to ${floor}, ${RECOVERY_SEAT_RULES[chosen]}`;
c.why += `; ${c.route} ${stoppedDescription}, so this finding goes to ${floor}, ${RECOVERY_SEAT_RULES[chosen]}`;
c.route = floor;
}
@@ -67,6 +67,21 @@ export function parseReplyToChoice(note: string): ReplyToChoice | null {
return null;
}
/** #2234: where Slack actually put a --reply-to update that its choice threaded. The thread's root was gone, so Slack posted
* the update as a new top-level message. Recorded after the post by the same actor, and read only for the receipt: retries and
* reconciliation keep the recorded choice. */
const REPLY_TO_PLACEMENT_PREFIX = "slack-reply-to-placement";
export function formatReplyToThreadMissing(threadTs: string): string {
return `${REPLY_TO_PLACEMENT_PREFIX} reason=thread-missing thread_ts=${threadTs}`;
}
/** The missing thread a placement note names, or null when the note is not one. Callers check the actor. */
export function parseReplyToThreadMissing(note: string): string | null {
const match = new RegExp(`^${REPLY_TO_PLACEMENT_PREFIX} reason=thread-missing thread_ts=(\\S+)$`).exec(note);
return match ? match[1]! : null;
}
/** The human-readable fallback line the verify result carries. */
export function describeReplyToFallback(choice: ReplyToFallback): string {
return "qitemId" in choice ? `${choice.reason} (qitem ${choice.qitemId})` : `${choice.reason} (thread ${choice.threadTs})`;
@@ -48,7 +48,6 @@ import type {
RestoreSnapshotSelection,
} from "./types.js";
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
import { classifyPaneRuntimeMatch } from "./seat-identity-reconciler.js";
import { isShellForeground } from "./shell-classifier.js";
import { rebindAndVerifyPaneIdentity } from "./seat-attention-reconciler.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
@@ -630,6 +629,9 @@ export class RestoreOrchestrator {
const matchedIds = new Set(targetNodes.map((n) => n.logicalId));
const unmatchedIds = logicalIds.filter((id) => !matchedIds.has(id));
if (targetNodes.length === 0) return { ok: false, code: "no_matching_nodes", message: `No nodes match logical ids: ${logicalIds.join(", ")}` };
if (targetNodes.some(saved => !allNodes.some(current => current.id === saved.id && current.logicalId === saved.logicalId))) {
return { ok: false, code: "selection_changed", message: "A snapshot seat was removed or recreated. Select history for the current seat." };
}
if (opts.selectedHistory && (targetNodes.length !== 1 || targetNodes[0]!.id !== opts.selectedHistory.nodeId
|| !allNodes.some(node => node.id === opts.selectedHistory!.nodeId && node.logicalId === logicalIds[0]))) {
return { ok: false, code: "selection_changed", message: "The selected node is no longer this seat." };
@@ -1351,7 +1353,8 @@ export class RestoreOrchestrator {
const restored = await this.postLaunchRestore(entry, rigId, data, launchResult.sessionName, launchResult,
{ ...opts, ...(claudeExecutable ? { claudeExecutable } : {}) }, warnings, priorState, seatRehydration, resumeSession);
const checked = this.checkRestoredGuidance(restored, entry.node, rigId, data, warnings);
const checked = { ...this.checkRestoredGuidance(restored, entry.node, rigId, data, warnings),
...(keptPane ? { launchStarted: true as const } : {}) };
return selectedHistory ? { ...checked, sourceSessionId: selectedHistory.historySessionId, resultSessionId: launchResult.session.id } : checked;
}
@@ -1410,8 +1413,8 @@ export class RestoreOrchestrator {
}
/** OPR.0.3.4.2 (B) — roll a just-launched session back to ZERO sessions for
* the awaiting-decision outcome: kill the live blank session, mark its row
* superseded, and restore the prior binding/session state (the existing
* the awaiting-decision outcome: remove a new blank terminal (preserving a
* reused pane), mark its row superseded, and restore the prior state (the existing
* launch-failure compensating action). The caller fires this ONLY on a
* POSITIVE determination the session is fresh/blank — never for
* unknown-but-possibly-valid continuity. */
@@ -1421,17 +1424,21 @@ export class RestoreOrchestrator {
launchedSessionId: string | undefined,
priorState: { binding: import("./types.js").Binding | null; sessions: { id: string; status: string }[] } | undefined,
assertCurrent?: () => boolean,
keepPane = false,
): Promise<boolean> {
if (assertCurrent && !assertCurrent()) return false;
try {
await this.tmuxAdapter.killSession(sessionName);
} catch { /* best-effort — the row + projection rollback below is the source of truth */ }
if (!keepPane) {
try {
await this.tmuxAdapter.killSession(sessionName);
} catch { /* best-effort — the row + projection rollback below is the source of truth */ }
}
if (assertCurrent && !assertCurrent()) return false;
if (launchedSessionId) {
try { this.sessionRegistry.updateStatus(launchedSessionId, "superseded"); } catch { /* best-effort */ }
}
if (priorState) {
this.restoreNodeState(nodeId, priorState);
// Pane takeover already ended the predecessor; keep its retired status.
this.restoreNodeState(nodeId, keepPane ? { ...priorState, sessions: [] } : priorState);
}
return true;
}
@@ -1465,7 +1472,7 @@ export class RestoreOrchestrator {
data: SnapshotData,
sessionName: string,
launchResult?: { ok: true; sessionName: string; session: import("./types.js").Session; binding: import("./types.js").Binding },
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; selectedHistory?: SelectedRestoreHistory; claudeExecutable?: string },
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; selectedHistory?: SelectedRestoreHistory; claudeExecutable?: string; keepPane?: boolean },
warnings?: string[],
priorState?: { binding: import("./types.js").Binding | null; sessions: { id: string; status: string }[] },
seatRehydration?: SeatSkillRehydration,
@@ -1507,6 +1514,7 @@ export class RestoreOrchestrator {
const rollbackOwnBlank = async (): Promise<boolean> => this.rollbackToZeroSession(
node.id, sessionName, launchResult?.session.id, priorState,
selected ? () => selected.beforeNative(launchResult?.session.id, launchResult?.binding.id) : undefined,
opts?.keepPane,
);
const changedDuringRollback = (): RestoreNodeResult => ({ nodeId: node.id, logicalId: node.logicalId,
status: "attention_required", error: "The selected terminal or source changed during cleanup. Its current owner was preserved; inspect the partial launch before retrying." });
@@ -2064,10 +2072,7 @@ export class RestoreOrchestrator {
}
// Legacy resume adapters do not write native metadata. Fill only the
// launched row's empty token after proof; never overwrite a hook/operator.
const provedClaudeWrapper = node.runtime === "claude-code"
&& classifyPaneRuntimeMatch(identity.command, node.runtime) === "mismatch"
&& isShellForeground(identity.command?.trim().toLowerCase() ?? "");
if ((node.runtime === "codex" || provedClaudeWrapper || managedClaudeResume) && sessionId && resumeToken) {
if ((node.runtime === "codex" || node.runtime === "claude-code") && sessionId && resumeToken) {
const current = this.db.prepare("SELECT node_id, session_name, status, resume_token FROM sessions WHERE id = ?").get(sessionId) as
{ node_id: string; session_name: string; status: string; resume_token: string | null } | undefined;
const sameSession = current?.node_id === node.id && current.session_name === sessionName && current.status === "running";
@@ -139,42 +139,25 @@ export class RigExpansionService {
id: pod.id,
label: pod.label,
...(pod.summary ? { summary: pod.summary } : {}),
members: pod.members.map((member) => ({
id: member.id,
runtime: member.runtime,
...(member.agentRef ? { agent_ref: member.agentRef } : {}),
...(member.profile ? { profile: member.profile } : {}),
...(member.codexConfigProfile ? { codex_config_profile: member.codexConfigProfile } : {}),
...(member.claudePermissions === undefined ? {} : { claude_permissions: member.claudePermissions }),
...(member.codexPermissions === undefined ? {} : { codex_permissions: member.codexPermissions }),
// OPR.0.4.8.3 Seam B: permission_policy rides the fragment→spec map like role.
// R2 (4ac243c3): PRESENCE-preserving — present-invalid values (null, …) flow
// to the canonical validator; only a truly absent key is omitted.
...("permissionPolicy" in member && member.permissionPolicy !== undefined
? { permission_policy: member.permissionPolicy }
: {}),
...(member.cwd ? { cwd: member.cwd } : {}),
...(member.model ? { model: member.model } : {}),
...(member.effort === undefined ? {} : { effort: member.effort }),
// OPR.0.4.6.FAC1: role rides the fragment→spec map (a
// provided role must never be silently dropped here).
...(member.role ? { role: member.role } : {}),
...(member.restorePolicy ? { restore_policy: member.restorePolicy } : {}),
...(member.label ? { label: member.label } : {}),
// OPR.0.5.6.3 repair amendment: session_source is carried FAITHFULLY —
// the route already normalizes valid shapes to the typed spec (whose
// field names are identical in snake_case form), and a present-INVALID
// value rides through RAW so the ONE canonical RigSpec validator
// rejects it structurally. A field-by-field re-map here was the
// original drop site (ref.version, wave-1 R2) and would crash on
// preserved-raw input; a faithful carry has no field list to forget.
// KEY PRESENCE, never truthiness, governs emission: null/false/
// primitive raw values must not vanish before canonical validation.
...("sessionSource" in member ? { session_source: member.sessionSource } : {}),
...(member.starterRef ? {
starter_ref: { name: member.starterRef.name },
} : {}),
})),
members: pod.members.map((member) => {
// Carry every canonical field, including invalid values, to the one
// RigSpec validator. Only the existing API aliases need translation;
// this is not a list of accepted member fields.
if (!member || typeof member !== "object" || Array.isArray(member)) return member;
const raw: Record<string, unknown> = { ...member };
for (const alias of ["agentRef", "codexConfigProfile", "claudePermissions", "claudeToken", "codexPermissions",
"permissionPolicy", "restorePolicy", "sessionSource", "starterRef"]) {
// These aliases historically select a string, otherwise the raw
// canonical value wins. Permission/session-source aliases use presence.
const stringAlias = ["agentRef", "codexConfigProfile", "restorePolicy"].includes(alias);
if (Object.prototype.hasOwnProperty.call(member, alias)
&& (!stringAlias || typeof member[alias] === "string")) {
raw[alias.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)] = member[alias];
}
delete raw[alias];
}
return raw;
}),
edges: pod.edges.map((edge) => ({
kind: edge.kind,
from: edge.from,
+25 -5
View File
@@ -9,8 +9,8 @@ import { ulid } from "ulid";
import { deriveComposeProjectName } from "./compose-project-name.js";
import type { ClaudeManagedBlockFile } from "./managed-blocks.js";
import { mergeSeatEnv, parseStoredSeatEnv, type SeatEnv } from "./seat-env.js";
import { withoutIgnoredRuntimeHomes } from "./seat-runtime-home.js";
import { parseClaudeSeatTokenRef, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
import { seatAccountHome, RUNTIME_HOME_VAR, withoutIgnoredRuntimeHomes } from "./seat-runtime-home.js";
import { claudeSeatTokenRefProblem, parseClaudeSeatTokenRef, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
import type {
Rig,
Node,
@@ -312,6 +312,8 @@ interface NodeOptions {
/** OPR.0.4.8.3 Seam B: per-seat permission_policy REF (builtin:<name> or spec-relative path). */
permissionPolicy?: string;
claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules;
/** A Claude seat's own subscription token file, by reference (spec `claude_token`). */
claudeTokenRef?: ClaudeSeatTokenRef;
codexPermissions?: import("./codex-member-permissions.js").CodexMemberPermissions;
/** The member's declared launch environment (spec `env:`); not merged with the rig's. */
env?: SeatEnv;
@@ -424,12 +426,17 @@ export class RigRepository {
* a restore and a handover successor all read this, so they cannot disagree. A CLAUDE_CONFIG_DIR or
* CODEX_HOME that isn't an absolute path is left out: OpenRig reports it as ignored (seat-runtime-home.ts),
* so the runtime resolves as if the seat declared none. */
getEffectiveSeatEnv(nodeId: string): SeatEnv {
getEffectiveSeatEnv(nodeId: string, ignoreAccountHome = false): SeatEnv {
if (!this.hasNodeColumn("env_json")) return {};
const row = this.db.prepare("SELECT rig_id, env_json FROM nodes WHERE id = ?")
.get(nodeId) as { rig_id: string; env_json: string | null } | undefined;
if (!row) return {};
return withoutIgnoredRuntimeHomes(mergeSeatEnv(this.getRigEnv(row.rig_id), parseStoredSeatEnv(row.env_json)));
const env = withoutIgnoredRuntimeHomes(mergeSeatEnv(this.getRigEnv(row.rig_id), parseStoredSeatEnv(row.env_json)));
for (const runtime of ignoreAccountHome ? [] : ["codex", "claude-code"] as const) {
const accountHome = seatAccountHome(this.db, nodeId, runtime);
if (accountHome) env[RUNTIME_HOME_VAR[runtime]] = accountHome;
}
return env;
}
/** OPR.0.4.8.3 Seam B — persist a rig's attached permission_policy REF (builtin:<name> or a
@@ -615,6 +622,13 @@ export class RigRepository {
// The envelope is written atomically with creation, including direct repository callers.
if (!this.db.inTransaction) return this.db.transaction(() => this.addNode(rigId, logicalId, opts))();
}
if (opts?.claudeTokenRef !== undefined) {
if (opts.runtime !== "claude-code") throw new Error("claude_token requires a claude-code seat");
const problem = claudeSeatTokenRefProblem(opts.claudeTokenRef);
if (problem) throw new Error(`claude_token: ${problem}`);
// The reference is written atomically with creation, including direct repository callers.
if (!this.db.inTransaction) return this.db.transaction(() => this.addNode(rigId, logicalId, opts))();
}
// Same-rig guard for podId
if (opts?.podId) {
const pod = this.db.prepare("SELECT rig_id FROM pods WHERE id = ?").get(opts.podId) as { rig_id: string } | undefined;
@@ -711,6 +725,10 @@ export class RigRepository {
);
}
if (opts?.claudeTokenRef !== undefined && !this.setNodeClaudeTokenRef(id, opts.claudeTokenRef)) {
throw new Error("claude_token needs the current database schema; the seat wasn't created");
}
if (opts?.claudePermissions !== undefined) {
if (opts.runtime !== "claude-code") throw new Error("claude_permissions requires a claude-code seat");
this.db.prepare("UPDATE nodes SET claude_permissions_json = ? WHERE id = ?")
@@ -810,7 +828,7 @@ export class RigRepository {
setNodeClaudeTokenRef(nodeId: string, ref: ClaudeSeatTokenRef | null): boolean {
if (!this.hasNodeColumn("claude_token_ref")) return false;
return this.db.prepare("UPDATE nodes SET claude_token_ref = ? WHERE id = ?")
.run(ref ? JSON.stringify({ label: ref.label, tokenFile: ref.tokenFile }) : null, nodeId).changes > 0;
.run(ref ? JSON.stringify({ label: ref.label, tokenFile: ref.tokenFile, ...(ref.kind ? { kind: ref.kind } : {}) }) : null, nodeId).changes > 0;
}
/** Persist the resolved Advisor; null removes a previous spec's selection. */
@@ -1074,6 +1092,7 @@ export class RigRepository {
effortChoice: row.effort_choice ?? null,
codexConfigProfile: row.codex_config_profile ?? null,
claudeTokenRef: parseClaudeSeatTokenRef(row.claude_token_ref),
...(row.account_home ? { accountHome: row.account_home } : {}),
permissionPolicy: row.permission_policy ?? null,
env: parseStoredSeatEnv(row.env_json),
cwd: row.cwd,
@@ -1170,6 +1189,7 @@ interface NodeRow {
claude_token_ref?: string | null;
permission_policy?: string | null;
env_json?: string | null;
account_home?: string | null;
cwd: string | null;
surface_hint: string | null;
workspace: string | null;
@@ -166,6 +166,7 @@ export class RigSpecCodec {
// OPR.0.4.8.3 Seam B: per-seat permission_policy ref round-trips through spec serialization.
if (m.permissionPolicy) member["permission_policy"] = m.permissionPolicy;
if (m.claudePermissions !== undefined) member["claude_permissions"] = m.claudePermissions;
if (m.claudeTokenRef) member["claude_token"] = { label: m.claudeTokenRef.label, file: m.claudeTokenRef.tokenFile };
if (m.codexPermissions !== undefined) member["codex_permissions"] = m.codexPermissions;
if (m.agentFile) member["agent_file"] = m.agentFile;
if (m.appendSystemPromptFile) member["append_system_prompt_file"] = m.appendSystemPromptFile;
@@ -247,6 +248,7 @@ export class LegacyRigSpecCodec {
if (node.effort != null) n["effort"] = node.effort;
if (node.advisor != null) n["advisor"] = node.advisor;
if (node.claudePermissions !== undefined) n["claude_permissions"] = node.claudePermissions;
if (node.claudeTokenRef) n["claude_token"] = { label: node.claudeTokenRef.label, file: node.claudeTokenRef.tokenFile };
if (node.codexPermissions !== undefined) n["codex_permissions"] = node.codexPermissions;
if (node.cwd != null) n["cwd"] = node.cwd;
if (node.surfaceHint != null) n["surface_hint"] = node.surfaceHint;
@@ -97,6 +97,7 @@ export class RigSpecExporter {
if (node.advisor) specNode.advisor = node.advisor;
const rules = node.runtime === "claude-code" ? readClaudePermissionRules(this.db, node.id) : undefined;
if (rules !== undefined) specNode.claudePermissions = rules;
if (node.runtime === "claude-code" && node.claudeTokenRef && !node.accountHome && node.claudeTokenRef.kind !== "api-key") specNode.claudeTokenRef = node.claudeTokenRef;
const envelope = node.runtime === "codex" ? readCodexMemberPermissions(this.db, node.id) : undefined;
if (envelope !== undefined) specNode.codexPermissions = envelope;
if (node.cwd) specNode.cwd = node.cwd;
@@ -189,6 +190,7 @@ export class RigSpecExporter {
if (node.permissionPolicy) member.permissionPolicy = node.permissionPolicy;
const rules = node.runtime === "claude-code" ? readClaudePermissionRules(this.rigRepo.db, node.id) : undefined;
if (rules !== undefined) member.claudePermissions = rules;
if (node.runtime === "claude-code" && node.claudeTokenRef && !node.accountHome && node.claudeTokenRef.kind !== "api-key") member.claudeTokenRef = node.claudeTokenRef;
const envelope = node.runtime === "codex" ? readCodexMemberPermissions(this.rigRepo.db, node.id) : undefined;
if (envelope !== undefined) member.codexPermissions = envelope;
if (node.env) member.env = node.env;
@@ -123,6 +123,7 @@ export class RigInstantiator {
effort: specNode.effort,
advisor: specNode.advisor,
claudePermissions: specNode.claudePermissions,
claudeTokenRef: specNode.claudeTokenRef,
codexPermissions: specNode.codexPermissions,
cwd: specNode.cwd,
surfaceHint: specNode.surfaceHint,
@@ -1341,7 +1342,7 @@ export class PodRigInstantiator {
const initialRefusal = eligible();
if (initialRefusal) return refuse(initialRefusal);
const retainedFields = new Set(["id", "label", "agent_ref", "profile", "runtime", "model", "effort", "advisor", "cwd", "role", "codex_config_profile", "permission_policy", "claude_permissions", "codex_permissions", "restore_policy"]);
const retainedFields = new Set(["id", "label", "agent_ref", "profile", "runtime", "model", "effort", "advisor", "cwd", "role", "codex_config_profile", "permission_policy", "claude_permissions", "claude_token", "codex_permissions", "restore_policy"]);
if (Object.keys(memberFragment).some(key => !retainedFields.has(key))) return refuse("Retry accepts only retained member fields; topology and startup overrides require a separate change.");
const rawSpec = { version: "0.2", name: rig.rig.name, pods: [{ id: podRow.namespace, label: podRow.label, members: [memberFragment], edges: [] }], edges: [] };
const validation = PodRigSpecSchema.validate(rawSpec);
@@ -1684,6 +1685,7 @@ export class PodRigInstantiator {
// createMemberNode or `rig up <spec>` seats lose their policy ref.
permissionPolicy: member.permissionPolicy,
claudePermissions: member.claudePermissions,
claudeTokenRef: member.claudeTokenRef,
codexPermissions: member.codexPermissions,
env: member.env,
sessionSource: member.sessionSource,
@@ -2076,6 +2078,7 @@ export class PodRigInstantiator {
// rig-level lives on the rig row; precedence applies at RESOLUTION, not storage.
permissionPolicy: input.member.permissionPolicy,
claudePermissions: input.member.claudePermissions,
claudeTokenRef: input.member.claudeTokenRef,
codexPermissions: input.member.codexPermissions,
env: input.member.env,
sessionSource: input.member.sessionSource,
+19 -1
View File
@@ -1,5 +1,6 @@
import { isEscalationTargetRef } from "./escalation-target-ref.js";
import { validateClaudePermissionRules } from "./claude-permission-rules.js";
import { specClaudeTokenRef } from "./claude-seat-token.js";
import { validateCodexMemberPermissions } from "./codex-member-permissions.js";
import type {
LegacyRigSpec,
@@ -64,7 +65,7 @@ const RIG_KEYS = new Set([
const POD_KEYS = new Set(["id", "label", "summary", "continuity_policy", "startup", "members", "edges"]);
const MEMBER_KEYS = new Set([
"id", "label", "agent_ref", "profile", "runtime", "codex_config_profile",
"model", "effort", "advisor", "role", "permission_policy", "claude_permissions", "codex_permissions", "cwd", "restore_policy",
"model", "effort", "advisor", "role", "permission_policy", "claude_permissions", "claude_token", "codex_permissions", "cwd", "restore_policy",
"compaction_strategy", "mechanic", "startup", "session_source", "starter_ref", "env",
"agent_file", "append_system_prompt_file", "plugin_dirs",
]);
@@ -484,6 +485,11 @@ function validateMember(member: Record<string, unknown>, index: number, podPrefi
const prefix = `${podPrefix}.members[${index}]`;
if (!isMapping(member)) return [`${prefix}: must be an object`];
errors.push(...rejectUnknownTopologyKeys(member, MEMBER_KEYS, prefix));
for (const field of ["model", "label"]) {
if (member[field] !== undefined && typeof member[field] !== "string") {
errors.push(`${prefix}.${field}: must be a string`);
}
}
// OPR.0.5.6.20 A5 — member-level compaction_strategy: all vocabulary decisions go
// through the manifest's one canonical site; this leg only classifies the answer.
@@ -528,6 +534,11 @@ function validateMember(member: Record<string, unknown>, index: number, podPrefi
try { validateClaudePermissionRules(member["claude_permissions"], `${prefix}.claude_permissions`); }
catch (error) { errors.push((error as Error).message); }
}
if (member["claude_token"] !== undefined) {
if (member["runtime"] !== "claude-code") errors.push(`${prefix}.claude_token: only valid when runtime is "claude-code"`);
try { specClaudeTokenRef(member["claude_token"], `${prefix}.claude_token`); }
catch (error) { errors.push((error as Error).message); }
}
if (member["codex_permissions"] !== undefined) {
if (member["runtime"] !== "codex") errors.push(`${prefix}.codex_permissions: only valid when runtime is "codex"`);
try { validateCodexMemberPermissions(member["codex_permissions"], `${prefix}.codex_permissions`); }
@@ -1269,6 +1280,7 @@ function normalizePod(raw: Record<string, unknown>): RigSpecPod {
role: m["role"] as string | undefined,
permissionPolicy: m["permission_policy"] as string | undefined,
...(m["claude_permissions"] === undefined ? {} : { claudePermissions: validateClaudePermissionRules(m["claude_permissions"]) }),
...(m["claude_token"] === undefined ? {} : { claudeTokenRef: specClaudeTokenRef(m["claude_token"], "claude_token") }),
...(m["codex_permissions"] === undefined ? {} : { codexPermissions: validateCodexMemberPermissions(m["codex_permissions"]) }),
env: normalizeSeatEnv(m["env"]),
cwd: m["cwd"] as string,
@@ -1391,6 +1403,11 @@ export class LegacyRigSpecSchema {
try { validateClaudePermissionRules(node["claude_permissions"], `nodes.${nodeWhere}.claude_permissions`); }
catch (error) { errors.push((error as Error).message); }
}
if (node["claude_token"] !== undefined) {
if (node["runtime"] !== "claude-code") errors.push(`nodes.${nodeWhere}.claude_token: only valid when runtime is "claude-code"`);
try { specClaudeTokenRef(node["claude_token"], `nodes.${nodeWhere}.claude_token`); }
catch (error) { errors.push((error as Error).message); }
}
if (node["codex_permissions"] !== undefined) {
if (node["runtime"] !== "codex") errors.push(`nodes.${nodeWhere}.codex_permissions: only valid when runtime is "codex"`);
try { validateCodexMemberPermissions(node["codex_permissions"], `nodes.${nodeWhere}.codex_permissions`); }
@@ -1461,6 +1478,7 @@ export class LegacyRigSpecSchema {
effort: typeof n["effort"] === "string" && n["effort"].trim() ? n["effort"].trim() : undefined,
advisor: typeof n["advisor"] === "string" && n["advisor"].trim() ? n["advisor"].trim() : undefined,
...(n["claude_permissions"] === undefined ? {} : { claudePermissions: validateClaudePermissionRules(n["claude_permissions"]) }),
...(n["claude_token"] === undefined ? {} : { claudeTokenRef: specClaudeTokenRef(n["claude_token"], "claude_token") }),
...(n["codex_permissions"] === undefined ? {} : { codexPermissions: validateCodexMemberPermissions(n["codex_permissions"]) }),
cwd: (n["cwd"] as string) ?? undefined,
surfaceHint: (n["surface_hint"] as string) ?? undefined,
@@ -16,6 +16,9 @@ export interface NodeBinding extends Binding {
/** Claude-only, session-scoped Advisor model. */
advisor?: string;
codexConfigProfile?: string;
/** A staged account-home selection; null explicitly returns to configured defaults before commit. */
accountHome?: string | null;
accountToken?: import("./claude-seat-token.js").ClaudeSeatTokenRef | null;
claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules;
/** A Codex seat's own permission envelope, read live; emitted instead of the posture decision. */
codexPermissions?: import("./codex-member-permissions.js").CodexMemberPermissions;
@@ -126,7 +129,10 @@ export type HarnessLaunchRecovery = "retry_fresh" | "attention_required";
// `launchedProcess` (Claude resume launches): the first process the launch's own readiness check
// pinned as the one it started, kept even when a later sample saw it replaced (#1077).
export type HarnessLaunchResult = { firstInput?: import("./community-harness-first-input.js").FirstInputPassed } & (
export type HarnessLaunchResult = { firstInput?: import("./community-harness-first-input.js").FirstInputPassed;
/** A community launcher that opts in was handed the seat's declared model as OPENRIG_MODEL: "confirmed" when the launch
* command's send succeeded, "uncertain" when typing began and the send then failed. Never proof of the model that runs. */
modelInput?: { model: string; dispatch: "confirmed" | "uncertain" } } & (
| { ok: true; resumeToken?: string; resumeType?: string; appliedLaunch?: import("./permission-drift.js").AppliedLaunchObservation;
/** Where resumeToken came from, when not a pane scrape: a community harness's launcher reported it. */
resumeProvenance?: "launcher" | "passed";
@@ -12,8 +12,8 @@ import type {
RungHealthEvent,
RungTrust,
} from "./activity-taxonomy.js";
import { COMMUNITY_HARNESS_RUNG_INVENTORY, EVIDENCE_RUNG_RANK, runtimeRungInventory } from "./activity-taxonomy.js";
import { sessionCommunityHarnessSelect } from "./session-registry.js";
import { COMMUNITY_HARNESS_RUNG_INVENTORY, COMMUNITY_RUNNER_RUNG_INVENTORY, EVIDENCE_RUNG_RANK, runtimeRungInventory } from "./activity-taxonomy.js";
import { sessionCommunityHarnessSelect, sessionCommunityRunnerSelect } from "./session-registry.js";
import { hookPrecedes } from "./agent-activity-store.js";
/** Default polling cadence: 1Hz. The default silence window is 3s, so
@@ -301,14 +301,15 @@ export class SeatActivityService {
try {
const rows = db.prepare(`
SELECT s.session_name as session_name, n.id as node_id, n.runtime as runtime,
COALESCE(b.attachment_type, 'tmux') as attachment_type, ${sessionCommunityHarnessSelect(db)} as community_harness
COALESCE(b.attachment_type, 'tmux') as attachment_type, ${sessionCommunityHarnessSelect(db)} as community_harness,
${sessionCommunityRunnerSelect(db)} as community_runner
FROM nodes n
JOIN sessions s ON s.node_id = n.id
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
LEFT JOIN bindings b ON b.node_id = n.id
WHERE s.status = 'running'
AND s.session_name IS NOT NULL
`).all() as Array<{ session_name: string; node_id: string; runtime: string | null; attachment_type: string; community_harness: string | null }>;
`).all() as Array<{ session_name: string; node_id: string; runtime: string | null; attachment_type: string; community_harness: string | null; community_runner: number | null }>;
const tmuxRows = rows.filter((r) => r.attachment_type === "tmux");
const liveTmux = new Set(tmuxRows.map((r) => r.session_name));
for (const pane of this.latestByPaneId.keys()) {
@@ -330,12 +331,13 @@ export class SeatActivityService {
// touching the launch machinery.
for (const r of tmuxRows) {
const known = this.ladder.get(r.node_id);
const inventory = runtimeRungInventory(r.runtime, r.community_harness);
const inventory = runtimeRungInventory(r.runtime, r.community_harness, r.community_runner === 1);
// A replacement occupant can keep the seat's session name; its own recorded app selection (or none) decides.
const communityHarnessChanged = !!known && (known.inventory === COMMUNITY_HARNESS_RUNG_INVENTORY) !== (inventory === COMMUNITY_HARNESS_RUNG_INVENTORY);
// A plain and a runner launch of the same app read differently, so a change between them re-declares too.
const communityHarnessChanged = !!known && communityKind(known.inventory) !== communityKind(inventory);
if (!known || known.inventory === null || known.sessionName !== r.session_name || communityHarnessChanged) {
this.declareRungInventory({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory);
this.restoreDurableHook({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory);
this.restoreDurableHook({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory, r.runtime);
}
}
@@ -684,13 +686,15 @@ export class SeatActivityService {
}
/** A runner seat declared with no hook evidence (after a daemon restart) takes its last recorded hook, so an idle seat
* that posts nothing still reads idle. Its seq is 0: any live hook outranks it. */
private restoreDurableHook(seat: { seatNodeId: string; sessionName: string }, inventory: AdapterRungInventory): void {
* that posts nothing still reads idle. Its seq is 0: any live hook outranks it. The seat's own runtime reads the
* record, as its live hooks are read: a community runner's inventory names no runtime of its own. */
private restoreDurableHook(seat: { seatNodeId: string; sessionName: string }, inventory: AdapterRungInventory,
seatRuntime: string | null): void {
if (!this.restoreHookEvidence || !inventory.rungs.some((r) => r.rung === "lifecycle-hooks" && r.durableBoundaries)) return;
const ladder = this.ladder.get(seat.seatNodeId);
if (!ladder || this.latestByRung(ladder, "lifecycle-hooks")) return;
let evidence: ActivityEvidence | null = null;
try { evidence = this.restoreHookEvidence(seat, inventory.runtime); } catch { evidence = null; }
try { evidence = this.restoreHookEvidence(seat, seatRuntime ?? inventory.runtime); } catch { evidence = null; }
if (evidence && evidence.seatNodeId === seat.seatNodeId && evidence.sessionName === seat.sessionName
&& evidence.rung === "lifecycle-hooks") this.reportEvidence({ ...evidence, seq: 0 });
}
@@ -926,3 +930,8 @@ export const SAMPLING_IDLE_DEBOUNCE_CAP_MS = 2_500;
export const RUNG_PROMOTION_AGREEMENT_COUNT = 50;
/** …spread over at least this window of production time. */
export const RUNG_PROMOTION_MIN_WINDOW_MS = 60 * 60 * 1000;
/** Which community inventory a seat has, if any: the reconcile pass re-declares when this changes for a session. */
function communityKind(inventory: unknown): "runner" | "plain" | null {
return inventory === COMMUNITY_RUNNER_RUNG_INVENTORY ? "runner" : inventory === COMMUNITY_HARNESS_RUNG_INVENTORY ? "plain" : null;
}
@@ -2,7 +2,7 @@ import { hasOtherSeatHistory, isStoppedSeatPredecessor, resolveCurrentSessionAdd
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
import { composerContainsOwnedText, ownCollapsedPaste, type ComposerInput, type ComposerOwnership } from "./composer-prompts.js";
import { composerPasteUncertainty, composerContainsOwnedText, ownCollapsedPaste, type ComposerInput, type ComposerOwnership } from "./composer-prompts.js";
/** A binding is captured before waiting. Never rebind an old operation to a new occupant. */
export interface GuardTarget {
@@ -414,7 +414,7 @@ export class SeatDeliveryGuard {
}
/** Called at the paste/key boundary after file and buffer preparation. */
async beforeInput(name: string, submit = false): Promise<void> {
async beforeInput(name: string, submit = false, pasteText?: string): Promise<void> {
if (!this.needsInputCheck(name)) return;
const target = this.target(name);
const lease = this.scope.getStore()!.get(target.nodeId)!;
@@ -433,6 +433,15 @@ export class SeatDeliveryGuard {
} else if (input.state !== "empty") {
throw new DeliveryGuardError("draft_input_busy", "The seat has an unsubmitted input. Automatic delivery is held.");
} else {
const uncertainty = pasteText === undefined ? null : composerPasteUncertainty(input, pasteText);
if (uncertainty) {
const message = uncertainty === "controls"
? "OpenRig couldn't confirm the layout of tabs or control characters. Send the message without them; nothing was pasted."
: uncertainty === "long"
? "OpenRig couldn't confirm ownership of Codex's collapsed long paste. Send a shorter message; nothing was pasted. Turning this seat's typing guard off explicitly disables draft protection."
: "OpenRig couldn't confirm the complete message would stay visible in this Codex viewport. Open a larger terminal or shorten the message, then retry this request; nothing was pasted.";
throw new DeliveryGuardError("draft_input_unknown", message);
}
lease.inputFrame = input.frame;
}
}
@@ -84,7 +84,7 @@ interface RunningSeatRow {
community_harness?: string | null;
}
type PaneObservation = { pid: number | null; command: string | null };
type PaneObservation = { pid: number | null; command: string | null; dead?: boolean };
export interface SeatIdentityReconcilerDeps {
db: Database.Database;
@@ -202,7 +202,7 @@ export class SeatIdentityReconciler {
// the sole-pane recheck below; do not sample when that capability is absent.
const nativeSeats: RunningSeatRow[] = [];
for (const seat of seats) {
if (!seat.tmux_pane || !liveSessions.has(seat.session_name)) continue;
if (!seat.tmux_pane || !liveSessions.has(seat.session_name) || panes.get(seat.tmux_pane)?.dead === true) continue;
if (seat.runtime === "codex") nativeSeats.push(seat);
else if (seat.runtime === "claude-code" && (seat.resume_token != null || this.tmux.listPanes)) {
try {
@@ -314,6 +314,16 @@ export class SeatIdentityReconciler {
// From the sweep's one batched list-panes when the pane is in it; otherwise read per pane as before.
const batched = panes?.get(seat.tmux_pane) ?? null;
if (batched?.dead === true) {
// Pane IDs can outlive their stored association after a move or server restart.
// Only observed membership in the exact session proves this executor exited.
const currentPanes = await this.tmux.listPanes?.(seat.session_name, true).catch(() => null);
if (!currentPanes?.some(pane => pane.id === seat.tmux_pane)) return this.tmuxUnavailableVerdict(seat, observedAt);
return {
...base, verdict: "pane_missing", evidenceSource: "pane_process", reason: "pane_exited",
evidence: { registeredPane: seat.tmux_pane, observedPid: null, observedCommand: batched.command, matchedLayer: null },
};
}
const pid = batched ? batched.pid : await this.tmux.getPanePid(seat.tmux_pane);
if (pid === null) {
// The registered pane no longer resolves. Distinguish "the whole tmux
@@ -1,3 +1,4 @@
import { ACCOUNT_HOME_PROVIDER_ENV, RUNTIME_HOME_VAR, type HomeRuntime } from "./seat-runtime-home.js";
import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, statSync, symlinkSync } from "node:fs";
import { createHash } from "node:crypto";
import path from "node:path";
@@ -83,7 +84,8 @@ export class SeatLaunchEnvironment {
private readonly sessionEnv: Readonly<Record<string, string | undefined>>,
private readonly daemonCwd: string,
private readonly cliPath?: string,
private readonly codexHome?: string) {}
private readonly codexHome?: string,
private readonly selectedAccountHome?: (nodeId: string, runtime: string, ignoreAccountHome?: boolean) => string | undefined) {}
private rigBin(): string {
const cli = realpathSync(this.cliPath ?? pairedCli());
@@ -100,14 +102,22 @@ export class SeatLaunchEnvironment {
return bin;
}
/** Build an identity-bound child command, scrubbing inherited credentials for a selected account home or refusing its launch. */
async command(session: string, command: string, target: { codexCwd?: string; nodeId?: string; generation?: string; runtime?: string;
/** The seat's own CODEX_HOME when it declares one; it wins over the daemon's. */
codexHome?: string;
accountHome?: string | null;
/** A Claude seat's own token step (claude-seat-token.ts), run before Claude in the staged subshell. A POSIX pane only:
* a caller whose command comes back without it knows the token wasn't applied. */
claudeTokenStep?: string } = {}): Promise<string> {
const codexHome = target.codexHome ?? this.codexHome;
const accountHome = target.accountHome !== undefined ? target.accountHome : (target.nodeId && target.runtime ? this.selectedAccountHome?.(target.nodeId, target.runtime) : undefined);
const clearing = target.accountHome === null;
const configuredHome = clearing && target.nodeId && target.runtime ? this.selectedAccountHome?.(target.nodeId, target.runtime, true) : undefined;
const homeVar = RUNTIME_HOME_VAR[target.runtime as HomeRuntime];
const clearStep = clearing && homeVar ? `unset ${homeVar}; ${configuredHome !== undefined ? `export ${homeVar}=${shellQuote(configuredHome)}; ` : ""}` : "";
const codexHome = (target.runtime === "codex" ? accountHome : undefined) ?? target.codexHome ?? this.codexHome;
const searchPath = this.sessionEnv.PATH;
const accountStep = clearStep + (accountHome ? `unset ${ACCOUNT_HOME_PROVIDER_ENV.join(" ")}; ` : "");
const codexEnv = target.codexCwd !== undefined
? [searchPath ? `PATH=${shellQuote(searchPath)}` : "", codexHome ? `CODEX_HOME=${shellQuote(codexHome)}` : ""].filter(Boolean)
: [];
@@ -115,7 +125,7 @@ export class SeatLaunchEnvironment {
try {
// Nushell does not expand "$PATH". Keep its pre-existing literal command.
const shell = path.basename(await this.tmux.getPaneCommand(session) ?? "").replace(/^-/, "");
if (shell === "nu" || shell === "nu.exe") throw new Error("Non-POSIX pane");
if (shell === "nu" || shell === "nu.exe" || ((accountHome || clearing) && !["bash", "zsh", "sh", "dash", "ksh", "mksh"].includes(shell))) throw new Error("Non-POSIX pane");
// Pi keeps its existing command on shells where this PATH expression is not supported.
if (target.runtime === "pi" && !["bash", "zsh", "sh", "dash", "ksh"].includes(shell)) {
throw new Error("Unsupported Pi pane shell");
@@ -143,14 +153,15 @@ export class SeatLaunchEnvironment {
const binDir = this.rigBin();
const env = publicSeatEnvironment({ OPENRIG_TRANSCRIPTS_LINES: "", OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS: "", ...this.sessionEnv, ...identity });
if (target.codexCwd !== undefined && codexHome) env.CODEX_HOME = codexHome;
if (accountHome && target.runtime === "claude-code") env.CLAUDE_CONFIG_DIR = accountHome;
// Classic Claude may be an rc alias or function, not a PATH executable.
// Its caller sources the staged command in a pane-shell subshell. Leading
// assignments preserve shell lookup; /usr/bin/env would bypass it.
if (target.runtime === "claude-code") {
const assignments = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`);
const launch = `${assignments.join(" ")} PATH=${shellQuote(binDir)}:"$PATH" ${command}`;
const launch = `${accountStep}${accountHome && target.claudeTokenStep ? `${target.claudeTokenStep} && ` : ""}${assignments.join(" ")} PATH=${shellQuote(binDir)}:"$PATH" ${command}`;
// The token step is POSIX shell; another shell (fish, tcsh) gets the command without it.
const token = target.claudeTokenStep && ["bash", "zsh", "sh", "dash", "ksh", "mksh"].includes(shell) ? [target.claudeTokenStep] : [];
const token = !accountHome && target.claudeTokenStep && ["bash", "zsh", "sh", "dash", "ksh", "mksh"].includes(shell) ? [target.claudeTokenStep] : [];
// ksh calls a function without exporting a leading assignment that isn't already
// exported, so it keeps the form above, where every assignment reaches Claude.
if (!["bash", "zsh", "sh", "dash"].includes(shell)) return [...token, launch].join("\n");
@@ -169,12 +180,13 @@ export class SeatLaunchEnvironment {
].join("\n");
}
const assignments = Object.entries(env).map(([key, value]) => shellQuote(`${key}=${value}`));
return `/usr/bin/env ${assignments.join(" ")} PATH=${shellQuote(binDir)}:"$PATH" ${command}`;
return `${accountStep}/usr/bin/env ${assignments.join(" ")} PATH=${shellQuote(binDir)}:"$PATH" ${command}`;
} catch {
// This best-effort correction is not a new launch admission gate. Do not
// include error details: transport errors may contain environment values.
console.warn("[seat launch] Environment correction unavailable; using the previous launch command.");
return fallback;
console.warn(accountHome || clearing ? "[seat launch] Isolated account launch unavailable; no agent started."
: "[seat launch] Environment correction unavailable; using the previous launch command.");
return accountHome || clearing ? "printf '%s\\n' 'OpenRig: isolated account launch unavailable; no agent started.' >&2; false" : fallback;
}
}
}
@@ -286,7 +286,7 @@ export class CodexInPlaceRestart {
let launch: HarnessLaunchResult;
if (report.paneShape === "shell_backed") {
const stopped = await this.stopUnderShell(pane, panePid, codexPid, codexStartedAt);
const stopped = await this.stopUnderShell(pane, panePid, codexPid, codexStartedAt, report.oldProcess);
if (stopped === "running") {
return { ...report, outcome: "not_started", recoveryCommand, message: "Codex did not exit after TERM and KILL; nothing was restarted and the original process is still running." };
}
@@ -443,8 +443,23 @@ export class CodexInPlaceRestart {
: `not established: ${evidence.reason ?? "no_log_row"}`;
}
// Resolve ambiguous launcher names before classifying the observed process chain.
const byPid = new Map(rows.map(row => [row.pid, row]));
const launcherFacts = new Map<number, ProcessFacts | null>();
const visited = new Set<number>([process.pid]);
let ancestor = process;
while (ancestor.pid !== panePid) {
const parent = byPid.get(ancestor.ppid);
if (!parent || visited.has(parent.pid)) break;
visited.add(parent.pid);
if (executableBase(parent) === "MainThread") {
launcherFacts.set(parent.pid, await this.io.readProcess(parent.pid));
}
ancestor = parent;
}
// Pane shape: the pane's root is Codex itself, or a shell whose child chain is Codex.
const chain = codexChain(rows, process, panePid);
const chain = codexChain(rows, process, panePid, launcherFacts);
const top = chain.at(-1)!;
const root = rows.find((row) => row.pid === panePid);
let paneShape: PaneShape;
@@ -574,12 +589,14 @@ export class CodexInPlaceRestart {
}
/** Shell-backed: TERM the Codex chain's top process (not the pane's shell), KILL after the timeout, then
* require the original process gone AND the shell back in the foreground. */
private async stopUnderShell(pane: string, panePid: number, pid: number, startedAt: string | null): Promise<"stopped" | "running" | "unknown"> {
* require both launcher and native process gone AND the shell back in the foreground. */
private async stopUnderShell(pane: string, panePid: number, pid: number, startedAt: string | null,
native: InPlaceRestartReport["oldProcess"]): Promise<"stopped" | "running" | "unknown"> {
const gone = async (): Promise<boolean | null> => {
const rows = await this.listProcesses();
if (rows.length === 0) return null;
if (rows.some((row) => row.pid === pid && (row.startedAt ?? null) === startedAt)) return false;
if (rows.some((row) => (row.pid === pid && (row.startedAt ?? null) === startedAt)
|| (row.pid === native.pid && (row.startedAt ?? null) === native.startedAt))) return false;
const command = await this.deps.tmux.getPaneCommand(pane);
const root = rows.find((row) => row.pid === panePid);
return command !== null && isShellForeground(command) && !!root;
@@ -596,7 +613,8 @@ export class CodexInPlaceRestart {
};
await this.deps.tmux.signalPid(pid, "TERM");
if (await wait()) return "stopped";
await this.deps.tmux.signalPid(pid, "KILL");
const launcherLive = (await this.listProcesses()).some((row) => row.pid === pid && (row.startedAt ?? null) === startedAt);
if (launcherLive) await this.deps.tmux.signalPid(pid, "KILL");
const last = await wait();
return last ? "stopped" : last === null ? "unknown" : "running";
}
@@ -850,8 +868,31 @@ function isStagedLaunchScript(row: NativeProcessRow): boolean {
return SH_RUNS_AS.has(executableBase(row)) && argv.length === 2 && /^(?:\/bin\/)?sh$/.test(argv[0]!) && isStagedSendScriptPath(argv[1]!);
}
const NODE_LAUNCH_FLAGS = new Set(["--no-warnings", "--trace-warnings", "--enable-source-maps",
"--preserve-symlinks", "--preserve-symlinks-main", "--no-deprecation", "--trace-deprecation",
"--throw-deprecation", "--inspect", "--inspect-brk"]);
const NODE_LAUNCH_VALUE_FLAGS = new Set(["-r", "--require", "--import", "--loader", "--experimental-loader",
"-C", "--conditions", "--title"]);
/** Identify the entry script, never an option's value or a script argument. Unknown option arity refuses the restart. */
function nodeLaunchScript(argv: string[]): string | undefined {
for (let i = 1; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--") return argv[i + 1];
if (!arg.startsWith("-")) return arg;
if (NODE_LAUNCH_VALUE_FLAGS.has(arg)) {
if (!argv[++i]) return undefined;
} else if (!NODE_LAUNCH_FLAGS.has(arg)
&& !(arg.includes("=") && process.allowedNodeEnvironmentFlags.has(arg))) {
return undefined;
}
}
return undefined;
}
/** The Codex process and its Codex launcher ancestors, deepest first, up to the pane root. */
function codexChain(rows: NativeProcessRow[], deepest: NativeProcessRow, panePid: number): NativeProcessRow[] {
function codexChain(rows: NativeProcessRow[], deepest: NativeProcessRow, panePid: number,
launcherFacts: ReadonlyMap<number, ProcessFacts | null>): NativeProcessRow[] {
const byPid = new Map(rows.map((row) => [row.pid, row]));
const chain = [deepest];
let current = deepest;
@@ -860,7 +901,16 @@ function codexChain(rows: NativeProcessRow[], deepest: NativeProcessRow, panePid
if (!parent) break;
const name = executableBase(parent);
// A Codex launcher above the native binary: codex itself, or node running Codex's launcher script.
const launcher = /^codex/i.test(name) || (name === "node" && processArgv(parent.command).some((arg) => /codex/i.test(arg)));
// Node can name its Linux main thread MainThread; require the OS executable before accepting it.
const facts = launcherFacts.get(parent.pid);
const mainThreadNode = name === "MainThread" && nodePath.basename(facts?.exe ?? "") === "node";
// ps flattens argument boundaries; only exact argv can distinguish an option value from the entry script.
const script = mainThreadNode ? nodeLaunchScript(facts?.argv ?? []) : undefined;
const codexScript = !!script && nodePath.isAbsolute(script)
&& (nodePath.basename(script) === "codex" || script.endsWith("/bin/codex.js"));
const launcher = /^codex/i.test(name)
|| (name === "node" && processArgv(parent.command).some((arg) => /codex/i.test(arg)))
|| (mainThreadNode && codexScript);
if (!launcher) break;
chain.push(parent);
current = parent;
@@ -1,3 +1,4 @@
import { ANTHROPIC_PROFILE_ENV, OUTRANKING_TOKEN_ENV } from "./claude-credential-precedence.js";
import os from "node:os";
import path from "node:path";
import type Database from "better-sqlite3";
@@ -7,21 +8,41 @@ import { parseStoredSeatEnv, type SeatEnv } from "./seat-env.js";
export const RUNTIME_HOME_VAR = { "claude-code": "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME" } as const;
export type HomeRuntime = keyof typeof RUNTIME_HOME_VAR;
/** Provider environment excluded by an explicit isolated account-home selection. */
export const ACCOUNT_HOME_PROVIDER_ENV = [...OUTRANKING_TOKEN_ENV, ...ANTHROPIC_PROFILE_ENV, "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL",
"OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN", "OPENAI_FEDERATION_RULE_ID", "OPENAI_IDENTITY_TOKEN_FILE", "OPENAI_BASE_URL", "OPENAI_ORG_ID", "OPENAI_ORGANIZATION", "OPENAI_PROJECT", "OPENAI_PROJECT_ID", "AWS_BEARER_TOKEN_BEDROCK"] as const;
export interface SeatRuntimeHome {
/** The seat's own home: an absolute path its member, else its rig, declares in `env:`. */
home?: string;
source?: "member" | "rig";
source?: "member" | "rig" | "account-switch";
/** A declared value that isn't an absolute path. It isn't used as the seat's home, and nothing is refused for it: the
* seat resolves as it did before, and status reports the value. */
ignored?: { source: "member" | "rig"; value: string };
}
/** An explicit native account-home switch, scoped to the node's current runtime. */
export function seatAccountHome(db: Database.Database, nodeId: string | null | undefined, runtime: string): string | undefined {
if (!nodeId) return undefined;
const columns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
if (!columns.some(column => column.name === "account_home")) return undefined;
const row = db.prepare("SELECT runtime, account_home FROM nodes WHERE id = ?").get(nodeId) as
{ runtime: string | null; account_home: unknown } | undefined;
if (!row || row.runtime !== runtime || row.account_home == null) return undefined;
if (typeof row.account_home !== "string" || !path.isAbsolute(row.account_home) || /[\0\r\n]/.test(row.account_home)) {
throw new Error("The stored account-home selection is invalid; no fallback account was selected.");
}
return row.account_home;
}
/** A seat's own runtime home, read from its stored `env:` (member, then rig, as the launch merges them). It covers only
* the declaration: the daemon's explicit value and the native default stay with each caller, so a seat that declares
* nothing resolves exactly as before. A configured home names where the seat's runtime looks; it isn't proof of which
* login a running process uses. */
export function seatRuntimeHome(db: Database.Database, nodeId: string | null | undefined, runtime: HomeRuntime): SeatRuntimeHome {
export function seatRuntimeHome(db: Database.Database, nodeId: string | null | undefined, runtime: HomeRuntime, ignoreAccountHome = false): SeatRuntimeHome {
if (!nodeId) return {};
const accountHome = ignoreAccountHome ? undefined : seatAccountHome(db, nodeId, runtime);
if (accountHome) return { home: accountHome, source: "account-switch" };
let row: { member: string | null; rig: string | null } | undefined;
try {
row = db.prepare(`SELECT n.env_json AS member, r.env_json AS rig FROM nodes n JOIN rigs r ON r.id = n.rig_id WHERE n.id = ?`)
@@ -40,8 +61,8 @@ export function seatRuntimeHome(db: Database.Database, nodeId: string | null | u
/** The home a seat's launches and OpenRig's readers use: its own, else the daemon's explicit value. Undefined means the
* runtime's native default. */
export function effectiveRuntimeHome(db: Database.Database, nodeId: string | null | undefined, runtime: HomeRuntime,
daemonHome: string | undefined): string | undefined {
return seatRuntimeHome(db, nodeId, runtime).home ?? daemonHome;
daemonHome: string | undefined, ignoreAccountHome = false): string | undefined {
return seatRuntimeHome(db, nodeId, runtime, ignoreAccountHome).home ?? daemonHome;
}
/** Every distinct home seats declare for a runtime (absolute values only), for readers that find a record by an id that is
@@ -57,6 +78,11 @@ export function declaredRuntimeHomes(db: Database.Database, runtime: HomeRuntime
if (value && path.isAbsolute(value)) homes.add(path.normalize(value));
}
} catch { /* A database from before seat env has no columns to read. */ }
try {
for (const row of db.prepare("SELECT account_home FROM nodes WHERE runtime = ? AND account_home IS NOT NULL").all(runtime) as Array<{ account_home: string }>) {
if (path.isAbsolute(row.account_home)) homes.add(row.account_home);
}
} catch { /* No account-home selections before the migration. */ }
return [...homes];
}
@@ -1,4 +1,8 @@
import { claudeSeatTokenRefProblem, claudeSeatTokenFileProblem, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
import { isolatedClaudeAccountProblem } from "./claude-credential-precedence.js";
import nodePath from "node:path";
import fs from "node:fs";
import { RUNTIME_HOME_VAR } from "./seat-runtime-home.js";
import { ulid } from "ulid";
import type Database from "better-sqlite3";
import type { RigRepository } from "./rig-repository.js";
@@ -77,6 +81,11 @@ export interface SetRuntimeInput {
model?: string;
effort?: string;
codexConfigProfile?: string;
/** Isolated target native account directory; never credential values. */
accountHome?: string;
/** Return to configured member/rig/daemon/native selection, not a historical credential. */
clearAccountHome?: boolean;
accountToken?: ClaudeSeatTokenRef;
/** Target native permission selection; absent or "inherit" means member and rig policy. */
permissionMode?: string;
/** How long the successor has to confirm the handover. */
@@ -91,12 +100,16 @@ export interface RuntimeSwitchRefusal {
switchId?: string;
/** For a failed switch: whether it committed (it didn't, for every refusal this service returns). */
committed?: boolean;
/** The uncommitted successor/pane exchange still needs recovery; do not retry the switch. */
recoveryPending?: boolean;
}
export interface RuntimeSwitchPlan {
seat: { ref: string; nodeId: string; rigId: string; session: string };
from: string;
to: string;
accountHome?: string | null;
accountToken?: ClaudeSeatTokenRef;
settings: RuntimeSwitchMaterials["settings"];
permission: { mode: string; source: "explicit" | "member_or_rig_policy" };
dropped: RuntimeSwitchMaterials["dropped"];
@@ -153,12 +166,31 @@ export class SeatRuntimeSwitchService {
} catch { return null; }
}
/** Validate and plan a successor-first runtime/account move, executing it under the seat's lifecycle guard unless dry-run. */
async setRuntime(input: SetRuntimeInput): Promise<SetRuntimeResult> {
const refuse = (code: string, message: string, guidance?: string): RuntimeSwitchRefusal => ({ ok: false, code, message, ...(guidance ? { guidance } : {}) });
if (input.runtime !== "codex" && input.runtime !== "claude-code") {
return refuse("invalid_runtime_settings", "Choose --runtime codex or --runtime claude-code.");
}
const target: SwitchRuntime = input.runtime;
if (input.clearAccountHome !== undefined && typeof input.clearAccountHome !== "boolean") return refuse("invalid_runtime_settings", "clearAccountHome must be true or false.");
if (input.clearAccountHome && (input.accountHome !== undefined || input.accountToken !== undefined)) return refuse("invalid_runtime_settings", "Choose either an account home/token or clearAccountHome, not both.");
if (input.accountHome !== undefined) {
if (typeof input.accountHome !== "string" || !nodePath.isAbsolute(input.accountHome) || /[\0\r\n]/.test(input.accountHome)) {
return refuse("invalid_runtime_settings", "accountHome must be an absolute directory path on one line.");
}
try {
if (!fs.statSync(input.accountHome).isDirectory()) throw new Error();
fs.accessSync(input.accountHome, fs.constants.R_OK | fs.constants.X_OK);
} catch { return refuse("invalid_runtime_settings", "The target account home must be an existing readable directory; nothing changed."); }
}
if (input.accountToken !== undefined) {
if (!input.accountHome || target !== "claude-code" || !input.accountToken ||
typeof input.accountToken.label !== "string" || typeof input.accountToken.tokenFile !== "string" ||
claudeSeatTokenRefProblem(input.accountToken) || claudeSeatTokenFileProblem(input.accountToken)) {
return refuse("invalid_runtime_settings", "A selected token needs a Claude account home, a label and a readable non-empty token file; nothing changed.");
}
}
const reason = input.reason?.trim();
if (!reason) return refuse("invalid_runtime_settings", "Give a --reason for the switch; it is recorded with it.");
const explicit: { model?: string; effort?: string; codexConfigProfile?: string; permissionMode?: string } = {};
@@ -198,14 +230,18 @@ export class SeatRuntimeSwitchService {
}
const binding = sessionRegistry.getBindingForNode(node.id);
const sessionName = binding?.tmuxSession ?? null;
const base = { seat: { ref: seatRef, nodeId: node.id, rigId: node.rigId, session: sessionName ?? "" }, from: node.runtime, to: target };
if (node.runtime === target) {
const base = { seat: { ref: seatRef, nodeId: node.id, rigId: node.rigId, session: sessionName ?? "" }, from: node.runtime, to: target, ...(input.clearAccountHome ? { accountHome: null } : {}) };
if (node.runtime === target && input.accountHome === undefined && !(input.clearAccountHome && node.accountHome)) {
if (explicit.model || explicit.effort || explicit.codexConfigProfile || explicit.permissionMode) {
return refuse("runtime_unchanged", `${seatRef} already runs ${target}.`,
"Change settings within one runtime with rig seat set-model, rig seat set-effort or rig seat set-permissions.");
}
return { ok: true, dryRun: !!input.dryRun, changed: false, ...base };
}
if (input.accountHome !== undefined && target === "claude-code" && node.cwd) {
const problem = isolatedClaudeAccountProblem(input.accountHome, node.cwd, !!input.accountToken, input.accountToken?.kind === "api-key");
if (problem) return refuse("invalid_runtime_settings", problem);
}
const predecessorGeneration = sessionRegistry.currentOccupantTenure(node.id)?.generationUuid;
const running = sessionRegistry.getSessionsForRig(node.rigId).filter((session) => session.nodeId === node.id && session.status === "running");
const unsupported = !sessionName || !binding?.tmuxPane ? "it has no bound tmux pane"
@@ -258,6 +294,8 @@ export class SeatRuntimeSwitchService {
...(envelope ? [{ setting: "codex permission envelope", value: envelope }] : [])];
const plan: RuntimeSwitchPlan = {
...base,
...(input.accountHome !== undefined ? { accountHome: input.accountHome } : {}),
...(input.accountToken ? { accountToken: input.accountToken } : {}),
settings: materials.settings,
permission: selection ? { mode: selection.mode, source: "explicit" } : { mode: "inherit", source: "member_or_rig_policy" },
dropped,
@@ -287,6 +325,7 @@ export class SeatRuntimeSwitchService {
return { ok: true, switchId: row.id, message: `Confirmed. OpenRig is moving ${row.canonical_session} to you now. Wait for the activation message before acting as the seat.` };
}
/** Stage and confirm the successor before commit; failed preparation retains the predecessor and records cleanup needs. */
private async execute(ctx: {
input: SetRuntimeInput & { reason: string }; node: NonNullable<ReturnType<RigRepository["getRig"]>>["nodes"][number]; seatRef: string; sessionName: string;
predecessorPane: string; predecessorGeneration: string; target: SwitchRuntime; materials: RuntimeSwitchMaterials;
@@ -326,7 +365,7 @@ export class SeatRuntimeSwitchService {
db.prepare(`UPDATE seat_runtime_switches SET phase = 'abandoned', outcome = ?, attention = ?, updated_at = ? WHERE id = ? AND committed_at IS NULL`)
.run(why, retired.ok ? null : `The staged successor couldn't be retired: ${retired.message}`, stamp(), id);
tmux.finishLaunchBinding(row.staging_session);
return { ...failed(`${why}${retired.ok ? "" : ` The staged successor couldn't be retired (${retired.message}); recover with rig seat set-runtime ${ctx.seatRef} --recover.`}`), switchId: id };
return { ...failed(`${why}${retired.ok ? "" : ` The staged successor couldn't be retired (${retired.message}); recover with rig seat set-runtime ${ctx.seatRef} --recover.`}`, retired.ok ? undefined : `Recovery is pending for ${ctx.seatRef}. Run rig seat set-runtime ${ctx.seatRef} --recover.`), switchId: id, ...(!retired.ok ? { recoveryPending: true } : {}) };
};
// 1. The successor, on the target runtime, beside the predecessor.
@@ -349,14 +388,16 @@ export class SeatRuntimeSwitchService {
claudePermissions: override.claudePermissions, kernelAuthority: override.kernelAuthority, teamPermissionDefault: override.teamPermissionDefault,
...(override.codexPermissions ? { codexPermissions: override.codexPermissions } : {}),
nonInterruptive: rigRepo.getRigNonInterruptive(node.rigId), launchPosture, ...(permissionMode ? { permissionMode } : {}),
seatEnv: withPluginDirs(rigRepo.getEffectiveSeatEnv(node.id), [corePluginDir, target === "claude-code" ? materials.seatSkills.pluginDir : undefined]),
seatEnv: withPluginDirs({ ...rigRepo.getEffectiveSeatEnv(node.id, ctx.input.accountHome === undefined),
...(ctx.input.accountHome !== undefined ? { [RUNTIME_HOME_VAR[target]]: ctx.input.accountHome } : {}) }, [corePluginDir, target === "claude-code" ? materials.seatSkills.pluginDir : undefined]),
...((corePluginDir || codexCorePluginDir) && node.cwd ? { seatProjectDir: node.cwd } : {}) },
onPaneCreated: (pane) => {
successorPane = pane;
db.prepare("UPDATE seat_runtime_switches SET successor_pane = ?, updated_at = ? WHERE id = ?").run(pane, stamp(), id);
},
staged: { prepare: (binding, adapter) => materials.prepare(binding, adapter),
binding: { ...materials.bindingAdditions(), runtimeSwitchFrom: { runtime: node.runtime!, session: sessionName, pane: ctx.predecessorPane, generation: ctx.predecessorGeneration } } },
binding: { ...materials.bindingAdditions(), ...(ctx.input.accountHome !== undefined ? { accountHome: ctx.input.accountHome, accountToken: ctx.input.accountToken ?? null }
: ctx.input.clearAccountHome || node.accountHome ? { accountHome: null, ...(node.accountHome ? { accountToken: null } : {}) } : {}), runtimeSwitchFrom: { runtime: node.runtime!, session: sessionName, pane: ctx.predecessorPane, generation: ctx.predecessorGeneration } } },
});
if (!launch.ok) {
const attention = launch.code === "successor_attention_required" ? " It is waiting at a login, trust or approval prompt." : "";
@@ -426,7 +467,7 @@ export class SeatRuntimeSwitchService {
db.prepare("UPDATE seat_runtime_switches SET attention = ?, updated_at = ? WHERE id = ?")
.run(`The commit failed (${(error as Error).message}) and the panes couldn't be exchanged back: ${back.message}`, stamp(), id);
return { ...failed(`The commit failed (${(error as Error).message}), and the panes couldn't be put back (${back.message}).`,
`The ${node.runtime} occupant still holds ${ctx.seatRef} by record. Recover with rig seat set-runtime ${ctx.seatRef} --recover.`), switchId: id };
`The ${node.runtime} occupant still holds ${ctx.seatRef} by record. Recover with rig seat set-runtime ${ctx.seatRef} --recover.`), switchId: id, recoveryPending: true };
}
return abandon(`The commit failed: ${(error as Error).message}.`);
}
@@ -443,6 +484,7 @@ export class SeatRuntimeSwitchService {
warnings: finished.ok ? warnings : [...warnings, `The switch committed, but ${finished.message} Finish it with rig seat set-runtime ${ctx.seatRef} --recover.`] };
}
/** Atomically move runtime, account references and queue custody to the confirmed successor; pane recovery follows separately. */
private commit(ctx: {
input: SetRuntimeInput & { reason: string }; node: NonNullable<ReturnType<RigRepository["getRig"]>>["nodes"][number]; sessionName: string;
predecessorPane: string; predecessorGeneration: string; target: SwitchRuntime; materials: RuntimeSwitchMaterials;
@@ -470,6 +512,9 @@ export class SeatRuntimeSwitchService {
occupant_lifecycle = 'active', continuity_outcome = 'fresh', handover_result = 'complete', previous_occupant = ?, handover_at = ? WHERE id = ?`)
.run(target, materials.settings.model.value, materials.settings.effort.value, materials.settings.advisor.value, materials.settings.codexConfigProfile.value,
...(spec ? [spec.name, spec.version, spec.hash, spec.restorePolicy] : []), ctx.sessionName, at, node.id);
db.prepare("UPDATE nodes SET account_home = ?, claude_token_ref = CASE WHEN ? THEN ? ELSE claude_token_ref END WHERE id = ?")
.run(ctx.input.accountHome ?? null, ctx.input.accountHome !== undefined || node.accountHome ? 1 : 0,
ctx.input.accountToken ? JSON.stringify(ctx.input.accountToken) : null, node.id);
new NativePermissionStore(db).write(node.id, ctx.selection, ctx.input.requestedBy?.trim() || ctx.sessionName, ctx.input.reason);
// One explicit Codex choice: the switch's mode replaces the seat's envelope here, and the event says so.
const replaced = target === "codex" && ctx.selection ? clearCodexMemberPermissions(db, node.id) : undefined;
@@ -8,7 +8,7 @@ import type { NodeInventoryEntry, SeatIdentityVerdict } from "./types.js";
import { NativePermissionStore, type StoredNativePermissionSelection, type ResolvedSeatPermission } from "./native-permission-store.js";
import { AppliedLaunchObservationStore, type StoredAppliedLaunchObservation } from "./applied-launch-observation-store.js";
import { planPermissionAccount, type PermissionAccount } from "./permission-account.js";
import { RUNTIME_HOME_VAR, seatRuntimeHome, type HomeRuntime } from "./seat-runtime-home.js";
import { seatAccountHome, RUNTIME_HOME_VAR, seatRuntimeHome, type HomeRuntime } from "./seat-runtime-home.js";
import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, readClaudeSeatTokenRef } from "./claude-seat-token.js";
const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
@@ -39,12 +39,12 @@ export interface SeatStatus {
runtime_home?: {
variable: "CLAUDE_CONFIG_DIR" | "CODEX_HOME";
home: string | null;
source: "member" | "rig" | "daemon" | "native_default";
ignored?: { source: "member" | "rig"; value: string };
source: "member" | "rig" | "account-switch" | "daemon" | "native_default";
ignored?: { source: "member" | "rig" | "account-switch"; value: string };
};
/** A Claude seat's own subscription token: its reference (never the value), and whether the file would give Claude the
* token at the next launch. When it wouldn't, Claude starts on the config dir's stored login, and `notice` says so. */
claude_token?: { label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
claude_token?: { kind?: "api-key"; label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
terminalPolicy: ReturnType<TerminalPolicyStore["account"]> | { state: "unknown"; error: string };
permissions: {
selectionState: "explicit" | "inherit" | "unknown";
@@ -190,13 +190,15 @@ export class SeatStatusService {
return { runtime_home: { variable, home: native, source: "native_default", ...ignored } };
}
/** Report a Claude token's stored reference and file availability without reading its value or verifying the login. */
private claudeTokenStatus(nodeId: string, runtime: string | null): Pick<SeatStatus, "claude_token"> {
if (runtime !== "claude-code") return {};
const ref = readClaudeSeatTokenRef(this.rigRepo.db, nodeId);
if (!ref) return {};
const problem = claudeSeatTokenFileProblem(ref);
return { claude_token: { label: ref.label, token_file: ref.tokenFile, file: problem ? "unusable" : "readable",
...(problem ? { problem, notice: claudeSeatTokenFallbackNotice(ref, problem) } : {}) } };
return { claude_token: { ...(ref.kind ? { kind: ref.kind } : {}), label: ref.label, token_file: ref.tokenFile, file: problem ? "unusable" : "readable",
...(problem ? { problem, notice: seatAccountHome(this.rigRepo.db, nodeId, "claude-code")
? "The selected account token is unusable; no agent will start." : claudeSeatTokenFallbackNotice(ref, problem) } : {}) } };
}
private terminalPolicyStatus(nodeId: string, runtime: string | null): SeatStatus["terminalPolicy"] {
+21 -2
View File
@@ -513,8 +513,14 @@ export class SessionRegistry {
// empty-token skip or a lower-rank no-op. Callers that only set-and-forget can
// ignore it; the adoption-capture path uses it so its audit event never
// falsely claims a captured write when the provenance guard refused it.
/** Record that this session was launched for a community harness, selected through its preset. */
recordCommunityHarnessSelection(sessionId: string, app: string): void {
/** Record that this session was launched for a community harness, selected through its preset. With `runner`, also
* record what that launch's preset declared (`runner: true` or not), so activity reads the launched occupant's
* declaration, never a later preset edit. Without it (a restore carrying only the selection) the field is left as is. */
recordCommunityHarnessSelection(sessionId: string, app: string, runner?: boolean): void {
if (runner !== undefined && sessionCommunityRunnerSelect(this.db, "sessions") !== "NULL") {
this.db.prepare("UPDATE sessions SET agent_app = ?, agent_runner = ? WHERE id = ?").run(app, runner ? 1 : 0, sessionId);
return;
}
this.db.prepare("UPDATE sessions SET agent_app = ? WHERE id = ?").run(app, sessionId);
}
@@ -942,3 +948,16 @@ export function sessionCommunityHarnessSelect(db: Database.Database, alias = "s"
}
return has ? `${alias}.agent_app` : "NULL";
}
const sessionsHaveCommunityRunner = new WeakMap<Database.Database, boolean>();
/** The SQL that reads whether a session's community app was launched as a runner: 1, 0, or NULL. NULL also on a schema
* without the column, so a legacy database reads every seat as before (never a runner opt-in). */
export function sessionCommunityRunnerSelect(db: Database.Database, alias = "s"): string {
let has = sessionsHaveCommunityRunner.get(db);
if (has === undefined) {
has = (db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name?: string }>).some(row => row.name === "agent_runner");
sessionsHaveCommunityRunner.set(db, has);
}
return has ? `${alias}.agent_runner` : "NULL";
}
@@ -238,6 +238,8 @@ const CLAUDE_STATUS_WARNINGS = [
/^Update available! Run: \S.*$/,
// The context hint, as Claude prints it; reported as the last line below the mode bar on one seat.
/^new task\? \/clear to save \d+(?:\.\d+)?k tokens$/,
// After Claude installs an update: "/restart to apply" (a Claude seat, 2026-10-30) or "Restart to apply".
/^✔ Update installed · (?:\/restart|Restart) to apply$/,
];
// Current Claude status rows need not end in "thinking)" or show "esc to interrupt".
// Completed summaries such as "✻ Crunched for 2s" lack the live ellipsis/timer shape.
@@ -457,7 +459,8 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
// Claude replaces its mode hint after our paste; that noninteractive hint is not a new composer.
const stableFooter = prompt.glyph === "❯" && (CLAUDE_MODE_FOOTER.test(footer) || isClaudePasteHint(footer)) ? "claude-composer" : footer;
const frame = JSON.stringify([prompt.glyph, prefix, width, stableFooter]);
const input: ComposerInput = { state: empty ? "empty" : "text", cursor: { ...snapshot.cursor }, suggestionRows, rows: visible, columns: width - prefix, cursorAtEnd, collapsedPaste: label, frame,
const nativeCodex = prompt.glyph !== "❯" && codexFooterSeparator(lines) === end;
const input: ComposerInput = { state: empty ? "empty" : "text", cursor: { ...snapshot.cursor }, suggestionRows, rows: visible, columns: width - prefix - (nativeCodex ? 1 : 0), cursorAtEnd, collapsedPaste: label, frame,
...(ghost ? { ghost } : {}) };
// A numbered body is allowed only as this operation's exact text in its
// previously empty composer. A newly observed menu gains no such authority.
@@ -467,6 +470,13 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
input.collapsedPaste = visible.join("\n");
input.pasteMatchesExpected = true;
}
if (nativeCodex) {
// Codex 0.164 fullscreen: composer <= 2/3 of the pane (at least eight
// rows, capped by the pane), less padding/status/hint rows. Leave the
// one-column right inset out of the native printable width.
input.pasteViewport = { columns: input.columns,
rows: Math.min(height, Math.max(8, Math.floor(height * 2 / 3))) - 4 };
}
candidates.push(input);
}
return candidates.length === 1 ? candidates[0]! : unknown(candidates.length ? "ambiguous_composer" : "unrecognized_composer", snapshot.cursor);
@@ -1730,6 +1740,8 @@ export interface SendOpts {
onInputEffect?: (phase: "pasted" | "submitted") => void;
/** Internal managed lifecycle prerequisite; never accepted from HTTP send options. */
beforeWrite?: () => void;
/** Internal: managed lifecycle input must preserve a person's draft even when the optional typing guard is off. */
preserveInput?: true;
/** The caller's request. Aborted while the send still waits, before any input starts, the send ends with
* `caller_cancelled` and never writes, then or later. Once input may have started it is ignored: the result stays
* attempted or written. Queue wakes and committed outbox deliveries have no caller and ignore it. */
@@ -3193,6 +3205,15 @@ export class SessionTransport {
const cancel = callerSignal(opts);
if (cancel?.aborted) return observe({ ...callerCancelled(sessionName), ...(waitMode ? waitEvidence : {}) });
const beforePaste = cancel ? () => { opts?.beforeWrite?.(); if (cancel.aborted) throw new DeliveryGuardError(CALLER_CANCELLED, "caller cancelled before the paste"); } : opts?.beforeWrite;
const preserveInput = opts?.preserveInput ? async () => {
const box = runtime === "claude-code" || runtime === "codex"
? await this.captureInputBox(readTarget, runtime, { remainingMs: INPUT_BOX_READ_DEADLINE_MS }) : null;
if (bindingChanged()) throw new DeliveryGuardError("draft_input_changed", "The managed input target changed; no input was written.");
if (box?.status !== "empty" && !(box?.status === "likely-suggestion" && box.confidence === "high")) {
throw new DeliveryGuardError(box?.status === "typed" ? "draft_input_busy" : "draft_input_unknown",
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`);
}
} : undefined;
// Set once the text write has been handed to the adapter: from then on a throw is not "nothing sent".
let writeIssued = false;
const tagIssued = (error: unknown): never => {
@@ -3204,7 +3225,9 @@ export class SessionTransport {
() => {
opts?.beforeWrite?.();
writeIssued = true;
if (promptOverride) return this.tmuxAdapter.sendText(sessionName, text, beforePaste, { bracketed: false, beforePaste: overrideBeforePaste });
if (promptOverride) return this.tmuxAdapter.sendText(sessionName, text, beforePaste, { bracketed: false,
beforePaste: preserveInput ? async () => { await overrideBeforePaste?.(); await preserveInput(); } : overrideBeforePaste });
if (preserveInput) return this.tmuxAdapter.sendText(sessionName, text, beforePaste, { beforePaste: preserveInput });
return beforePaste ? this.tmuxAdapter.sendText(sessionName, text, beforePaste) : this.tmuxAdapter.sendText(sessionName, text);
},
(result) => result.ok ? "ok" : "failed",
@@ -552,9 +552,12 @@ export class StartupOrchestrator {
// 5. Launch harness (unless skipped for legacy nodes)
// A community harness's seat: record the selection on this session before the app starts. It's provenance for
// later reads (identity, activity, labels, restore), not launch success or identity proof.
// later reads (identity, activity, labels, restore), not launch success or identity proof. Whether this launch's
// preset declares a runner is recorded with it, so activity follows the occupant as launched, not a later edit:
// only a harness launch samples it, and a continuation that launches nothing keeps what was recorded.
if (input.adapter instanceof CommunityHarnessRuntimeAdapter) {
try { this.sessionRegistry.recordCommunityHarnessSelection(input.sessionId, input.adapter.runtime); }
try { this.sessionRegistry.recordCommunityHarnessSelection(input.sessionId, input.adapter.runtime,
input.skipHarnessLaunch ? undefined : input.adapter.runner()); }
catch (err) { errors.push(`Couldn't record the community harness selection: ${(err as Error).message}`); return this.fail(input, "failed", errors); }
}
@@ -595,6 +598,13 @@ export class StartupOrchestrator {
sessionId: input.sessionId, ...launchResult.firstInput });
warnings.push("First startup input passed at launch; submission is not confirmed.");
}
if (launchResult.modelInput?.dispatch === "confirmed") {
this.eventBus.emit({ type: "node.startup_model_passed", rigId: input.rigId, nodeId: input.nodeId,
sessionId: input.sessionId, model: launchResult.modelInput.model, resume: !!launchResumeToken });
warnings.push(`Model "${launchResult.modelInput.model}" passed to the launcher at launch; which model runs is not confirmed.`);
} else if (launchResult.modelInput) {
warnings.push(`Model "${launchResult.modelInput.model}": the launch command's send is uncertain, so it isn't recorded as passed.`);
}
if (launchResult.ok) {
appliedLaunch = launchResult.appliedLaunch;
resumedProcess = launchResult.resumedProcess;
@@ -85,7 +85,7 @@ export interface StagedLaunch {
canonicalSession: string;
/** Target materials (seat storage, projection, pre-launch files), before the harness starts; returns notes. */
prepare: (binding: NodeBinding, adapter: RuntimeAdapter) => Promise<string[]>;
binding?: Partial<Pick<NodeBinding, "seatSkillsRoot" | "claudeManagedBlockFile" | "runtimeSwitchFrom">>;
binding?: Partial<Pick<NodeBinding, "seatSkillsRoot" | "claudeManagedBlockFile" | "runtimeSwitchFrom" | "accountHome" | "accountToken">>;
}
export type SuccessorLaunchResult =
@@ -471,8 +471,8 @@ export class HerdrAdapter implements TerminalProvider {
return this.deps.viewer?.lookup(key, pane) ?? { ok: false, code: "viewer_not_provisioned", error: "No qualified viewer mapping." };
}
async followView(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>): Promise<OpenViewResult> {
return this.deps.following?.follow(viewId, view, controls, stillCurrent)
async followView(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>, membershipError?: string): Promise<OpenViewResult> {
return this.deps.following?.follow(viewId, view, controls, stillCurrent, membershipError)
?? { provider: "herdr", ok: false, opened: [], absent: [], degraded: [], pages: 0,
code: "follow_unsupported", error: "This endpoint has no qualified following allocator." };
}
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import path from "node:path";
import { getDefaultOpenRigPath } from "../../openrig-compat.js";
import { HerdrViewer, findAttributedViewerPanes, readHerdrViewerBody as body, parseHerdrViewerPane as pane,
import { HerdrViewer, ViewerCreateNotDispatchedError, findAttributedViewerPanes, readHerdrViewerBody as body, parseHerdrViewerPane as pane,
type HerdrViewerRun, type InspectViewerSeat, type ViewerKey, type ViewerMapping, type ViewerPane } from "./herdr-viewer.js";
import { DEFAULT_FOLLOW_POLICY, planFollowingTiles, type FollowControls, type FollowPolicy, type FollowTile, type FollowingStatus } from "./terminal-follow-policy.js";
import type { ComposedPane, ComposedView, OpenViewResult } from "./terminal-provider.js";
@@ -37,11 +37,11 @@ export class HerdrFollowView {
private pending: Promise<unknown> = Promise.resolve();
constructor(private readonly deps: HerdrFollowDeps) {}
follow(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>): Promise<OpenViewResult> {
const result = this.pending.then(() => this.apply(viewId, view, controls, stillCurrent));
follow(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>, membershipError?: string): Promise<OpenViewResult> {
const result = this.pending.then(() => this.apply(viewId, view, controls, stillCurrent, membershipError));
this.pending = result.catch(() => {}); return result;
}
private async apply(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>): Promise<OpenViewResult> {
private async apply(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>, membershipError?: string): Promise<OpenViewResult> {
const id = createHash("sha256").update(JSON.stringify([this.deps.socketPath, viewId])).digest("hex");
const root = path.join(this.deps.root ?? getDefaultOpenRigPath("private/terminal-following/provider"), id);
const file = path.join(root, "canvas.json"), log = path.join(root, "events.log");
@@ -74,8 +74,9 @@ export class HerdrFollowView {
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
if (!controls.enabled) return result(true, "disabled"); // Keep the fixed canvas exactly as it is.
if (!view?.task?.complete) {
if (state) { notice("Task membership is incomplete; keeping the previous view."); save(); }
return result(false, "retained", "Task membership is incomplete; keeping the previous view.");
const message = membershipError ?? "Task membership is incomplete; keeping the previous view.";
if (state) { notice(message); save(); }
return result(false, "retained", message);
}
if ((this.deps.platform ?? process.platform) !== "linux") throw new Error("Following is qualified on Linux Herdr 0.9.5/protocol 22 only.");
const status = body(await this.deps.run(["status", "server", "--json"]));
@@ -84,7 +85,7 @@ export class HerdrFollowView {
}
if (!await stillCurrent()) throw new Error("Task membership changed before reconciliation; keeping the previous view.");
if (!state) {
state = { version: 1, cwd: path.join(root, "strip"), phase: "creating", policy: { ...DEFAULT_FOLLOW_POLICY, pins: [], suppressed: {} },
state = { version: 1, cwd: path.join(root, "strip"), phase: "creating", policy: { ...DEFAULT_FOLLOW_POLICY, ...(view.task.kind === "slice" || view.task.kind === "mission" ? { maxTiles: 4 } : {}), pins: [], suppressed: {} },
tiles: [], events: [`Following ${view.task.name}. Opening a tile can change an agent's terminal size.`] };
mkdirSync(state.cwd, { recursive: true, mode: 0o700 }); save();
// Persist first. An uncertain response is enumerated on the next call,
@@ -160,9 +161,12 @@ export class HerdrFollowView {
throw new Error(`${tile.seat}: Core's current attachment differs from the retained viewer; keeping the previous view unconfirmed.`);
}
}
const current = await currentPane(tile.viewer);
let current: ViewerPane;
try { current = await currentPane(tile.viewer); }
catch (error) { throw new Error(`${tile.seat}: unknown: ${error instanceof Error ? error.message : String(error)}`); }
const verified = await viewers.lookup(tile.member, tile.target);
if (!verified.ok || JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error("A retained viewer is unconfirmed; keeping the whole view.");
if (!verified.ok) throw new Error(`${tile.seat}: ${verified.phase ?? "unknown"}: ${verified.error} Keeping the whole view.`);
if (JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error(`${tile.seat}: Viewer mapping changed; keeping the whole view.`);
observations.set(tile.key, { focused: current.focused!, input: await this.deps.input?.(tile.target) ?? "unknown" });
}
const plan = planFollowingTiles(state.tiles, members, true, observations, policy, now());
@@ -173,19 +177,24 @@ export class HerdrFollowView {
if (!target || !member.key.hostId || !member.key.nodeId) { notice(`${join.seat}: ${member.reason ?? "Terminal is unavailable; the member remains in this task."}`); continue; }
const memberKey = { hostId: member.key.hostId, nodeId: member.key.nodeId };
let verified = await viewers.ensure(memberKey, target, stillCurrent, async (cwd) => {
await currentPane(state!.pane!);
if (!await stillCurrent()) throw new Error("Membership changed before the split.");
const anchor = state!.tiles.at(-1)?.viewer;
if (anchor) await currentPane(anchor);
try {
await currentPane(state!.pane!);
if (!await stillCurrent()) throw new Error("Membership changed before the split.");
if (anchor) await currentPane(anchor);
} catch (error) {
throw new ViewerCreateNotDispatchedError(error instanceof Error ? error.message : String(error));
}
const split = body(await this.deps.run(["pane", "split", "--pane", anchor?.paneId ?? state!.pane!.pane_id,
"--direction", anchor && state!.tiles.length % 2 ? "right" : "down", "--no-focus", "--cwd", cwd]));
return JSON.stringify({ root_pane: split.pane });
});
const originalFailure = !verified.ok ? `${verified.phase ?? "unknown"}: ${verified.error}` : undefined;
for (let attempt = 0; !verified.ok && attempt < 5; attempt++) {
await new Promise(resolve => setTimeout(resolve, 50));
verified = await viewers.lookup(memberKey, target); // read-only: never repeats a create/run.
}
if (!verified.ok) { notice(`${join.seat}: ${verified.error}`); continue; }
if (!verified.ok) { notice(`${join.seat}: ${originalFailure}; confirmation: ${verified.phase ?? "unknown"}: ${verified.error}`); continue; }
state.tiles.push({ key: join.key, seat: join.seat, target, member: { hostId: member.key.hostId, nodeId: member.key.nodeId }, viewer: verified.viewer });
notice(`${join.seat}: ${join.reason}`); save();
}
@@ -252,13 +261,15 @@ export class HerdrFollowView {
}
for (const member of view.task.members) if (member.availability !== "available") notice(`${member.seat}: ${member.reason ?? member.availability}`);
for (const overflow of plan.overflow) notice(`${overflow.seat}: ${overflow.reason}`);
if (!members.length && !state.tiles.length) notice("No open obligation remains. Automatic following has stopped; the workspace is kept. Use --follow to resume it.");
if (!members.length && !state.tiles.length) notice(view.task.kind === "qitem"
? "No open obligation remains. Automatic following has stopped; the workspace is kept. Use --follow to resume it."
: "No active work; waiting for associated work while following this slice.");
save();
const unavailableJoins = plan.joins.filter(join => !state!.tiles.some(tile => tile.key === join.key)
&& view.task!.members.some(m => keyOf(m.key.hostId, m.key.nodeId) === join.key && m.availability === "available"));
const answer = result(unavailableJoins.length === 0, members.length === 0 && state.tiles.length === 0 ? "empty" : "following",
unavailableJoins.length ? "Some current members have unconfirmed viewers; see the explanation strip. Inspect before refreshing." : undefined, plan.nextAt);
if (answer.following!.state === "empty") answer.following!.enabled = false;
if (view.task.kind === "qitem" && answer.following!.state === "empty") answer.following!.enabled = false;
answer.following!.overflow = plan.overflow;
answer.following!.tiles = state.tiles.map(t => ({ key: t.key, seat: t.seat, departedAt: t.departedAt,
...(plan.retained.find(r => r.key === t.key) ? { reason: plan.retained.find(r => r.key === t.key)!.reason } : {}) }));
@@ -10,9 +10,11 @@ import type { ComposedPane } from "./terminal-provider.js";
export interface ViewerKey { hostId: string; nodeId: string }
export interface ViewerMapping { workspaceId: string; paneId: string; terminalId: string; readOnly: boolean }
export type ViewerResult = { ok: true; viewer: ViewerMapping; created: boolean }
| { ok: false; code: string; error: string };
| { ok: false; code: string; error: string; phase?: Entry["phase"] };
export type InspectViewerSeat = (argv: string[]) => Promise<{ sessionId: string; nodeId: string }>;
export type HerdrViewerRun = (argv: string[]) => Promise<string>;
/** Only the caller which has not dispatched a create may assert this. */
export class ViewerCreateNotDispatchedError extends Error {}
export type CreateViewerPane = (cwd: string, label: string) => Promise<string>;
export interface ViewerPane { workspace_id: string; pane_id: string; terminal_id: string; cwd: string; focused?: boolean }
type Pane = ViewerPane;
@@ -21,7 +23,6 @@ interface Entry {
phase: "creating" | "created" | "starting"; pane?: Pane;
}
interface Journal { version: 1; entries: Record<string, Entry> }
const unavailable = (error: string): ViewerResult => ({ ok: false, code: "viewer_unconfirmed", error });
const runFile = promisify(execFile);
/** The supported CLI, never guessed socket RPC method parity. Its reported
@@ -103,16 +104,18 @@ export class HerdrViewer {
return findAttributedViewerPanes(this.run, entry.cwd);
}
private async ensureOnce(key: ViewerKey, target: ComposedPane, provision = true, stillCurrent: () => Promise<boolean> = async () => true, createPane?: CreateViewerPane): Promise<ViewerResult> {
let entry: Entry | undefined;
const unconfirmed = (error: string): ViewerResult => ({ ok: false, code: "viewer_unconfirmed", error, ...(entry ? { phase: entry.phase } : {}) });
try {
if (this.platform !== "linux") return { ok: false, code: "viewer_unsupported", error: "Ensure-viewer is qualified on Linux Herdr 0.9.5/protocol 22 only." };
const status = body(await this.run(["status", "server", "--json"]));
if (status.version !== "0.9.5" || status.protocol !== 22 || status.running !== true || status.socket !== this.socketPath) {
return unavailable("The qualified Herdr version/protocol or selected socket was not confirmed.");
return unconfirmed("The qualified Herdr version/protocol or selected socket was not confirmed.");
}
if (provision && !await stillCurrent()) return unavailable("Task membership changed while waiting to provision; refresh its preview.");
if (!target.attachArgv) return unavailable("Only verified local tmux bindings support ensure-viewer.");
if (provision && !await stillCurrent()) return unconfirmed("Task membership changed while waiting to provision; refresh its preview.");
if (!target.attachArgv) return unconfirmed("Only verified local tmux bindings support ensure-viewer.");
const binding = await this.inspect(target.attachArgv);
if (binding.nodeId !== key.nodeId) return unavailable("The current tmux binding does not match this member’s node identity.");
if (binding.nodeId !== key.nodeId) return unconfirmed("The current tmux binding does not match this member’s node identity.");
if (provision) mkdirSync(this.root, { recursive: true, mode: 0o700 });
const endpoint = createHash("sha256").update(this.socketPath).digest("hex");
const file = path.join(this.root, `${endpoint}.json`);
@@ -122,36 +125,49 @@ export class HerdrViewer {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
journal = { version: 1, entries: {} };
}
if (journal.version !== 1 || !journal.entries || typeof journal.entries !== "object" || Array.isArray(journal.entries)) return unavailable("Viewer provenance is unreadable; no provider mutation was attempted.");
if (journal.version !== 1 || !journal.entries || typeof journal.entries !== "object" || Array.isArray(journal.entries)) return unconfirmed("Viewer provenance is unreadable; no provider mutation was attempted.");
const save = () => {
const tmp = `${file}.${randomUUID()}.tmp`;
writeFileSync(tmp, `${JSON.stringify(journal)}\n`, { mode: 0o600 }); renameSync(tmp, file);
};
const id = JSON.stringify([key.hostId, key.nodeId]);
let entry = journal.entries[id];
entry = journal.entries[id];
let created = false;
if (entry && (entry.command !== target.paneCommand || entry.readOnly !== target.readOnly)) {
return unavailable("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
return unconfirmed("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
}
if (!entry) {
if (!provision) return { ok: false, code: "viewer_not_provisioned", error: "No owned viewer has been provisioned for this member." };
if (Object.keys(journal.entries).length >= 256) return unavailable("This endpoint has 256 retained viewer records; inspect its viewer provenance before adding more.");
if (Object.keys(journal.entries).length >= 256) return unconfirmed("This endpoint has 256 retained viewer records; inspect its viewer provenance before adding more.");
const token = randomUUID();
const cwd = path.join(this.root, endpoint, token); mkdirSync(cwd, { recursive: true, mode: 0o700 });
entry = { cwd, label: `openrig-viewer-${token}`, command: target.paneCommand, argv: target.attachArgv, readOnly: target.readOnly, phase: "creating" };
journal.entries[id] = entry; save();
// No retry here. A timeout is reconciled by enumeration on the next request.
const reply = body(await (createPane ? createPane(cwd, entry.label)
: this.run(["workspace", "create", "--cwd", cwd, "--label", entry.label, "--no-focus"])));
let raw: string;
try {
raw = await (createPane ? createPane(cwd, entry.label)
: this.run(["workspace", "create", "--cwd", cwd, "--label", entry.label, "--no-focus"]));
} catch (error) {
if (createPane && error instanceof ViewerCreateNotDispatchedError) {
// This invocation positively refused BEFORE dispatch, not an empty
// enumeration or a timeout after a possible provider effect.
delete journal.entries[id]; save();
try { rmdirSync(cwd); } catch { /* An unused directory must not prevent a later join. */ }
return unconfirmed(error.message);
}
throw error;
}
const reply = body(raw);
const made = pane(reply.root_pane);
if (!made || made.cwd !== cwd) return unavailable("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
if (!made || made.cwd !== cwd) return unconfirmed("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
entry.pane = made; entry.phase = "created"; save(); created = true;
}
if (!entry.pane) {
// A unique private cwd attributes a previously initiated create, not a
// caller ID or mutable display label. Empty/unreadable is still unknown.
const found = await this.ownedPanes(entry);
if (found.length !== 1) return unavailable("Uncertain viewer creation has no unique private-cwd mapping.");
if (found.length !== 1) return unconfirmed("Uncertain viewer creation has no unique private-cwd mapping.");
entry.pane = found[0]!; entry.phase = "created"; if (provision) save();
}
let current: Pane | null;
@@ -160,13 +176,13 @@ export class HerdrViewer {
if (!current || current.pane_id !== entry.pane.pane_id || current.terminal_id !== entry.pane.terminal_id
|| current.workspace_id !== entry.pane.workspace_id || current.cwd !== entry.cwd) {
if (!provision || (await this.ownedPanes(entry)).length !== 0) {
return unavailable("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
return unconfirmed("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
}
// Only a complete empty readback retires provenance. Remove our empty
// attribution directory, never provider panes or arbitrary journal paths.
if (!/^openrig-viewer-[0-9a-f-]{36}$/.test(entry.label)
|| entry.cwd !== path.join(this.root, endpoint, entry.label.slice("openrig-viewer-".length))) {
return unavailable("Absent viewer provenance has no owned attribution directory.");
return unconfirmed("Absent viewer provenance has no owned attribution directory.");
}
try { rmdirSync(entry.cwd); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
@@ -174,8 +190,10 @@ export class HerdrViewer {
return { ok: false, code: "viewer_not_provisioned", error: "The old viewer is confirmed absent; ensure again to create a verified replacement." };
}
if (entry.phase === "created") {
if (!provision) return unavailable("The created viewer process is not yet confirmed.");
if (!await stillCurrent()) return unavailable("Task membership changed before the viewer process started.");
if (!provision) return unconfirmed("The created viewer process is not yet confirmed.");
if (!await stillCurrent()) return unconfirmed("Task membership changed before the viewer process started.");
const beforeRun = await this.inspect(target.attachArgv);
if (beforeRun.nodeId !== key.nodeId || beforeRun.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed before the viewer process started.");
entry.phase = "starting"; save();
// pane run rc0/empty stdout is not proof. It is sent ONCE then checked.
await this.run(["pane", "run", current.pane_id, entry.command]);
@@ -195,11 +213,11 @@ export class HerdrViewer {
&& (actual[expected.length] === `=${targetName}` || actual[expected.length] === binding.sessionId);
});
const afterBinding = await this.inspect(target.attachArgv);
if (afterBinding.nodeId !== key.nodeId || afterBinding.sessionId !== binding.sessionId) return unavailable("The seat binding changed while provisioning its viewer.");
if (!verified) return unavailable("The viewer process is not confirmed; no pane run was repeated.");
if (afterBinding.nodeId !== key.nodeId || afterBinding.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed while provisioning its viewer.");
if (!verified) return unconfirmed("The viewer process is not confirmed; no pane run was repeated.");
return { ok: true, viewer: { workspaceId: current.workspace_id, paneId: current.pane_id, terminalId: current.terminal_id, readOnly: entry.readOnly }, created };
} catch (error) {
return unavailable(`Viewer effect is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
return unconfirmed(`Viewer effect is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
}
}
@@ -0,0 +1,201 @@
import * as fs from "node:fs";
import path from "node:path";
import type Database from "better-sqlite3";
import { parseFrontmatter, type SliceIndexer } from "../slices/slice-indexer.js";
import { parseScopeTags } from "../slices/qitem-membership.js";
import { belongsToProject } from "../workspace/project-catalog.js";
import { NODE_FILE_PRECEDENCE } from "../scope/node-file.js";
import { projectTaskOwners, type TaskSeat, type TaskTerminalView } from "./task-terminal-view.js";
export interface ScopeViewError { code: "scope_unsupported" | "scope_not_found" | "scope_ambiguous" | "scope_unavailable"; error: string }
type IndexContext = Pick<SliceIndexer, "slicesRoot" | "additionalSliceRoots" | "projectId">;
interface Scope { name: string; id?: string; title: string; source: string; mission?: string; missionSource?: string }
const MAX_ENTRIES = 4096, MAX_HEADER_BYTES = 16384, MAX_SOURCE_BYTES = 8 * 1024 * 1024;
const MAX_ROWS = 50000, MAX_TAG_BYTES = 16384, MAX_TOTAL_TAG_BYTES = 8 * 1024 * 1024;
class ScopeLimit extends Error {}
const unavailable = (error: string): ScopeViewError => ({ code: "scope_unavailable", error });
/** Strict bounded identity read over the configured index roots. The review
* listing's first-match and unreadable-as-empty fallbacks are not authority
* for retiring viewers. No inferred project, substring match or remote I/O. */
function readScopeIndex(index: IndexContext): Scope[] | ScopeViewError {
let entries = 0, bytes = 0;
const scopes: Scope[] = [], seenRoots = new Set<string>(), seenScopes = new Map<string, Scope>();
const list = (dir: string, visit: (name: string) => void) => {
const handle = fs.opendirSync(dir);
try {
for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
if (++entries > MAX_ENTRIES) throw new ScopeLimit(`Scope index exceeds its ${MAX_ENTRIES}-entry work limit.`);
if (entry.isDirectory() && !entry.name.startsWith(".")) visit(entry.name);
}
} finally { handle.closeSync(); }
};
const add = (dir: string, name: string, mission?: string, missionSource?: string) => {
const source = fs.realpathSync(dir), existing = seenScopes.get(source);
if (existing) {
if (missionSource && !existing.missionSource) { existing.mission = mission; existing.missionSource = missionSource; }
return;
}
let raw = "";
for (const file of NODE_FILE_PRECEDENCE) {
let fd: number;
try { fd = fs.openSync(path.join(dir, file), "r"); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
try {
const buffer = Buffer.alloc(MAX_HEADER_BYTES);
const size = fs.readSync(fd, buffer, 0, buffer.length, 0);
bytes += size;
if (bytes > MAX_SOURCE_BYTES) throw new ScopeLimit("Scope index exceeds its 8-MiB source-byte work limit.");
raw = buffer.subarray(0, size).toString("utf8");
if (raw.startsWith("---") && !raw.slice(3).includes("\n---")) {
if (size === MAX_HEADER_BYTES) throw new ScopeLimit("Scope frontmatter exceeds its 16-KiB header work limit.");
throw new Error("Unterminated scope frontmatter.");
}
} finally { fs.closeSync(fd); }
break; // SPEC.md wins; an unreadable selected node never falls back.
}
const fields = parseFrontmatter(raw);
const id = typeof fields.id === "string" && fields.id ? fields.id : undefined;
const title = typeof fields.title === "string" && fields.title ? fields.title
: typeof fields.slice === "string" && fields.slice ? fields.slice : raw.match(/^# (.+)$/m)?.[1]?.trim() || name;
const scope = { name, id, title, source, mission, missionSource };
seenScopes.set(source, scope); scopes.push(scope);
};
try {
for (const [position, root] of [index.slicesRoot, ...index.additionalSliceRoots].entries()) {
let real: string;
try { real = fs.realpathSync(root); }
catch (error) {
// Explicit legacy compatibility roots may not exist. The primary
// configured index and every existing root must remain readable.
if (position > 0 && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
throw error;
}
if (seenRoots.has(real)) continue;
seenRoots.add(real);
list(root, name => {
const dir = path.join(root, name), nested = path.join(dir, "slices");
let isMission = false;
try { isMission = fs.statSync(nested).isDirectory(); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
if (isMission) {
const missionSource = fs.realpathSync(dir);
list(nested, child => add(path.join(nested, child), child, name, missionSource));
}
else add(dir, name);
});
}
} catch (error) {
return unavailable(error instanceof ScopeLimit ? `${error.message} This index is too large for this bounded read; use a smaller configured index or a qitem view.`
: "Scope index sources are unavailable or malformed; inspect the configured sources and refresh the view.");
}
return scopes;
}
function selectSlice(scopes: Scope[], key: string): Scope | ScopeViewError {
if (!key || key.trim() !== key || /[\\/\x00-\x1f\x7f]/.test(key)) return { code: "scope_not_found", error: "Invalid slice name or id." };
const matches = scopes.filter(scope => scope.name === key || scope.id === key);
if (!matches.length) return { code: "scope_not_found", error: `No indexed slice exactly matches '${key}'.` };
if (matches.length !== 1) return { code: "scope_ambiguous", error: `Multiple indexed slices match '${key}'; choose an unambiguous scope.` };
const selected = matches[0]!;
// Flat legacy slices have no mission context to place a colliding alias.
if (!selected.missionSource && scopes.some(other => other !== selected && (other.name === selected.name || other.id === selected.name
|| (selected.id && (other.id === selected.id || other.name === selected.id))))) {
return { code: "scope_ambiguous", error: `Slice '${key}' has a colliding name or id association.` };
}
return selected;
}
/** Active typed-tag owners only, with one bounded local queue projection.
* A cap or malformed record is incomplete, never authoritative empty. */
export function readScopedTaskTerminalView(db: Database.Database, view: string, index: IndexContext,
resolveSeat: (address: string) => TaskSeat | null, readIndex = () => readScopeIndex(index)): TaskTerminalView | ScopeViewError {
if (!view.startsWith("slice:")) return { code: "scope_unsupported", error: "Authoritative following for this scope kind is not implemented." };
const identities = readIndex();
if ("code" in identities) return identities;
const scope = selectSlice(identities, view.slice(6));
if ("code" in scope) return scope;
// At most two linear index passes per resolve, never a scan per queue row.
const claims = new Map([...new Set([scope.name, scope.id].filter((key): key is string => !!key))].map(key => {
const candidates = identities.filter(candidate => candidate.name === key || candidate.id === key);
const byMission = new Map<string, Scope[]>();
for (const candidate of candidates) {
if (!candidate.mission) continue;
const sameMission = byMission.get(candidate.mission) ?? [];
sameMission.push(candidate); byMission.set(candidate.mission, sameMission);
}
return [key, { count: candidates.length, byMission }] as const;
}));
return db.transaction(() => {
const issues = new Set<string>();
const selected: Array<{ qitem_id: string; destination_session: string; state: string }> = [];
const tagSize = "coalesce(length(CAST(tags AS BLOB)),0)";
const rows = db.prepare(`SELECT qitem_id,destination_session,state,${tagSize} AS tag_bytes,
CASE WHEN ${tagSize}<=${MAX_TAG_BYTES} THEN tags ELSE NULL END AS tags
FROM queue_items INDEXED BY idx_queue_items_state WHERE state IN ('pending','in-progress','blocked') LIMIT ?`)
.iterate(MAX_ROWS + 1) as Iterable<{ qitem_id: string; destination_session: string; state: string; tags: string | null; tag_bytes: number }>;
let scanned = 0, bytes = 0;
for (const row of rows) {
if (++scanned > MAX_ROWS || (bytes += row.tag_bytes) > MAX_TOTAL_TAG_BYTES) {
issues.add("Scoped queue enumeration reached its row/encoded-byte work limit; membership is incomplete."); break;
}
if (row.tag_bytes > MAX_TAG_BYTES) { issues.add("Some active scope tags exceed their per-record work limit; membership is incomplete."); continue; }
let tags: unknown;
try { tags = row.tags === null ? [] : JSON.parse(row.tags); } catch { tags = null; }
if (!Array.isArray(tags) || tags.some(tag => typeof tag !== "string")) {
issues.add("Some active scope tags are malformed; membership is incomplete."); continue;
}
if (index.projectId && !belongsToProject(row.tags, index.projectId)) continue;
const parsed = parseScopeTags(row.tags);
let here = false, elsewhere = false, unplaced = false;
const mission = parsed.missions.size === 1 ? parsed.missions.values().next().value : undefined;
for (const [key, claim] of claims) {
if (!parsed.slices.has(key)) continue;
if (claim.count === 1) { here = true; continue; }
// A reused name/id requires exactly one mission tag and one indexed
// claimant in that mission. Never assign it by first-match or absence.
const placed = mission === undefined ? [] : claim.byMission.get(mission) ?? [];
if (placed.length !== 1) { unplaced = true; continue; }
if (placed[0]!.source === scope.source) here = true; else elsewhere = true;
}
if (unplaced || (here && elsewhere)) {
issues.add(`Queue row '${row.qitem_id}' has unplaceable or contradictory colliding slice tags; membership is incomplete.`);
} else if (here) selected.push(row);
}
selected.sort((a, b) => a.qitem_id.localeCompare(b.qitem_id));
return projectTaskOwners({ kind: "slice", id: scope.id ?? scope.name, name: scope.title,
complete: issues.size === 0, issues: [...issues] }, selected, resolveSeat);
})();
}
/** Cache identities only under live primary-root notification coverage.
* Queue ownership is always read anew. Missing legacy roots are checked on
* every call; an appearing/unwatched root disables generation reuse. */
export function createScopedTaskResolver(db: Database.Database, index: IndexContext,
resolveSeat: (address: string) => TaskSeat | null, observed: () => boolean) {
let cached: Scope[] | undefined;
return {
invalidate() { cached = undefined; },
resolve(view: string): TaskTerminalView | ScopeViewError {
let reusable = false;
try {
const primary = fs.realpathSync(index.slicesRoot);
reusable = observed() && index.additionalSliceRoots.every(root => {
try { const real = fs.realpathSync(root); return real === primary || real.startsWith(primary + path.sep); }
catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
});
} catch { /* Unconfirmed coverage always takes a fresh bounded read. */ }
if (!reusable) cached = undefined;
try {
const result = readScopedTaskTerminalView(db, view, index, resolveSeat, () => {
if (cached) return cached;
const next = readScopeIndex(index);
if (reusable && Array.isArray(next)) cached = next;
return next;
});
if ("code" in result) cached = undefined;
return result;
} catch (error) { cached = undefined; throw error; }
},
};
}
@@ -14,7 +14,10 @@ export interface TaskTerminalMember {
viewer?: import("./herdr-viewer.js").ViewerMapping;
}
export interface TaskMembership {
qitemId: string;
kind: "qitem" | "slice" | "mission";
id: string;
/** Present only for a genuine queue episode. */
qitemId?: string;
name: string;
complete: boolean;
issues: string[];
@@ -160,6 +163,16 @@ function readTaskTerminalSnapshot(
};
for (const row of selected) visit(row.qitem_id);
return projectTaskOwners({ kind: "qitem", id: qitemId, qitemId, name: selected[0]!.summary?.trim() || qitemId,
complete: issues.length === 0, issues: [...new Set(issues)] }, selected, resolveSeat);
}
/** Shared owner/availability fold; the caller owns authoritative work enumeration. */
export function projectTaskOwners(
task: Omit<TaskMembership, "members">,
selected: readonly { qitem_id: string; destination_session: string; state: string }[],
resolveSeat: (address: string) => TaskSeat | null,
): TaskTerminalView {
const owners = new Map<string, TaskTerminalMember>();
const inputs = new Map<string, ViewMemberInput>();
const resolved = new Map<string, TaskSeat | null>();
@@ -189,7 +202,7 @@ function readTaskTerminalSnapshot(
});
}
const members = [...owners.values()].sort((a, b) => JSON.stringify(a.key).localeCompare(JSON.stringify(b.key)) || a.seat.localeCompare(b.seat));
return { task: { qitemId, name: selected[0]!.summary?.trim() || qitemId, complete: issues.length === 0, issues: [...new Set(issues)], members }, members: [...inputs.values()] };
return { task: { ...task, members }, members: [...inputs.values()] };
}
/** Preserve local stopped/unlaunched owners as well as current registry aliases.
@@ -25,14 +25,14 @@ export class TerminalFollowing {
const saved = JSON.parse(readFileSync(file, "utf8")) as { version: number; selections: Selection[] };
if (saved.version !== 1 || !Array.isArray(saved.selections) || saved.selections.length > 64
|| saved.selections.some(s => typeof s.enabled !== "boolean" || s.selector?.provider !== "herdr"
|| typeof s.selector.view !== "string" || !s.selector.view.startsWith("qitem:")
|| typeof s.selector.view !== "string" || !/^(qitem|slice):.+/.test(s.selector.view)
|| (s.selector.session !== undefined && typeof s.selector.session !== "string"))) throw new Error("Invalid following selectors.");
for (const selected of saved.selections) this.selections.set(this.key(selected.selector), selected);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") this.loadError = "Saved following selectors are unreadable; their file was preserved.";
}
this.unsubscribe = bus.subscribe(event => {
if (/^(queue|session|node)\./.test(event.type)) this.changed();
if (/^(queue|session|node)\./.test(event.type) || event.type === "scope.sources_changed") this.changed();
});
if (this.selections.size) this.changed();
}
@@ -63,7 +63,7 @@ export class TerminalFollowing {
// A complete empty task has finished. Keep its provider workspace, but
// retire automatic work and release the active-selector slot. Explicit
// --follow can resume the same journaled workspace if work returns.
if (result.ok && result.following?.state === "empty") {
if (selected.selector.view.startsWith("qitem:") && result.ok && result.following?.state === "empty") {
this.selections.delete(key); selected.enabled = false;
result.following.enabled = false;
this.save();
@@ -149,5 +149,5 @@ export interface TerminalProvider {
openView(view: ComposedView): Promise<OpenViewResult>;
ensureViewer?(key: ViewerKey, pane: ComposedPane, stillCurrent?: () => Promise<boolean>): Promise<ViewerResult>;
lookupViewer?(key: ViewerKey, pane: ComposedPane): Promise<ViewerResult>;
followView?(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>): Promise<OpenViewResult>;
followView?(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>, membershipError?: string): Promise<OpenViewResult>;
}
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more