Compare commits

...
Author SHA1 Message Date
dev-driver 80c5b8db5d chore(release): prepare 0.7.6 2026-10-29 09:56:07 +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
158 changed files with 5477 additions and 582 deletions

No files matched your search

+231 -6
View File
@@ -8,6 +8,231 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [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 +892,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 +961,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.6/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.6/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.6` 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.6/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.6/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` |
@@ -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
+73 -3
View File
@@ -15,14 +15,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 +48,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 +146,43 @@ 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.
## An example: Hermes
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
@@ -242,7 +281,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 +294,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
+36 -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.6/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.6/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,46 @@ 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`; the built-in layout uses `operator-human@kernel` in the
examples below. Check the actual pane with `rig capture operator-human@kernel`:
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 operator-human@kernel --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 operator-human@kernel` 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
+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.6`. 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
+80 -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,82 @@ 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. Duplicate names or aliases require disambiguation
in the configured index; no first-match scope is selected.
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 retain `view_required` (400). 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.
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.6",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7346,7 +7346,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.6",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7384,7 +7384,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.6",
"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.6",
"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.6",
"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.6",
"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")
@@ -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 });
});
});
+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.5`). 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`.
@@ -211,14 +211,24 @@ 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.
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
@@ -1319,6 +1329,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.6",
"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"` (normally `operator-human@kernel`).
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 } : {}),
};
}
@@ -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);
}
+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";
}
@@ -457,7 +457,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 +468,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);
@@ -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." };
}
@@ -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,
@@ -252,13 +253,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 } : {}) }));
@@ -0,0 +1,171 @@
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 }
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 Set<string>();
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) => {
const source = fs.realpathSync(dir);
if (seenScopes.has(source)) return;
seenScopes.add(source);
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;
scopes.push({ name, id, title, source });
};
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) list(nested, child => add(path.join(nested, child), child));
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]!;
// A unique name cannot certify which rows an ambiguous alias names.
if (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;
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);
if (parsed.slices.has(scope.name) || (scope.id && parsed.slices.has(scope.id))) 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>;
}
@@ -1,3 +1,4 @@
import type { ScopeViewError } from "./scope-task-terminal-view.js";
import { tileAttachArgv } from "./tile-attach.js";
import { shellQuote } from "../../adapters/shell-quote.js";
import type { ViewerKey, ViewerResult } from "./herdr-viewer.js";
@@ -90,6 +91,7 @@ export interface TerminalPreview {
grids: ReturnType<typeof buildGridRoot>[];
status: ProviderStatus;
task?: TaskMembership;
capabilities: { ensureViewer: boolean };
viewDescriptor?: ViewDescriptor;
}
@@ -136,6 +138,10 @@ export interface TerminalServiceDeps {
listScopeSeats(scope: string): Promise<LiveSeatRow[] | null> | LiveSeatRow[] | null;
/** Exact local queue episode; unavailable owners must remain in task.members. */
resolveTaskView?(qitemId: string): Promise<TaskTerminalView | null> | TaskTerminalView | null;
/** Authoritative scoped work in this daemon's configured index context. */
resolveScopeView?(view: string): Promise<TaskTerminalView | ScopeViewError> | TaskTerminalView | ScopeViewError;
/** Drop generation-cached scope identities on the same source event as following. */
invalidateScopeView?(): void;
/** Known rig names (for the `views` listing). */
listRigNames(): Promise<string[]> | string[];
/** Read-only host resolution for the composer (registry unavailable → null for every id). */
@@ -181,24 +187,29 @@ export class TerminalService {
private localTmux?: Promise<string | undefined>;
private following?: TerminalFollowing;
private unsubscribeScope?: () => void;
constructor(private readonly deps: TerminalServiceDeps) {
if (deps.invalidateScopeView) this.unsubscribeScope = deps.eventBus?.subscribe(event => {
if (event.type === "scope.sources_changed") deps.invalidateScopeView?.();
});
if (deps.eventBus) this.following = new TerminalFollowing(
deps.followingStatePath ?? getDefaultOpenRigPath("private/terminal-following/selectors.json"), deps.eventBus,
(selector, controls) => this.renderFollowing(selector, controls));
}
async stop(): Promise<void> { await this.following?.stop(); }
async stop(): Promise<void> { this.unsubscribeScope?.(); await this.following?.stop(); }
async followView(req: OpenViewRequest & FollowControls): Promise<OpenViewResult> {
if (!this.following) return errorResult("herdr", "follow_unavailable", "This daemon has no event-driven terminal following service.");
if (!req.view.startsWith("qitem:")) return errorResult("herdr", "follow_view_required", "Follow requires qitem:<id>, whose membership has an authoritative completeness signal.");
if (!/^(qitem|slice):.+/.test(req.view)) return errorResult("herdr", "follow_view_required", "Follow requires an authoritative qitem:<id> or slice:<id-or-name> view.");
if (req.view.startsWith("slice:") && !this.deps.resolveScopeView) return errorResult("herdr", "scope_unsupported", "This daemon has no authoritative slice membership resolver.");
const selected = this.selectProvider({ ...req, provider: req.provider ?? "herdr" });
if ("ok" in selected) return selected;
if (selected.name !== "herdr" || !selected.provider.followView) return errorResult(selected.name, "follow_unsupported", "Follow is supported by the qualified Herdr provider only.");
if (req.expectedPlan !== undefined) {
if (req.view.startsWith("slice:") || req.expectedPlan !== undefined) {
const composed = await this.resolveComposed(req.view, selected.provider.panesPerPage);
if ("code" in composed) return errorResult(selected.name, composed.code, composed.error);
if (req.expectedPlan !== this.planId(selected.name, composed, selected.session)) return errorResult(selected.name, "preview_changed", "Task membership changed since preview; refresh before enabling following.");
if (req.expectedPlan !== undefined && req.expectedPlan !== this.planId(selected.name, composed, selected.session)) return errorResult(selected.name, "preview_changed", "Task membership changed since preview; refresh before enabling following.");
}
const { enabled, maxTiles, graceMs, focusRetentionMs, pin, unpin, suppress, unsuppress, suppressionMs } = req;
return this.following.open({ view: req.view, provider: "herdr", ...(selected.session !== undefined ? { session: selected.session } : {}) },
@@ -210,9 +221,11 @@ export class TerminalService {
if ("ok" in selected) return selected;
if (!selected.provider.followView) return errorResult(selected.name, "follow_unsupported", "This provider cannot follow a task.");
let composed: ComposedView | null = null;
let membershipError: string | undefined;
try {
const current = await this.resolveComposed(selector.view, selected.provider.panesPerPage);
if (!("code" in current)) composed = { ...current, opened: current.opened.map(p => this.viewerPane(p)) };
else membershipError = `${current.code}: ${current.error}${selector.view.startsWith("slice:") ? " Refresh or reopen the slice under its current unambiguous key." : ""}`;
} catch { /* Unreadable membership is supplied as unknown, never empty. */ }
const plan = composed && this.planId(selected.name, composed, selected.session);
const stillCurrent = async () => {
@@ -221,7 +234,7 @@ export class TerminalService {
return !("code" in current) && current.task?.complete === true && this.planId(selected.name,
{ ...current, opened: current.opened.map(p => this.viewerPane(p)) }, selected.session) === plan;
};
const result = await selected.provider.followView(selector.view, composed, controls, stillCurrent);
const result = await selected.provider.followView(selector.view, composed, controls, stillCurrent, membershipError);
return { ...result, ...(selected.session !== undefined ? { session: selected.session } : {}),
...(composed?.task ? { task: composed.task } : {}), viewDescriptor: { kind: controls.enabled && result.following?.enabled !== false ? "following" : "fixed", name: composed?.task?.name ?? selector.view } };
}
@@ -353,6 +366,7 @@ export class TerminalService {
if ("code" in composed) return errorResult(providerName, composed.code, composed.error);
await this.viewerMappings(provider, composed);
return { provider: providerName, ...(session !== undefined ? { session } : {}), view: req.view, composed, viewDescriptor: composed.viewDescriptor, ...(composed.task ? { task: composed.task } : {}),
capabilities: { ensureViewer: !!composed.task && !!provider.ensureViewer },
grids: composed.pages.map(page => buildGridRoot(page, composed.columns)), planId: this.planId(providerName, composed, session), status: await provider.status() };
}
@@ -377,7 +391,7 @@ export class TerminalService {
/** Explicit owner operation: membership never grants input authority. The
* HTTP route supplies the existing owner bearer/browser boundary. */
async ensureViewer(req: OpenViewRequest & { member: ViewerKey; expectedPlan: string }): Promise<ViewerResult> {
if (!req.view.startsWith("qitem:")) return { ok: false, code: "view_required", error: "Ensure-viewer requires a qitem view." };
if (!/^(qitem|slice):.+/.test(req.view)) return { ok: false, code: "view_required", error: "Ensure-viewer requires an authoritative qitem or slice view." };
const selected = this.selectProvider({ ...req, provider: req.provider ?? "herdr" });
if ("ok" in selected) return { ok: false, code: selected.code ?? "unknown_provider", error: selected.error ?? "Provider unavailable." };
if (!selected.provider.ensureViewer) return { ok: false, code: "viewer_unsupported", error: "This provider has no qualified ensure-viewer operation." };
@@ -458,6 +472,11 @@ export class TerminalService {
return task ? { id: view, members: task.members, task: task.task }
: { code: "view_not_found", error: `unknown local queue episode '${view.slice(6)}'` };
}
if (view.startsWith("slice:")) {
if (!this.deps.resolveScopeView) return { code: "scope_unsupported", error: "This daemon has no authoritative slice membership resolver." };
const scope = await this.deps.resolveScopeView(view);
return "code" in scope ? scope : { id: view, members: scope.members, task: scope.task };
}
if (view.startsWith("saved:")) {
const saved = this.deps.viewsStore.get(view.slice(6));
if (saved) return { id: saved.id, members: saved.members.map(savedMemberToInput) };
+25 -1
View File
@@ -38,9 +38,11 @@ export interface Node {
advisor?: string | null;
/** OPR.0.7.0.7: the seat's explicit effort choice (rig seat set-effort); null = none. */
effortChoice?: string | null;
/** Explicit native account-home selection; a path only, not credential data. */
accountHome?: string | null;
codexConfigProfile?: string | null;
/** A Claude seat's own subscription credential, as a reference (operator label and token-file path); null = none. */
claudeTokenRef?: { label: string; tokenFile: string } | null;
claudeTokenRef?: { label: string; tokenFile: string; kind?: "api-key" } | null;
/** OPR.0.4.8.3 Seam B: attached permission_policy REF (builtin:<name> or spec-relative custom
* path), or null when none is attached (= the floor). */
permissionPolicy?: string | null;
@@ -118,6 +120,7 @@ export interface Session {
// -- Event types --
export type RigEvent =
| { type: "scope.sources_changed"; scope: string }
| { type: "proof.judged" | "proof.sources_changed"; scope: string; revision: string }
| { type: "event.delivery_poisoned"; poisonedSeq: number; error: string; payloadSha: string }
| { type: "rig.created"; rigId: string }
@@ -250,6 +253,7 @@ export type RigEvent =
| { type: "pod.deleted"; rigId: string; podId: string }
| { type: "node.startup_pending"; rigId: string; nodeId: string; startupProof?: StartupProofSelection }
| { type: "node.startup_first_input_passed"; rigId: string; nodeId: string; sessionId: string; path: string; submission: "unconfirmed" }
| { type: "node.startup_model_passed"; rigId: string; nodeId: string; sessionId: string; model: string; resume: boolean }
| { type: "node.startup_ready"; rigId: string; nodeId: string; previousStatus?: "failed" | "attention_required"; submission?: { status: "unverified" | "staged"; reasons: string[]; warning?: string; diagnostics?: StartupSubmissionDiagnostic[] } }
| { type: "node.startup_failed"; rigId: string; nodeId: string; error: string; sessionId?: string; freshContextPending?: boolean; forkContextPending?: { parentToken: string; isRestore: boolean }; submissionDiagnostics?: StartupSubmissionDiagnostic[]; stage?: StartupFailureStage; hasPendingWork?: boolean; requestedResumeToken?: string | null; requestedResumeType?: string | null; probe?: import("./native-resume-probe-evidence.js").NativeResumeProbeEvidence }
// OPR.0.4.3.06 — startup proof (challenge-verified orientation). Append-only.
@@ -495,6 +499,8 @@ export interface RestoreValidationBlocker {
}
export interface RestoreNodeResult {
/** An in-place restore started a replacement shell; a later refusal cannot leave the predecessor untouched. */
launchStarted?: true;
nodeId: string;
logicalId: string;
/** Set only by an explicit selected-history startup; these are runtime row IDs, not native tokens. */
@@ -560,6 +566,8 @@ export type ContinuityOutcome = "resumed" | "rebuilt" | "forked" | "fresh" | "fa
export type HandoverResult = "complete" | "unchanged" | "partial" | "failed" | null;
export type AgentActivityState = "running" | "needs_input" | "idle" | "unknown";
export type AgentActivityEvidenceSource =
/** Explicit pane liveness (including tmux pane_dead), not inferred input or activity. */
| "pane_process"
| "runtime_hook"
| "pane_heuristic"
/** ACTIVITY D1+D2 — tmux `#{window_activity}` motion (SeatActivityService). A fact about BYTES on
@@ -576,6 +584,8 @@ export interface AgentActivity {
turnId?: string | null;
/** Native Claude hook actor: null is the main session; absent means an older or unrecognized relay. */
hookAgentId?: string | null;
/** Recorded on a hook for a session launched for a community runner (its launch fact, not the hook's claim). */
communityRunner?: boolean;
state: AgentActivityState;
reason: string;
@@ -647,6 +657,7 @@ export interface SeatIdentityVerdict {
| "pane_pid_gone"
| "binding_pane_missing"
| "session_missing"
| "pane_exited"
| "tmux_unavailable"
| null;
evidence: {
@@ -668,6 +679,12 @@ export function identityVerdictConfirmsSessionMissing(verdict: SeatIdentityVerdi
return verdict?.reason === "session_missing";
}
/** Positive executor absence only. Unknown identity or an unreadable pane never proves an exit.
* Callers must first gate the verdict to the current session, binding and occupant. */
export function identityVerdictConfirmsExecutorStopped(verdict: SeatIdentityVerdict | null | undefined): boolean {
return identityVerdictConfirmsSessionMissing(verdict) || verdict?.reason === "pane_exited";
}
/**
* OPR.0.4.3.19 — the two verdict kinds that down-rank a `running`/`active`
* projection to a non-green state. `verified`, `tmux_unavailable`, and an
@@ -1082,6 +1099,8 @@ export interface LegacyRigSpecNode {
effort?: string;
advisor?: string;
claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules;
/** A Claude seat's own subscription token file, by reference (spec `claude_token`); read as Claude starts. */
claudeTokenRef?: import("./claude-seat-token.js").ClaudeSeatTokenRef;
codexPermissions?: import("./codex-member-permissions.js").CodexMemberPermissions;
cwd?: string;
surfaceHint?: string;
@@ -1203,6 +1222,8 @@ export interface RigSpecPodMember {
* custom path). Absent = the floor. Overrides the rig-level ref. */
permissionPolicy?: string;
claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules;
/** A Claude seat's own subscription token file, by reference (spec `claude_token`); read as Claude starts. */
claudeTokenRef?: import("./claude-seat-token.js").ClaudeSeatTokenRef;
/** A Codex member's own permission envelope; the seat's live record afterwards. */
codexPermissions?: import("./codex-member-permissions.js").CodexMemberPermissions;
/** OPR.0.7.0.9 — Claude members only: their own agent file, appended system prompt file and plugin folders,
@@ -1483,6 +1504,8 @@ export interface ExpansionPodFragment {
label: string;
summary?: string;
members: Array<{
/** Canonical RigSpec member fields pass through unchanged; the schema validates them. */
[field: string]: unknown;
id: string;
runtime: string;
agentRef?: string;
@@ -1493,6 +1516,7 @@ export interface ExpansionPodFragment {
effort?: unknown;
codexConfigProfile?: string;
claudePermissions?: unknown;
claudeToken?: unknown;
codexPermissions?: unknown;
/** OPR.0.4.8.3 Seam B: per-seat permission_policy REF — threaded through the
* expansion ingress exactly like role (never silently dropped). Typed UNKNOWN
+11 -7
View File
@@ -1,5 +1,5 @@
import { runtimeSwitchActivityRole } from "./runtime-switch-custody.js";
import { physicalSessionName } from "../domain/session-registry.js";
import { physicalSessionName, sessionCommunityHarnessSelect, sessionCommunityRunnerSelect } from "../domain/session-registry.js";
import { existsSync } from "node:fs";
import { Hono, type Context } from "hono";
import { streamSSE } from "hono/streaming";
@@ -11,6 +11,7 @@ import { verifyStartupProof } from "../domain/startup-proof.js";
import type { ActivityEvidence, EvidenceRoute } from "../domain/activity-taxonomy.js";
import type { AgentActivity } from "../domain/types.js";
import * as parkedQuery from "../domain/parked-query.js";
import { nodeExecutorConfirmedStopped } from "../domain/node-inventory.js";
import { runtimeRungInventory } from "../domain/activity-taxonomy.js";
import { validateResumeToken } from "../domain/resume-token-validation.js";
import { claudeHookFromLaunchedProcess, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
@@ -59,7 +60,7 @@ export function evidenceFromHookActivity(input: {
}, needsInput: { count: 1, reason: "permission_prompt" } };
}
// A runner's exit report clears what its last turn said: the seat is no longer at a prompt or mid-turn.
if (input.activity.rawEvent === "Stop" && isRunnerExit(input.activity.rawSubtype, input.runtime)) {
if (input.activity.rawEvent === "Stop" && isRunnerExit(input.activity.rawSubtype, input.runtime, input.activity.communityRunner === true)) {
return { ...base, activity: "unknown", needsInput: { count: 0, reason: null } };
}
switch (input.activity.state) {
@@ -86,7 +87,8 @@ export function restoredRunnerHookEvidence(
const latest = store.getLatestForNode({ nodeId: seat.seatNodeId, sessionName: seat.sessionName, order: "source" });
if (!latest || latest.evidenceSource !== "runtime_hook" || latest.generationProvenance !== "resolved") return null;
const state = !latest.stale ? latest.state
: latest.reason === "stale_runtime_hook" ? hookEventState(latest.rawEvent, latest.rawSubtype, runtime) : "unknown";
: latest.reason === "stale_runtime_hook"
? hookEventState(latest.rawEvent, latest.rawSubtype, runtime, latest.communityRunner === true) : "unknown";
return evidenceFromHookActivity({ seatNodeId: seat.seatNodeId, sessionName: seat.sessionName, runtime,
activity: { ...latest, state }, seq: 0 });
}
@@ -529,8 +531,9 @@ activityRoutes.post("/hooks", async (c) => {
// the emitter supplied an old sessionName, so check both identities before
// declaring an inventory (which would reactivate a retired seat).
const currentSession = oracle && emitted?.nodeId
? store.db.prepare("SELECT session_name, status FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1")
.get(emitted.nodeId) as { session_name: string; status: string } | undefined
? store.db.prepare(`SELECT s.session_name, s.status, ${sessionCommunityHarnessSelect(store.db)} AS community_harness,
${sessionCommunityRunnerSelect(store.db)} AS community_runner FROM sessions s WHERE s.node_id = ? ORDER BY s.id DESC LIMIT 1`)
.get(emitted.nodeId) as { session_name: string; status: string; community_harness: string | null; community_runner: number | null } | undefined
: undefined;
const suppliedSessionName = stringOrNull(body.sessionName);
if (oracle && emitted?.nodeId && emitted.sessionName
@@ -550,7 +553,8 @@ activityRoutes.post("/hooks", async (c) => {
if (!oracle.hasRungInventory(emitted.nodeId, emitted.sessionName)) {
oracle.declareRungInventory(
{ seatNodeId: emitted.nodeId, sessionName: emitted.sessionName },
runtimeRungInventory(runtime),
// The same recorded launch facts the reconcile pass reads, so a community seat's first hook gets its own inventory.
runtimeRungInventory(runtime, currentSession?.community_harness ?? null, currentSession?.community_runner === 1),
);
}
const evidence = evidenceFromHookActivity({
@@ -626,7 +630,7 @@ activityRoutes.get("/parked", (c) => {
const { diagnoseSeatParked, diagnoseRigParked, PARKED_OBLIGATION_LIMIT } = parkedQuery;
const deps = {
getSeatState: (id: string) => oracle.getSeatState(id),
getSeatState: (id: string) => nodeExecutorConfirmedStopped(rigRepo.db, id) ? null : oracle.getSeatState(id),
listOpenObligations: (destination: string, limit: number) => ({
rows: queueRepo
.list({ destinationSession: destination, state: ["pending", "in-progress", "blocked"], limit })
+46 -9
View File
@@ -1,3 +1,5 @@
import { OCCUPANT_GENERATION_HEADER, runtimeSwitchCustodyRefusal } from "./runtime-switch-custody.js";
import { SeatStatusService } from "../domain/seat-status-service.js";
import fs from "node:fs";
import path from "node:path";
import { Hono, type Context } from "hono";
@@ -14,8 +16,8 @@ import { readClaudeSeatTokenRef } from "../domain/claude-seat-token.js";
import type { ProviderKind } from "../domain/provider/provider-types.js";
import { checkMoveReport, credentialRefusal, findCredential, MOVE_REPORT_TAG, readMoveReport, renderMoveReport, type MoveReport } from "../domain/provider/subscription-move-report.js";
import { QueueRepositoryError, type QueueRepository } from "../domain/queue-repository.js";
import { requireSenderIdentity, resolveRecordedProvenance } from "./require-sender-identity.js";
import { seatLifecycleService } from "./seat.js";
import { requireSenderIdentity, resolveRecordedProvenance, transportSenderSession } from "./require-sender-identity.js";
import { seatLifecycleService, runtimeSwitchService } from "./seat.js";
import {
readUsageAcrossHosts,
selectUsageHosts,
@@ -58,7 +60,7 @@ function claudeSeatOf(c: Context): ClaudeSeatRelaunch {
if (!rigRepo) return null;
const row = rigRepo.db.prepare(`SELECT n.id, n.rig_id, n.logical_id, n.runtime, r.name, s.status, b.tmux_pane FROM sessions s
JOIN nodes n ON n.id = s.node_id JOIN rigs r ON r.id = n.rig_id LEFT JOIN bindings b ON b.node_id = n.id
WHERE s.session_name = ? ORDER BY s.created_at DESC, s.id DESC LIMIT 1`)
WHERE s.session_name = ? ORDER BY s.id DESC LIMIT 1`)
.get(seat) as { id: string; rig_id: string; logical_id: string; runtime: string | null; name: string; status: string; tmux_pane: string | null } | undefined;
return row?.runtime === "claude-code"
? { nodeId: row.id, rigId: row.rig_id, rigName: row.name, logicalId: row.logical_id, running: row.status === "running", pane: row.tmux_pane ?? null } : null;
@@ -88,12 +90,13 @@ function claudeSeatOf(c: Context): ClaudeSeatRelaunch {
const node = result.launched?.find((n) => n.logicalId === logicalId);
if (node?.status === "resumed") return { ok: true, status: "resumed", warnings: result.warnings ?? [] };
// Restore stops for a decision, or leaves an attached CLI alone, before it touches the seat.
const untouched = node?.status === "awaiting-decision" || node?.status === "left_attached";
const untouched = !node?.launchStarted && (node?.status === "awaiting-decision" || node?.status === "left_attached");
return { ok: false, effect: untouched ? "none" : "unknown", code: node?.status ?? "not_launched", message: node?.error ?? "The seat wasn't launched from the snapshot." };
},
};
}
/** Expose provider views and switches, delegating isolated account homes to the successor-first runtime switch service. */
export function providerRoutes(opts: ProviderRoutesOptions = {}): Hono {
const router = new Hono();
// Refresh legs across hosts live as long as these routes: overlapping requests join a running leg, and the last
@@ -314,12 +317,10 @@ export function providerRoutes(opts: ProviderRoutesOptions = {}): Hono {
// A Claude seat's own token file, or `storedLogin=1` for its config dir's stored login.
const tokenFile = c.req.query("storedLogin") === "1" ? null : c.req.query("tokenFile");
// An unsafe verdict is a valid 200 response, not an error.
return c.json(await svc.precheck({ seat, toAccount, ...(tokenFile !== undefined ? { tokenFile } : {}) }, seatRestartOf(c), claudeSeatOf(c)), 200);
return c.json(await svc.precheck({ seat, toAccount, ...(tokenFile !== undefined ? { tokenFile } : {}), ...(c.req.query("apiKeyFile") !== undefined ? { apiKeyFile: c.req.query("apiKeyFile") } : {}) }, seatRestartOf(c), claudeSeatOf(c)), 200);
});
router.post("/switch", async (c) => {
const svc = svcOf(c);
if (!svc) return c.json({ error: "provider_service_unavailable" }, unavailable);
let raw: unknown;
try {
raw = await c.req.json();
@@ -331,8 +332,16 @@ export function providerRoutes(opts: ProviderRoutesOptions = {}): Hono {
return c.json({ error: "body must be a JSON object" }, 400);
}
const body = raw as Record<string, unknown>;
if (body["apiKeyFile"] !== undefined && (body["accountHome"] !== undefined || body["clearAccountHome"] === true)) {
return c.json({ error: "apiKeyFile cannot be combined with accountHome or clearAccountHome; switch credentials in place separately" }, 400);
}
const seat = body["seat"];
const toAccount = body["toAccount"];
const clearAccountHome = body["clearAccountHome"] === true;
if (body["clearAccountHome"] !== undefined && typeof body["clearAccountHome"] !== "boolean") return c.json({ error: "clearAccountHome must be true or false" }, 400);
if (clearAccountHome && (body["toAccount"] !== undefined || body["accountHome"] !== undefined || body["tokenFile"] !== undefined)) {
return c.json({ error: "clearAccountHome cannot select an account or token" }, 400);
}
const toAccount = body["toAccount"] ?? (clearAccountHome ? "default" : undefined);
const forceUnsafe = body["forceUnsafe"] ?? false;
if (typeof seat !== "string" || seat.trim() === "" || typeof toAccount !== "string" || toAccount.trim() === "") {
return c.json({ error: "seat and toAccount are required" }, 400);
@@ -340,12 +349,40 @@ export function providerRoutes(opts: ProviderRoutesOptions = {}): Hono {
if (typeof forceUnsafe !== "boolean") {
return c.json({ error: "forceUnsafe must be a boolean" }, 400);
}
if (body["accountHome"] !== undefined || clearAccountHome) {
if ((body["tokenFile"] !== undefined && typeof body["tokenFile"] !== "string") || (!clearAccountHome && typeof body["accountHome"] !== "string") || typeof body["reason"] !== "string" ||
(body["model"] !== undefined && typeof body["model"] !== "string") ||
(body["codexConfigProfile"] !== undefined && typeof body["codexConfigProfile"] !== "string") ||
(body["dryRun"] !== undefined && typeof body["dryRun"] !== "boolean")) {
return c.json({ ok: false, code: "invalid_runtime_settings", message: "Account-home switching needs a path and reason, with optional model/profile and a Claude token-file reference." }, 400);
}
const rigRepo = c.get("rigRepo" as never) as RigRepository;
const custody = runtimeSwitchCustodyRefusal(rigRepo.db, transportSenderSession(c), c.req.header(OCCUPANT_GENERATION_HEADER));
if (custody) return c.json(custody.body, custody.status);
const resolved = new SeatStatusService({ rigRepo }).getStatus(seat);
if (!resolved.ok) return c.json(resolved, 404);
const result = await runtimeSwitchService(c).setRuntime({ seatRef: seat, runtime: resolved.status.runtime ?? "",
...(clearAccountHome ? { clearAccountHome: true } : { accountHome: body["accountHome"] as string }), reason: body["reason"], dryRun: body["dryRun"] === true,
...(typeof body["tokenFile"] === "string" ? { accountToken: { label: toAccount, tokenFile: body["tokenFile"] } } : {}),
requestedBy: transportSenderSession(c) ?? null,
...(typeof body["model"] === "string" ? { model: body["model"] } : {}),
...(typeof body["codexConfigProfile"] === "string" ? { codexConfigProfile: body["codexConfigProfile"] } : {}),
});
return c.json(result, result.ok ? "recoveryPending" in result && result.recoveryPending ? 202 : 200
: result.code === "invalid_runtime_settings" ? 400 : 409);
}
const tokenFile = body["tokenFile"];
if (tokenFile !== undefined && tokenFile !== null && typeof tokenFile !== "string") {
return c.json({ error: "tokenFile must be a path, or null for the stored login" }, 400);
}
const svc = svcOf(c);
if (!svc) return c.json({ error: "provider_service_unavailable" }, unavailable);
const apiKeyFile = body["apiKeyFile"];
if (apiKeyFile !== undefined && (typeof apiKeyFile !== "string" || tokenFile !== undefined)) {
return c.json({ error: "apiKeyFile must be a path and cannot be combined with tokenFile" }, 400);
}
// Business outcome (incl. failed_safely / rebind_in_progress) is an explicit 200 payload.
return c.json(await svc.switchAccount({ seat, toAccount, forceUnsafe, ...(tokenFile !== undefined ? { tokenFile } : {}) },
return c.json(await svc.switchAccount({ seat, toAccount, forceUnsafe, ...(tokenFile !== undefined ? { tokenFile } : {}), ...(typeof apiKeyFile === "string" ? { apiKeyFile } : {}) },
seatRestartOf(c), claudeSeatOf(c)), 200);
});
+3 -99
View File
@@ -94,105 +94,9 @@ function normalizeExpansionPodFragment(raw: Record<string, unknown>): ExpansionP
id,
label: typeof label === "string" ? label : id,
summary: typeof raw["summary"] === "string" ? raw["summary"] : undefined,
members: members.map((member) => {
const m = (member ?? {}) as Record<string, unknown>;
// OPR.0.5.6.3 presence invariant: track KEY PRESENCE, not value shape —
// any present sessionSource/session_source (null, primitive, mode-only,
// ref:null, unknown mode, malformed ref fields) must reach the ONE
// canonical validator; only a truly absent key stays absent (the
// permission_policy R2 precedent, applied to session_source).
const hasSessionSource = "sessionSource" in m || "session_source" in m;
const rawSessionSource: unknown = "sessionSource" in m ? m["sessionSource"] : m["session_source"];
let sessionSource: import("../domain/types.js").SessionSourceSpec | undefined;
if (rawSessionSource !== undefined && rawSessionSource !== null && typeof rawSessionSource === "object") {
const ss = rawSessionSource as Record<string, unknown>;
const mode = ss["mode"];
const ref = ss["ref"];
if (ref !== null && typeof ref === "object") {
const refRec = ref as Record<string, unknown>;
const kind = refRec["kind"];
if (mode === "fork" && (kind === "native_id" || kind === "artifact_path" || kind === "name" || kind === "last")) {
const value = typeof refRec["value"] === "string" ? (refRec["value"] as string) : undefined;
sessionSource = { mode: "fork", ref: { kind, ...(value !== undefined ? { value } : {}) } };
} else if (mode === "rebuild" && kind === "artifact_set" && Array.isArray(refRec["value"])) {
const paths: string[] = [];
for (const p of refRec["value"] as unknown[]) {
if (typeof p === "string" && p.trim() !== "") paths.push(p);
}
if (paths.length > 0) {
sessionSource = { mode: "rebuild", ref: { kind: "artifact_set", value: paths } };
}
} else if (mode === "agent_image") {
// OPR.0.5.6.3 repair: agent_image rides the ingress like fork/rebuild.
// Valid v0 shape (image_name kind, non-empty string value, string|number
// version) constructs typed with SCHEMA-PARITY String() coercion, exactly
// like rigspec-schema.ts normalize (YAML `version: 3` arrives as a JSON
// number; omitting it would recreate the silent-default defect).
const versionRaw = refRec["version"];
const validValue = kind === "image_name"
&& typeof refRec["value"] === "string" && refRec["value"].trim() !== "";
const validVersion = versionRaw === undefined
|| typeof versionRaw === "string" || typeof versionRaw === "number";
if (validValue && validVersion) {
const version = versionRaw === undefined ? undefined : String(versionRaw);
sessionSource = {
mode: "agent_image",
ref: { kind: "image_name", value: refRec["value"] as string, ...(version !== undefined ? { version } : {}) },
};
}
}
}
}
// Presence fallthrough: a present value that did not normalize to a valid
// typed shape carries RAW so the canonical validator rejects it structurally
// — never converted to absence.
if (hasSessionSource && sessionSource === undefined) {
sessionSource = rawSessionSource as import("../domain/types.js").SessionSourceSpec;
}
return {
id: typeof m["id"] === "string" ? m["id"] : "",
runtime: typeof m["runtime"] === "string" ? m["runtime"] : "",
agentRef:
typeof m["agentRef"] === "string"
? m["agentRef"]
: typeof m["agent_ref"] === "string"
? m["agent_ref"]
: undefined,
profile: typeof m["profile"] === "string" ? m["profile"] : undefined,
codexConfigProfile:
typeof m["codexConfigProfile"] === "string"
? m["codexConfigProfile"]
: typeof m["codex_config_profile"] === "string"
? m["codex_config_profile"]
: undefined,
...("claudePermissions" in m ? { claudePermissions: m["claudePermissions"] }
: "claude_permissions" in m ? { claudePermissions: m["claude_permissions"] } : {}),
...("codexPermissions" in m ? { codexPermissions: m["codexPermissions"] }
: "codex_permissions" in m ? { codexPermissions: m["codex_permissions"] } : {}),
cwd: typeof m["cwd"] === "string" ? m["cwd"] : undefined,
model: typeof m["model"] === "string" ? m["model"] : undefined,
// Raw presence: the canonical RigSpec schema trims a text effort and ignores any other value with an advisory.
...("effort" in m ? { effort: m["effort"] } : {}),
// R2 (4ac243c3): PRESERVE raw presence — a present-invalid value (null, number,
// object, …) must reach the canonical RigSpec validator as-is and reject there;
// only a truly ABSENT key stays absent. No route-local validation, no coercion.
...("permissionPolicy" in m
? { permissionPolicy: m["permissionPolicy"] }
: "permission_policy" in m
? { permissionPolicy: m["permission_policy"] }
: {}),
restorePolicy:
typeof m["restorePolicy"] === "string"
? m["restorePolicy"]
: typeof m["restore_policy"] === "string"
? m["restore_policy"]
: undefined,
label: typeof m["label"] === "string" ? m["label"] : undefined,
// Presence-governed, never truthiness: null/false/primitive raw values
// must survive to canonical validation, not vanish at a truthy check.
...(hasSessionSource ? { sessionSource } : {}),
};
}),
// Preserve the member as authored; the service translates legacy aliases and
// the canonical RigSpec validator decides its shape and accepted fields.
members: members as ExpansionPodFragment["members"],
edges: Array.isArray(raw["edges"])
? raw["edges"].map((edge) => {
const e = (edge ?? {}) as Record<string, unknown>;
+8 -2
View File
@@ -229,7 +229,7 @@ function handoverDeps(c: Context): ConstructorParameters<typeof SeatHandoverServ
}
/** Slice 16: the live runtime switch, sharing the handover's dependencies. */
function runtimeSwitchService(c: Context): SeatRuntimeSwitchService {
export function runtimeSwitchService(c: Context): SeatRuntimeSwitchService {
const instantiator = c.get("podInstantiator" as never) as { runtimeSwitchMaterialContext?: () => { skillsRoot?: string; systemSkills?: string[]; systemWorldError?: string } } | undefined;
return new SeatRuntimeSwitchService({ ...handoverDeps(c), queueRepo: c.get("queueRepo" as never) as QueueRepository | undefined,
materialResolution: instantiator?.runtimeSwitchMaterialContext?.(), openrigHome: OPENRIG_HOME });
@@ -247,12 +247,15 @@ seatRoutes.post("/set-runtime/:seatRef", async (c) => {
const body = parsed as Record<string, unknown>;
const text = (key: string): string | undefined | null => body[key] === undefined ? undefined : typeof body[key] === "string" ? body[key] as string : null;
const fields = { runtime: text("runtime"), reason: text("reason"), model: text("model"), effort: text("effort"),
codexConfigProfile: text("codexConfigProfile"), permissionMode: text("permissionMode") };
codexConfigProfile: text("codexConfigProfile"), permissionMode: text("permissionMode"), accountHome: text("accountHome"), accountLabel: text("accountLabel"), tokenFile: text("tokenFile") };
const wrong = Object.entries(fields).find(([, value]) => value === null);
if (wrong) return c.json({ ok: false, code: "invalid_runtime_settings", message: `${wrong[0]} must be a string.` }, 400);
if (body["dryRun"] !== undefined && typeof body["dryRun"] !== "boolean") {
return c.json({ ok: false, code: "invalid_runtime_settings", message: "dryRun must be true or false." }, 400);
}
if (body["clearAccountHome"] !== undefined && typeof body["clearAccountHome"] !== "boolean") {
return c.json({ ok: false, code: "invalid_runtime_settings", message: "clearAccountHome must be true or false." }, 400);
}
const timeout = body["confirmTimeoutSeconds"];
if (timeout !== undefined && (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 10 || timeout > 3600)) {
return c.json({ ok: false, code: "invalid_runtime_settings", message: "confirmTimeoutSeconds must be a whole number from 10 to 3600." }, 400);
@@ -263,6 +266,9 @@ seatRoutes.post("/set-runtime/:seatRef", async (c) => {
...(fields.model ? { model: fields.model } : {}), ...(fields.effort ? { effort: fields.effort } : {}),
...(fields.codexConfigProfile ? { codexConfigProfile: fields.codexConfigProfile } : {}),
...(fields.permissionMode ? { permissionMode: fields.permissionMode } : {}),
...(fields.accountHome !== undefined ? { accountHome: fields.accountHome! } : {}),
...(body["clearAccountHome"] === true ? { clearAccountHome: true } : {}),
...(fields.tokenFile !== undefined ? { accountToken: { label: fields.accountLabel ?? "selected-account", tokenFile: fields.tokenFile! } } : {}),
...(typeof timeout === "number" ? { confirmTimeoutMs: timeout * 1000 } : {}),
});
if (result.ok) return c.json(result, "recoveryPending" in result && result.recoveryPending ? 202 : 200);
+2 -2
View File
@@ -75,7 +75,7 @@ function getDeps(c: { get: (key: string) => unknown }) {
function narrowLaunchErrorStatus(code: string | undefined): 404 | 409 | 500 {
if (code === "rig_not_found" || code === "no_matching_nodes" || code === "snapshot_not_found" || code === "snapshot_wrong_rig" || code === "no_usable_snapshot") return 404;
if (code === "snapshot_unusable") return 409;
if (code === "snapshot_unusable" || code === "selection_changed") return 409;
return 500;
}
@@ -285,7 +285,7 @@ nodesRoutes.post("/:logicalId/launch", async (c) => {
return c.json(result, result.ok ? 201 : result.code === "failed" ? 500 : 409);
}
if (node.podId) {
if (node.podId || body.snapshotId !== undefined) {
if (body.snapshotId === undefined) {
const instantiator = c.get("podInstantiator" as never) as PodRigInstantiator | undefined;
const firstStart = await instantiator?.launchFirstMaterializedNode(rigId, node.id);
+2
View File
@@ -1,3 +1,4 @@
import type { EventBus } from "../domain/event-bus.js";
import { selectedProject, projectMission, workSource, projectReadResponse } from "../domain/workspace/project-read.js";
// Slice Story View v0 — HTTP routes.
//
@@ -120,6 +121,7 @@ export function slicesRoutes(): Hono {
const deps = getDeps(c);
if (!deps) return c.json({ error: "slices_indexer_unavailable" }, 503);
deps.indexer.invalidate();
(c.get("eventBus" as never) as EventBus | undefined)?.emit({ type: "scope.sources_changed", scope: deps.indexer.slicesRoot });
return c.json({ ok: true });
});
+4 -3
View File
@@ -50,8 +50,9 @@ function statusForOpen(ok: boolean, code: string | undefined): 200 | 400 | 404 |
if (ok) return 200;
if (code === "view_required" || code === "unknown_provider") return 400;
if (code === "invalid_terminal_session" || code === "unsupported_terminal_session") return 400;
if (code === "view_not_found") return 404;
if (code === "preview_changed") return 409;
if (code === "view_not_found" || code === "scope_not_found") return 404;
if (code === "preview_changed" || code === "scope_ambiguous" || code === "scope_unavailable") return 409;
if (code === "scope_unsupported") return 400;
// provider-unavailable / layout-unsupported / honest-partial: a truthful 200 body.
return 200;
}
@@ -114,7 +115,7 @@ export function terminalRoutes(opts?: { bearerToken?: string | null }): Hono {
}
const result = await svc.ensureViewer({ ...parsed, view: parsed.view ?? "", expectedPlan: raw.expectedPlan,
member: { hostId: member.hostId, nodeId: member.nodeId } });
return c.json(result, result.ok ? 200 : result.code === "preview_changed" ? 409 : result.code === "view_not_found" ? 404 : 200);
return c.json(result, statusForOpen(result.ok, result.ok ? undefined : result.code));
});
app.get("/views", async (c) => {
+22 -7
View File
@@ -1,3 +1,4 @@
import { seatAccountHome, effectiveRuntimeHome, type HomeRuntime } from "./domain/seat-runtime-home.js";
import { reportRestoreDeliveryFailure } from "./domain/compaction-restore-notice.js";
import { ClaudeTokenUsage, claudeTokenUsageReader } from "./domain/provider/claude-token-usage.js";
import type { ClaudeTokenIdentity } from "./domain/provider/provider-types.js";
@@ -112,7 +113,7 @@ import { TranscriptStore } from "./domain/transcript-store.js";
import { resumeRunningTranscriptCaptures, wireTranscriptCaptureStops } from "./domain/transcript-capture.js";
import { probeSessionActivity, SessionTransport, inspectComposerInput } from "./domain/session-transport.js";
import { AgentActivityStore } from "./domain/agent-activity-store.js";
import { hookOrPaneActivity, nodeSessionConfirmedMissing } from "./domain/node-inventory.js";
import { hookOrPaneActivity, nodeExecutorConfirmedStopped } from "./domain/node-inventory.js";
import { HistoryQuery } from "./domain/history-query.js";
import { AskService } from "./domain/ask-service.js";
import { WakeResolveService, WAKE_SESSIONS_SQL } from "./domain/wake-resolve-service.js";
@@ -321,6 +322,7 @@ export function collectAllowlistedProviderAuthEnv(
export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult> {
const daemonHome = os.homedir();
const configuredCodexHome = process.env.CODEX_HOME || undefined;
const configuredClaudeHome = process.env.CLAUDE_CONFIG_DIR;
if (configuredCodexHome && !nodePath.isAbsolute(configuredCodexHome)) {
throw new Error(`CODEX_HOME must be an absolute path for managed seats: ${configuredCodexHome}`);
}
@@ -691,7 +693,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
},
});
const { SeatLaunchEnvironment } = await import("./domain/seat-launch-environment.js");
const seatLaunchEnvironment = new SeatLaunchEnvironment(tmuxAdapter, launchSessionEnv, process.cwd(), undefined, configuredCodexHome);
const seatLaunchEnvironment = new SeatLaunchEnvironment(tmuxAdapter, launchSessionEnv, process.cwd(), undefined, configuredCodexHome,
(nodeId, runtime, ignoreAccountHome) => ignoreAccountHome
? effectiveRuntimeHome(db, nodeId, runtime as HomeRuntime, runtime === "codex" ? configuredCodexHome : configuredClaudeHome, true)
: seatAccountHome(db, nodeId, runtime));
const nodeLauncher = new NodeLauncher({
db,
rigRepo,
@@ -707,7 +712,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const snapshotRepo = new SnapshotRepository(db);
const checkpointStore = new CheckpointStore(db);
const snapshotCapture = new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, checkpointStore });
const claudeManagedLaunch = new ClaudeManagedLaunch(db, { ...launchSessionEnv, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR }, {
const claudeManagedLaunch = new ClaudeManagedLaunch(db, { ...launchSessionEnv, CLAUDE_CONFIG_DIR: configuredClaudeHome }, {
OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN: process.env.OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN,
}, (session, key) => tmuxAdapter.getSessionEnv(session, key));
claudeManagedLaunch.onTokenNotApplied = (event) => { eventBus.emit(event); };
@@ -925,7 +930,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter, readFile: (p: string) => fs.readFileSync(p, "utf-8") });
const runtimeSettings = new ContextPackSettingsStore().resolveConfig();
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, restoreReadPolicy, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const codexAdapter = new CodexRuntimeAdapter({ prepareTeamWorkspace, prepareSeatFolder, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: daemonHome }, codexHome: configuredCodexHome, seatHome: (nodeId: string) => seatRuntimeHome(db, nodeId, "codex").home, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), resolveCorePluginDir: () => vendoredCorePluginDir(), resolveCorePluginSelected: (nodeId: string) => Boolean(seatCodexCorePluginDir({ id: nodeId, runtime: "codex" }, db)), resolveHooksEnabled: async () => {
const codexAdapter = new CodexRuntimeAdapter({ prepareTeamWorkspace, prepareSeatFolder, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: daemonHome }, codexHome: configuredCodexHome, seatHome: (nodeId: string, ignoreAccountHome?: boolean) => seatRuntimeHome(db, nodeId, "codex", ignoreAccountHome).home, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), resolveCorePluginDir: () => vendoredCorePluginDir(), resolveCorePluginSelected: (nodeId: string) => Boolean(seatCodexCorePluginDir({ id: nodeId, runtime: "codex" }, db)), resolveHooksEnabled: async () => {
const { SettingsStore } = await import("./domain/user-settings/settings-store.js");
return new SettingsStore().resolveOne("runtime.codex.hooks_enabled").value as boolean;
}, resolveManagedConfigSettings: async () => {
@@ -2000,7 +2005,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
deps.sliceIndexer = sliceIndexer;
if (sliceIndexer.isReady()) {
const { watchProofSources } = await import("./domain/proof/source-watch.js");
deps.proofSourceWatch = watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus);
deps.proofSourceWatch = watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
sliceIndexer.invalidate();
eventBus.emit({ type: "scope.sources_changed", scope: sliceIndexer.slicesRoot });
});
}
deps.sliceDetailProjector = sliceDetailProjector;
// Living Notes Packet 2 (OPR.0.4.4.20): the composed-review gatherer.
@@ -2034,6 +2042,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const { createRexCliTransport } = await import("./domain/terminal/rex-transport.js");
const { CmuxLayoutService } = await import("./domain/cmux-layout-service.js");
const { TerminalViewsStore } = await import("./domain/terminal/terminal-views-store.js");
const { createScopedTaskResolver } = await import("./domain/terminal/scope-task-terminal-view.js");
const { readTaskTerminalView, resolveLocalTaskSeat } = await import("./domain/terminal/task-terminal-view.js");
const { getSelfHostId } = await import("./domain/hosts/fanout-contract.js");
const { getNodeInventory, getNodeInventoryForRigs } = await import("./domain/node-inventory.js");
@@ -2076,6 +2085,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
rex: new RexAdapter({ transport: createRexCliTransport(opts?.rexRun ? { run: opts.rexRun, binary: () => "rex" } : {}) }),
};
const scopeTasks = deps.sliceIndexer ? createScopedTaskResolver(db, deps.sliceIndexer, address =>
resolveLocalTaskSeat(db, address, getSelfHostId(), sessionRegistry.resolveAddress(address)?.nodeId),
() => deps.proofSourceWatch?.observation().state === "watching") : null;
deps.terminalService = new TerminalService({
eventBus,
resolveProvider: (name) => providerMap[name] ?? null,
@@ -2125,6 +2137,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
},
resolveTaskView: (qitemId) => readTaskTerminalView(db, qitemId, (address) =>
resolveLocalTaskSeat(db, address, getSelfHostId(), sessionRegistry.resolveAddress(address)?.nodeId)),
resolveScopeView: view => scopeTasks?.resolve(view)
?? { code: "scope_unavailable", error: "The configured scope index is unavailable." },
invalidateScopeView: () => scopeTasks?.invalidate(),
listRigNames: () => rigRepo.listRigs().map((r) => r.name),
resolveHost: (id) => {
const res = loadHostRegistry();
@@ -2231,7 +2246,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const node = db.prepare("SELECT node_id FROM sessions WHERE session_name = ? ORDER BY id DESC LIMIT 1").get(session) as { node_id: string } | undefined;
// A confirmed-missing session reads unknown, as in the node inventory: its last Stop
// or pane sample can't stand for a seat that no longer exists.
return node && !nodeSessionConfirmedMissing(db, node.node_id) ? seatActivityService.getSeatState(node.node_id) : null;
return node && !nodeExecutorConfirmedStopped(db, node.node_id) ? seatActivityService.getSeatState(node.node_id) : null;
});
const watchdogPolicyEngine = new WatchdogPolicyEngine({
jobsRepo: watchdogJobsRepoInstance,
@@ -2295,7 +2310,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
)
.all(rigName) as Array<{ node_id: string; session_name: string }>;
const parkedDeps = {
getSeatState: (seatNodeId: string) => seatActivityService.getSeatState(seatNodeId),
getSeatState: (seatNodeId: string) => nodeExecutorConfirmedStopped(db, seatNodeId) ? null : seatActivityService.getSeatState(seatNodeId),
listOpenObligations: (destinationSession: string, limit: number) => ({
rows: queueRepoInstance.listOpenObligations(destinationSession, limit),
limit,
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more