mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 03:31:38 -10:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80c5b8db5d | ||
|
|
b6bd4633fb | ||
|
|
6498383a4d | ||
|
|
ad8c479ec2 | ||
|
|
7462f1f9f9 | ||
|
|
89e5f9217e | ||
|
|
ab5a233982 | ||
|
|
6cc3626529 | ||
|
|
2a73f1809a | ||
|
|
c67b829616 | ||
|
|
0820fad101 | ||
|
|
3c7cfe26ca | ||
|
|
75602eb977 | ||
|
|
ef82979f12 | ||
|
|
d1cdfad212 | ||
|
|
2f6263b7dc | ||
|
|
0fcb7040e1 | ||
|
|
e61faf5d80 | ||
|
|
9039c86ad9 | ||
|
|
9924040ba4 | ||
|
|
e3d9ce488d | ||
|
|
fc50fb8d71 | ||
|
|
5a690c9c8b |
No files matched your search
+231
-6
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Generated
+5
-5
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.6",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 } } }, []);
|
||||
|
||||
@@ -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,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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); `
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user