mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d8bac42e | ||
|
|
72ad78e01a | ||
|
|
ad799ebf70 | ||
|
|
f5cd2a34bf | ||
|
|
710001a384 | ||
|
|
6e4530ac54 | ||
|
|
d6ba98cbeb | ||
|
|
e0dc58cd21 | ||
|
|
5f83c1e183 | ||
|
|
c00c6321e3 | ||
|
|
a962341d52 | ||
|
|
7da9a48c1f | ||
|
|
510719037d | ||
|
|
7ea2177018 | ||
|
|
6eb2cb97f1 | ||
|
|
d4d6a01df0 | ||
|
|
85ee9f4ec8 | ||
|
|
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
+451
-6
@@ -8,6 +8,451 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.7]
|
||||
|
||||
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
|
||||
idle prompt under Claude's "Update installed" line is recognized again, managed
|
||||
compaction no longer types into a message you haven't sent, and a Claude seat
|
||||
launched through a wrapper script can be rebuilt again.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Restart the daemon after installing** so the fixes take effect:
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.7
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
There are no database migrations. The bundled openrig-core plugin stays at
|
||||
0.1.23 with unchanged files.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A Claude seat under the "Update installed" line reads idle again.** After
|
||||
Claude installs an update, it prints "✔ Update installed · /restart to apply"
|
||||
(or "Restart to apply") below its mode bar. OpenRig didn't recognize that line,
|
||||
so a seat at an empty prompt read unknown instead of idle, and managed
|
||||
compaction waited. Both spellings are now recognized; lines that only look
|
||||
similar still read unknown. ([#2311](https://github.com/mvschwarz/openrig/pull/2311))
|
||||
- **Managed compaction no longer types into a draft.** With the optional typing
|
||||
guard off (the default), compaction's preparation prompt could be pasted onto
|
||||
a message someone had typed but not sent. Now the preparation prompt,
|
||||
`/compact` and the restore prompts check the input box first: typed or
|
||||
unreadable input holds the step with a reason. A held restore prompt goes ahead
|
||||
on its own once the box is empty; a manual `rig compact` says why it stopped,
|
||||
to be retried once the box is empty. An empty box or Claude's own suggestion
|
||||
goes ahead.
|
||||
([#2347](https://github.com/mvschwarz/openrig/pull/2347))
|
||||
- **A Claude seat launched through a wrapper script can be rebuilt again.** On
|
||||
0.7.6, `rig seat handover --source rebuild` on a seat whose `claude` is a `#!`
|
||||
script that starts the real Claude without `exec` stopped after 30 seconds with
|
||||
"Waiting to confirm the new Claude process in this pane", though the new Claude
|
||||
was running. The readiness check now also accepts the script's own direct child
|
||||
when it names this launch's conversation; every other check is unchanged.
|
||||
([#2341](https://github.com/mvschwarz/openrig/issues/2341), [#2354](https://github.com/mvschwarz/openrig/pull/2354))
|
||||
- **A failed handover no longer moves the seat's resume target** to the new,
|
||||
unprimed conversation while the seat keeps its previous occupant.
|
||||
([#2354](https://github.com/mvschwarz/openrig/pull/2354))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **Other reasons managed compaction can wait are unchanged.**
|
||||
- **The draft check reads the input box just before pasting.** Typing that starts
|
||||
in the same moment can still be appended to.
|
||||
- **A Claude seat whose current occupant generation was never registered,** such as
|
||||
an adopted Claude carrying another generation, or a managed launch whose tenure
|
||||
record failed to write, keeps its first conversation token, but later hooks don't
|
||||
move it.
|
||||
- **The 0.7.6 known limits below still apply.**
|
||||
|
||||
### Thanks
|
||||
|
||||
- @oneandonlydean for the report and the evidence in #2341.
|
||||
|
||||
## [0.7.6]
|
||||
|
||||
OpenRig 0.7.6 lets one terminal workspace follow the current owners of a slice's
|
||||
open work through handoffs and completions. It also keeps more seat settings
|
||||
through expansion and restore, reports retained dead panes honestly, and repairs
|
||||
queue and Slack delivery paths.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Restart the daemon after installing** so the new code and database updates
|
||||
take effect:
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.6
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
Two migrations add the selected account-home path to nodes and the runner
|
||||
declaration to sessions. Both have prefix 141; their distinct full filenames
|
||||
identify them. ([#2143](https://github.com/mvschwarz/openrig/pull/2143), [#2239](https://github.com/mvschwarz/openrig/pull/2239))
|
||||
- **The bundled openrig-core plugin advances from 0.1.22 to 0.1.23.** Claude's
|
||||
activity hook clears permission marks after a tool batch. Managed refresh
|
||||
preserves replaced files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`;
|
||||
reapply local edits you still need from that copy. ([#2209](https://github.com/mvschwarz/openrig/pull/2209))
|
||||
|
||||
### Follow the work in a slice
|
||||
|
||||
- **Open `rig terminal open slice:<name-or-id> --follow --provider herdr`.**
|
||||
Membership comes from pending, in-progress and blocked queue rows tagged with
|
||||
that exact slice. Owners appear once with all their obligations listed;
|
||||
unavailable owners and tile overflow stay named.
|
||||
Handoffs retain the association unless their tags are replaced. New slice
|
||||
views default to four automatic tiles; `--max-tiles` changes that. Known empty
|
||||
slices stay following; incomplete reads retain the previous viewers. Pins,
|
||||
drafts, unknown input and focus retain departed viewers, and closing a viewer
|
||||
never stops its agent. Slice following targets the Linux Herdr 0.9.5 /
|
||||
protocol 22 path. Mission following is not included.
|
||||
([#2259](https://github.com/mvschwarz/openrig/pull/2259))
|
||||
- **Named Herdr sessions use their configured endpoint throughout viewer and
|
||||
follow operations.** The TUI's terminal action opens the agent, pod or rig
|
||||
currently in view. ([#2205](https://github.com/mvschwarz/openrig/pull/2205), [#2232](https://github.com/mvschwarz/openrig/pull/2232))
|
||||
- **Client-visible HTTP change:** `/api/terminal/viewer/ensure` now returns 400
|
||||
for `view_required` and `unknown_provider`, where it returned 200 with an
|
||||
error body. Clients should continue reading the body code. Missing slice
|
||||
identities return 404; ambiguous or unavailable identities return 409.
|
||||
([#2259](https://github.com/mvschwarz/openrig/pull/2259))
|
||||
|
||||
- **When a slice's folder name repeats across missions, tag its work with the
|
||||
slice's unique ID** (`rig queue create --slice <id>`) or add
|
||||
`--mission <mission>`. A row tagged only with the shared name cannot be placed,
|
||||
so the view reads incomplete until it is.
|
||||
([#2261](https://github.com/mvschwarz/openrig/pull/2261))
|
||||
|
||||
- **A join refused before a pane split can join on a later refresh.** Hold
|
||||
notices retain the original seat, phase and reason. A created viewer whose
|
||||
attach has not yet run can continue without replacing its pane.
|
||||
([#2271](https://github.com/mvschwarz/openrig/pull/2271))
|
||||
|
||||
### Seats, accounts and harnesses
|
||||
|
||||
- **Expansion keeps the member settings accepted by `rig up`,** including env,
|
||||
effort and startup settings. Restores retain the model saved with
|
||||
`rig seat set-model`. ([#2250](https://github.com/mvschwarz/openrig/pull/2250), [#2227](https://github.com/mvschwarz/openrig/pull/2227), [#2228](https://github.com/mvschwarz/openrig/pull/2228))
|
||||
- **Move a running Codex or Claude Code seat to an existing account home** with
|
||||
`rig provider switch --seat <seat> --to-account <name> --account-home <dir>`;
|
||||
preview with `--dry-run`. This starts a fresh conversation with a handover,
|
||||
retaining the seat's address and work. `--clear-account-home` returns to the
|
||||
configured defaults. ([#2143](https://github.com/mvschwarz/openrig/pull/2143))
|
||||
- **Claude subscription/API-key switching has a same-conversation path:**
|
||||
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`;
|
||||
return with `--to-account plan --token-file <abs>`. In-flight work is
|
||||
interrupted. The API-key path remains unqualified; verify the resulting
|
||||
account and conversation. A new Claude member can instead name a prepared
|
||||
subscription token in its spec with `claude_token: {label: name, file: /abs/token}`.
|
||||
([#2191](https://github.com/mvschwarz/openrig/pull/2191), [#2246](https://github.com/mvschwarz/openrig/pull/2246))
|
||||
- **Relaunch retains the selected seat and its observed executable.** Codex
|
||||
identity reads preserve OS argument boundaries, and in-place restart can
|
||||
recognize a verified Node launcher named `MainThread`. This does not establish
|
||||
that the reported #2200 restart refusal is fixed. ([#2196](https://github.com/mvschwarz/openrig/pull/2196), [#2212](https://github.com/mvschwarz/openrig/pull/2212), [#2247](https://github.com/mvschwarz/openrig/pull/2247))
|
||||
- **Managed seats inherit the daemon's SSH agent socket** for git over SSH;
|
||||
check the daemon's agent with `ssh-add -l`. ([#2222](https://github.com/mvschwarz/openrig/pull/2222))
|
||||
- **Community presets can report turns and accept a declared model.**
|
||||
`runner: true` with `launch_result: true` records runner activity per launch.
|
||||
`passes_model: true` passes the requested model as `OPENRIG_MODEL`; it does not
|
||||
observe the effective model. The Cursor and Hermes examples leave this off
|
||||
pending native qualification. These are opt-in preset fields, not a completed
|
||||
integration with every harness. ([#2239](https://github.com/mvschwarz/openrig/pull/2239), [#2243](https://github.com/mvschwarz/openrig/pull/2243))
|
||||
|
||||
- **Draft integration author contracts** describe harness, runner and platform
|
||||
duties, distinguishing implemented interfaces from proposals. Start at
|
||||
[the author-contract entry point](docs/reference/author-contracts.md).
|
||||
([#2272](https://github.com/mvschwarz/openrig/pull/2272))
|
||||
|
||||
### Delivery and Slack
|
||||
|
||||
- **Original queue wakes retry after a selection prompt clears.** Retries identify
|
||||
OpenRig as the sender, workflow entry keeps its caller, and prompt-blocked
|
||||
alerts remain routable when the original sender is on another host.
|
||||
([#2235](https://github.com/mvschwarz/openrig/pull/2235), [#2229](https://github.com/mvschwarz/openrig/pull/2229), [#2237](https://github.com/mvschwarz/openrig/pull/2237))
|
||||
- **Work sent to a terminal-only seat gets a recovery finding for a person.**
|
||||
Cross-host `rig queue create --verify` reads the destination's receipt;
|
||||
unreadable receipts remain unconfirmed without replaying the create.
|
||||
([#2192](https://github.com/mvschwarz/openrig/pull/2192), [#2193](https://github.com/mvschwarz/openrig/pull/2193))
|
||||
- **Slack button answers resume decisions parked on the person asked.** Replies
|
||||
Slack places outside a deleted thread are recorded at their actual location,
|
||||
so the next reply reaches the right conversation. ([#2245](https://github.com/mvschwarz/openrig/pull/2245), [#2240](https://github.com/mvschwarz/openrig/pull/2240))
|
||||
- **Use `rig slack setup --reactions off` to stop acknowledgement emoji waking
|
||||
agents.** The default remains `route`, and regenerating the app manifest
|
||||
preserves the choice. Alert selection filters eligible notifications without
|
||||
dropping older eligible asks behind an arbitrary row cap. ([#2241](https://github.com/mvschwarz/openrig/pull/2241), [#2183](https://github.com/mvschwarz/openrig/pull/2183))
|
||||
|
||||
### Status, inventory and help
|
||||
|
||||
- **A readiness deadline no longer ends a still-starting daemon.** The CLI
|
||||
returns a nonzero pending result naming the child PID, launch endpoint and
|
||||
log. It leaves the child running and retains the startup reservation, without
|
||||
declaring readiness or allowing a duplicate start. Follow that pending
|
||||
guidance before recovery. ([#2267](https://github.com/mvschwarz/openrig/pull/2267))
|
||||
- **A verified exited process in a retained pane reads `pane_exited`,** instead
|
||||
of running and ready. Inventory avoids discarded graph loads, scopes startup
|
||||
history to the requested rig's nodes, and reuses node identity for activity
|
||||
reads. These changes make no universal latency claim. ([#2248](https://github.com/mvschwarz/openrig/pull/2248), [#2225](https://github.com/mvschwarz/openrig/pull/2225),
|
||||
[#2226](https://github.com/mvschwarz/openrig/pull/2226), [#2236](https://github.com/mvschwarz/openrig/pull/2236))
|
||||
- **Completed mission labels reflect accepted proof**, without making proof
|
||||
readiness complete a mission. Snapshot ages use UTC correctly; emoji stay
|
||||
whole when TUI health, config and rig-table cells are shortened. ([#2249](https://github.com/mvschwarz/openrig/pull/2249),
|
||||
[#2251](https://github.com/mvschwarz/openrig/pull/2251), [#2252](https://github.com/mvschwarz/openrig/pull/2252), [#2253](https://github.com/mvschwarz/openrig/pull/2253))
|
||||
- **Smaller command fixes:** chatroom tmux rig names stay literal; a trailing
|
||||
slash in `OPENRIG_URL` still identifies the same daemon; a trailing slash in
|
||||
the missions root no longer hides a slice's mission. `rig host doctor` names
|
||||
the entry replies actually reach. ([#2218](https://github.com/mvschwarz/openrig/pull/2218), [#2220](https://github.com/mvschwarz/openrig/pull/2220), [#2221](https://github.com/mvschwarz/openrig/pull/2221), [#2214](https://github.com/mvschwarz/openrig/pull/2214))
|
||||
- **Help and contributor checks:** edge help explains deferred escalation
|
||||
targets; section addressing ignores HTML comments; the guide covers shared
|
||||
kernel-dashboard recovery. Install pins, help links and earlier release text
|
||||
are corrected. Scenario fixtures honor send verification, execute tmux
|
||||
directly, and reject unknown step verbs at schema load. ([#2204](https://github.com/mvschwarz/openrig/pull/2204), [#2211](https://github.com/mvschwarz/openrig/pull/2211),
|
||||
[#2213](https://github.com/mvschwarz/openrig/pull/2213), [#2238](https://github.com/mvschwarz/openrig/pull/2238), [#2244](https://github.com/mvschwarz/openrig/pull/2244), [#2254](https://github.com/mvschwarz/openrig/pull/2254), [#2256](https://github.com/mvschwarz/openrig/pull/2256), [#2257](https://github.com/mvschwarz/openrig/pull/2257), [#2215](https://github.com/mvschwarz/openrig/pull/2215), [#2216](https://github.com/mvschwarz/openrig/pull/2216),
|
||||
[#2217](https://github.com/mvschwarz/openrig/pull/2217))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **The Codex hook-refresh refusal in [#2200](https://github.com/mvschwarz/openrig/issues/2200)
|
||||
remains unresolved.** If `rig seat restart <seat> --keep-conversation` refuses,
|
||||
nothing is stopped: leave the seat running and report the refusal with CLI
|
||||
and daemon versions. A refused restart has not refreshed the hooks.
|
||||
- **A viewer whose attach already ran and then exited back to a shell is not
|
||||
restarted automatically.** The whole slice view can hold until that tile is
|
||||
resolved. No tested workaround is offered for that case.
|
||||
- **Following protects uncertain input by retaining tiles.** A larger workspace
|
||||
or explicit `--max-tiles` can help with overflow. Protected Codex delivery can
|
||||
still retain messages it cannot confirm before paste; retained is not
|
||||
delivered. Disabling its typing guard removes protection and is not recovery.
|
||||
- **Internet-facing hosted members remain experimental, unsupported, off by
|
||||
default and at your own risk.** Slice following does not require them.
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @seanmcn, @DeryFerd, @Aschenbath, @Vaishnavi220506, @bradvin,
|
||||
@jay23606, @rudycelekli and @Nikhi00718 for merged contributions, and to
|
||||
@akushonkamen, @jcmurrayii, @KM199 and @pdurlej for contributed changes, reports
|
||||
and reproductions carried into this release.
|
||||
|
||||
## [0.7.5]
|
||||
|
||||
OpenRig 0.7.5 adds `rig terminal open qitem:<id> --follow`: keep one terminal
|
||||
workspace as a task's current owners join and leave. Joining viewers do not
|
||||
request focus; departures remove viewers, not the agents or conversations.
|
||||
Follow mode is scoped to Linux Herdr 0.9.5 / protocol 22. The exercised journey
|
||||
used shell stand-ins; real-agent follow qualification and active render cost
|
||||
are not established by that result. Changes since 0.7.4:
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Restart the daemon after installing** to run the new code and database
|
||||
update. Migration 126 adds deferred `escalates_to` target addresses to the
|
||||
edges table, preserving existing edges. Previously inert escalation edges
|
||||
now affect recovery for seats without a delegation parent.
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.5
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
- **The bundled openrig-core plugin goes from 0.1.21 to 0.1.22.** Its
|
||||
`queue-handoff` skill teaches agents to park waits on CI or a long job with
|
||||
a continuation and wake. As before, managed refresh preserves replaced
|
||||
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply any local
|
||||
edits you still need from that copy. No setting is added or removed.
|
||||
- **A Codex hook-refresh refusal remains unresolved.** The earlier remedy
|
||||
`rig seat restart <seat> --keep-conversation --reason "refresh hooks"`
|
||||
(preview with `--plan`) was reported to refuse one Codex seat upgraded from
|
||||
0.6.x. Its cause is unknown: the inspected 0.7.4 check already accepts the
|
||||
reported process tree. [#2200](https://github.com/mvschwarz/openrig/issues/2200)
|
||||
is not fixed in 0.7.5; this is not an established refusal of all staged-script
|
||||
launches. A refused restart does not complete the hook refresh. If it
|
||||
refuses, nothing is stopped: leave the seat running and report the refusal
|
||||
with CLI and daemon versions. If Codex asks for hook review, review the
|
||||
hooks you intend to enable.
|
||||
|
||||
### Follow one task in one workspace
|
||||
|
||||
- **Follow the current owners of a queue item:**
|
||||
`rig terminal open qitem:<id> --follow --provider herdr`. The view derives
|
||||
membership from the task's open rows and explicitly linked successors.
|
||||
Joining seats get verified viewer tiles in the same workspace. A complete
|
||||
empty task drains removable tiles and keeps the workspace; after retained
|
||||
tiles are gone, following stops. Run `--follow` again to resume it.
|
||||
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
|
||||
- **Keep control of the view:** `--pin`, `--unpin`, `--suppress`,
|
||||
`--unsuppress`, `--suppression-ms` and `--max-tiles` adjust presentation.
|
||||
`--no-follow` leaves the layout in place. The default is eight tiles,
|
||||
four seconds of departure grace and twelve more seconds for a focused
|
||||
leaver. Pins can exceed the cap; input-retained leavers do not consume a
|
||||
joining seat's slot. See [terminal sessions](docs/reference/terminal-sessions.md).
|
||||
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
|
||||
- **Uncertain input keeps its tile.** Typed drafts and unreadable composers
|
||||
retain departed viewers, including Pi, terminal and community harness
|
||||
seats whose input cannot be confirmed. Unpinning does not override that.
|
||||
There is one delayed input recheck, then queue/session events or an explicit
|
||||
follow command can reconsider it. Failed or incomplete reads retain the
|
||||
previous view with an explanation. Activity hooks do not trigger renders.
|
||||
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
|
||||
- **Protected Codex delivery no longer strands a message it can't submit.** On a
|
||||
Codex seat whose typing guard is draft-aware or openrig-urgent, a message
|
||||
whose complete text can't be confirmed visible before paste is retained, not
|
||||
sent, instead of being pasted where it can't be submitted. The default (guard
|
||||
off) is unchanged. Use a larger pane or a shorter message; turning the typing
|
||||
guard off turns draft protection off and is not a recovery. Pastes over 1,000
|
||||
code points are retained. Wrapped lines use a conservative bound, so most
|
||||
wrapped wakes in common four-tile views can be retained even when they fit
|
||||
natively; approximate standard-envelope examples are one-paragraph bodies over
|
||||
about 115 characters at 80×22, 192 at 80×24, or 526 at 120×30 (examples, not
|
||||
limits). A later resize is not atomic, and layouts with Codex's image-enabled
|
||||
ambient pet are unqualified.
|
||||
([#2242](https://github.com/mvschwarz/openrig/pull/2242))
|
||||
- **Codex 0.164's two-row composer footer is recognized.** A departed Codex
|
||||
agent's empty composer no longer reads as unconfirmed input that keeps its
|
||||
tile. Delivery's draft-aware and openrig-urgent typing guards use the same
|
||||
reader, so an empty Codex 0.164 composer there can now receive typed
|
||||
delivery. ([#2206](https://github.com/mvschwarz/openrig/pull/2206))
|
||||
- **Clients can ensure a missing viewer without relaying out existing panes**
|
||||
through `POST /api/terminal/viewer/ensure`. It rechecks task membership and
|
||||
existing viewer identity before creating or reusing a tile. Preview names
|
||||
`not-provisioned`, `verified` or `unconfirmed`; membership gives no input
|
||||
authority. This path has the same Linux Herdr 0.9.5 / protocol 22 scope.
|
||||
([#2133](https://github.com/mvschwarz/openrig/pull/2133))
|
||||
- **An unconfirmed pane no longer counts as opened.** CLI and TUI output name
|
||||
it as degraded with `pane_unconfirmed`, distinguish it from a skipped seat,
|
||||
and omit its restart-command report. An all-unconfirmed open exits nonzero.
|
||||
Inspect before retrying; this is not proof that nothing opened.
|
||||
([#2173](https://github.com/mvschwarz/openrig/pull/2173))
|
||||
|
||||
### Fixes by what you see
|
||||
|
||||
- **Slow successful sends have time to answer.** Local and HTTP `rig send`
|
||||
wait 60 seconds, plus an explicit idle wait, instead of five seconds locally
|
||||
or thirty over HTTP. A timeout still means unconfirmed. The advice now says
|
||||
that a busy recipient may hold input off-screen: missing pane text does not
|
||||
prove non-delivery, so reconcile before another send or duplicate queue item.
|
||||
No automatic resend is added. This does not diagnose the daemon's latency.
|
||||
([#2198](https://github.com/mvschwarz/openrig/pull/2198))
|
||||
- **A wake that was unroutable before its seat had a pane is retried once the
|
||||
binding exists,** through the row's existing wake intent. The outbox does
|
||||
not send that same intent again after it is settled as delivered.
|
||||
([#2170](https://github.com/mvschwarz/openrig/pull/2170))
|
||||
- **A parentless seat's recovery can follow its declared `escalates_to`
|
||||
address,** including another rig on the same daemon. A delegation parent
|
||||
still takes precedence. Unavailable or ambiguous targets use the explained
|
||||
fallback; an already-open recovery keeps its recorded owner. This is one
|
||||
escalation hop, not cross-host routing.
|
||||
([#1772](https://github.com/mvschwarz/openrig/pull/1772))
|
||||
- **A stuck finding needs evidence to close.** Delivery, pickup or explicit
|
||||
custody can resolve it; a changed reporting threshold, an unreadable
|
||||
registry or a failed wake becoming unconfirmed cannot silently clear it.
|
||||
([#2153](https://github.com/mvschwarz/openrig/pull/2153))
|
||||
- **Fresh Claude handovers wait for the current launch.** Retained scrollback
|
||||
or an echoed shell command cannot make a new process look ready. The context
|
||||
packet waits for the matching process and Claude's complete drawn input frame.
|
||||
([#2159](https://github.com/mvschwarz/openrig/pull/2159))
|
||||
- **Runtime send refusals return HTTP 409 rather than 500.** A stale recorded
|
||||
Claude pane names `rig reconcile-session <seat>` as the repair; nothing is
|
||||
typed on that refusal. ([#2157](https://github.com/mvschwarz/openrig/pull/2157))
|
||||
- **`rig down` keeps a rig down after a rolled-back start.** It records the
|
||||
explicit stop without rewriting the superseded session's history. The
|
||||
`rig start --last` help now explains which rigs return and how an explicit
|
||||
stop keeps them out; the kernel is not selected by `--last`.
|
||||
([#2166](https://github.com/mvschwarz/openrig/pull/2166))
|
||||
- **Status and configuration describe the selected rig.** `rig ps` reports
|
||||
`periodicSnapshotActive` only for rigs the scheduler actually snapshots
|
||||
([#2168](https://github.com/mvschwarz/openrig/pull/2168)); the web Overview
|
||||
reads the rig's current exported configuration instead of a same-named
|
||||
library template ([#2188](https://github.com/mvschwarz/openrig/pull/2188)).
|
||||
- **Host labels and next steps are clearer.** The TUI shows the configured
|
||||
local host name while retaining its stable identity
|
||||
([#2162](https://github.com/mvschwarz/openrig/pull/2162)). `rig host add` and
|
||||
`pair` explain how to opt an HTTP host into remote Feed attention; registration
|
||||
leaves that setting unchanged ([#2179](https://github.com/mvschwarz/openrig/pull/2179)).
|
||||
- **Small CLI corrections:** `attach --self` reads tmux's rewritten separators
|
||||
([#2178](https://github.com/mvschwarz/openrig/pull/2178)); transcript and
|
||||
capture line counts use their normalized values on local and remote paths
|
||||
([#2164](https://github.com/mvschwarz/openrig/pull/2164)); stdio MCP warns on
|
||||
out-of-range daemon ports while continuing to serve, without substituting a
|
||||
port ([#2160](https://github.com/mvschwarz/openrig/pull/2160)).
|
||||
- **Notification adapters release unread HTTP response bodies,** without
|
||||
changing their send result or waiting for cleanup. No performance improvement
|
||||
is claimed here. ([#2172](https://github.com/mvschwarz/openrig/pull/2172))
|
||||
- **Scope audits agree on slice folders numbered 100 and above.** The guide
|
||||
also explains that moving a slice reopens its proof judgments; a clean
|
||||
structural audit is not a proof judgment.
|
||||
([#2194](https://github.com/mvschwarz/openrig/pull/2194))
|
||||
|
||||
### Presets, hosted members and guides
|
||||
|
||||
- **The `pi-tui` example runs Pi's own terminal UI as a community harness.**
|
||||
It gives each seat its own Pi agent folder and passes startup input to Pi.
|
||||
One Pi 1.4.0 / OpenRig 0.7.4 run showed a parked wake returning to the same
|
||||
session and an unattended handoff to a second seat. It did not prove Pi
|
||||
chooses that coordination by itself. Resume is not declared. Use it
|
||||
unattended: a wake can submit a person's half-typed draft, and unrecognized
|
||||
dialogs can receive the send. Busy-turn wakes and other Pi versions remain
|
||||
unqualified. ([#2182](https://github.com/mvschwarz/openrig/pull/2182),
|
||||
[#2184](https://github.com/mvschwarz/openrig/pull/2184))
|
||||
- **Interactive Pi setup is explicit:** copy the coordination skills and use
|
||||
`delivery_hint: send_text` for a startup file that must reach a preset's
|
||||
first input. A plain terminal seat does not receive queue wakes. Setting its
|
||||
typing guard to `off` can type notices into a shell; keep the default for a
|
||||
seat that can return to one. These are documented limits, not new guards.
|
||||
([#2174](https://github.com/mvschwarz/openrig/pull/2174),
|
||||
[#2180](https://github.com/mvschwarz/openrig/pull/2180),
|
||||
[#2181](https://github.com/mvschwarz/openrig/pull/2181),
|
||||
[#2185](https://github.com/mvschwarz/openrig/pull/2185))
|
||||
- **Hosted-member guidance names its real reach.** A member without a wake
|
||||
trigger checks work when its owner next starts a session; a listed tool is
|
||||
not a grant, and completed work closes with `no-follow-on`. Hosted members
|
||||
exposed over the internet remain **experimental, unsupported, off by default
|
||||
and at your own risk**. The guide explains narrow grants and token revocation;
|
||||
this terminal-follow release requires no internet exposure.
|
||||
([#2187](https://github.com/mvschwarz/openrig/pull/2187),
|
||||
[#2189](https://github.com/mvschwarz/openrig/pull/2189))
|
||||
- **Publishing a team bundle:** add and declare a README after `rig export`,
|
||||
then run `rig bundle check` at the revision being submitted
|
||||
([#2176](https://github.com/mvschwarz/openrig/pull/2176)). Queue guidance
|
||||
teaches parking long waits with a continuation and wake
|
||||
([#2167](https://github.com/mvschwarz/openrig/pull/2167)).
|
||||
- **Contributor maintenance:** the test guide corrects installed-scenario
|
||||
counts and cases ([#2165](https://github.com/mvschwarz/openrig/pull/2165));
|
||||
the compaction receipt fixture shares one clock, with no product change
|
||||
([#2169](https://github.com/mvschwarz/openrig/pull/2169)). Published release
|
||||
notes, the capability map and roadmap records were carried forward
|
||||
([#2161](https://github.com/mvschwarz/openrig/pull/2161),
|
||||
[#2175](https://github.com/mvschwarz/openrig/pull/2175),
|
||||
[#2197](https://github.com/mvschwarz/openrig/pull/2197)).
|
||||
|
||||
### Limits carried forward
|
||||
|
||||
- **Follow mode is Linux Herdr 0.9.5 / protocol 22 only.** Other platforms,
|
||||
providers and selector kinds retain one-shot open. Its exercised journey
|
||||
used shell stand-ins, not native agents. Active render cost is unmeasured.
|
||||
The first workspace on an empty provider may gain focus, and a viewer can
|
||||
resize the agent's terminal; `ignore-size` is not resize isolation.
|
||||
- **Unreadable composers retain departed tiles.** This includes input the
|
||||
inspector cannot read on Pi, terminal or community harness seats; unpinning
|
||||
alone does not establish that closing is safe.
|
||||
- **Escalation across rigs is same-daemon only.** Upgrade followed by snapshot
|
||||
restore of deferred escalation targets has not been qualified.
|
||||
- **A webhook 2xx is acceptance of the ring, not pickup.** A hosted member's
|
||||
activity remains unknown between calls. Principals record attribution, not
|
||||
authentication. See [hosted members](docs/reference/hosted-members.md).
|
||||
- **Reviewed-history resume remains a client operation through startup review,**
|
||||
not a CLI command. Kernel restore evidence lasts one daemon process.
|
||||
- **OpenClaw interrupted local turns are not recovered; Hermes has no declared
|
||||
resume or measured idle gateway exit.** `rig down` stops a community harness's pane; survival of independently spawned
|
||||
children remains untested. Inspect actual processes before relying on their exit.
|
||||
- **The reported Codex restart refusal in #2200 remains unresolved,** as do
|
||||
the other 0.7.4 limits unless a change above names them fixed: Codex permission qualification
|
||||
only on 0.164.0, earlier removed hook approvals, SSH tiles after a Herdr
|
||||
restart, unqualified native cmux, Slack-only shared channels and the existing
|
||||
browser-sizing and old Slack-thread limits. Isolated account-home switching
|
||||
is not included.
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @icey-zzz (#1772), @Aschenbath (#2153, #2159, #2162, #2169, #2179),
|
||||
@DeryFerd (#2164, #2165), @Nikhi00718 (#2172) and @dajiaohuang (#2188).
|
||||
Thanks also to @rudycelekli for the contribution from #1416 completed in #2160;
|
||||
to @AdamWhitehurst (#1544, #2049), @kevinhsykes (#2151), @aanestesia (#605),
|
||||
@todie (#1419) and @tgrundtvig (#21), whose reports led to fixes here; and to
|
||||
the other people whose reports and reviews led to these fixes.
|
||||
|
||||
## [0.7.4]
|
||||
|
||||
OpenRig 0.7.4 lets an assistant you already use work as a member of your team.
|
||||
@@ -667,10 +1112,11 @@ from the commit tagged `v0.7.3`. The changes since 0.7.2:
|
||||
current TUI sends it (a request without a seat identity); an older TUI's
|
||||
answer on someone else's ask is refused.
|
||||
([#2006](https://github.com/mvschwarz/openrig/pull/2006))
|
||||
- **A Codex seat's permission settings are passed as launch flags; that Codex
|
||||
enforces them was not proven natively.** Going back to an older OpenRig keeps
|
||||
them unread (see Before you upgrade). The Codex trust fixes stop new duplicate
|
||||
hook groups but don't remove existing ones.
|
||||
- **A Codex seat's permission settings were enforced natively in our test with
|
||||
Codex 0.164.0,** including after a managed restore and a restart. Other Codex
|
||||
versions weren't tested. Going back to an older OpenRig keeps them unread (see
|
||||
Before you upgrade). The Codex trust fixes stop new duplicate hook groups but
|
||||
don't remove existing ones.
|
||||
([#2048](https://github.com/mvschwarz/openrig/pull/2048),
|
||||
[#2035](https://github.com/mvschwarz/openrig/pull/2035))
|
||||
- **The cmux path wasn't tried in a real cmux**, and a remote host's rigs are
|
||||
@@ -735,8 +1181,7 @@ reported [#1653](https://github.com/mvschwarz/openrig/issues/1653) (worked on in
|
||||
reported [#980](https://github.com/mvschwarz/openrig/issues/980) (addressed by
|
||||
[#2081](https://github.com/mvschwarz/openrig/pull/2081)); @rakoort, whose
|
||||
suggestion in [#1679](https://github.com/mvschwarz/openrig/issues/1679) became
|
||||
[#2086](https://github.com/mvschwarz/openrig/pull/2086) (the issue's main
|
||||
report is still open); @george661, whose
|
||||
[#2086](https://github.com/mvschwarz/openrig/pull/2086); @george661, whose
|
||||
[#1710](https://github.com/mvschwarz/openrig/issues/1710) led to
|
||||
[#2093](https://github.com/mvschwarz/openrig/pull/2093).
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.7/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.7/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
|
||||
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
|
||||
|
||||
### Install it yourself
|
||||
|
||||
This runs OpenRig's install script from the `v0.7.4` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
This runs OpenRig's install script from the `v0.7.7` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Integration author contracts — DRAFT for 0.7.6
|
||||
|
||||
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
|
||||
|
||||
1. **[Harness and runner](harness-runner-contract.md):** launch the agent, report its conversation identity and activity, deliver input, handle permission requests and cancellation, and reopen the requested conversation.
|
||||
2. **[Execution and platform](execution-platform-contract.md):** choose placement, deliver files and credential references, distinguish owned from attached resources, preserve native state, return artifacts and reconcile shutdown.
|
||||
|
||||
These are draft author responsibilities. Some use implemented OpenRig interfaces; others describe a proposed integration whose code remains with its author. Including the guide in release documentation does not make every described capability available.
|
||||
|
||||
## Candidate and support status
|
||||
|
||||
This draft targets the **unsealed 0.7.6 candidate**. Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. Later release repairs and version text are still being assembled; no final tag or package publication is asserted here. References below use the immutable base instead of a moving branch.
|
||||
|
||||
| Area | Status at this baseline | What the status means |
|
||||
|---|---|---|
|
||||
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
|
||||
| `runner: true` with `launch_result: true` | Implemented candidate source | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
|
||||
| `passes_model: true` | Implemented candidate source | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
|
||||
| The dsh/ACP input, cancellation and refusal-first permission recipe | Proposed author implementation | No end-to-end dsh integration is established. Native reopen capabilities and the tools-off case remain implementer questions. |
|
||||
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
|
||||
| Environment preparation, projection, artifact export and owned-resource cleanup | Measured in a private wrapper; generic seam proposed | The platform chapter makes the responsibilities reusable. Core does not provide a generic platform driver or `DeploySpec` loader at this baseline. |
|
||||
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
|
||||
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
|
||||
|
||||
## How the pieces compose
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
R["RigSpec: members, relationships, work arrangement"] --> A["AgentSpec: role, context and startup declarations"]
|
||||
R --> H["Built-in runtime or community preset"]
|
||||
H --> N["Harness / runner: native conversation and control"]
|
||||
H --> P["Author platform integration: placement and resources"]
|
||||
P --> N
|
||||
P --> F["Workspace, retained state and returned artifacts"]
|
||||
R --> S["Optional application services"]
|
||||
```
|
||||
|
||||
**RigSpec remains the composer.** AgentSpec describes reusable agent intent; the runtime or preset selects how the native agent starts. An integration can surround that launch with an environment-specific wrapper. A bundled application's database or Compose service is software the team operates; it is distinct from the environment in which an agent executes.
|
||||
|
||||
The terminal provider, native harness protocol and network connection also have different jobs. A terminal provider carries input and output; a runner understands native turn boundaries; a granted MCP or other connection exposes coordination operations. An HTTPS connection does not by itself provision compute, awaken a suspended application or establish native resume. Each integration states which of those effects it implements.
|
||||
|
||||
| Owner | Responsibility |
|
||||
|---|---|
|
||||
| OpenRig Core | Team and work identity, queue records, existing launcher contracts, activity interpretation and declared configuration. |
|
||||
| Harness/runner author | Native launch and protocol, exact native reference, prompt framing, truthful activity, permission/cancel behavior and native resume. |
|
||||
| Platform integration author | Placement binding, destination projection, resource ownership, state transfer, artifact delivery and resource reconciliation. |
|
||||
| Operator / credential owner | Selected environments, grants, provider credentials and spending authority. |
|
||||
|
||||
The queue records who owns work. It does not prove that an old provider process has stopped, that a new runtime consumed its history, or that guest bytes reached the consumer. Those observations belong in the launch, resume and export receipts alongside the work record. This division reuses the queue rather than creating another task system.
|
||||
|
||||
## Start from an existing example
|
||||
|
||||
For a terminal harness, begin with the pinned [community preset reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md) and the [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
|
||||
|
||||
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
|
||||
|
||||
For a contained worker, the [platform composition](execution-platform-contract.md#a-minimal-composition-using-existing-surfaces) pairs an ordinary coordinator with a worker preset. It uses existing member and preset fields. The wrapper must prepare or attach to the selected environment explicitly; declaring a runtime does not provision it. Its illustrated member list is a fragment, and its launcher is a skeleton, not a ready-to-run provider package.
|
||||
|
||||
A rig bundle can carry declarations, roles and supporting scripts. Do not confuse those files with hydrated image layers, installed binaries, registered presets, credentials or live sessions. State what the recipient must supply.
|
||||
|
||||
## Lifetime and recovery
|
||||
|
||||
**Persistent** and **Non-persistent** describe execution lifetime. A non-persistent invocation has an explicit completion condition tied to its assigned work. **Durable** describes interruption recovery and is independent of that lifetime. An agent can run briefly and still resume its retained conversation later.
|
||||
|
||||
Retaining a workspace and reconstruction material can restore useful competence without restoring the same native conversation. Label that as reconstruction. Native resume additionally requires the correct harness-qualified reference and supported native state. The platform chapter's measured example restored copied history into recreated containers on the same host; it does not establish new-host migration or RAM restoration.
|
||||
|
||||
## Questions contributors can help resolve
|
||||
|
||||
- At a pinned dsh version, which advertised method reopens a session, and can a useful first case run without permission requests?
|
||||
- Which additional harness would justify a shared protocol runner rather than a separate wrapper?
|
||||
- Which provider effects can use the existing projection, native-reference and lifecycle dispatch, and which need a new maintained interface?
|
||||
- What exact state and ownership must travel when compute is recreated or moved?
|
||||
|
||||
These questions remain part of the draft. An unanswered contributor thread does not prevent authors from using the implemented interfaces or reviewing the proposed responsibilities. Compatibility claims should name versions, observed behavior and unsupported operations; they should not imply a support guarantee from a manifest alone.
|
||||
@@ -11,3 +11,4 @@ launch_result: true
|
||||
resume: true
|
||||
startup_assisted: false
|
||||
first_input: true
|
||||
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
|
||||
@@ -34,6 +34,12 @@ chat_store() {
|
||||
printf '%s/chats/%s/%s/store.db' "$CURSOR_CONFIG_DIR" "$sum" "$1"
|
||||
}
|
||||
|
||||
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model) goes to a new chat only. A resumed chat isn't
|
||||
# passed one: whether Cursor's --resume keeps the chat's earlier model or takes a new one hasn't been checked.
|
||||
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
set -- --model "$OPENRIG_MODEL" "$@"
|
||||
fi
|
||||
|
||||
# Preserve the file's exact bytes, including trailing newlines. A read error stops before the harness starts.
|
||||
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
|
||||
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
|
||||
|
||||
@@ -10,3 +10,4 @@ limits:
|
||||
- no guidance, skills or startup files are placed
|
||||
- needs-input and approval prompts are read from the screen only
|
||||
names_conversation: true
|
||||
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
|
||||
@@ -24,6 +24,21 @@ if ! grep -qs OPENRIG_SESSION_NAME "$HERMES_HOME/config.yaml"; then
|
||||
echo "openrig-hermes-launch: $HERMES_HOME/config.yaml doesn't pass OPENRIG_SESSION_NAME to Hermes's tools, so rig run from Hermes won't act as this seat; see config.yaml.example" >&2
|
||||
fi
|
||||
|
||||
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model): Hermes takes it only from config.yaml, and
|
||||
# Hermes v0.22.4 fixes a session's launch settings when it creates it. So it's written for a new session only, and only
|
||||
# into a config.yaml that is absent or empty: a seat's own config is left byte-for-byte as it is, whatever it says. On a
|
||||
# resume this launcher applies no OpenRig model.
|
||||
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
case "$OPENRIG_MODEL" in
|
||||
''|*[!A-Za-z0-9._:/@+-]*) echo "openrig-hermes-launch: not writing unusual model id \"$OPENRIG_MODEL\" into $HERMES_HOME/config.yaml" >&2 ;;
|
||||
*) if [ -s "$HERMES_HOME/config.yaml" ]; then
|
||||
echo "openrig-hermes-launch: the seat has its own $HERMES_HOME/config.yaml; OPENRIG_MODEL was not applied; set the model there" >&2
|
||||
else
|
||||
printf 'model: "%s"\n' "$OPENRIG_MODEL" >> "$HERMES_HOME/config.yaml"
|
||||
fi ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
exec "$hermes" chat -c "$OPENRIG_RESUME_REF"
|
||||
fi
|
||||
|
||||
@@ -4,6 +4,9 @@ A community harness is a coding harness an outside contributor maintains. You in
|
||||
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
|
||||
harnesses, and it reads their activity only from what the screen shows.
|
||||
|
||||
For what an integration author owns end to end, including a runner's duties and running a harness somewhere else, see
|
||||
the draft [integration author contracts](author-contracts.md).
|
||||
|
||||
## What a community harness gets
|
||||
|
||||
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
|
||||
@@ -15,14 +18,14 @@ it never reads an unsupported capability as idle, working, ready or verified.
|
||||
| Delivery | yes | `rig send` and queue wakes paste the text into the pane and press Enter, as for any seat. A pane that's back at a shell is refused (`target_runtime_unverified`) and nothing is typed. OpenRig doesn't confirm the harness took the message; `rig send --verify` checks the pane afterwards. |
|
||||
| Identity | when declared | With `launch_result`, the conversation is shown as reported by its launcher; with `names_conversation`, as named by OpenRig. Neither is observed by OpenRig. Without either, the identity is unavailable. |
|
||||
| Resume | when declared | `resume: true` with one of the two above (see [Reopening a conversation](#reopening-a-conversation-resume-true)). Without it, a resume says `Resume is unsupported: community harness "<name>" is launch-only`, and a restore stops and asks. |
|
||||
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. |
|
||||
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
|
||||
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
|
||||
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. |
|
||||
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
|
||||
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
|
||||
|
||||
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
|
||||
configuration.
|
||||
configuration, or let a launcher that opts in take the seat's declared model (`passes_model`, below).
|
||||
|
||||
**Check that the harness answers on its own first.** Before writing a preset, start the harness in a terminal outside OpenRig, with the
|
||||
same configuration and credentials your launcher will give it, and send it one prompt. Use the folder your launcher gives the seat, not
|
||||
@@ -48,6 +51,8 @@ launch_result: true # optional: the launcher reports the conversatio
|
||||
resume: true # optional, needs one of those two; declare it only once your harness's resume is checked
|
||||
startup_assisted: true # optional: startup submission needs a person's check (below)
|
||||
first_input: true # optional: launcher takes the first turn from OPENRIG_FIRST_INPUT (below)
|
||||
# runner: true # optional, needs launch_result: the launcher reports the seat's activity (below)
|
||||
# passes_model: true # optional: the launcher takes the seat's declared model from OPENRIG_MODEL (below)
|
||||
```
|
||||
|
||||
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
|
||||
@@ -144,6 +149,45 @@ For a reported conversation, OpenRig also says so plainly when:
|
||||
- **the saved conversation isn't this harness's own** (a former runtime's id, say): it isn't passed to the launcher. A restore stops and asks, and elsewhere the resume says it isn't possible;
|
||||
- **the record's folder can't be made:** the launcher couldn't be told what to reopen, so nothing starts.
|
||||
|
||||
## Runner presets (`runner: true`)
|
||||
|
||||
A plain preset's seat reads its activity from the screen. A **runner** preset is for a launcher that hosts the agent itself
|
||||
and reports what it's doing, as OpenRig's own Pi runner does. The seat's activity then follows those reports.
|
||||
|
||||
- **It needs `launch_result: true`.** A runner reports the conversation it opened in its launch record, so `runner: true`
|
||||
without `launch_result` is refused.
|
||||
- **The declaration is fixed at launch.** Each launch, a restore's included, records on the session what the preset
|
||||
declares at that moment. Editing the preset changes the next launch, not a running seat. A session recorded before this
|
||||
field existed reads as plain.
|
||||
- **No record means attention.** When a runner's launch record doesn't arrive within the wait, or its folder can't be
|
||||
made, the seat needs attention instead of reading ready. A plain `launch_result` preset still starts, with its identity
|
||||
unavailable.
|
||||
- **Input is as for any seat.** A send pastes the text and presses Enter, so a runner should submit everything up to that
|
||||
Enter as one prompt.
|
||||
|
||||
**What the runner posts.** The runner posts each event to `POST /api/activity/hooks` with the local activity hook token,
|
||||
one at a time and in order, without making the agent wait. It finds the address and token in `OPENRIG_URL` (or
|
||||
`OPENRIG_PORT`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else in `$OPENRIG_HOME/activity-endpoint.json`. Every post names:
|
||||
- the seat, as `sessionName` and `nodeId`;
|
||||
- `runtime`, the preset's name;
|
||||
- `generation`, from `OPENRIG_OCCUPANT_GENERATION`;
|
||||
- an increasing `sequence`, and `occurredAt`;
|
||||
- `hookEvent` and `subtype`, as in the table.
|
||||
|
||||
| The runner posts | When | The seat reads |
|
||||
|---|---|---|
|
||||
| `UserPromptSubmit` | it gave the agent a prompt | working |
|
||||
| `active` or `PreToolUse` | the agent is working | working |
|
||||
| `Notification` / `permission_prompt` | the agent asked for a permission | needs input |
|
||||
| `Stop` / the turn's stop reason | the turn ended | idle |
|
||||
| `Stop` / `<preset name>_exited` | the agent's process ended | unknown, never idle |
|
||||
|
||||
- **The screen still counts,** but only as evidence of work: on its own it never reads the seat idle.
|
||||
- **The generation attributes a post; it isn't authentication.** A post with an earlier occupant's generation doesn't
|
||||
count for the live seat. The hook token is one local token, so any process that holds it can post for any seat.
|
||||
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
|
||||
[harness and runner contract](harness-runner-contract.md).
|
||||
|
||||
## An example: Hermes
|
||||
|
||||
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
|
||||
@@ -242,7 +286,7 @@ on it, not Pi choosing by itself to park or hand work on.
|
||||
Copy both and maintain them as your own.
|
||||
|
||||
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
|
||||
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted` or `first_input` is refused by an older
|
||||
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner` or `passes_model` is refused by an older
|
||||
OpenRig that doesn't know the field.
|
||||
|
||||
## Pass the first startup input at launch
|
||||
@@ -255,6 +299,37 @@ OpenRig records `node.startup_first_input_passed` with the path and `submission:
|
||||
|
||||
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
|
||||
|
||||
## Pass the seat's declared model at launch
|
||||
|
||||
A format-1 preset may set `passes_model: true` (a boolean, default false). Then a model declared for the seat (in the rig
|
||||
spec, or set later with `rig seat set-model`) reaches its launcher as `OPENRIG_MODEL`, on new and resumed launches alike. A seat that declares no model gets none,
|
||||
and an inherited `OPENRIG_MODEL` is cleared. The launcher maps it into its harness's own setting; Core knows no harness's
|
||||
model flag or config key.
|
||||
|
||||
OpenRig keeps three things apart:
|
||||
- **requested:** the declared model, as `rig ps --nodes` shows it ("configured", or "not-declared");
|
||||
- **passed:** the launcher was handed the model. OpenRig records `node.startup_model_passed` with the model and whether the
|
||||
launch was a resume only when the launch command's send succeeded, and a later failure of the launch doesn't undo that.
|
||||
It never says passed from the declaration alone or for a launch that failed before anything was typed. When typing
|
||||
began and the send then failed, the model's dispatch is uncertain: it isn't recorded as passed, and it isn't retyped;
|
||||
- **effective:** the model that actually runs. OpenRig doesn't observe it for a community harness, so it reads UNKNOWN.
|
||||
|
||||
Passing a model never makes OpenRig pick a default, substitute another model, or retry with one. A model the harness
|
||||
refuses is the harness's own error.
|
||||
|
||||
The examples apply the request for a new session only; on a resume, the example launcher doesn't apply it, and which
|
||||
model a resumed session runs stays unknown unless the harness's own evidence says otherwise:
|
||||
- **Cursor:** `--model "$OPENRIG_MODEL"` for a new chat. What a resumed chat runs hasn't been checked: the launcher leaving
|
||||
out `--model` doesn't show that it keeps its earlier model.
|
||||
- **Hermes:** Hermes takes its model only from the seat's `config.yaml`, so for a new session the launcher writes
|
||||
`model: "<id>"` there only when that file is absent or empty. A seat that has its own `config.yaml` keeps it
|
||||
byte-for-byte, and the launcher says the model wasn't applied: set it in that file. A seat set up from
|
||||
`config.yaml.example` already has a config, so set its model there. Hermes v0.22.4 fixes a session's
|
||||
launch settings when it creates it (its own source), so a resumed Hermes session runs with what it was created with.
|
||||
|
||||
Both example presets ship with `passes_model: false` until that mapping has been checked with the real harness. Presets
|
||||
without the field and built-in runtimes are unchanged, and older OpenRig versions reject it as unknown.
|
||||
|
||||
## Interactive Pi in a terminal seat: the coordination skills
|
||||
|
||||
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Execution and platform integration contract — draft
|
||||
|
||||
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
|
||||
|
||||
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. Inclusion of this draft in 0.7.6 documentation does not implement a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
|
||||
|
||||
## Keep the identities and responsibilities separate
|
||||
|
||||
RigSpec composes members, their AgentSpecs/profiles, runtimes, working directories and relationships. AgentSpec supplies reusable role, startup and context declarations. A built-in runtime or registered community preset supplies a launch path. An environment integration supplies placement and provider-specific resource handling around that path. Existing declarations do not automatically provision a remote filesystem, credentials or compute.
|
||||
|
||||
| Identity or handle | Meaning and responsibility |
|
||||
|---|---|
|
||||
| Stable seat and work identity | The member's canonical address/node and owned work records. OpenRig retains this identity across executions; an environment IP or display name does not replace it. |
|
||||
| Native session identity | The harness's conversation token/UUID, qualified by harness and resume-reference type. The harness owns its format and resume semantics. |
|
||||
| Environment binding | The integration's durable record linking placement, immutable provider resource IDs, native-state location, projection and ownership. It is separate from the native session token. |
|
||||
| Execution generation | A particular launch/process instance, with observable process/start identity when available. A new process can resume an old conversation. |
|
||||
| Viewer/client | A terminal attachment or connection to an execution. Closing it need not stop either the native process or compute. |
|
||||
|
||||
An application service bundled with a rig is a separate responsibility. An artifact browser or database in `RigSpec.services` serves the team's application; its presence does not place an agent inside that service. Bundling service definitions also does not bundle image bytes, native binaries, authenticated homes or live runtime state.
|
||||
|
||||
## A minimal composition using existing surfaces
|
||||
|
||||
The following is a **fragment of one pod** (`pods[].members`), using existing RigSpec fields, not a complete standalone RigSpec:
|
||||
|
||||
```yaml
|
||||
pods:
|
||||
- id: dev
|
||||
label: Development
|
||||
members:
|
||||
- id: coordinator
|
||||
agent_ref: local:agents/coordinator
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
- id: worker
|
||||
agent_ref: local:agents/worker
|
||||
profile: default
|
||||
runtime: example-contained-claude
|
||||
```
|
||||
|
||||
The worker runtime names an author-installed community preset. This preset shape uses existing fields exercised on the 0.7.4 baseline:
|
||||
|
||||
```yaml
|
||||
format: 1
|
||||
name: example-contained-claude
|
||||
maintainer: Integration author
|
||||
command: ./scripts/launch-contained-worker
|
||||
launch_result: true
|
||||
resume: true
|
||||
startup_assisted: true
|
||||
limits:
|
||||
- Requires an explicitly prepared environment binding and guest projection.
|
||||
- Native resume is qualified only for the documented retained-state recipe.
|
||||
- Viewer closure does not implement turn cancellation or resource deletion.
|
||||
```
|
||||
|
||||
This is an integration skeleton, not an installed or qualified preset. The author must supply the executable, resolve its path against the actual launch working directory, register the preset in the existing OpenRig home, and prepare the member's host working directory. The launcher implements the preset's declared launch-result and typed-resume behavior; the runner chapter defines those mechanics. `startup_assisted` requires a startup-submission check; it does not hold back submission or establish successful context delivery or ingestion. A member's model setting is not automatically honored by a custom launcher: its resolved native model/effort must be explicit in that launcher's supported configuration. With `passes_model: true`, Core passes the declared model through `OPENRIG_MODEL`; the launcher must map and apply it. The effective model remains unknown to Core.
|
||||
|
||||
For a contained worker, the launcher may resolve a private binding, create or attach to the selected resource, project explicit inputs, then connect to the native process inside it. In the measured recipe, OpenRig ran on the host, the plain coordinator used the built-in native runtime, and the worker used a private community launcher. The wrapper performed projection, provider-reference delivery, resume validation and artifact export explicitly. Those effects were not supplied by the preset declarations themselves. The integration must not assume host guidance/skills, hook paths or environment variables automatically reach a community runtime's guest.
|
||||
|
||||
A downstream author can implement this composition today around a qualified provider API and native harness. It remains an author-maintained integration until maintained platform support is separately implemented and documented. Validate against the target OpenRig schema and effective launch configuration; a parsed field or successful process start alone does not establish its effect.
|
||||
|
||||
## Placement and launch receipts
|
||||
|
||||
Before mutation, resolve the selected host/provider, workspace, image/native versions, reachability, credential references, resource request and ownership. Record existing resources and the selected resource scope. Host headroom includes supervisors, gateway and other team members; a workload memory request is not proof of their combined cap. Report requested, admitted and observed resources separately. Do not infer enforced CPU/memory or isolation policy from accepted flags.
|
||||
|
||||
Persist a launch intent before a create operation. Use an attempt/retry identity where the provider supports it; otherwise reconcile the original operation before another create. A public binding receipt carries only nonsecret values. If the harness's native resume reference works like a bearer credential (anyone holding it can reopen the conversation), keep it in secret-held receipt data, never in the public receipt. A useful public receipt identifies:
|
||||
|
||||
- canonical seat, harness version and native resume-reference type;
|
||||
- provider/host/workspace, immutable environment ID and execution generation;
|
||||
- image/policy versions, requested resources and observed readiness;
|
||||
- inner working directory, private native-state location and projection manifest;
|
||||
- a public-safe native reference (never a bearer-like resume token), creator or attached-consumer disposition, and exact owned resource handles;
|
||||
- lifecycle operations supported, completion observations, pending effects and errors.
|
||||
|
||||
A resource name or IP can be reused. Reconcile immutable ID, workspace and any available generation/fingerprint before acting. A lost response is **unconfirmed**, not success or absence. Record provider failures without exposing credentials. Readiness should identify what is ready: provider reachable, filesystem projected, native process started, team identity authenticated, or task completed.
|
||||
|
||||
## Project files and credential references deliberately
|
||||
|
||||
Stage only declared inputs: role/startup material, selected skills, current work context, schemas and required native settings. Preserve unmanaged files and report conflicts. Review every path-bearing setting for its destination: host-only hook scripts, interpreters, status collectors and loopback URLs may not exist or be reachable in the guest. Missing activity or permission transport must remain unsupported rather than being inferred from the base harness name.
|
||||
|
||||
Distinguish three observations: host staging, destination delivery and native ingestion. A destination manifest can record relative path, byte count and hash; a useful task using the current context revision/nonce can show consumption. Neither observation substitutes for the other. Current working input may change between tasks while an earlier artifact stays immutable.
|
||||
|
||||
Retained native history is a different data class from ordinary project context. Preserve its exact bytes through the harness's supported resume path; do not edit records or synthesize a conversation. Preserve a complete checkpoint prefix with byte length/hash if the harness may append after the checkpoint. Record which native-state files are required and which remain unavailable. Never substitute a fresh conversation seeded with a report while calling it native resume.
|
||||
|
||||
Keep secret values out of RigSpec, preset YAML, bindings, argv and public receipts. The provider/credential owner supplies scoped references or native-supported credential helpers and controls revocation. A successful login, provider registration or model-list request is not evidence of usable inference credit or an authorized spend envelope. Do not infer subscription portability from a provider name or copy an authenticated owner home into a guest.
|
||||
|
||||
Authenticate guest coordination through the actual granted member route. A shared source IP is not a seat identity. Record the grant-bound principal and scope separately from resource placement; distinct principals do not imply per-seat row ACLs or isolation between co-located secrets.
|
||||
|
||||
## Own only the effects assigned to the integration
|
||||
|
||||
Record **created** versus **attached** for each launch receipt. The creator may stop/delete the exact resource it created under the selected policy. An attached consumer normally closes only its own client and any separately identified process it owns; it must preserve the external environment, native session, workspace and shared services. Attachment must not silently transfer provider custody. An explicit owner-authorized transfer is a separate recorded action.
|
||||
|
||||
Describe these effects independently:
|
||||
|
||||
| Operation | Required distinction |
|
||||
|---|---|
|
||||
| Cancel a turn | Ask the runner to stop current work and observe its result. Unsupported cancellation must not be replaced silently by killing compute. |
|
||||
| Close a viewer | Release that client/connection; report whether native execution continues or is unknown. |
|
||||
| Stop native execution | End the identified native process and observe its exit. This is separate from task success. |
|
||||
| Stop compute | Release the documented provider allocation; identify retained storage and actual provider/process state. |
|
||||
| Delete an owned environment | Remove the exact creator-owned resource and reconcile final absence, including provider-managed dependents. |
|
||||
|
||||
Use graceful shutdown first where supported, then only explicitly owned, identified handles for stronger termination. Never equate pane disappearance with process or resource release. Persistent/non-persistent lifetime describes when execution should stop; durability describes recovery. An explicit non-persistent completion condition must name its invocation/work scope. An unrelated queue closure or an empty queue is not permission to terminate a member, and no new lifecycle YAML syntax is specified here.
|
||||
|
||||
## Resume exactly, or return an honest disposition
|
||||
|
||||
A resume request identifies the seat, harness-qualified native reference, prior binding, expected environment boundary and retained state. Validate those before launching the native harness. Reject mismatched reference types, missing/changed history, wrong immutable resources, unavailable required files and duplicate active executions. Never use “latest” as a replacement for an exact requested conversation or silently start fresh on failure.
|
||||
|
||||
Separate the outcomes: same native conversation resumed; new conversation rebuilt from artifacts; fresh launch; unsupported; refused; or unconfirmed. Environment continuity has its own outcome: same retained environment, recreated compute with copied state, different host, or unknown. A matching UUID alone does not show that native history was consumed.
|
||||
|
||||
If a provider stops exposing file transfer after canonical-process exit, capture artifacts/history and publish any retained resume descriptor **while transfer is still available**. Verify destination bytes before shutdown. After completion, use host/provider observations instead of assuming guest exec still works. For recreated compute, explicitly transfer the retained checkpoint and required artifact into the successor environment and validate them before publishing its resume descriptor. A new environment ID is expected and must be recorded as a successor, not disguised as the old environment.
|
||||
|
||||
The measured private recipe demonstrated this latter boundary: the original sandbox was deleted; copied native history and task bytes were restored into new containers on the same physical host; the native harness launched with `--resume` and the retained UUID. Its first new reply recalled a private conversation-only marker before tools, with no marker in the new input and no native-history tool read. It then verified artifact 1, used changed task context, produced artifact 2 and returned it for independent coordinator verification. This establishes one bounded native-return/work result. It does **not** establish new-host migration, RAM restoration, same-container stop/start, cgroup enforcement, general portability or shipped platform support.
|
||||
|
||||
## Export results before teardown
|
||||
|
||||
A guest path/hash note means bytes exist in a guest; it is not delivery to the consumer. Identify the actor holding the provider transfer handle. In the measured recipe that actor was the host wrapper, not the guest worker.
|
||||
|
||||
The worker publishes its report once and records the task/path/hash on its existing child. The wrapper validates the binding, task, allowed path, file type, size and expected hash; transfers to a fresh host staging path; checks the returned bytes/schema/context; and publishes without overwriting an existing result. It records an actual consumer-accessible destination, hash, byte count and actor through the authenticated work route. The worker closes its child after the matching export receipt; the coordinator independently reads/verifies those bytes before closing its parent. An ambiguous transfer is reconciled by existing bytes/hash, not a new report or duplicate task.
|
||||
|
||||
Complete exports before resource deletion. If output cannot be returned, preserve the exact resource/owner and failure state. Cleanup then reconciles the exact owned workload, supervisor, volumes and provider records to the intended stopped/absent state. Keep reused engines, caches, authenticated environments and attached resources. Revoke scoped credentials/grants through their owners and record remaining state. “Delete requested” and “absence verified” are different receipts.
|
||||
|
||||
## Proposed generic platform seam — not implemented
|
||||
|
||||
The existing composition need not acquire another YAML format to describe these responsibilities. If a maintained platform seam is selected, the following **design pseudocode** names the smallest useful operations; it is not a loader or API available in this release:
|
||||
|
||||
```text
|
||||
prepare(placement, ownership, resourceRequest, credentialRefs) -> bindingReceipt
|
||||
project(binding, explicitFiles, nativeCheckpoint?) -> deliveryReceipt
|
||||
launch(binding, harnessLaunch, exactResumeRequest?) -> executionReceipt
|
||||
observe(binding, execution) -> observedState | unsupported | unconfirmed
|
||||
export(binding, artifactRequest) -> publicationReceipt
|
||||
shutdown(binding, execution, selectedEffect) -> effectReceipt
|
||||
```
|
||||
|
||||
The provider implementation interprets its image/network/compute configuration. The runner interprets native protocol and control. Core would retain typed provenance and route the selected effects; it must not reinterpret arbitrary provider keys, leak secrets or collapse ownership into a stop boolean. Each mutating operation needs an attempt identity and reconciliation route; partial receipts remain useful after failure. Capability reporting must expose unsupported projection, cancellation, resume or cleanup rather than letting method names imply support.
|
||||
|
||||
The most useful author evidence follows the real work path: exact resolved placement and native launch; delivered current context; authenticated work ownership; returned artifact bytes; exact resume boundary and consumed retained state; and final resource disposition. A parser pass, launcher success, viewer reconnect or same display name leaves the corresponding effect unconfirmed. These distinctions let an author implement and diagnose an integration; they do not create a new release gate or require another runtime campaign.
|
||||
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
|
||||
script will do and changes nothing:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/scripts/install.sh | sh`, installs the
|
||||
latest published `@openrig/cli` with `npm install -g`,
|
||||
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
|
||||
Codex and may install a missing one, as described below. A failed step prints
|
||||
@@ -848,13 +848,47 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
|
||||
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
|
||||
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
|
||||
| Kernel seats fail within a second of the first start, and tmux reports "server exited unexpectedly" | Check that your tmux can capture a pane, then start the failed seats again; see the tmux check below this table. |
|
||||
| Shared terminal is absent | Inspect the existing kernel binding and recovery state; use standalone `rig tui` while resolving it. |
|
||||
| Shared terminal is absent | Check the kernel's `operator.human` terminal separately from its agents; recover only that seat as described below. Standalone `rig tui` remains available while resolving it. |
|
||||
| Viewing terminal was closed | Open the conversations again with `rig terminal open saved:kernel --window`; `rig tui --shared` is only the dashboard fallback if that window cannot open. Do not relaunch the team. |
|
||||
| Daemon restarted but tmux survived | Re-read `rig status` and the existing queue; a daemon restart is not a fresh project. |
|
||||
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when that start finds all of its seats lost; a kernel that was already down before the reboot stays down, and `rig start` names `rig up kernel --existing`. Check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
|
||||
| Launch reports no usable snapshot | Inspect the existing rig and retained project files, then follow the same-seat recovery below. |
|
||||
| Work is waiting on a prompt or decision | Read the row, transition and named prompt; preserve the obligation until the missing decision arrives. |
|
||||
|
||||
**When the shared kernel dashboard is missing.** The dashboard runs in the
|
||||
kernel's `operator.human` terminal seat. Restoring the operator, advisor and
|
||||
queue worker does not also restore an unselected terminal. Read
|
||||
`rig ps --nodes --rig kernel --json` and use the terminal's registered
|
||||
`canonicalSessionName` for the `operator.human` row. Replace `<dashboard-session>`
|
||||
in the commands below with that name. Check the actual pane with
|
||||
`rig capture "<dashboard-session>"`: a persisted running row alone does not prove
|
||||
that its tmux session is alive.
|
||||
A daemon or transport error is not proof that the terminal is absent.
|
||||
|
||||
- If only the viewing window closed and the TUI is still running, reopen it
|
||||
with `rig terminal open saved:kernel --window`.
|
||||
- If the TUI exited but its terminal shell remains, run `rig tui` once in that
|
||||
existing pane. A fresh seat is unnecessary.
|
||||
- If the terminal session itself is absent, start only that existing seat:
|
||||
|
||||
```sh
|
||||
rig seat launch "<dashboard-session>" --fresh --reason "recover missing shared kernel dashboard"
|
||||
```
|
||||
|
||||
The terminal has no agent conversation to resume. Its fresh-start action
|
||||
runs `rig tui` after the shell is ready; sibling agent sessions stay
|
||||
unchanged. This command intentionally omits `--stop`, so it refuses to
|
||||
replace a live occupant. Check the result before repeating it, then run
|
||||
`rig capture "<dashboard-session>"` to confirm the TUI and
|
||||
`rig ps --nodes --rig kernel --full` to check the seats. Once the dashboard
|
||||
is running, open its view with `rig terminal open saved:kernel --window`.
|
||||
|
||||
Opening `saved:kernel` places a view of existing sessions; it cannot create a
|
||||
missing terminal. Whole-kernel recovery is for the all-seats-lost case in the
|
||||
table, and `rig up kernel --existing` refuses a kernel with live sessions.
|
||||
A retained `excluded_from_subset` reason may come from an earlier subset
|
||||
restore; inspect its event before attributing it to the latest reboot.
|
||||
|
||||
**When the kernel's seats all fail at the first start.** A report on Oracle Linux 10
|
||||
([#980](https://github.com/mvschwarz/openrig/issues/980)) traced this to the distribution's packaged tmux, whose
|
||||
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. `rig setup` (its tmux
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Harness and runner contract (DRAFT for 0.7.6)
|
||||
|
||||
> **Status.** This is a draft for contributors.
|
||||
> - **In the 0.7.6 candidate, not yet released:** the `runner` field and OpenRig's side of declaration, identity and activity
|
||||
> (sections 2-4).
|
||||
> - **Proposed runner-author behavior:** the input, cancellation and permission recipe in sections 5-7, and the
|
||||
> runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
|
||||
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
|
||||
> handoff. These interfaces do not establish that a particular runner implements them correctly.
|
||||
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
|
||||
>
|
||||
> No harness has been run end to end under this contract yet. The first candidate is dsh over ACP.
|
||||
>
|
||||
> Candidate scope and pinned references are in the [joined author guide](author-contracts.md). The
|
||||
> [community presets reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md)
|
||||
> describes the implemented fields at that base; this chapter also labels proposed author behavior.
|
||||
|
||||
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
|
||||
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
|
||||
the same harness can run in many places, and one place can host many harnesses.
|
||||
|
||||
## 1. Plain preset or runner preset?
|
||||
|
||||
| | Plain community preset | Runner preset (`runner: true`) |
|
||||
|---|---|---|
|
||||
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) |
|
||||
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input |
|
||||
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt |
|
||||
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably |
|
||||
|
||||
**If your harness works well as a terminal app, a plain preset is enough. Use a runner only when you can report state
|
||||
honestly.** A runner that guesses is worse than a quiet screen read as unknown.
|
||||
|
||||
**Vocabulary.** "Preset", "runner preset", `launch_result`, `resume`, `first_input` and `passes_model` are existing
|
||||
terms at the candidate base. Broader names you may see in design discussion, such as *HarnessSpec*, are **proposals**, not file
|
||||
formats or loaders that exist.
|
||||
|
||||
## 2. Declare it: what the preset says, fixed at launch
|
||||
|
||||
```yaml
|
||||
# $OPENRIG_HOME/community-harnesses/dsh.yaml
|
||||
format: 1
|
||||
name: dsh
|
||||
command: dsh-seat # your runner; it starts the agent itself
|
||||
maintainer: <you>
|
||||
runner: true # in the 0.7.6 candidate
|
||||
launch_result: true # required with runner
|
||||
resume: true # only after you've checked a real reopen and a refused missing one
|
||||
limits:
|
||||
- permission requests are refused (first version)
|
||||
- a prompt whose whole text is /abort can't be sent
|
||||
- fork unsupported
|
||||
```
|
||||
|
||||
- **`runner: true` requires `launch_result: true`,** and can't be combined with `names_conversation`.
|
||||
- **The declaration is fixed at launch.** Each launch, a restore's included, records what the preset declares at that
|
||||
moment. Editing the preset changes the next launch, not a running seat.
|
||||
- **A preset without `runner` behaves exactly as today,** and so does a session recorded before the field existed.
|
||||
- **The other fields keep their existing meanings:**
|
||||
- `first_input: true` passes the seat's first startup turn in a file named by `OPENRIG_FIRST_INPUT`;
|
||||
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`.
|
||||
|
||||
## 3. Identity: report the conversation once, in the launch record
|
||||
|
||||
- **When:** after the agent has opened or reopened its conversation (for ACP: after `session/new`, `session/load` or
|
||||
`session/resume`).
|
||||
- **How:** write the attempt's launch record to the path in `OPENRIG_LAUNCH_RESULT`, with the attempt from
|
||||
`OPENRIG_LAUNCH_ATTEMPT`. The record is either the conversation:
|
||||
|
||||
```json
|
||||
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "nativeRef": "<conversation id>", "producer": "dsh-seat 0.3.0; dsh 0.2.1-rc.2; ACP 1"}
|
||||
```
|
||||
|
||||
or the agent's own word that it doesn't exist:
|
||||
|
||||
```json
|
||||
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "error": "missing", "producer": "dsh-seat 0.3.0"}
|
||||
```
|
||||
|
||||
- **`producer` is free text** for your versions. There are no structured version or capability fields.
|
||||
- **The launch record is the only identity channel.** Don't report identity on the activity endpoint.
|
||||
- **No record means attention.** For a runner preset:
|
||||
- **a new launch** with no usable record within OpenRig's wait leaves the seat needing attention, not ready. So does a
|
||||
new launch where OpenRig can't create the record's folder: the runner still starts, without `OPENRIG_LAUNCH_RESULT`;
|
||||
- **on a resume,** a folder OpenRig can't create means nothing starts, because the launcher can't be told what to
|
||||
reopen. When the folder exists but no usable record arrives, the resume command has already been sent, and the
|
||||
resume reads unconfirmed (section 8).
|
||||
- **OpenRig shows this identity as launcher-reported.** It doesn't observe the conversation itself.
|
||||
|
||||
## 4. Activity: report turn boundaries
|
||||
|
||||
Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer <token>`.
|
||||
- **Finding the base and token:** take them from `OPENRIG_URL` (or `OPENRIG_PORT`, with `OPENRIG_HOST` defaulting to
|
||||
`127.0.0.1`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else from `$OPENRIG_HOME/activity-endpoint.json`.
|
||||
- **Posting:** post one at a time, in order, with a short timeout. Never make the agent wait on a post.
|
||||
|
||||
```json
|
||||
{"sessionName": "dev-builder@my-rig", "nodeId": "<node id>", "runtime": "dsh",
|
||||
"generation": "<OPENRIG_OCCUPANT_GENERATION>", "sequence": 42, "occurredAt": "2026-10-29T12:00:00Z",
|
||||
"hookEvent": "UserPromptSubmit", "subtype": "accepted", "turnId": "7"}
|
||||
```
|
||||
|
||||
| When | `hookEvent` / `subtype` | The seat reads |
|
||||
|---|---|---|
|
||||
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
|
||||
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
|
||||
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
|
||||
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
|
||||
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
|
||||
| The agent process ended | `Stop` / `<preset name>_exited` | unknown, **never idle** |
|
||||
| Your runner or the agent hit an error | `Notification` / `runtime_error` | recorded, but not used as activity: the prior activity evidence keeps its usual lifetime. It doesn't itself request or clear attention |
|
||||
|
||||
- **Liveness:** state `livenessSeconds` (a whole number of seconds from 1 to 3600; any other value is ignored) on every
|
||||
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
|
||||
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
|
||||
non-exit `Stop` or a beat while holding permission attention.
|
||||
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
|
||||
|
||||
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
|
||||
- **"Accepted" means your runner submitted its Nth prompt.** It isn't the agent's own acknowledgement, and it isn't
|
||||
tied to a particular `rig send`.
|
||||
- **The generation attributes a post; it isn't authentication.**
|
||||
- A post carrying an earlier occupant's generation never counts for the live seat.
|
||||
- The hook token is one local token, so any process holding it can post for any seat.
|
||||
|
||||
## 5. Input: one submitted message is one prompt
|
||||
|
||||
Sections 5-7 specify the proposed first dsh/ACP runner recipe. They do not change permission or input handling in
|
||||
existing built-in runtimes or require every possible runner protocol to use ACP.
|
||||
|
||||
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
|
||||
- **What arrives:** `rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
|
||||
is **one block**, newlines included. Send it to the agent as **one** prompt (for ACP, one `session/prompt`).
|
||||
- **Never split a block,** and never act on lines inside it.
|
||||
- **Control input:** the only control is a block that is exactly `/abort`, or Ctrl-C typed outside a paste.
|
||||
- Ordinary sends and reminders arrive inside a From/To envelope, so they never equal `/abort`, whatever lines they
|
||||
contain.
|
||||
- **A message that arrives mid-turn** waits for the next turn. Report it as queued, never as steering the current turn.
|
||||
- **Limits to keep and to list under `limits`:**
|
||||
- a prompt whose whole text is `/abort` can't be sent as a prompt;
|
||||
- a Ctrl-C byte inside a paste also aborts;
|
||||
- without a TTY, input is read line by line;
|
||||
- this isn't a binary-safe channel.
|
||||
|
||||
## 6. Cancel: requested isn't the same as done
|
||||
|
||||
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
|
||||
and record when you sent it.
|
||||
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
|
||||
Then post `Stop` / `cancelled`.
|
||||
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
|
||||
|
||||
## 7. Permissions: refuse every request (first version)
|
||||
|
||||
- **Refuse at once:** answer every permission request immediately with a refusal (for ACP,
|
||||
`{"outcome": {"outcome": "cancelled"}}`).
|
||||
- **Make it visible:** show what was asked in the pane, and post `Notification` / `permission_prompt`.
|
||||
- **Hold that attention** as your last post through the end of the turn, so the seat reads needs input. Don't post the
|
||||
turn's `Stop` or idle beats while holding it.
|
||||
- **It clears** when your next prompt is accepted, or when an operator clears it
|
||||
(`rig seat clear-attention <session> --reason "…"`).
|
||||
- **Never leave a request pending.**
|
||||
- **Not in the first version:**
|
||||
- answering from the seat's permission setting;
|
||||
- answers typed into the pane;
|
||||
- routing an approval to another seat.
|
||||
- So a job that needs an edit or a command approved can't finish under this version. Agents that can run without
|
||||
asking are the best first case.
|
||||
- **Unknown requests from the agent:** answer with JSON-RPC `-32601` and carry on.
|
||||
|
||||
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
|
||||
|
||||
- On a resume, OpenRig passes the conversation to reopen in `OPENRIG_RESUME_REF`. Use the method your agent
|
||||
advertises: ACP's `session/load` (capability `loadSession`), or your agent's own reopen method, named with its
|
||||
capability and pinned version in your `limits`.
|
||||
|
||||
| What happened | Write in the launch record | What OpenRig does |
|
||||
|---|---|---|
|
||||
| The same conversation reopened | the same `nativeRef` | resumed |
|
||||
| The agent says it doesn't exist | `error: "missing"` | reports it missing; nothing new starts |
|
||||
| Anything else: a timeout, an error, or the working folder no longer matches the conversation's | no record, or an error other than `missing` | **unconfirmed**: the seat keeps its conversation and needs attention |
|
||||
| A different conversation opened | the other id | a mismatch; OpenRig doesn't switch to it |
|
||||
| OpenRig couldn't create the record's folder | (nothing to write) | nothing starts |
|
||||
|
||||
- **Never start a new conversation on a resume.**
|
||||
- A conversation saved under another harness's runtime is never handed to your launcher.
|
||||
|
||||
## 9. Context, model and shutdown: whose job is what
|
||||
|
||||
| Responsibility | OpenRig does | Your preset or runner does |
|
||||
|---|---|---|
|
||||
| **First startup turn** | Writes it to a private file and passes `OPENRIG_FIRST_INPUT` (with `first_input: true`) | Gives those exact bytes to the agent through its own first-prompt route |
|
||||
| **Guidance, skills, startup files** | Doesn't place them for community harnesses; a launch warns that they weren't delivered | Puts whatever the agent needs where *your* harness reads it, or says in `limits` that it isn't supported |
|
||||
| **Model** | Passes the seat's declared model as `OPENRIG_MODEL` (with `passes_model: true`); never substitutes one | Maps it to the harness's own setting. OpenRig shows the effective model as unknown |
|
||||
| **Placing the agent somewhere else** (a sandbox, another machine) | See the [execution platform chapter](execution-platform-contract.md) | Keeps the runner independent of where it runs |
|
||||
| **Shutdown** | `rig seat stop` and `rig down` end the seat's tmux session at once: no graceful stop handshake reaches the runner, its pane just closes. OpenRig ends nothing outside that session, and does no external provider cleanup | **(Proposed)** When the runner's pane closes (a hangup) or it's asked to stop, it attempts where possible to cancel any running turn and end the agent process it started, and lists in `limits` anything that may keep running. Cleanup of external compute needs the platform owner to reconcile it |
|
||||
|
||||
## 10. What counts as evidence
|
||||
|
||||
- **Keep three things separate:**
|
||||
- **`rig send --verify`** checks the pane before and after a send. It's pane evidence, not proof the agent received
|
||||
or finished anything. Mirror each accepted prompt in your pane (for example `you ▸ …`); OpenRig doesn't yet promise
|
||||
that `--verify` recognises it.
|
||||
- **A turn ending isn't the work being done.** Task completion is whatever the work's own record says: a queue task
|
||||
marked done, or an artifact written.
|
||||
- **Your own tests** should run against a fake agent that covers each section above.
|
||||
- **The useful real check** for a maintainer:
|
||||
- a seat starts and reads ready only after the handshake;
|
||||
- one multi-line message becomes one prompt;
|
||||
- the seat reads working, then idle;
|
||||
- a refused permission reads needs input;
|
||||
- `/abort` ends a turn as cancelled;
|
||||
- a resume reopens the same conversation, and a missing one is refused, never replaced.
|
||||
|
||||
That's the same promotion test the presets reference describes, applied to these declarations. Passing it is
|
||||
compatibility evidence for the versions you name, not certification.
|
||||
|
||||
## Contributor checklist
|
||||
|
||||
- [ ] The preset sets `runner: true` and `launch_result: true`, and not `names_conversation`.
|
||||
- [ ] After the agent opens or reopens its conversation, the runner writes the launch record, with versions in `producer`.
|
||||
- [ ] Activity posts carry `sessionName`, `nodeId`, `runtime`, `generation`, `sequence`, `occurredAt`, `hookEvent` and
|
||||
`subtype`. They're sent in order and never block the agent.
|
||||
- [ ] One submitted message becomes exactly one prompt. Only a bare `/abort` block, or Ctrl-C outside a paste, is control.
|
||||
- [ ] Cancel records "requested" when sent, and "cancelled" only on the agent's own stop reason.
|
||||
- [ ] Every permission request is refused at once, shown, and held as needs input until the next accepted prompt.
|
||||
- [ ] An agent exit posts `Stop` / `<preset name>_exited`, never a plain idle.
|
||||
- [ ] A resume reopens the exact conversation. It reports `missing` only on the agent's word, and otherwise leaves it
|
||||
unconfirmed.
|
||||
- [ ] Unknown agent requests get `-32601`.
|
||||
- [ ] `limits` names the input limits, the permission limit, what keeps running after a stop, and anything not supported.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **For the dsh maintainer:** which method and capability does dsh advertise for reopening a session, at a pinned
|
||||
version: `session/load` with `loadSession`, or `session/resume`?
|
||||
2. **For the dsh maintainer:** can dsh's ACP profile run with tools off? A first journey that needs no permission
|
||||
answers is the simplest to check.
|
||||
3. **For OpenRig:** should a later version route permission requests to a chosen seat, as `rig acp run` already does
|
||||
with queue tasks, instead of refusing them?
|
||||
4. **For OpenRig:** once a second ACP harness exists, should one shared ACP runner live in OpenRig rather than one per
|
||||
harness?
|
||||
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
For a new install, use the latest release's tag, for example `v0.7.4`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.7`. If OpenRig is already installed, use the tag
|
||||
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
|
||||
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
|
||||
you want built.
|
||||
|
||||
@@ -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,89 @@ when other clients establish the agent window's dimensions. It is **not resize
|
||||
isolation**: opening a tile can change the agent's terminal size when it is the
|
||||
only sizing client. Observers of a Herdr terminal do not themselves resize it;
|
||||
a controlling browser can. No cross-version resize guarantee is implied.
|
||||
|
||||
|
||||
## Following a slice's current work
|
||||
|
||||
Use `rig terminal open slice:<name-or-id> --follow --provider herdr` to keep
|
||||
one workspace following the current destination owners of that slice's active
|
||||
queue rows. `pending`, `in-progress` and `blocked` rows count. A member can own
|
||||
several rows; `task.members[].obligations` preserves every row and state.
|
||||
|
||||
A slice resolves within the daemon's configured slice index, by exact directory
|
||||
name or unique frontmatter `id`. Tags must name it exactly: `rig queue create
|
||||
--slice <name-or-id> ...` associates a row. Handoff inherits those tags unless
|
||||
replaced; merely linking an untagged child does not associate it. A mission-only
|
||||
tag does not put a row in every slice. No project is inferred from a tag or the
|
||||
caller's working directory. A bare name or ID claimed by multiple slices is
|
||||
`scope_ambiguous`; no first-match scope is selected. Selecting a unique key can
|
||||
still follow a slice whose other alias is reused: a row carrying that colliding
|
||||
alias must have exactly one `mission:` tag naming exactly one indexed claimant.
|
||||
Rows placed in another mission are excluded. Missing, contradictory or otherwise
|
||||
unplaceable mission/alias tags make the snapshot incomplete, retaining previous
|
||||
viewers. A unique alias on its own retains exact matching. Flat legacy slices
|
||||
without an indexed parent mission cannot disambiguate a colliding alias.
|
||||
|
||||
Slice keys are case-sensitive and can include Unicode, plus signs and internal
|
||||
spaces. They cannot be empty or contain slash, backslash, control characters,
|
||||
or leading/trailing whitespace. Pass the literal selector in JSON or a quoted
|
||||
CLI argument; encode it once as a GET query value. Do not double-decode it.
|
||||
|
||||
Slice previews and open results use `task: {kind:"slice", id, name, complete,
|
||||
issues, members}`. `id` is the unique frontmatter id, or the directory name when
|
||||
no id is present. There is no synthetic `qitemId`. Preserve the preview's `view`
|
||||
for the ensure request. All successful previews, including qitem previews,
|
||||
include `capabilities: {ensureViewer:boolean}`. True means that the resolved
|
||||
view/provider implements ensure; the existing member, completeness, owner and
|
||||
`expectedPlan` checks still apply. A missing capability/task on an older daemon
|
||||
is unsupported scoped membership, not an empty team.
|
||||
|
||||
New slice workspaces default to four automatic tiles; `--max-tiles` overrides
|
||||
that. Qitem views retain their eight-tile default. The full membership and
|
||||
obligation list remains available when tiles exceed capacity. Pins, drafts,
|
||||
unknown input and focus retention still protect departed viewers. Closing a
|
||||
viewer never stops its agent. Opening a tile can change the agent's dimensions.
|
||||
|
||||
A known empty slice remains enabled in the same workspace; later associated
|
||||
work can join it. An incomplete queue read (`task.complete:false` plus `issues`)
|
||||
retains previous viewers. A missing scope is `scope_not_found`, duplicate scope
|
||||
identity is `scope_ambiguous`, unreadable/capped index data is `scope_unavailable`,
|
||||
and an unavailable resolver is `scope_unsupported`. None is authoritative empty.
|
||||
Ensure of an incomplete snapshot returns `viewer_unconfirmed`; changed plans
|
||||
return `preview_changed` before provisioning. Mission following is a separate
|
||||
increment; the existing mission display projection is not authoritative follow
|
||||
or ensure capability.
|
||||
|
||||
Ordinary ownership changes follow queue/session/node events. Node and directory
|
||||
changes under the primary configured index use the existing filesystem watcher,
|
||||
with a coalesced scope invalidation event. There is no repeating membership
|
||||
poll. Structural edits under additional legacy roots, or a filesystem where
|
||||
watching is unavailable, require `rig`'s existing slice refresh endpoint
|
||||
`POST /api/slices/refresh` or reopening the view. Missing or unreadable sources
|
||||
keep prior viewers; they never certify that the work disappeared.
|
||||
|
||||
|
||||
Scope identity reads are bounded to 4,096 directory entries, 16 KiB per selected
|
||||
node-header read, and 8 MiB of source bytes per index generation. The byte budget
|
||||
counts the bounded read, including text after short frontmatter. An index that
|
||||
exceeds these limits is too large for this read; use a smaller configured index
|
||||
or a qitem view. This is separate from unreadable or malformed sources.
|
||||
Queue membership reads are bounded to 50,000 active rows plus one lookahead,
|
||||
16 KiB of encoded tags per row and 8 MiB of aggregate encoded tags. They remain
|
||||
fresh on each render. Identities are reused within a scope-source generation
|
||||
only while the primary-root watcher is live and existing additional roots are
|
||||
covered by that root. A source event or explicit refresh invalidates them;
|
||||
errors discard the cache. Unavailable watching or an existing unwatched legacy
|
||||
root uses fresh bounded identity reads instead. Missing legacy roots are checked
|
||||
on each call so an appearing root cannot reuse an older cached identity set.
|
||||
|
||||
Error bodies remain authoritative. `scope_unsupported` is HTTP 400,
|
||||
`scope_not_found` is 404, and `scope_ambiguous`/`scope_unavailable` are 409.
|
||||
An incomplete queue snapshot is a successful preview with `task.complete:false`;
|
||||
ensure returns `viewer_unconfirmed` without a provider effect. Unsupported
|
||||
ensure view kinds now return `view_required` (400), where they previously returned
|
||||
200 with an error body. Unsupported follow view kinds
|
||||
retain `follow_view_required` in a 200 `ok:false` body, as before. A changed
|
||||
preview remains `preview_changed` (409). During automatic refresh a missing or
|
||||
ambiguous slice retains the existing tiles and names the source problem in the
|
||||
explanation strip; refresh or reopen it under the current unambiguous key.
|
||||
+152
-178
@@ -1,192 +1,166 @@
|
||||
# What changed in OpenRig 0.7.1
|
||||
# What changed in OpenRig 0.7.7
|
||||
|
||||
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
|
||||
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
|
||||
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
|
||||
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
|
||||
after an upgrade it can still hold the previous version's note.
|
||||
This note is for an agent on an install that was just upgraded to 0.7.7. OpenRig
|
||||
0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats. If you
|
||||
upgraded from 0.7.5 or earlier, also read "What changed in OpenRig 0.7.6" below.
|
||||
For the full capability map, read
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. After the upgraded
|
||||
daemon starts, `rig context get reference/whats-new.md` describes this version.
|
||||
Without a running daemon, read the packaged `daemon/docs/reference/whats-new.md`;
|
||||
`$OPENRIG_HOME/reference/whats-new.md` can still be the previous copy until startup.
|
||||
|
||||
## After the upgrade
|
||||
|
||||
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
|
||||
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
|
||||
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
|
||||
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
|
||||
`daemon.db_conflict` (`warn`). None is removed.
|
||||
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
|
||||
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
|
||||
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
|
||||
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
|
||||
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
|
||||
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
|
||||
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
|
||||
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
|
||||
anyone edited the file; if someone did, reapply the change from the copy.
|
||||
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
|
||||
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
|
||||
next launch, restore or handover.
|
||||
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
|
||||
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
|
||||
doesn't repair it; ask the person which mode they want and set it once.
|
||||
- Restart the daemon (`rig daemon stop`, then `rig daemon start`) to load the new
|
||||
code. There are no database migrations.
|
||||
- The bundled openrig-core plugin stays at 0.1.23 with unchanged files, so there
|
||||
is no managed plugin refresh.
|
||||
|
||||
## New things you can do
|
||||
## The fixes
|
||||
|
||||
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
|
||||
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
|
||||
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
|
||||
readable (`rig seat held-messages`) and is never replayed.
|
||||
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
|
||||
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
|
||||
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
|
||||
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
|
||||
interventions are guidance only.
|
||||
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
|
||||
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
|
||||
one line to shared guidance files.
|
||||
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
|
||||
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
|
||||
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
|
||||
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
|
||||
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
|
||||
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
|
||||
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
|
||||
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
|
||||
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
|
||||
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
|
||||
--seat <session>` gives each caller its own seat.
|
||||
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
|
||||
`docs/reference/a2a-agents.md` covers A2A agents.
|
||||
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
|
||||
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
|
||||
`docs/reference/community-harness-examples/`.
|
||||
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
|
||||
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
|
||||
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
|
||||
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
|
||||
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
|
||||
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
|
||||
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
|
||||
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
|
||||
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
|
||||
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
|
||||
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
|
||||
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
|
||||
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
|
||||
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
|
||||
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
|
||||
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
|
||||
strands an accepted create or join whose effect hadn't landed.
|
||||
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
|
||||
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
|
||||
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
|
||||
`rig stream show <id>` says what became of an observation.
|
||||
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
|
||||
the kernel starts seats only for those.
|
||||
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
|
||||
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
|
||||
deliberate start.
|
||||
- A Claude seat at an empty prompt reads idle again when Claude prints
|
||||
"✔ Update installed · /restart to apply" (or "Restart to apply") below its mode
|
||||
bar after installing an update. Before, that line hid the mode bar, so the seat
|
||||
read unknown and managed compaction waited for an idle prompt it could not see.
|
||||
Both spellings are recognized; lines that only look similar still read unknown.
|
||||
([#2311](https://github.com/mvschwarz/openrig/pull/2311))
|
||||
- Managed compaction checks the input box before it pastes its preparation
|
||||
prompt, `/compact` or a restore prompt, even with the typing guard off. Typed,
|
||||
unreadable or changed input holds that step. A manual `rig compact` then says it
|
||||
stopped to preserve the input box; read `rig compact <seat> --state` and retry
|
||||
once the box is empty. A held restore prompt waits and goes ahead on its own once
|
||||
the box is empty. An empty box or a high-confidence suggestion goes ahead.
|
||||
Ordinary `rig send` and the typing-guard defaults are unchanged.
|
||||
([#2347](https://github.com/mvschwarz/openrig/pull/2347))
|
||||
- A Claude seat whose `claude` is a `#!` wrapper that starts the real Claude without
|
||||
`exec` can be rebuilt again (`rig seat handover --source rebuild`): readiness also
|
||||
accepts the wrapper's direct child when it names this launch's conversation token,
|
||||
and refuses every other process as before. A rebuild that fails no longer leaves
|
||||
the seat's resume target on the new, unprimed conversation.
|
||||
([#2341](https://github.com/mvschwarz/openrig/issues/2341), [#2354](https://github.com/mvschwarz/openrig/pull/2354))
|
||||
|
||||
## Behaviour that changed
|
||||
## Limits to keep in view
|
||||
|
||||
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
|
||||
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
|
||||
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
|
||||
what wasn't delivered.
|
||||
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
|
||||
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
|
||||
at `hold`.
|
||||
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
|
||||
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
|
||||
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
|
||||
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
|
||||
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
|
||||
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
|
||||
shows an MCP enablement menu, a question or a permission prompt.
|
||||
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
|
||||
`--yes` command.
|
||||
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
|
||||
and `--no-kernel`.
|
||||
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
|
||||
listener, such as Tailscale's, can't be verified.
|
||||
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
|
||||
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
|
||||
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
|
||||
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
|
||||
writable roots.
|
||||
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
|
||||
guidance files byte for byte.
|
||||
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
|
||||
nor actor gets the shared `actor_required` error.
|
||||
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
|
||||
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
|
||||
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
|
||||
which clears nothing. Keys for a recognized native menu are sent as before.
|
||||
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
|
||||
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
|
||||
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
|
||||
runs, so it doesn't relaunch them beside themselves.
|
||||
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
|
||||
handover. A launch whose staging fails stops instead of falling back.
|
||||
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
|
||||
request once.
|
||||
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
|
||||
longer stops health diagnoses from listing.
|
||||
- Other reasons managed compaction can wait are unchanged.
|
||||
- The input-box check is a read just before the paste, not a lock: typing that
|
||||
starts in the same moment can still be appended to.
|
||||
- If a seat's current occupant generation was never registered (an adopted Claude
|
||||
with another generation, or a managed launch whose tenure record failed to write),
|
||||
hooks don't move its resume token after the first one.
|
||||
- The 0.7.6 limits below still apply.
|
||||
|
||||
## Output that changed shape
|
||||
---
|
||||
|
||||
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
|
||||
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
|
||||
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
|
||||
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
|
||||
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
|
||||
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
|
||||
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
|
||||
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
|
||||
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
|
||||
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
|
||||
`--since` to resume, instead of an empty list.
|
||||
## What changed in OpenRig 0.7.6
|
||||
|
||||
## What to stop doing
|
||||
The changes from 0.7.5 to 0.7.6, for an install upgrading from 0.7.5 or earlier.
|
||||
|
||||
- **Don't read `lastNudgeInputBox`.** It's gone.
|
||||
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
|
||||
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
|
||||
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
|
||||
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
|
||||
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
|
||||
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
|
||||
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
|
||||
### After the upgrade
|
||||
|
||||
## Known gaps in 0.7.1
|
||||
- Restart the daemon (`rig daemon stop`, then `rig daemon start`) to load the new
|
||||
code. Two database migrations record account-home selection and whether a
|
||||
community harness launch declared a runner. Both use prefix 141, with distinct
|
||||
full filenames.
|
||||
- The bundled openrig-core plugin goes from 0.1.22 to 0.1.23. Its Claude activity
|
||||
hook clears permission marks after tool batches. Managed refresh preserves
|
||||
replaced files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply
|
||||
local edits you still need from the preserved copy.
|
||||
|
||||
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
|
||||
its prompt, press Enter in its pane, or ask the person to.
|
||||
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
|
||||
acting on it.
|
||||
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
|
||||
marks any alerts waiting to post to Slack as already seen.
|
||||
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
|
||||
(`tmux -V`).
|
||||
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
|
||||
everything" wait. On a slow host, start the daemon yourself first.
|
||||
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
|
||||
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
|
||||
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
|
||||
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
|
||||
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
|
||||
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
|
||||
gateway folder or database instead: that can lose newer people, bindings and delivery records.
|
||||
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
|
||||
--provider herdr`.
|
||||
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
|
||||
first. Pair a decision room with quiet hours, or decide before asking.
|
||||
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
|
||||
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
|
||||
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
|
||||
falling back to credential-bearing command-line arguments.
|
||||
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
|
||||
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
|
||||
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
|
||||
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
|
||||
only claude.ai subscription logins and reports `not_subscription_login`.
|
||||
### Follow a slice's work in one workspace
|
||||
|
||||
Use `rig terminal open slice:<name-or-id> --follow --provider herdr`. Membership
|
||||
comes from pending, in-progress and blocked queue rows tagged with the exact
|
||||
slice. Associate a row with `rig queue create --slice <name-or-id>`; a handoff
|
||||
inherits its tags unless replaced. A mission-only tag does not associate every
|
||||
slice. One owner appears once, with all its obligations listed.
|
||||
|
||||
When a slice's folder name repeats across missions, tag its work with the slice's
|
||||
unique ID (`rig queue create --slice <id>`) or add `--mission <mission>`. A row
|
||||
tagged only with the shared name cannot be placed, so the view reads incomplete
|
||||
until it is.
|
||||
|
||||
New slice views default to four automatic tiles (`--max-tiles` overrides it);
|
||||
qitem views keep eight. A known empty slice stays following and can repopulate.
|
||||
An incomplete read keeps the prior viewers. Missing or ambiguous scope identities
|
||||
are errors, never an empty team. Pins, drafts, unknown input and focus retain
|
||||
former viewers. Closing a viewer does not stop its agent.
|
||||
|
||||
An owner's join refused before any pane split can join on a later refresh;
|
||||
hold notices keep the original seat, phase and reason. A created viewer whose
|
||||
attach has not yet run can continue without replacing its pane. A viewer whose
|
||||
attach already ran and then exited back to a shell is not restarted
|
||||
automatically, so the whole slice view can hold until that tile is resolved.
|
||||
No tested workaround is offered for that case.
|
||||
|
||||
Slice following targets the Linux Herdr 0.9.5 / protocol 22 path. Mission
|
||||
following is not included. See
|
||||
`reference/terminal-sessions.md` for controls, bounds and refresh behavior.
|
||||
Configured Herdr session endpoints now carry through viewer/follow operations.
|
||||
The TUI's terminal action opens the agent, pod or rig currently being viewed.
|
||||
|
||||
### Changes agents should act on
|
||||
|
||||
- **A readiness deadline does not end a still-starting daemon.** A nonzero
|
||||
pending result names the child PID, launch endpoint and log. The child stays
|
||||
running and the startup reservation stays in place; readiness is not
|
||||
confirmed. Follow the returned pending guidance before attempting recovery.
|
||||
- **Read exited state honestly.** A verified exited process in a retained pane
|
||||
reads `pane_exited`, instead of running and ready. Its outstanding work uses
|
||||
the existing recovery-finding route. Unknown observations still mean unknown.
|
||||
- **Use the same member settings when expanding.** `rig expand` now carries raw
|
||||
members to the canonical rig validator, keeping env, effort and startup
|
||||
settings. Restores keep the model saved with `rig seat set-model`.
|
||||
- **Account-home moves start a fresh conversation with a handover.**
|
||||
`rig provider switch --seat <seat> --to-account <name> --account-home <dir>`
|
||||
selects an existing home; preview with `--dry-run`. `--clear-account-home`
|
||||
returns to configured defaults, not historical credentials. Check the moved
|
||||
seat's account and conversation.
|
||||
- **Claude API-key switching resumes the same conversation but interrupts work.**
|
||||
Use `rig provider switch --seat <seat> --to-account api --api-key-file <abs>`;
|
||||
return with `--to-account plan --token-file <abs>`. The API-key path remains
|
||||
unqualified. Do not combine it with account-home selectors. Credential files
|
||||
stay private; budgets remain the caller's responsibility.
|
||||
- **A new Claude member can name its subscription token in the spec:**
|
||||
`claude_token: {label: name, file: /abs/token}`, beside its own
|
||||
`env.CLAUDE_CONFIG_DIR`. This stores a file reference, not the token. Validation
|
||||
does not read the file; a missing or unreadable token uses the existing launch
|
||||
fallback notice, so verify the account actually used.
|
||||
- **Git over SSH uses the daemon's SSH agent** in managed seats. Make sure that
|
||||
agent has the intended key (`ssh-add -l`).
|
||||
- **Slack button answers resume decisions parked on the person asked.** Set
|
||||
`rig slack setup --reactions off` if acknowledgement emoji should not wake
|
||||
agents. The default remains `route`; manifest regeneration preserves it.
|
||||
Replies posted outside a deleted Slack thread are tracked where Slack put them.
|
||||
- **Queue delivery keeps its original intent.** A wake refused at a selection
|
||||
prompt retries after it clears. Cross-host `queue create --verify` reads the
|
||||
destination's receipt. An unreadable receipt stays unconfirmed; do not replay
|
||||
a create just because verification could not establish delivery.
|
||||
- **Preset authors can opt into `runner: true` and `passes_model: true`.** The
|
||||
runner also requires `launch_result: true` and reports activity through the
|
||||
existing hook route. Model input arrives as `OPENRIG_MODEL`. Passed means the
|
||||
launch command was sent, not that the harness applied that model; the observed
|
||||
model remains unknown. Cursor and Hermes examples leave model passing off
|
||||
pending native qualification.
|
||||
Start with the draft [integration author contracts](author-contracts.md) for
|
||||
harness, runner and platform duties, with implemented interfaces distinguished from proposals.
|
||||
- **Update viewer API handling.** `/api/terminal/viewer/ensure` returns 400 for
|
||||
`view_required` and `unknown_provider` where it previously returned 200 with
|
||||
an error body. Slice not-found is 404; ambiguous/unavailable is 409. Continue
|
||||
reading the body code. Unsupported follow kinds keep `follow_view_required`
|
||||
in a 200 `ok:false` response.
|
||||
|
||||
### Limits to keep in view
|
||||
|
||||
- The reported [Codex hook-refresh refusal (#2200)](https://github.com/mvschwarz/openrig/issues/2200)
|
||||
remains unresolved. A verified Node-launcher fix is included, but that does
|
||||
not establish that this report is fixed. If
|
||||
`rig seat restart <seat> --keep-conversation` refuses, nothing is stopped:
|
||||
leave the seat running and report the refusal with CLI and daemon versions.
|
||||
A refused restart has not refreshed hooks; review any Codex hook prompt.
|
||||
- Retained messages are not delivered. Protected Codex pastes and unreadable
|
||||
composers can still retain work; inspect the explanation, use a larger pane
|
||||
or shorten the message. Turning the typing guard off removes protection.
|
||||
- Inventory now avoids redundant reads, but no general speed or stability
|
||||
improvement is claimed without comparable measurements.
|
||||
- Internet-facing hosted members remain experimental, unsupported, off by
|
||||
default and at your own risk. Slice following does not require them.
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.7",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7346,7 +7346,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.7",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7384,7 +7384,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.7",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7426,7 +7426,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.7",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.7",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.7",
|
||||
"type": "module",
|
||||
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
|
||||
"keywords": [
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -842,6 +842,13 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
if (unverified) {
|
||||
throw new StartupChildPendingError(`Daemon child ${pid} responds at ${healthzUrl}, but listener verification is incomplete: ${lastProbe} Child left running; startup reservation retained. Inspect 'rig daemon status', daemon-start.lock and daemon.log before recovery.`);
|
||||
}
|
||||
// Expiring the readiness budget is not evidence that this owned child failed.
|
||||
// Keep launch exclusion until an operator can inspect a slow pre-bind startup.
|
||||
assertChild();
|
||||
if (deps.isProcessAlive(pid!)) {
|
||||
assertChild();
|
||||
throw new StartupChildPendingError(`Daemon startup is pending after the readiness deadline (child PID ${pid}). Readiness is not confirmed; child left running and startup reservation retained. Last probe: ${lastProbe}. Launch endpoint: ${healthzUrl}. Inspect that URL and ${LOG_FILE}; this check does not publish readiness, adopt the child or release the reservation. To restart manually, stop child PID ${pid} and wait for exit; inspect ${path.join(OPENRIG_DIR, "daemon-start.lock")}, and only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'. 'rig daemon stop' cannot target this unpublished child, and another start remains blocked by the reservation.`);
|
||||
}
|
||||
throw new Error(`${summarizeDaemonStartFailure(healthzUrl, deps.readFile(resolveLifecycleFile(deps, "daemon.log")))}. Last probe: ${lastProbe}`);
|
||||
}
|
||||
assertChild();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** An older daemon can ignore new fields. A read-only plan must acknowledge the exact selection before a mutation. */
|
||||
export function accountHomePlanMatches(plan: Record<string, unknown>, home: string | null,
|
||||
token?: { label: string; tokenFile: string }): boolean {
|
||||
if (plan["ok"] !== true || plan["dryRun"] !== true || plan["accountHome"] !== home) return false;
|
||||
const selected = plan["accountToken"] as { label?: unknown; tokenFile?: unknown } | null | undefined;
|
||||
return token ? selected?.label === token.label && selected?.tokenFile === token.tokenFile : selected == null;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ describe("chatroom watch --tmux arguments", () => {
|
||||
|
||||
const TMUX_SOCKET = `openrig-chatroom-test-${process.pid}`;
|
||||
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
|
||||
const names = ["my-rig", "my rig", "it's", 'say "hi"', "$(echo pwned)", "`id`", "a;b", "a$HOME", "-rig",
|
||||
const names = ["my-rig", "my rig", "it's", 'say "hi"', "$(echo pwned)", "`id`", "a;b", "-rig",
|
||||
"a#b", "a##b", "a#{b}", "a#[fg=red]b", "a##[b", "a;", ";"];
|
||||
|
||||
describe.runIf(tmuxAvailable)("chatroom watch --tmux against a real tmux server (private socket)", () => {
|
||||
@@ -72,14 +72,18 @@ describe.runIf(tmuxAvailable)("chatroom watch --tmux against a real tmux server
|
||||
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", rig]), { timeout: 5000 });
|
||||
});
|
||||
|
||||
// Whether tmux changes a `.` depends on its version (3.5a writes `_`; the tmux CI runs keeps the `.`), so the CLI
|
||||
// prints the name tmux reports, which is always the session tmux has.
|
||||
it("reports the name tmux used for a rig name with a ., whichever form its version keeps", async () => {
|
||||
const out = path.join(dir, "argv-dot.json");
|
||||
const reported = start("release-0.7", out);
|
||||
expect(["[email protected]", "chatroom@release-0_7"]).toContain(reported);
|
||||
// tmux versions differ in how they normalize dots and literal dollars in session names.
|
||||
// The reported name must address the real session, while the watcher still gets the original rig name.
|
||||
it.each([
|
||||
["release-0.7", ["[email protected]", "chatroom@release-0_7"]],
|
||||
["a$HOME", ["chatroom@a$HOME", "chatroom@a\\$HOME"]],
|
||||
] as const)("reports the name tmux used for %s", async (rig, expected) => {
|
||||
const out = path.join(dir, `argv-normalized-${rig}.json`);
|
||||
const reported = start(rig, out);
|
||||
expect(expected).toContain(reported);
|
||||
expect(execFileSync("tmux", ["-L", TMUX_SOCKET, "list-sessions", "-F", "#{session_name}"], { encoding: "utf8" }).split("\n"))
|
||||
.toContain(reported);
|
||||
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", "release-0.7"]), { timeout: 5000 });
|
||||
expect(spawnSync("tmux", ["-L", TMUX_SOCKET, "has-session", "-t", `=${reported}:`]).status).toBe(0);
|
||||
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", rig]), { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -1202,6 +1202,7 @@ describe("Daemon Lifecycle", () => {
|
||||
it("start: surfaces native module runtime mismatch inline when healthz never comes up", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deps = mockDeps({
|
||||
isProcessAlive: vi.fn(() => false), // Crash diagnostics require observed process absence.
|
||||
fetch: vi.fn(async () => { throw new Error("connection refused"); }),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === LOG_FILE) {
|
||||
@@ -1232,6 +1233,7 @@ describe("Daemon Lifecycle", () => {
|
||||
it("start: surfaces the recent daemon log line when startup crashes for another reason", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deps = mockDeps({
|
||||
isProcessAlive: vi.fn(() => false), // Crash diagnostics require observed process absence.
|
||||
fetch: vi.fn(async () => { throw new Error("connection refused"); }),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === LOG_FILE) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { startDaemon, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import { startDaemon, getDaemonStatus, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import { acquireDaemonStartLock } from "../src/daemon-start-lock.js";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
@@ -67,6 +67,8 @@ describe("child-bound startup", () => {
|
||||
? { ok: true, json: async () => ({ ...f.body, pid: 888 }) } : fetch(url);
|
||||
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/identity mismatch.*127.0.0.2/);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
expect(f.deps.kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(f.lock.release).toHaveBeenCalledWith(false);
|
||||
});
|
||||
it("publishes the verified child, naming the unverified listener, when only a secondary listener stays uncertain", async () => {
|
||||
vi.useFakeTimers();
|
||||
@@ -114,7 +116,7 @@ describe("child-bound startup", () => {
|
||||
expect((outcome as Error).message).toMatch(/identity mismatch.*observed 888/);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
});
|
||||
it("gives up on a never-answering endpoint within one readiness deadline", async () => {
|
||||
it("returns pending within one readiness deadline without stopping its still-starting child", async () => {
|
||||
vi.useFakeTimers();
|
||||
const f = fixture(); const fetch = f.deps.fetch;
|
||||
f.deps.fetch = async (url) => (await fetch(url), new Promise(() => {}));
|
||||
@@ -122,7 +124,11 @@ describe("child-bound startup", () => {
|
||||
expect((outcome as Error).message).toMatch(/Last probe: .*timed out|Last probe: .*healthz/);
|
||||
expect(elapsed).toBeLessThanOrEqual(20_000);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
expect(f.deps.kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
expect(f.lock.release).toHaveBeenCalledWith(true);
|
||||
expect((outcome as Error).message).toMatch(/pending.*12345/);
|
||||
expect((outcome as Error).message).toContain("Launch endpoint: http://127.0.0.1:19873/healthz");
|
||||
expect((outcome as Error).message).toContain("daemon.log");
|
||||
});
|
||||
// Synchronous work crossing the deadline is modelled by a clock jump inside one fetch, before
|
||||
// any timer runs. Post-spawn fetches: 0 primary read, 1 primary listener check, 2 secondary check.
|
||||
@@ -190,6 +196,8 @@ describe("child-bound startup", () => {
|
||||
};
|
||||
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/exited/);
|
||||
expect(f.files.has(STATE_FILE)).toBe(false);
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
expect(f.lock.release).toHaveBeenCalledWith(false);
|
||||
});
|
||||
it("handles an asynchronous native spawn error without a PID", async () => {
|
||||
const f = fixture();
|
||||
@@ -280,3 +288,52 @@ describe("local startup reservation", () => {
|
||||
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it.each(["missing", "stale"] as const)("keeps a slow child discoverable with %s old state and excludes a duplicate launch", async (oldState) => {
|
||||
vi.useFakeTimers();
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-pending-start-"));
|
||||
const f = fixture(), fetch = f.deps.fetch;
|
||||
const previousState = oldState === "stale"
|
||||
? JSON.stringify({ pid: 999, host: "127.0.0.2", port: 19874, db: "old.sqlite", startedAt: "old" })
|
||||
: undefined;
|
||||
if (previousState) f.files.set(STATE_FILE, previousState);
|
||||
f.deps.isProcessAlive = pid => pid === f.child.pid && f.child.exitCode === null;
|
||||
let ready = false;
|
||||
f.deps.acquireStartLock = () => acquireDaemonStartLock(home);
|
||||
f.deps.fetch = async url => {
|
||||
const response = await fetch(url);
|
||||
if (!ready) throw Object.assign(new Error("still initializing"), { code: "ECONNREFUSED" });
|
||||
return response;
|
||||
};
|
||||
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${opts.port}`);
|
||||
vi.stubEnv("RIGGED_URL", "");
|
||||
try {
|
||||
const began = Date.now(), result = startDaemon(opts, f.deps).catch((error: Error) => error);
|
||||
await vi.runAllTimersAsync();
|
||||
const outcome = await result;
|
||||
expect(outcome).toBeInstanceOf(Error);
|
||||
expect((outcome as Error).message).toMatch(/pending.*12345/);
|
||||
const message = (outcome as Error).message;
|
||||
expect(message).toContain("Launch endpoint: http://127.0.0.1:19873/healthz");
|
||||
expect(message).not.toContain("127.0.0.2:19874");
|
||||
expect(message).toContain("this check does not publish readiness, adopt the child or release the reservation");
|
||||
expect(message).toContain("stop child PID 12345 and wait for exit");
|
||||
expect(message).toContain(path.join(path.dirname(STATE_FILE), "daemon-start.lock"));
|
||||
expect(message).toContain("only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'");
|
||||
expect(message).toContain("'rig daemon stop' cannot target this unpublished child");
|
||||
expect(Date.now() - began).toBeLessThanOrEqual(20_000);
|
||||
expect(f.deps.kill).not.toHaveBeenCalled();
|
||||
expect(f.child.exitCode).toBeNull();
|
||||
expect(f.files.get(STATE_FILE)).toBe(previousState);
|
||||
expect(fs.readFileSync(path.join(home, "daemon-start.lock"), "utf8")).toContain('"childPid":12345');
|
||||
ready = true;
|
||||
await expect(getDaemonStatus(f.deps)).resolves.toMatchObject({ state: "running", healthy: true, port: opts.port });
|
||||
expect(f.files.get(STATE_FILE)).toBe(previousState); // A status probe never retroactively publishes start readiness.
|
||||
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/already reserved/);
|
||||
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -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.6`). This marker describes the pack's teaching, not publication or live
|
||||
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
|
||||
What changed in the installed version, and what to do about it, is one read away:
|
||||
`rig context get reference/whats-new.md`.
|
||||
@@ -34,6 +34,8 @@ one command away. Your memory of the fleet is a claim about the past.
|
||||
filters to running and **hides detached, exited and attention states** — which are exactly the
|
||||
cells you were scanning for. A seat whose tmux session is confirmed gone reads `detached`
|
||||
(activity reason `session_missing`), and `rig ps --nodes` says to restore or relaunch it.
|
||||
A verified exited process in a retained pane reads `pane_exited`, instead of running and ready;
|
||||
outstanding work uses the existing recovery-finding route. An unknown observation is not an exit.
|
||||
`rig ps --nodes` also shows the model each seat was last seen using (`modelObservation`, with its
|
||||
source and time), or unknown; it never shows the configured model in its place. A Claude seat at
|
||||
Claude Code's MCP-server approval menu reads as needing input (`mcp_gate`), a Codex seat at its
|
||||
@@ -211,14 +213,33 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
type. Only if the window cannot open, `rig tui --shared` is the dashboard-only fallback; explain
|
||||
the failure. In the window's `--json` output, `reusedWorkspace` with no new tiles means an
|
||||
existing view was reused, which is success. `--provider herdr` or `--provider cmux` opens the
|
||||
view in a provider that is already running, without opening a window; each such open adds a new
|
||||
workspace, so to come back to a view, focus its open space.
|
||||
view in a provider that is already running, without opening a window. Ordinary opens add a new
|
||||
workspace, so to come back to a fixed view, focus its open space; task follow mode reuses one.
|
||||
**`qitem:<id>` as the view** previews or opens the current local owners of that task: its
|
||||
pending, in-progress and blocked rows through explicitly linked successors, without closed
|
||||
predecessors or past senders. It's a read-only membership view: stopped, missing, external and
|
||||
unresolved owners are named, and incomplete lineage is reported rather than cut short. It doesn't
|
||||
follow the task as it moves, and an explicit open still has the provider's usual workspace
|
||||
effects. Preview and open responses carry `viewDescriptor` (`kind`, `name`).
|
||||
unresolved owners are named, and incomplete lineage is reported rather than cut short. Ordinary
|
||||
open is one-shot. Add **`--follow --provider herdr`** to keep one workspace as the task's owners
|
||||
join and leave (Linux Herdr 0.9.5 / protocol 22 only). Joining viewers do not request focus;
|
||||
departures close viewers, not agents or conversations. Typed drafts and unreadable composers
|
||||
retain departed tiles, including unconfirmed Pi, terminal and community-harness input.
|
||||
`--pin`, `--suppress` and `--max-tiles` adjust the view; `--no-follow` keeps the fixed layout.
|
||||
A complete empty task drains removable tiles and stops following once none remain; an explicit
|
||||
`--follow` resumes that workspace. `rig terminal views --json` shows saved active selectors.
|
||||
The exercised journey used shell stand-ins, not native agents, and active render cost remains
|
||||
unmeasured. See `reference/terminal-sessions.md` for controls and limits. Preview and open
|
||||
responses carry `viewDescriptor` (`kind`, `name`); membership grants no input authority.
|
||||
**`rig terminal open slice:<name-or-id> --follow --provider herdr`** follows owners of the
|
||||
slice's exact-tagged pending, in-progress and blocked rows, with every obligation listed.
|
||||
Associate rows with `rig queue create --slice <name-or-id>`; handoffs inherit tags unless replaced.
|
||||
New slice views default to four automatic tiles (`--max-tiles` overrides it). Known empty slices
|
||||
stay following; incomplete reads keep prior viewers, and draft/focus protection still applies.
|
||||
A join refused before a pane split can join on a later refresh, with the original hold reason retained.
|
||||
An attach that ran and then exited to a shell is not restarted automatically; the whole slice view
|
||||
can hold until that tile is resolved. No tested workaround is offered for that case.
|
||||
Slice following targets the Linux Herdr 0.9.5 / protocol 22 path. Mission following is not included.
|
||||
An unconfirmed pane is reported as `pane_unconfirmed`, not counted as opened. Inspect before
|
||||
retrying: an unconfirmed result does not prove that nothing opened.
|
||||
Add `--session <name>` to select a Herdr session for that request; `--session default`
|
||||
explicitly selects its default session. `rig terminal status --session <name>` inspects
|
||||
the same endpoint. Omitting the flag keeps the daemon's configured endpoint. Herdr tiles come
|
||||
@@ -252,8 +273,10 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
beats a rig match; the rest use the default channel). After a change, invite the app to each
|
||||
channel, run `rig slack verify`, and restart the daemon (`rig daemon stop`, then `start`).
|
||||
Disabling and enabling Slack instead marks alerts still waiting as already posted, so they're
|
||||
never posted. An emoji reaction on any message posted for an ask reaches the asking seat
|
||||
never posted. By default, an emoji reaction on a message posted for an ask reaches the asking seat
|
||||
as a task tagged `human-reaction`: a signal to interpret, not an answer, and the ask stays open.
|
||||
`rig slack setup --reactions off` disables that routing; the default is `route`, and regenerating
|
||||
the app manifest preserves the setting.
|
||||
It needs the `reactions:read` scope and the `reaction_added` event (reinstall the app);
|
||||
`rig slack verify` warns when the scope is missing. A long ask is posted in numbered thread parts; one that
|
||||
still can't be posted comes back to the asking seat as a `slack-undeliverable` task. Replies
|
||||
@@ -507,6 +530,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
`append_system_prompt_file`, `plugin_dirs` and `advisor` (an Advisor model). They're copied into
|
||||
that seat's own storage, never the project, so one seat in a shared repository can have its own
|
||||
persona, prompt or plugins. A bundle's before-install view lists them.
|
||||
- **A new Claude member can name a prepared subscription token** with
|
||||
`claude_token: {label: name, file: /abs/token}` beside its `env.CLAUDE_CONFIG_DIR`; the seat stores
|
||||
the file reference and reads it at launch. A missing/unreadable token uses the existing fallback
|
||||
notice, so verify the account actually used.
|
||||
- **`rig community-harness list`**: list installed community-harness presets and declared limits
|
||||
(not tested by OpenRig). A preset can declare how its app reports the conversation each launch
|
||||
made (`launch_result`), reopens one on resume (`resume`) or lets OpenRig name it
|
||||
@@ -514,7 +541,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
that's gone reads `missing`, never quietly replaced. A preset's `first_input: true` hands the
|
||||
harness its first startup text through a private file named by `OPENRIG_FIRST_INPUT`. Examples for
|
||||
Cursor, Hermes and OpenClaw are in `docs/reference/community-harness-examples/`; OpenClaw's local
|
||||
and Gateway-client examples each resume a named conversation.
|
||||
and Gateway-client examples each resume a named conversation. A preset can declare `runner: true`
|
||||
(with `launch_result: true`) so activity follows its runner reports, recorded per launch.
|
||||
`passes_model: true` sends the declared model as `OPENRIG_MODEL`; it does not establish the
|
||||
effective model. Cursor and Hermes examples leave model passing off pending native qualification.
|
||||
`docs/reference/community-harness-presets.md` is the contract: what a community harness gets from
|
||||
OpenRig and what it doesn't (no turn-cancel verb, no approvals reaching OpenRig), how to write
|
||||
one, and how promotion is judged. Whether processes a harness starts outside its pane survive
|
||||
@@ -1213,6 +1243,13 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
never the token, and `--stored-login` moves it back. These seat account and runtime moves were
|
||||
tested with stubs, not real accounts, before 0.7.2: check a moved seat's account and conversation
|
||||
yourself after the first move.
|
||||
- **Move to an existing account home** with `rig provider switch --seat <seat> --to-account <name>
|
||||
--account-home <dir>` (preview with `--dry-run`). It starts a fresh conversation with a handover
|
||||
and retains the seat's address and work. `--clear-account-home` returns to configured defaults.
|
||||
- **Claude subscription/API-key switching resumes the same conversation and interrupts work:**
|
||||
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`; return with
|
||||
`--to-account plan --token-file <abs>`. The API-key path remains unqualified; verify the resulting
|
||||
account and conversation. Do not combine API-key and account-home selectors.
|
||||
- **`rig provider status --refresh --provider claude --claude-token-file <absolute path>
|
||||
--credential-label <label>`** — read a labelled Claude token's usage on demand. Each refresh spends
|
||||
one model turn; status and the TUI show only the cached reading, and the label is yours, not an
|
||||
@@ -1319,6 +1356,10 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
with secret names, never values): OpenRig then POSTs a pointer to the row when work lands, and a
|
||||
2xx records only that the ring was accepted, not that anything woke. When work waits for a
|
||||
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
|
||||
**Internet-facing hosted members are experimental, unsupported, off by default and at your
|
||||
own risk.** Local assistants can connect locally. Internet access needs your own TLS tunnel or
|
||||
proxy and a narrowly scoped, revocable token; see `reference/hosted-members.md`. Task terminal
|
||||
following does not require internet exposure.
|
||||
- **`rig skill loadout --runtime <claude-code|codex>`** — inspect the exact catalog revision,
|
||||
selectors, target, and current/missing/shadowed/conflicting state for one working directory.
|
||||
`--apply` writes only the managed ownership set, is idempotent, and refuses local edits or
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.7",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -27,8 +27,17 @@ fallback. Explain the window failure and help with the chosen fallback.
|
||||
The shared dashboard is the kernel's `operator.human` terminal, an ordinary TUI,
|
||||
not an agent or human-message inbox. Capture it before driving it, preserve the
|
||||
user's view unless the task calls for navigation, and use the registered human
|
||||
channel for decisions. If the TUI has exited, its shell remains; run `rig tui`
|
||||
there once.
|
||||
channel for decisions. If the TUI has exited but its shell remains, run
|
||||
`rig tui` there once. If the terminal session itself is absent, inspect
|
||||
`rig ps --nodes --rig kernel --json` for its registered `canonicalSessionName`
|
||||
and recover only that seat with `rig seat launch <session> --fresh --reason
|
||||
"recover missing shared kernel dashboard"`.
|
||||
Its fresh-start action runs `rig tui`; confirm that with `rig capture <session>`
|
||||
before opening `saved:kernel`. Keep healthy agent sessions running and omit
|
||||
`--stop`. Restoring selected agent seats does not restore an unselected
|
||||
`operator.human`, and opening a view cannot create its missing session. See
|
||||
"Incomplete setup and restart" in `$OPENRIG_HOME/reference/getting-started.md`
|
||||
for the window, TUI and missing-terminal cases.
|
||||
|
||||
When a person asks you to watch something or remind them, in any words, read
|
||||
`$OPENRIG_HOME/reference/keeping-a-watch.md` first and follow it.
|
||||
|
||||
@@ -259,6 +259,14 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource; freshInPlace?: boolean; beforeNativeDispatch?: () => boolean },
|
||||
): Promise<HarnessLaunchResult> {
|
||||
binding = { ...binding };
|
||||
if (binding.accountHome !== undefined && (!this.claudeManagedLaunch || !this.seatLaunchEnvironment)) {
|
||||
return { ok: false, error: "Isolated account-home launch support is unavailable; no agent started." };
|
||||
}
|
||||
const accountHome = binding.accountHome !== undefined ? binding.accountHome : this.claudeManagedLaunch?.accountHome?.(binding.nodeId);
|
||||
if (accountHome) {
|
||||
try { this.claudeManagedLaunch!.assertAccountHome(binding.nodeId, accountHome, binding.accountHome ? binding.accountToken ?? null : undefined); }
|
||||
catch (error) { return { ok: false, error: (error as Error).message }; }
|
||||
}
|
||||
const claudeCommand = binding.claudeExecutable ? `exec ${shellQuote(binding.claudeExecutable)}` : "claude";
|
||||
this.autoLaunches.delete(binding.nodeId);
|
||||
opts = { ...opts, ...(opts.forkSource ? { forkSource: { ...opts.forkSource } } : {}) };
|
||||
@@ -280,14 +288,22 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
managed = await this.claudeManagedLaunch!.prepare({ nodeId: binding.nodeId, cwd: binding.cwd,
|
||||
...(binding.claudeExecutable ? { executable: binding.claudeExecutable } : {}),
|
||||
session: binding.tmuxSession, pane: binding.tmuxPane, generation: binding.launchGeneration,
|
||||
...(binding.accountHome !== undefined ? { accountHome: binding.accountHome, accountToken: binding.accountHome === null ? binding.accountToken : binding.accountToken ?? null } : {}),
|
||||
...(binding.runtimeSwitchFrom ? { runtimeSwitch: { fromRuntime: binding.runtimeSwitchFrom.runtime, session: binding.runtimeSwitchFrom.session,
|
||||
pane: binding.runtimeSwitchFrom.pane, generation: binding.runtimeSwitchFrom.generation } } : {}) }, binding.permissionMode);
|
||||
} catch (error) { return { ok: false, error: (error as Error).message }; }
|
||||
}
|
||||
// A seat's own subscription token: the managed launch carries it; the classic launch gets its step in the staged
|
||||
// subshell. A launch that can't apply it still starts, and says so.
|
||||
const seatToken = managed ? null : this.claudeManagedLaunch?.seatTokenLaunch?.(binding.nodeId) ?? null;
|
||||
// subshell. Subscription-token fallback is reported; an unusable API key refuses the launch.
|
||||
let seatToken: ReturnType<ClaudeManagedLaunch["seatTokenLaunch"]> = null;
|
||||
if (!managed) {
|
||||
try {
|
||||
seatToken = this.claudeManagedLaunch?.seatTokenLaunch?.(binding.nodeId,
|
||||
binding.accountHome !== undefined ? { accountHome: binding.accountHome, ref: binding.accountHome === null ? binding.accountToken : binding.accountToken ?? null, required: !!binding.accountHome } : {}) ?? null;
|
||||
} catch (error) { return { ok: false, error: (error as Error).message }; }
|
||||
}
|
||||
const tokenWarnings: string[] = [...(managed?.warnings ?? seatToken?.warnings ?? [])];
|
||||
if (seatToken) tokenWarnings.push(...(await this.claudeManagedLaunch?.tokenPrecedenceWarnings?.(binding.nodeId, seatToken.ref) ?? []));
|
||||
const session = binding.tmuxSession;
|
||||
const assertSelected = opts.beforeNativeDispatch ? () => {
|
||||
if (!opts.beforeNativeDispatch!()) throw new Error("Selected history changed before Claude input");
|
||||
@@ -295,13 +311,15 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
const beforeManagedInput = opts.beforeNativeDispatch ? () => { assertSelected?.(); managed?.assertCurrent?.(); } : managed?.assertCurrent;
|
||||
const sendClassic = async (cmd: string) => {
|
||||
if (!this.seatLaunchEnvironment) {
|
||||
if (seatToken?.ref.kind === "api-key") return { ok: false as const, code: "api_key_unavailable", message: "This launch cannot apply the selected API key; no Claude process started." };
|
||||
if (seatToken) tokenWarnings.push(this.claudeManagedLaunch?.tokenNotApplied?.(binding.nodeId, seatToken.ref, "this launch path can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "this launch path can't run the token step"));
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false as const, code: "selection_changed", message: "Selected history changed before Claude input" };
|
||||
return assertSelected ? this.tmux.sendText(session, cmd, assertSelected) : this.tmux.sendText(session, cmd);
|
||||
}
|
||||
const staged = await this.seatLaunchEnvironment.command(session, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration,
|
||||
runtime: this.runtime, ...(seatToken ? { claudeTokenStep: seatToken.step } : {}) });
|
||||
runtime: this.runtime, ...(binding.accountHome !== undefined ? { accountHome: binding.accountHome } : {}), ...(seatToken ? { claudeTokenStep: seatToken.step } : {}) });
|
||||
if (seatToken && !staged.includes(seatToken.step)) {
|
||||
if (seatToken.ref.kind === "api-key") return { ok: false as const, code: "api_key_unavailable", message: "This pane cannot apply the selected API key; no Claude process started." };
|
||||
tokenWarnings.push(this.claudeManagedLaunch?.tokenNotApplied?.(binding.nodeId, seatToken.ref, "the seat's pane shell can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "the seat's pane shell can't run the token step"));
|
||||
}
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false as const, code: "selection_changed", message: "Selected history changed before Claude input" };
|
||||
@@ -449,7 +467,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
|
||||
/** Best-effort public seam for user-scope Claude bootstrap used by managed sessions. */
|
||||
ensureManagedBootstrap(binding: { nodeId?: string; cwd?: string | null; tmuxSession?: string | null; permissionMode?: NodeBinding["permissionMode"] }): void {
|
||||
ensureManagedBootstrap(binding: { nodeId?: string; cwd?: string | null; tmuxSession?: string | null; permissionMode?: NodeBinding["permissionMode"]; accountHome?: string | null }): void {
|
||||
this.provisionManagedBootstrap(binding);
|
||||
}
|
||||
|
||||
@@ -978,10 +996,12 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
return resolveConcreteHint(path, content, orientation);
|
||||
}
|
||||
|
||||
private provisionManagedBootstrap(binding: { nodeId?: string; cwd?: string | null; tmuxSession?: string | null; permissionMode?: NodeBinding["permissionMode"] }): void {
|
||||
private provisionManagedBootstrap(binding: { nodeId?: string; cwd?: string | null; tmuxSession?: string | null; permissionMode?: NodeBinding["permissionMode"]; accountHome?: string | null }): void {
|
||||
// OPR.0.4.8.2 agnostic rip-out: provisionRigPermissions (C2) removed — OpenRig no longer
|
||||
// authors any config-file permission policy. Trust/onboarding (C3/C4) are neutral plumbing, kept.
|
||||
const home = this.fs.homedir ?? (typeof process !== "undefined" ? process.env.HOME : undefined);
|
||||
const accountHome = binding.accountHome !== undefined ? binding.accountHome
|
||||
: binding.nodeId ? this.claudeManagedLaunch?.accountHome(binding.nodeId) : undefined;
|
||||
// Match launchHarness: an explicit permission mode exports the selected home to the child, so
|
||||
// only that home is provisioned.
|
||||
const managed = binding.permissionMode !== undefined ? this.claudeManagedLaunch : undefined;
|
||||
@@ -991,17 +1011,17 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
const failures: string[] = [];
|
||||
if (managed) {
|
||||
// The seat's own home when it declares one (member or rig `env:`), else the daemon's.
|
||||
if (cwd) statePaths.push(managed.configPaths(cwd, binding.nodeId).statePath);
|
||||
if (cwd) statePaths.push(managed.configPaths(cwd, binding.nodeId, accountHome).statePath);
|
||||
} else {
|
||||
if (home) statePaths.push(nodePath.join(home, ".claude.json"));
|
||||
// A classic seat types `claude` into its pane's shell, which inherits the tmux server's
|
||||
if (!accountHome && home) statePaths.push(nodePath.join(home, ".claude.json"));
|
||||
// Without an isolated account home, a classic seat inherits the tmux server's
|
||||
// environment, not the daemon's, so the bootstrap cannot see which home that child reads.
|
||||
// When the seat's own `env:` or the daemon selects CLAUDE_CONFIG_DIR, provision that home too. This deliberately
|
||||
// replaces #565's rule that classic bootstrap writes exactly one file and leaves the
|
||||
// selected home untouched: a child that inherits the daemon's selection read neither.
|
||||
try {
|
||||
const selectedCwd = cwd ?? (binding.nodeId ? this.claudeManagedLaunch?.boundCwd(binding.nodeId) : undefined);
|
||||
const selected = selectedCwd ? this.claudeManagedLaunch?.selectedStatePath(selectedCwd, binding.nodeId) : undefined;
|
||||
const selected = selectedCwd ? this.claudeManagedLaunch?.selectedStatePath(selectedCwd, binding.nodeId, accountHome) : undefined;
|
||||
if (selected && !statePaths.includes(selected)) statePaths.push(selected);
|
||||
} catch (err) {
|
||||
failures.push((err as Error).message);
|
||||
|
||||
@@ -103,8 +103,14 @@ export class ClaudeResumeAdapter {
|
||||
} catch (error) { return { ok: false, code: "permission_selection_refused", message: (error as Error).message }; }
|
||||
}
|
||||
// A seat's own subscription token, as on a fresh launch (claude-code-adapter.ts).
|
||||
const seatToken = managed || !nodeId ? null : this.options.claudeManagedLaunch?.seatTokenLaunch?.(nodeId) ?? null;
|
||||
let seatToken: ReturnType<ClaudeManagedLaunch["seatTokenLaunch"]> = null;
|
||||
if (!managed && nodeId) {
|
||||
try { seatToken = this.options.claudeManagedLaunch?.seatTokenLaunch?.(nodeId) ?? null; }
|
||||
catch (error) { return { ok: false, code: "api_key_unavailable", message: (error as Error).message }; }
|
||||
}
|
||||
const requiresApiKey = seatToken?.ref.kind === "api-key";
|
||||
const tokenWarnings: string[] = [...(managed?.warnings ?? seatToken?.warnings ?? [])];
|
||||
if (seatToken) tokenWarnings.push(...(await this.options.claudeManagedLaunch?.tokenPrecedenceWarnings?.(nodeId!, seatToken.ref) ?? []));
|
||||
const choice = { claudePermissions, kernelAuthority, teamPermissionDefault, nonInterruptive, launchPosture: resolvedPosture, permissionMode: selectedPermissionMode };
|
||||
const posture = claudePostureFlag(process.env, resolvedPosture, selectedPermissionMode);
|
||||
const appliedLaunch = observeClaudePermission(posture);
|
||||
@@ -121,6 +127,7 @@ export class ClaudeResumeAdapter {
|
||||
|
||||
const sendClassic = async () => {
|
||||
if (!this.options.seatLaunchEnvironment) {
|
||||
if (requiresApiKey) return { ok: false as const, code: "api_key_unavailable", message: "This launch cannot apply the selected API key; no Claude process started." };
|
||||
if (seatToken) tokenWarnings.push(this.options.claudeManagedLaunch?.tokenNotApplied?.(nodeId!, seatToken.ref, "this launch path can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "this launch path can't run the token step"));
|
||||
if (beforeNativeDispatch && !beforeNativeDispatch()) return { ok: false as const, code: "selection_changed", message: "Selected history changed before Claude resume input" };
|
||||
return assertSelected ? this.tmux.sendText(tmuxSessionName, cmd, assertSelected) : this.tmux.sendText(tmuxSessionName, cmd);
|
||||
@@ -128,6 +135,7 @@ export class ClaudeResumeAdapter {
|
||||
const staged = await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: "claude-code", nodeId,
|
||||
...(seatToken ? { claudeTokenStep: seatToken.step } : {}) });
|
||||
if (seatToken && !staged.includes(seatToken.step)) {
|
||||
if (requiresApiKey) return { ok: false as const, code: "api_key_unavailable", message: "This pane cannot apply the selected API key; no Claude process started." };
|
||||
tokenWarnings.push(this.options.claudeManagedLaunch?.tokenNotApplied?.(nodeId!, seatToken.ref, "the seat's pane shell can't run the token step") ?? claudeSeatTokenFallbackNotice(seatToken.ref, "the seat's pane shell can't run the token step"));
|
||||
}
|
||||
if (beforeNativeDispatch && !beforeNativeDispatch()) return { ok: false as const, code: "selection_changed", message: "Selected history changed before Claude resume input" };
|
||||
|
||||
@@ -155,7 +155,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
private resolveHomeDirByPid: ResolveHomeDirByPid;
|
||||
private codexHome?: string;
|
||||
/** A seat's own CODEX_HOME from its member or rig `env:`, when it declares one. */
|
||||
private seatHome?: (nodeId: string) => string | undefined;
|
||||
private seatHome?: (nodeId: string, ignoreAccountHome?: boolean) => string | undefined;
|
||||
/** One thread-id resolver per Codex home its seats use, the daemon's included. */
|
||||
private threadResolvers = new Map<string, CodexThreadIdResolver>();
|
||||
private launchPath?: string;
|
||||
@@ -215,7 +215,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
/** Explicit CODEX_HOME; absent preserves the legacy OS/PID home behavior. */
|
||||
codexHome?: string;
|
||||
/** A seat's own CODEX_HOME (an absolute path its member or rig `env:` declares). It wins over `codexHome` for that seat. */
|
||||
seatHome?: (nodeId: string) => string | undefined;
|
||||
seatHome?: (nodeId: string, ignoreAccountHome?: boolean) => string | undefined;
|
||||
/** Match the daemon's prerequisite probe even if the pane's login shell rewrites PATH. */
|
||||
launchPath?: string;
|
||||
verifyProfilePreflight?: (profile: string, cwd?: string, home?: string) => Promise<CodexProfileProbeResult>;
|
||||
@@ -285,28 +285,28 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
/** The home a seat's Codex uses: its own from its member or rig `env:`, else the daemon's explicit CODEX_HOME. Undefined
|
||||
* keeps the legacy OS/PID home behavior. */
|
||||
private homeFor(binding: { nodeId?: string } | undefined): string | undefined {
|
||||
private homeFor(binding: { nodeId?: string; accountHome?: string | null } | undefined): string | undefined {
|
||||
return this.ownHome(binding) ?? this.codexHome;
|
||||
}
|
||||
|
||||
/** Only the seat's own CODEX_HOME, undefined when it declares none. Probes and readers get it as an extra argument
|
||||
* only when it's set, so a seat without one calls them exactly as before. */
|
||||
private ownHome(binding: { nodeId?: string } | undefined): string | undefined {
|
||||
return binding?.nodeId ? this.seatHome?.(binding.nodeId) : undefined;
|
||||
private ownHome(binding: { nodeId?: string; accountHome?: string | null } | undefined): string | undefined {
|
||||
return binding?.accountHome ?? (binding?.nodeId ? this.seatHome?.(binding.nodeId, binding.accountHome === null) : undefined);
|
||||
}
|
||||
|
||||
private ownHomeCommandOpt(binding: { nodeId?: string }): { codexHome?: string } {
|
||||
private ownHomeCommandOpt(binding: { nodeId?: string; accountHome?: string | null }): { codexHome?: string } {
|
||||
const own = this.ownHome(binding);
|
||||
return own ? { codexHome: own } : {};
|
||||
}
|
||||
|
||||
private ownHomeOpt(binding: { nodeId?: string }): { home?: string } {
|
||||
private ownHomeOpt(binding: { nodeId?: string; accountHome?: string | null }): { home?: string } {
|
||||
const own = this.ownHome(binding);
|
||||
return own ? { home: own } : {};
|
||||
}
|
||||
|
||||
/** A thread id read for this seat's process, in the seat's own home when it declares one. */
|
||||
private readThreadIdForSeat(binding: { nodeId?: string }, pid: number, identity?: string): Promise<string | undefined> | string | undefined {
|
||||
private readThreadIdForSeat(binding: { nodeId?: string; accountHome?: string | null }, pid: number, identity?: string): Promise<string | undefined> | string | undefined {
|
||||
const own = this.ownHome(binding);
|
||||
if (own) return this.readThreadIdByPid(pid, identity, own);
|
||||
return identity === undefined ? this.readThreadIdByPid(pid) : this.readThreadIdByPid(pid, identity);
|
||||
@@ -860,6 +860,9 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
binding: NodeBinding,
|
||||
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource; beforeNativeDispatch?: () => boolean },
|
||||
): Promise<HarnessLaunchResult> {
|
||||
if (binding.accountHome !== undefined && !this.seatLaunchEnvironment) {
|
||||
return { ok: false, error: "Isolated account-home launch support is unavailable; no agent started." };
|
||||
}
|
||||
if (!binding.tmuxSession) {
|
||||
return { ok: false, error: "No tmux session bound — cannot launch Codex harness" };
|
||||
}
|
||||
@@ -1120,7 +1123,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
private async seatCommand(binding: NodeBinding, cmd: string): Promise<string> {
|
||||
return this.seatLaunchEnvironment
|
||||
? await this.seatLaunchEnvironment.command(binding.tmuxSession!, cmd, { codexCwd: binding.cwd, nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime, ...this.ownHomeCommandOpt(binding) })
|
||||
? await this.seatLaunchEnvironment.command(binding.tmuxSession!, cmd, { codexCwd: binding.cwd, nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime, ...(binding.accountHome !== undefined ? { accountHome: binding.accountHome } : {}), ...this.ownHomeCommandOpt(binding) })
|
||||
: this.launchCommand(cmd, this.homeFor(binding));
|
||||
}
|
||||
|
||||
|
||||
@@ -93,21 +93,34 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
} catch (error) {
|
||||
return { ok: false, error: `Couldn't prepare the first input for community harness "${this.runtime}": ${(error as Error).message}; nothing was started.` };
|
||||
}
|
||||
const preset = found.preset.firstInput ? { ...found.preset, command: firstInput
|
||||
let command = found.preset.firstInput ? (firstInput
|
||||
? `/usr/bin/env OPENRIG_FIRST_INPUT=${shellQuote(firstInput.path)} ${found.preset.command}`
|
||||
: `/usr/bin/env -u OPENRIG_FIRST_INPUT ${found.preset.command}` } : found.preset;
|
||||
let passed = false;
|
||||
: `/usr/bin/env -u OPENRIG_FIRST_INPUT ${found.preset.command}`) : found.preset.command;
|
||||
// An opted-in launcher gets the seat's declared model, or none (an inherited value is cleared). Passed is what it was
|
||||
// handed, not which model runs.
|
||||
const model = found.preset.passesModel ? binding.model?.trim() || undefined : undefined;
|
||||
if (found.preset.passesModel) command = model ? `/usr/bin/env OPENRIG_MODEL=${shellQuote(model)} ${command}` : `/usr/bin/env -u OPENRIG_MODEL ${command}`;
|
||||
const preset = command === found.preset.command ? found.preset : { ...found.preset, command };
|
||||
// The first input counts as passed once typing began, as before. The model's dispatch is confirmed only by a send that
|
||||
// succeeded, which a later failure of the launch doesn't undo; typing that began and then failed leaves it uncertain;
|
||||
// a launch that failed before typing passed nothing.
|
||||
let typed = false;
|
||||
let sent = false;
|
||||
const handed = () => ({ ...(firstInput ? { firstInput } : {}),
|
||||
...(model ? { modelInput: { model, dispatch: sent ? "confirmed" as const : "uncertain" as const } } : {}) });
|
||||
try {
|
||||
const result = await this.launchPreset(session, preset, resumeRef, firstInput ? () => { passed = true; } : undefined);
|
||||
return { ...result, ...(passed ? { firstInput } : {}) };
|
||||
const result = await this.launchPreset(session, preset, resumeRef, firstInput || model ? () => { typed = true; } : undefined,
|
||||
model ? () => { sent = true; } : undefined);
|
||||
return { ...result, ...(typed ? handed() : {}) };
|
||||
} catch (error) {
|
||||
if (!passed) throw error;
|
||||
return { ok: false, firstInput, error: `Community harness "${this.runtime}" launch is uncertain: ${(error as Error).message}` };
|
||||
if (!typed) throw error;
|
||||
return { ok: false, ...handed(), error: `Community harness "${this.runtime}" launch is uncertain: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
private async launchPreset(session: string, preset: CommunityHarnessPreset, resumeRef: string | undefined, beforeInput?: () => void): Promise<HarnessLaunchResult> {
|
||||
if (preset.namesConversation) return this.launchNamed(session, preset.command, resumeRef, beforeInput);
|
||||
private async launchPreset(session: string, preset: CommunityHarnessPreset, resumeRef: string | undefined, beforeInput?: () => void,
|
||||
onSent?: () => void): Promise<HarnessLaunchResult> {
|
||||
if (preset.namesConversation) return this.launchNamed(session, preset.command, resumeRef, beforeInput, onSent);
|
||||
let attempt: LaunchAttempt | undefined;
|
||||
let program = preset.command;
|
||||
// The record is optional: a folder that can't be made leaves the identity unavailable, and the harness still launches.
|
||||
@@ -130,6 +143,9 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
: program;
|
||||
const sent = await this.deps.tmux.sendShellCommand(session, command, beforeInput, { stageIfLong: true, execInScript: true });
|
||||
if (!sent.ok) return { ok: false, error: `Couldn't start community harness "${this.runtime}": ${sent.message}` };
|
||||
onSent?.();
|
||||
// A runner reports its conversation in this record; without one, its seat needs a person rather than reading ready.
|
||||
if (!attempt && storageNote && preset.runner) return { ok: false, recovery: "attention_required", error: storageNote };
|
||||
if (!attempt) return storageNote ? { ok: true, warnings: [storageNote] } : { ok: true };
|
||||
const read = await waitForLaunchResult(attempt, this.runtime, { ...this.deps.launchResultWait, root: this.deps.launchResultRoot?.() ?? launchResultRoot() });
|
||||
if (resumeRef) {
|
||||
@@ -147,16 +163,17 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
error: `Community harness "${this.runtime}": asked to resume conversation ${resumeRef}, its launcher reports conversation ${read.nativeRef} instead. The seat keeps conversation ${resumeRef}, and OpenRig doesn't switch it to ${read.nativeRef}. Nothing was restarted.` };
|
||||
}
|
||||
}
|
||||
return read.ok
|
||||
? { ok: true, resumeToken: read.nativeRef, resumeType: `community:${this.runtime}`, resumeProvenance: "launcher" }
|
||||
: { ok: true, warnings: [`community harness "${this.runtime}": its launch identity is unavailable: ${read.reason}`] };
|
||||
if (read.ok) return { ok: true, resumeToken: read.nativeRef, resumeType: `community:${this.runtime}`, resumeProvenance: "launcher" };
|
||||
const unavailable = `community harness "${this.runtime}": its launch identity is unavailable: ${read.reason}`;
|
||||
return preset.runner ? { ok: false, recovery: "attention_required", error: unavailable } : { ok: true, warnings: [unavailable] };
|
||||
}
|
||||
|
||||
/** OpenRig names the conversation: a new one gets a fresh name in OPENRIG_CONVERSATION, and a resume passes the saved name
|
||||
* in OPENRIG_RESUME_REF. Nothing reports back, so the name is recorded as passed, not observed, and readiness says only
|
||||
* that a process runs: a harness that hasn't yet found a gone conversation reads as ready. Never creating one on a
|
||||
* resume is the launcher's job; OpenRig never asks for a new conversation on a resume. */
|
||||
private async launchNamed(session: string, preset: string, resumeRef: string | undefined, beforeInput?: () => void): Promise<HarnessLaunchResult> {
|
||||
private async launchNamed(session: string, preset: string, resumeRef: string | undefined, beforeInput?: () => void,
|
||||
onSent?: () => void): Promise<HarnessLaunchResult> {
|
||||
const name = resumeRef ?? conversationName(session);
|
||||
const program = `/usr/bin/env ${resumeRef ? "OPENRIG_RESUME_REF" : "OPENRIG_CONVERSATION"}=${shellQuote(name)} ${preset}`;
|
||||
const command = this.deps.seatLaunchEnvironment
|
||||
@@ -164,6 +181,7 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
: program;
|
||||
const sent = await this.deps.tmux.sendShellCommand(session, command, beforeInput, { stageIfLong: true, execInScript: true });
|
||||
if (!sent.ok) return { ok: false, error: `Couldn't start community harness "${this.runtime}": ${sent.message}` };
|
||||
onSent?.();
|
||||
return { ok: true, resumeToken: name, resumeType: `community:${this.runtime}`, resumeProvenance: "passed" };
|
||||
}
|
||||
|
||||
@@ -185,6 +203,12 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
return found.ok && found.preset.firstInput;
|
||||
}
|
||||
|
||||
/** Whether this launch's preset declares an OpenRig-aware runner, recorded on the session with the launch. */
|
||||
runner(): boolean {
|
||||
const found = loadCommunityHarnessPreset(this.runtime, this.deps.presetsDir?.() ?? communityHarnessesDir());
|
||||
return found.ok && found.preset.runner;
|
||||
}
|
||||
|
||||
/** A process running isn't proof that the startup paste was submitted. */
|
||||
startupAssisted(): boolean {
|
||||
const found = loadCommunityHarnessPreset(this.runtime, this.deps.presetsDir?.() ?? communityHarnessesDir());
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -844,6 +844,7 @@ export class ClaudeCompactionEnforcer {
|
||||
&& this.pendingPreCompactPrep.get(input.sessionName) === attempt;
|
||||
const sendOpts = {
|
||||
openrigNotice: true as const,
|
||||
preserveInput: true as const,
|
||||
beforeWrite: () => {
|
||||
if (!isCurrent()) throw new DeliveryGuardError("compaction_cancelled", "Managed compaction was cancelled or replaced; no further input is authorized.");
|
||||
},
|
||||
@@ -1282,7 +1283,7 @@ export class ClaudeCompactionEnforcer {
|
||||
overflowCompactInstruction: composed.instructionInCommand ? null : attempt.compactInstruction,
|
||||
usedPercentage: input.usedPercentage!, thresholdPercent: policy.thresholdPercent,
|
||||
preCompactInstruction: policy.preCompactInstruction, preparation: attempt, constructedAt: this.now(),
|
||||
}), { openrigNotice: true, beforeWrite: () => {
|
||||
}), { openrigNotice: true, preserveInput: true, beforeWrite: () => {
|
||||
this.reconcilePreparations();
|
||||
if (this.pendingPreCompactPrep.get(input.sessionName) !== attempt || attempt.controller.signal.aborted || attempt.status === "stopped")
|
||||
throw new DeliveryGuardError("preparation_stopped", "This compaction attempt ended before preparation input.");
|
||||
@@ -1348,7 +1349,7 @@ export class ClaudeCompactionEnforcer {
|
||||
// deadline. (The automatic path used to send into a seat still finishing its preparation turn.)
|
||||
// Idle comes from a live pane read: the newest hook can predate the rest of the preparation turn.
|
||||
return this.sessionTransport.send(input.sessionName, composed.command,
|
||||
{ openrigNotice: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs, attempt.deadlineAt - this.now())), readinessFromPaneOnly: true } : {}) });
|
||||
{ openrigNotice: true, preserveInput: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs, attempt.deadlineAt - this.now())), readinessFromPaneOnly: true } : {}) });
|
||||
};
|
||||
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase">;
|
||||
let guardRefused = false;
|
||||
|
||||
@@ -8,6 +8,9 @@ export const OUTRANKING_TOKEN_ENV = [
|
||||
"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY",
|
||||
] as const;
|
||||
|
||||
/** Profile and federation selectors outrank stored login, but not an explicit subscription token. */
|
||||
export const ANTHROPIC_PROFILE_ENV = ["ANTHROPIC_PROFILE", "ANTHROPIC_FEDERATION_RULE_ID", "ANTHROPIC_ORGANIZATION_ID"] as const;
|
||||
|
||||
export interface CredentialConflict {
|
||||
name: string;
|
||||
source: string;
|
||||
@@ -25,10 +28,29 @@ export interface CredentialPrecedence {
|
||||
notChecked: string[];
|
||||
}
|
||||
|
||||
/** Match the bounded profile-fingerprint read: a FIFO cannot hold the open, and only a regular file up to 1 MiB is read.
|
||||
* Kept synchronous because launch assertions must finish before native input is dispatched. */
|
||||
function readCredentialSettings(file: string): string {
|
||||
const maxBytes = 1024 * 1024;
|
||||
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (!stat.isFile() || stat.size > maxBytes) throw new Error("Unusable credential settings file");
|
||||
const buffer = Buffer.alloc(maxBytes + 1);
|
||||
let length = 0;
|
||||
for (;;) {
|
||||
const count = fs.readSync(fd, buffer, length, buffer.length - length, length);
|
||||
if (count === 0) return buffer.subarray(0, length).toString("utf8");
|
||||
length += count;
|
||||
if (length > maxBytes) throw new Error("Credential settings file exceeds limit");
|
||||
}
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
|
||||
/** One settings file's `apiKeyHelper` and outranking `env` entries, and with `storedLogin` its `env` block's
|
||||
* `CLAUDE_CODE_OAUTH_TOKEN`. An absent file has none. */
|
||||
export function settingsFileConflicts(file: string, scope: string, read: (file: string) => string = (f) => fs.readFileSync(f, "utf8"),
|
||||
opts: { storedLogin?: boolean } = {}): { conflicts: CredentialConflict[]; instead?: CredentialConflict[]; checked?: string; notChecked?: string } {
|
||||
export function settingsFileConflicts(file: string, scope: string, read: (file: string) => string = readCredentialSettings,
|
||||
opts: { storedLogin?: boolean; apiKey?: boolean; isolatedAccountHome?: boolean; profileSelectorsOnly?: boolean } = {}): { conflicts: CredentialConflict[]; instead?: CredentialConflict[]; checked?: string; notChecked?: string } {
|
||||
let text: string;
|
||||
try { text = read(file); } catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === "ENOENT"
|
||||
@@ -39,12 +61,14 @@ export function settingsFileConflicts(file: string, scope: string, read: (file:
|
||||
try { settings = JSON.parse(text) as typeof settings; } catch { return { conflicts: [], notChecked: `${scope} settings ${file} (not valid JSON)` }; }
|
||||
const remedy = `remove it from ${file} (OpenRig doesn't edit settings files)`;
|
||||
const conflicts: CredentialConflict[] = [];
|
||||
if (settings?.apiKeyHelper !== undefined) conflicts.push({ name: "apiKeyHelper", source: `${scope} settings ${file}`, remedy });
|
||||
if (!opts.profileSelectorsOnly && !opts.apiKey && settings?.apiKeyHelper !== undefined) conflicts.push({ name: "apiKeyHelper", source: `${scope} settings ${file}`, remedy });
|
||||
const env = settings?.env && typeof settings.env === "object" ? settings.env as Record<string, unknown> : {};
|
||||
for (const name of OUTRANKING_TOKEN_ENV) {
|
||||
const names = opts.apiKey ? [...OUTRANKING_TOKEN_ENV, "ANTHROPIC_BASE_URL"] : opts.profileSelectorsOnly ? ANTHROPIC_PROFILE_ENV
|
||||
: [...OUTRANKING_TOKEN_ENV, ...(opts.isolatedAccountHome ? ["ANTHROPIC_BASE_URL", ...ANTHROPIC_PROFILE_ENV] : [])];
|
||||
for (const name of names) {
|
||||
if (env[name] !== undefined) conflicts.push({ name, source: `the env block of ${scope} settings ${file}`, remedy });
|
||||
}
|
||||
const instead = opts.storedLogin && env.CLAUDE_CODE_OAUTH_TOKEN !== undefined
|
||||
const instead = opts.storedLogin && !opts.profileSelectorsOnly && env.CLAUDE_CODE_OAUTH_TOKEN !== undefined
|
||||
? [{ name: "CLAUDE_CODE_OAUTH_TOKEN", source: `the env block of ${scope} settings ${file}`, remedy }] : [];
|
||||
return { conflicts, ...(instead.length ? { instead } : {}), checked: `${scope} settings ${file}` };
|
||||
}
|
||||
@@ -65,3 +89,21 @@ export function projectRootForLocalSettings(cwd: string): string | null {
|
||||
if (path.dirname(dir) === dir) return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** An isolated home may own an API helper, but project settings must not choose another account. A selected token also
|
||||
* requires its home not to contain a helper or env that would outrank that token. Only names are reported. */
|
||||
export function isolatedClaudeAccountProblem(home: string, cwd: string, selectedToken: boolean, apiKey = false): string | null {
|
||||
const root = projectRootForLocalSettings(cwd) ?? cwd;
|
||||
const files = [
|
||||
path.join(home, "settings.json"),
|
||||
...new Set([root, cwd].flatMap(dir => [path.join(dir, ".claude/settings.json"), path.join(dir, ".claude/settings.local.json")])),
|
||||
];
|
||||
for (const file of files) {
|
||||
const check = settingsFileConflicts(file, "account or project", undefined, { storedLogin: !apiKey, isolatedAccountHome: true, apiKey,
|
||||
profileSelectorsOnly: file === path.join(home, "settings.json") && !selectedToken });
|
||||
if (check.conflicts.length || check.instead?.length || check.notChecked) {
|
||||
return "Claude credential settings could override the selected account home or token; remove those settings or make them readable before switching. Nothing changed.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import { parseClaudePermissionModes } from "./permission-drift.js";
|
||||
import { validateNativePermissionSelection } from "./native-permission-selection.js";
|
||||
import { seatLaunchAdditionsForLaunch } from "./seat-launch-additions.js";
|
||||
import { NO_SESSION_BUS } from "./seat-runtime-dir.js";
|
||||
import { effectiveRuntimeHome } from "./seat-runtime-home.js";
|
||||
import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, claudeSeatTokenStep, readClaudeSeatTokenRef, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
|
||||
import { OUTRANKING_TOKEN_ENV, projectRootForLocalSettings, settingsFileConflicts, type CredentialConflict, type CredentialPrecedence } from "./claude-credential-precedence.js";
|
||||
import { ACCOUNT_HOME_PROVIDER_ENV, seatAccountHome, effectiveRuntimeHome } from "./seat-runtime-home.js";
|
||||
import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, claudeSeatTokenOutrankedNotice, claudeSeatTokenStep, readClaudeSeatTokenRef, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
|
||||
import { isolatedClaudeAccountProblem, OUTRANKING_TOKEN_ENV, projectRootForLocalSettings, settingsFileConflicts, type CredentialConflict, type CredentialPrecedence } from "./claude-credential-precedence.js";
|
||||
|
||||
export interface ClaudeLaunchTarget {
|
||||
/** A running seat's verified executable, carried through its in-place continuation. */
|
||||
@@ -26,6 +26,9 @@ export interface ClaudeLaunchTarget {
|
||||
pane?: string | null;
|
||||
/** A successor may have a reserved generation before its tenure is committed. */
|
||||
generation?: string;
|
||||
/** The staged successor's isolated account home, before it is committed. */
|
||||
accountHome?: string | null;
|
||||
accountToken?: ClaudeSeatTokenRef | null;
|
||||
/** Slice 16: a Claude successor staged beside the seat's current occupant on another runtime (from #1214 by
|
||||
* Aschenbath). The seat still records that occupant until the switch commits; it must still be bound exactly. */
|
||||
runtimeSwitch?: { fromRuntime: string; session: string; pane: string; generation: string };
|
||||
@@ -130,11 +133,40 @@ export class ClaudeManagedLaunch {
|
||||
/** The launch step that gives the seat's Claude its own subscription token (`rig provider switch --seat`), and the
|
||||
* launch's warning when the file won't apply (checked without reading it); null for a seat without a reference.
|
||||
* Launches without an explicit permission mode use it too, through the seat launch environment. */
|
||||
seatTokenLaunch(nodeId: string, opts: { removeOnFailure?: boolean } = {}): { ref: ClaudeSeatTokenRef; step: string; warnings: readonly string[] } | null {
|
||||
const ref = readClaudeSeatTokenRef(this.db, nodeId);
|
||||
seatTokenLaunch(nodeId: string, opts: { removeOnFailure?: boolean; required?: boolean; ref?: ClaudeSeatTokenRef | null; accountHome?: string | null } = {}): { ref: ClaudeSeatTokenRef; step: string; warnings: readonly string[] } | null {
|
||||
const ref = opts.ref === undefined ? readClaudeSeatTokenRef(this.db, nodeId) : opts.ref;
|
||||
const required = opts.required || !!this.accountHome(nodeId);
|
||||
if (!ref) return null;
|
||||
const problem = claudeSeatTokenFileProblem(ref);
|
||||
return { ref, step: claudeSeatTokenStep(ref, shellQuote, opts), warnings: Object.freeze(problem ? [this.tokenNotApplied(nodeId, ref, problem)] : []) };
|
||||
if (ref.kind === "api-key") {
|
||||
if (problem) throw new Error(`Selected API-key file unavailable: ${problem}. No Claude process started.`);
|
||||
const row = this.db.prepare("SELECT cwd FROM nodes WHERE id = ?").get(nodeId) as { cwd: string | null } | undefined;
|
||||
if (!row?.cwd || !path.isAbsolute(row.cwd)) throw new Error("Selected API-key launch has no absolute working directory; no agent started.");
|
||||
const cwd = row.cwd;
|
||||
const root = projectRootForLocalSettings(cwd) ?? cwd;
|
||||
for (const file of new Set([path.join(this.configPaths(cwd, nodeId, opts.accountHome).configDir, "settings.json"),
|
||||
path.join(cwd, ".claude", "settings.json"), path.join(cwd, ".claude", "settings.local.json"), path.join(root, ".claude", "settings.local.json")])) {
|
||||
const found = settingsFileConflicts(file, "selected", undefined, { apiKey: true });
|
||||
if (found.conflicts.length || found.notChecked) throw new Error("Claude credential settings could override the selected API key or could not be checked; no Claude process started.");
|
||||
}
|
||||
}
|
||||
return { ref, step: claudeSeatTokenStep(ref, shellQuote, { ...opts, required }), warnings: Object.freeze(problem ? [required ? "The selected account token is unusable; no agent will start."
|
||||
: this.tokenNotApplied(nodeId, ref, problem)] : []) };
|
||||
}
|
||||
|
||||
/** The switch's credential-precedence check, said as a launch warning for a seat with its own token reference, such as a
|
||||
* spec's `claude_token`: what Claude Code would use ahead of it. The launch goes ahead, so it's never a refusal. Only
|
||||
* the settings files count: the token step unsets the outranking names in the launch environment before Claude starts.
|
||||
* An account-home seat is skipped: its home is the selection, checked when it was made. So is an API-key reference,
|
||||
* which Claude Code ranks above an apiKeyHelper and whose step scrubs the outranking names itself. */
|
||||
async tokenPrecedenceWarnings(nodeId: string, ref: ClaudeSeatTokenRef): Promise<string[]> {
|
||||
if (ref.kind === "api-key" || this.accountHome(nodeId)) return [];
|
||||
try {
|
||||
const found = await this.credentialPrecedence(nodeId, { settingsOnly: true });
|
||||
return found.conflicts.length ? [claudeSeatTokenOutrankedNotice(ref, found.conflicts)] : [];
|
||||
} catch (error) {
|
||||
return [`This seat's Claude subscription token (${ref.label}): what would take precedence over it couldn't be checked (${(error as Error).message}).`];
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a launch that didn't apply the seat's token is recorded (the daemon's event log); unset, it's only reported. */
|
||||
@@ -153,13 +185,14 @@ export class ClaudeManagedLaunch {
|
||||
* the settings files it loads. When the token applies, the launch unsets those names for Claude's process, so a pane
|
||||
* shell's own exports can't outrank it. With `storedLogin`, also a `CLAUDE_CODE_OAUTH_TOKEN` it would receive, which Claude
|
||||
* uses ahead of the stored login; a pane shell's own exports can't be read then. Managed settings (a file, MDM or the
|
||||
* server) aren't read; the restarted seat's /status is the evidence for those. */
|
||||
async credentialPrecedence(nodeId: string, opts: { storedLogin?: boolean } = {}): Promise<CredentialPrecedence> {
|
||||
* server) aren't read; the restarted seat's /status is the evidence for those. With `settingsOnly`, the launch
|
||||
* environment isn't read either. */
|
||||
async credentialPrecedence(nodeId: string, opts: { storedLogin?: boolean; apiKey?: boolean; settingsOnly?: boolean } = {}): Promise<CredentialPrecedence> {
|
||||
const target = this.target(nodeId);
|
||||
const cwd = target.cwd!;
|
||||
const conflicts: CredentialConflict[] = [];
|
||||
const instead: CredentialConflict[] = [];
|
||||
const names = opts.storedLogin ? [...OUTRANKING_TOKEN_ENV, "CLAUDE_CODE_OAUTH_TOKEN"] : OUTRANKING_TOKEN_ENV;
|
||||
const names = opts.apiKey || opts.settingsOnly ? [] : opts.storedLogin ? [...OUTRANKING_TOKEN_ENV, "CLAUDE_CODE_OAUTH_TOKEN"] : OUTRANKING_TOKEN_ENV;
|
||||
const found = (name: string) => name === "CLAUDE_CODE_OAUTH_TOKEN" ? instead : conflicts;
|
||||
for (const name of names) {
|
||||
if (this.sessionEnv[name]) {
|
||||
@@ -173,7 +206,7 @@ export class ClaudeManagedLaunch {
|
||||
found(name).push({ name, source: "this seat's env: (member or rig) or a one-off launch name", remedy: `remove ${name} from this seat's env: in its rig spec` });
|
||||
}
|
||||
}
|
||||
const checked = [opts.storedLogin ? "the launch environment"
|
||||
const checked: string[] = opts.settingsOnly ? [] : [opts.storedLogin ? "the launch environment"
|
||||
: "the launch environment (a pane shell's own exports are unset for Claude's process when the token applies)"];
|
||||
const notChecked = ["managed settings (a managed-settings.json file, MDM or the server)",
|
||||
...(opts.storedLogin ? ["the pane shell's own exports (its startup files), which a launch without an explicit permission mode keeps"] : [])];
|
||||
@@ -188,6 +221,7 @@ export class ClaudeManagedLaunch {
|
||||
for (const [file, scope] of files) {
|
||||
const settings = settingsFileConflicts(file, scope, undefined, opts);
|
||||
conflicts.push(...settings.conflicts);
|
||||
if (opts.apiKey && settings.notChecked) conflicts.push({ name: "unreadable credential settings", source: `${scope} settings ${file}`, remedy: "make the settings readable and valid before switching" });
|
||||
instead.push(...(settings.instead ?? []));
|
||||
if (settings.checked) checked.push(settings.checked);
|
||||
if (settings.notChecked) notChecked.push(settings.notChecked);
|
||||
@@ -200,18 +234,32 @@ export class ClaudeManagedLaunch {
|
||||
return this.target(nodeId).cwd!;
|
||||
}
|
||||
|
||||
/** Check again at launch: settings may have changed since the switch was committed. */
|
||||
assertAccountHome(nodeId: string, home: string, token?: ClaudeSeatTokenRef | null): void {
|
||||
const row = this.db.prepare("SELECT cwd FROM nodes WHERE id = ?").get(nodeId) as { cwd: string | null } | undefined;
|
||||
if (!row?.cwd) throw new Error("The selected account's working directory is unavailable; no agent started.");
|
||||
const selected = token === undefined ? readClaudeSeatTokenRef(this.db, nodeId) : token;
|
||||
const problem = isolatedClaudeAccountProblem(home, row.cwd, !!selected, selected?.kind === "api-key");
|
||||
if (problem) throw new Error(problem);
|
||||
}
|
||||
|
||||
/** The native account-home mode, which excludes inherited provider credentials. */
|
||||
accountHome(nodeId: string): string | undefined {
|
||||
return seatAccountHome(this.db, nodeId, "claude-code");
|
||||
}
|
||||
|
||||
/** The config dir a seat's Claude selects: its own home from its member or rig `env:`, else the daemon's. */
|
||||
private selectedConfigDir(nodeId: string | undefined): string | undefined {
|
||||
return effectiveRuntimeHome(this.db, nodeId, "claude-code", this.sessionEnv.CLAUDE_CONFIG_DIR);
|
||||
private selectedConfigDir(nodeId: string | undefined, ignoreAccountHome = false): string | undefined {
|
||||
return effectiveRuntimeHome(this.db, nodeId, "claude-code", this.sessionEnv.CLAUDE_CONFIG_DIR, ignoreAccountHome);
|
||||
}
|
||||
|
||||
/** Bootstrap and the launched child must use the same native selection.
|
||||
* Unset config uses HOME/.claude.json; even an explicit default-looking
|
||||
* directory selects <configDir>/.claude.json instead (#154/#225).
|
||||
*/
|
||||
configPaths(cwd: string, nodeId?: string): { configDir: string; statePath: string } {
|
||||
configPaths(cwd: string, nodeId?: string, accountHome?: string | null): { configDir: string; statePath: string } {
|
||||
const { HOME } = this.sessionEnv;
|
||||
const CLAUDE_CONFIG_DIR = this.selectedConfigDir(nodeId);
|
||||
const CLAUDE_CONFIG_DIR = accountHome ?? this.selectedConfigDir(nodeId, accountHome === null);
|
||||
if (!HOME || !path.isAbsolute(HOME)) throw new Error("Claude managed launch context is unresolved: absolute HOME is required.");
|
||||
const configDir = path.resolve(cwd, CLAUDE_CONFIG_DIR ?? path.join(HOME, ".claude"));
|
||||
return { configDir, statePath: path.join(CLAUDE_CONFIG_DIR === undefined ? HOME : configDir, ".claude.json") };
|
||||
@@ -219,18 +267,20 @@ export class ClaudeManagedLaunch {
|
||||
|
||||
/** The state file under the seat's own or the daemon's non-empty CLAUDE_CONFIG_DIR, or undefined when neither
|
||||
* selects one. Classic bootstrap provisions it in addition to HOME/.claude.json. */
|
||||
selectedStatePath(cwd: string, nodeId?: string): string | undefined {
|
||||
return this.selectedConfigDir(nodeId) ? this.configPaths(cwd, nodeId).statePath : undefined;
|
||||
selectedStatePath(cwd: string, nodeId?: string, accountHome?: string | null): string | undefined {
|
||||
const selected = accountHome ?? this.selectedConfigDir(nodeId, accountHome === null);
|
||||
// Clearing preserves even an explicitly empty baseline, just as the managed child does.
|
||||
return (accountHome === null ? selected !== undefined : !!selected) ? this.configPaths(cwd, nodeId, accountHome).statePath : undefined;
|
||||
}
|
||||
|
||||
private context(cwd: string, nodeId: string, selectedExecutable?: string) {
|
||||
private context(cwd: string, nodeId: string, accountHome?: string | null, selectedExecutable?: string) {
|
||||
const { PATH, HOME } = this.sessionEnv;
|
||||
const CLAUDE_CONFIG_DIR = this.selectedConfigDir(nodeId);
|
||||
const CLAUDE_CONFIG_DIR = accountHome ?? this.selectedConfigDir(nodeId, accountHome === null);
|
||||
if (!PATH || !HOME || !path.isAbsolute(HOME)) throw new Error("Claude managed launch context is unresolved: managed PATH and absolute HOME are required.");
|
||||
// Relative/empty PATH entries are interpreted at the intended seat cwd,
|
||||
// including for /usr/bin/env shebangs inside the selected executable.
|
||||
const search = PATH.split(path.delimiter).map(p => path.resolve(cwd, p));
|
||||
const { configDir } = this.configPaths(cwd, nodeId);
|
||||
const { configDir } = this.configPaths(cwd, nodeId, accountHome);
|
||||
const env: Record<string, string> = { PATH: search.join(path.delimiter), HOME };
|
||||
// Session storage needs a directory, but exporting the default changes
|
||||
// Claude's global config selection. Preserve an unset native selection.
|
||||
@@ -266,7 +316,7 @@ export class ClaudeManagedLaunch {
|
||||
throw new Error("Claude managed launch context is unresolved: a bound pane and current occupant are required.");
|
||||
}
|
||||
// A staged switch successor: the seat's current occupant is still bound exactly, and the successor is elsewhere.
|
||||
const staged = !!switching && switching.fromRuntime !== "claude-code" && switching.session === before.session
|
||||
const staged = !!switching && (switching.fromRuntime !== "claude-code" || target.accountHome !== undefined) && switching.session === before.session
|
||||
&& switching.pane === before.pane && switching.generation === before.generation
|
||||
&& !!target.session && target.session !== before.session && !!target.pane && target.pane !== before.pane
|
||||
&& !!target.generation && target.generation !== before.generation;
|
||||
@@ -275,17 +325,21 @@ export class ClaudeManagedLaunch {
|
||||
|| (!staged && target.pane != null && target.pane !== before.pane)) {
|
||||
throw new Error("Claude managed launch target disagrees with the current binding; no input or selection changed.");
|
||||
}
|
||||
const context = this.context(cwd, target.nodeId, target.executable);
|
||||
const accountHome = target.accountHome !== undefined ? target.accountHome : seatAccountHome(this.db, target.nodeId, "claude-code");
|
||||
if (accountHome) this.assertAccountHome(target.nodeId, accountHome, target.accountHome ? target.accountToken ?? null : undefined);
|
||||
const credentialKeys: readonly string[] = accountHome ? ACCOUNT_HOME_PROVIDER_ENV : [];
|
||||
const context = this.context(cwd, target.nodeId, accountHome, target.executable);
|
||||
// This is the existing managed session channel, not arbitrary shell variables.
|
||||
// Filter out the explicit capability environment and overwrite launch identity.
|
||||
const inherited = Object.keys(this.sessionEnv).filter(key => this.sessionEnv[key] !== undefined
|
||||
&& !MANAGED_LAUNCH_KEYS.includes(key));
|
||||
&& !MANAGED_LAUNCH_KEYS.includes(key) && !credentialKeys.includes(key));
|
||||
if (inherited.some(key => !/^[A-Z_][A-Z0-9_]*$/.test(key))) throw new Error("Invalid managed environment key.");
|
||||
const assertCurrent = () => {
|
||||
if (accountHome) this.assertAccountHome(target.nodeId, accountHome, target.accountHome ? target.accountToken ?? null : undefined);
|
||||
if (JSON.stringify(this.target(target.nodeId, switching?.fromRuntime)) !== JSON.stringify(before)
|
||||
|| JSON.stringify(this.context(cwd, target.nodeId, target.executable)) !== JSON.stringify(context)
|
||||
|| JSON.stringify(this.context(cwd, target.nodeId, accountHome, target.executable)) !== JSON.stringify(context)
|
||||
|| JSON.stringify(Object.keys(this.sessionEnv).filter(key => this.sessionEnv[key] !== undefined
|
||||
&& !["PATH", "HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"].includes(key))) !== JSON.stringify(inherited)) {
|
||||
&& !["PATH", "HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"].includes(key) && !credentialKeys.includes(key))) !== JSON.stringify(inherited)) {
|
||||
throw new Error("Claude managed launch context changed during capability discovery/input; retry explicitly.");
|
||||
}
|
||||
};
|
||||
@@ -299,7 +353,7 @@ export class ClaudeManagedLaunch {
|
||||
const identity: Record<string, string> = { OPENRIG_NODE_ID: target.nodeId, OPENRIG_RUNTIME: "claude-code",
|
||||
...(before.session ? { OPENRIG_SESSION_NAME: before.session } : {}),
|
||||
...(generation ? { OPENRIG_OCCUPANT_GENERATION: generation } : {}) };
|
||||
const seatKeys = await this.seatEnvKeys(target.nodeId, before.session ?? undefined);
|
||||
const seatKeys = (await this.seatEnvKeys(target.nodeId, before.session ?? undefined)).filter(key => !credentialKeys.includes(key));
|
||||
const publicEnv = publicSeatEnvironment(this.sessionEnv);
|
||||
const assignments = Object.entries({ ...context.env, ...publicEnv, ...identity }).map(([key, value]) => `export ${shellQuote(`${key}=${value}`)}`);
|
||||
const forwarded = inherited.filter(key => !(key in identity) && !(key in publicEnv)).map(key => `export ${key}="\${${key}-}"`);
|
||||
@@ -337,14 +391,16 @@ export class ClaudeManagedLaunch {
|
||||
// removals are worked out, so the value never passes through OpenRig. The reduction keeps the name the step sets; when the
|
||||
// file can't be read, Claude still starts, and a token the reduction would have removed is removed by the step instead.
|
||||
const tokenKept = selectedKeys.includes("CLAUDE_CODE_OAUTH_TOKEN");
|
||||
const token = this.seatTokenLaunch(target.nodeId, { removeOnFailure: !tokenKept });
|
||||
const token = this.seatTokenLaunch(target.nodeId, { removeOnFailure: !tokenKept, accountHome: target.accountHome,
|
||||
...(target.accountHome !== undefined ? { ref: target.accountHome === null ? target.accountToken : target.accountToken ?? null, required: !!target.accountHome } : {}) });
|
||||
const tokenStep = token ? `${token.step} && ` : "";
|
||||
const warnings = token?.warnings ?? Object.freeze([]);
|
||||
const warnings = Object.freeze([...(token?.warnings ?? []), ...(token ? await this.tokenPrecedenceWarnings(target.nodeId, token.ref) : [])]);
|
||||
return Object.freeze({ assertCurrent, warnings, configDir: context.configDir, executable: context.executable, executableIsScript: context.executableIsScript, command: (args: readonly string[], launchEnv: Readonly<Record<string, string>> = {}) => {
|
||||
assertCurrent();
|
||||
const launch = Object.entries(launchEnv).map(([key, value]) => `export ${shellQuote(`${key}=${value}`)}`);
|
||||
if (token?.ref.kind === "api-key") this.seatTokenLaunch(target.nodeId, { ref: token.ref, accountHome: target.accountHome });
|
||||
const launch = Object.entries(launchEnv).filter(([key]) => !credentialKeys.includes(key)).map(([key, value]) => `export ${shellQuote(`${key}=${value}`)}`);
|
||||
const keep = [...selectedKeys, ...terminalKeys, "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS",
|
||||
...Object.keys(launchEnv), ...(token ? ["CLAUDE_CODE_OAUTH_TOKEN"] : [])];
|
||||
...Object.keys(launchEnv).filter(key => !credentialKeys.includes(key)), ...(token ? [token.ref.kind === "api-key" ? "ANTHROPIC_API_KEY" : "CLAUDE_CODE_OAUTH_TOKEN"] : [])];
|
||||
// env -0 keeps multiline values out of the name parser. The existing daemon's Node reads
|
||||
// that pipe with an empty environment (no pane NODE_OPTIONS); only removal names reach argv.
|
||||
const removalArgs = `const data=require("node:fs").readFileSync(0,"utf8"); if(!data.endsWith("\\0"))process.exit(1); `
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { execPath } from "node:process";
|
||||
import type Database from "better-sqlite3";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { OUTRANKING_TOKEN_ENV } from "./claude-credential-precedence.js";
|
||||
import { OUTRANKING_TOKEN_ENV, type CredentialConflict } from "./claude-credential-precedence.js";
|
||||
|
||||
/** A Claude seat's own subscription credential: an operator label and an absolute token-file path. A reference only: the
|
||||
// Read at launch through a private pipe, with fixed errors and a bounded non-blocking fd read.
|
||||
const API_KEY_READER = `const fs=require('node:fs'); let fd; const data=Buffer.alloc(8193); try {
|
||||
fd=fs.openSync(process.argv[1], fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
||||
const st=fs.fstatSync(fd); if(!st.isFile() || ![0o400,0o600].includes(st.mode & 0o777) || st.size<1 || st.size>8192) process.exit(1);
|
||||
const n=fs.readSync(fd,data,0,data.length,null); const key=data.subarray(0,n).toString('utf8').trim();
|
||||
if(n>8192 || !/^[!-~]+$/.test(key)) process.exit(1); process.stdout.write(key);
|
||||
} catch { process.exitCode=1; } finally { data.fill(0); if(fd!==undefined)fs.closeSync(fd); }`;
|
||||
|
||||
/** A Claude seat's own subscription or API credential: an operator label and an absolute token-file path. A reference only: the
|
||||
* launch script reads the file when Claude starts, so the value never passes through OpenRig. The label is the
|
||||
* operator's name for it, not an account the provider observed. */
|
||||
export interface ClaudeSeatTokenRef {
|
||||
label: string;
|
||||
tokenFile: string;
|
||||
/** Omitted for existing subscription-token references. */
|
||||
kind?: "api-key";
|
||||
}
|
||||
|
||||
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/;
|
||||
@@ -20,13 +31,28 @@ export function claudeSeatTokenRefProblem(ref: ClaudeSeatTokenRef): string | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A spec's `claude_token: { label, file }` as the seat's reference; it throws, naming `where`, when it can't be one. The
|
||||
* file isn't read: a missing or unreadable one starts the seat with the usual fallback notice. */
|
||||
export function specClaudeTokenRef(value: unknown, where: string): ClaudeSeatTokenRef {
|
||||
const fields = value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
if (!fields || typeof fields["label"] !== "string" || typeof fields["file"] !== "string"
|
||||
|| Object.keys(fields).some((key) => key !== "label" && key !== "file")) {
|
||||
throw new Error(`${where}: must be { label, file }, the token file's label and absolute path`);
|
||||
}
|
||||
const ref = { label: fields["label"] as string, tokenFile: fields["file"] as string };
|
||||
const problem = claudeSeatTokenRefProblem(ref);
|
||||
if (problem) throw new Error(`${where}: ${problem}`);
|
||||
return ref;
|
||||
}
|
||||
|
||||
/** The stored reference, or null when it's absent or unreadable. */
|
||||
export function parseClaudeSeatTokenRef(value: unknown): ClaudeSeatTokenRef | null {
|
||||
if (typeof value !== "string" || value === "") return null;
|
||||
try {
|
||||
const { label, tokenFile } = JSON.parse(value) as { label?: unknown; tokenFile?: unknown };
|
||||
const { label, tokenFile, kind } = JSON.parse(value) as { label?: unknown; tokenFile?: unknown; kind?: unknown };
|
||||
if (kind !== undefined && kind !== "api-key") return null;
|
||||
if (typeof label !== "string" || typeof tokenFile !== "string") return null;
|
||||
return claudeSeatTokenRefProblem({ label, tokenFile }) === null ? { label, tokenFile } : null;
|
||||
return claudeSeatTokenRefProblem({ label, tokenFile }) === null ? { label, tokenFile, ...(kind === "api-key" ? { kind } : {}) } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -46,6 +72,7 @@ export function claudeSeatTokenFileProblem(ref: ClaudeSeatTokenRef): string | nu
|
||||
let stat: fs.Stats;
|
||||
try { stat = fs.statSync(ref.tokenFile); } catch { return `the token file ${ref.tokenFile} doesn't exist`; }
|
||||
if (!stat.isFile()) return `${ref.tokenFile} isn't a regular file`;
|
||||
if (ref.kind === "api-key" && (![0o400, 0o600].includes(stat.mode & 0o777) || stat.size > 8192)) return "the API-key file must be private (0600 or 0400) and at most 8192 bytes";
|
||||
if (stat.size === 0) return `the token file ${ref.tokenFile} is empty`;
|
||||
try { fs.accessSync(ref.tokenFile, fs.constants.R_OK); } catch { return `the token file ${ref.tokenFile} isn't readable by the daemon's user`; }
|
||||
return null;
|
||||
@@ -53,6 +80,7 @@ export function claudeSeatTokenFileProblem(ref: ClaudeSeatTokenRef): string | nu
|
||||
|
||||
/** What a launch that couldn't apply the seat's token says, wherever it's reported. */
|
||||
export function claudeSeatTokenFallbackNotice(ref: ClaudeSeatTokenRef, problem: string): string {
|
||||
if (ref.kind === "api-key") return `This seat's Claude API key (${ref.label}) is unavailable: ${problem}. No Claude process will start; fix its file or switch back to a subscription.`;
|
||||
return `This seat's Claude subscription token (${ref.label}) wasn't applied: ${problem}. Claude started without it, on its config dir's `
|
||||
+ "stored login (or a token the daemon gives every seat), which may be another account. Fix the file, or choose another with "
|
||||
+ "rig provider switch --seat.";
|
||||
@@ -62,12 +90,33 @@ export function claudeSeatTokenFallbackNotice(ref: ClaudeSeatTokenRef, problem:
|
||||
* names Claude Code ranks above it are unset for this process only (the pane shell's own exports included), so it's the
|
||||
* token Claude uses. When the file can't be read or is empty, Claude still starts, on the config dir's stored login, and
|
||||
* the pane says so; any token the launch already carried is kept unless `removeOnFailure`, and an empty one is never
|
||||
* exported. */
|
||||
export function claudeSeatTokenStep(ref: ClaudeSeatTokenRef, quote: (value: string) => string, opts: { removeOnFailure?: boolean } = {}): string {
|
||||
const notice = claudeSeatTokenFallbackNotice(ref, `${ref.tokenFile} couldn't be read when Claude started`);
|
||||
return `{ __openrig_token=$(/bin/cat -- ${quote(ref.tokenFile)} 2>/dev/null); if [ -n "$__openrig_token" ]; `
|
||||
+ `then unset ${OUTRANKING_TOKEN_ENV.join(" ")}; CLAUDE_CODE_OAUTH_TOKEN=$__openrig_token; export CLAUDE_CODE_OAUTH_TOKEN; `
|
||||
+ `else ${opts.removeOnFailure ? "unset CLAUDE_CODE_OAUTH_TOKEN; " : ""}printf '%s\\n' ${quote(`OpenRig: ${notice}`)} >&2; fi; unset __openrig_token; }`;
|
||||
* exported. API-key references instead fail closed and scrub other auth/routing variables. A pane shell with tracing on
|
||||
* (set -x) would print a subscription token as it's read and assigned, so tracing is suspended for those and restored
|
||||
* afterwards. */
|
||||
export function claudeSeatTokenStep(ref: ClaudeSeatTokenRef, quote: (value: string) => string, opts: { removeOnFailure?: boolean; required?: boolean } = {}): string {
|
||||
if (ref.kind === "api-key") {
|
||||
// A missing API key must never silently fall back to a subscription or another account.
|
||||
const file = quote(ref.tokenFile);
|
||||
const scrub = [...OUTRANKING_TOKEN_ENV, "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_PROFILE", "ANTHROPIC_FEDERATION_RULE_ID", "ANTHROPIC_ORGANIZATION_ID"];
|
||||
return `{ set +x; unset ${scrub.join(" ")}; if [ -f ${file} ] && [ -r ${file} ]; then __openrig_token=$(/usr/bin/env -i ${quote(execPath)} -e ${quote(API_KEY_READER)} ${file} 2>/dev/null); else __openrig_token=; fi; `
|
||||
+ `if [ -n "$__openrig_token" ]; then ANTHROPIC_API_KEY=$__openrig_token; export ANTHROPIC_API_KEY; unset __openrig_token; `
|
||||
+ `else unset __openrig_token; printf '%s\\n' 'OpenRig: selected API-key file unavailable; no Claude process started.' >&2; exit 1; fi; }`;
|
||||
}
|
||||
const notice = opts.required ? "OpenRig: the selected account's token could not be read; no agent started."
|
||||
: `OpenRig: ${claudeSeatTokenFallbackNotice(ref, `${ref.tokenFile} couldn't be read when Claude started`)}`;
|
||||
const restore = 'if [ -n "$__openrig_x" ]; then set -x; fi; unset __openrig_x; ';
|
||||
return `{ case $- in *x*) __openrig_x=1; set +x;; *) __openrig_x=;; esac; `
|
||||
+ `__openrig_token=$(/bin/cat -- ${quote(ref.tokenFile)} 2>/dev/null); if [ -n "$__openrig_token" ]; `
|
||||
+ `then unset ${OUTRANKING_TOKEN_ENV.join(" ")}; CLAUDE_CODE_OAUTH_TOKEN=$__openrig_token; export CLAUDE_CODE_OAUTH_TOKEN; unset __openrig_token; ${restore}`
|
||||
+ `else ${opts.removeOnFailure || opts.required ? "unset CLAUDE_CODE_OAUTH_TOKEN; " : ""}printf '%s\\n' ${quote(notice)} >&2; unset __openrig_token; ${restore}${opts.required ? "false;" : ""} fi; }`;
|
||||
|
||||
}
|
||||
|
||||
/** What a launch says when a setting would make Claude Code use another credential ahead of the seat's token, in the
|
||||
* switch's words. The launch goes ahead: it's a warning, not a refusal. */
|
||||
export function claudeSeatTokenOutrankedNotice(ref: ClaudeSeatTokenRef, conflicts: readonly CredentialConflict[]): string {
|
||||
return `This seat's Claude subscription token (${ref.label}) may not be the credential Claude uses. Claude Code would use these instead: `
|
||||
+ `${conflicts.map((c) => `${c.name} (${c.source}; ${c.remedy})`).join("; ")}.`;
|
||||
}
|
||||
|
||||
/** Whether a launch warning is the one a launch that couldn't apply the seat's token gives. */
|
||||
|
||||
@@ -15,7 +15,7 @@ import { BUILT_IN_RUNTIMES, isCommunityHarnessRuntime } from "./built-in-runtime
|
||||
export { BUILT_IN_RUNTIMES, isCommunityHarnessRuntime };
|
||||
|
||||
export const COMMUNITY_HARNESS_PRESET_FORMAT = 1;
|
||||
const FIELDS = ["format", "name", "command", "maintainer", "limits", "launch_result", "names_conversation", "resume", "startup_assisted", "first_input"] as const;
|
||||
const FIELDS = ["format", "name", "command", "maintainer", "limits", "launch_result", "names_conversation", "resume", "startup_assisted", "first_input", "runner", "passes_model"] as const;
|
||||
const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
|
||||
export interface CommunityHarnessPreset {
|
||||
@@ -33,6 +33,11 @@ export interface CommunityHarnessPreset {
|
||||
startupAssisted: boolean;
|
||||
/** The launcher takes OPENRIG_FIRST_INPUT, a private file containing the composed first turn. */
|
||||
firstInput: boolean;
|
||||
/** The launcher takes the seat's declared model from OPENRIG_MODEL and maps it into its harness's own setting. */
|
||||
passesModel: boolean;
|
||||
/** The command is an OpenRig-aware runner: it submits each pasted block as one prompt and posts the seat's turn
|
||||
* boundaries as activity hooks, so its seat's activity reads from those hooks. Requires `launch_result`. */
|
||||
runner: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
@@ -72,7 +77,12 @@ function parsePreset(name: string, path: string, text: string): CommunityHarness
|
||||
if (raw.resume !== undefined && typeof raw.resume !== "boolean") return bad("resume must be true or false");
|
||||
if (raw.startup_assisted !== undefined && typeof raw.startup_assisted !== "boolean") return bad("startup_assisted must be true or false");
|
||||
if (raw.first_input !== undefined && typeof raw.first_input !== "boolean") return bad("first_input must be true or false");
|
||||
if (raw.passes_model !== undefined && typeof raw.passes_model !== "boolean") return bad("passes_model must be true or false");
|
||||
if (raw.names_conversation !== undefined && typeof raw.names_conversation !== "boolean") return bad("names_conversation must be true or false");
|
||||
if (raw.runner !== undefined && typeof raw.runner !== "boolean") return bad("runner must be true or false");
|
||||
if (raw.runner === true && raw.launch_result !== true) {
|
||||
return bad("runner needs launch_result: true: a runner reports the conversation it opened in its launch record");
|
||||
}
|
||||
if (raw.launch_result === true && raw.names_conversation === true) {
|
||||
return bad("launch_result and names_conversation can't both be true: either the launcher reports the conversation, or OpenRig names it");
|
||||
}
|
||||
@@ -81,7 +91,7 @@ function parsePreset(name: string, path: string, text: string): CommunityHarness
|
||||
}
|
||||
return { ok: true, preset: { name, command: raw.command as string, maintainer: raw.maintainer as string, limits: limits as string[],
|
||||
launchResult: raw.launch_result === true, namesConversation: raw.names_conversation === true, resume: raw.resume === true,
|
||||
startupAssisted: raw.startup_assisted === true, firstInput: raw.first_input === true, path } };
|
||||
startupAssisted: raw.startup_assisted === true, firstInput: raw.first_input === true, runner: raw.runner === true, passesModel: raw.passes_model === true, path } };
|
||||
}
|
||||
|
||||
/** The first shell construct in a preset command, or null when it's one program with its arguments. The command is
|
||||
|
||||
@@ -137,6 +137,8 @@ export interface ComposerInput {
|
||||
state: "empty" | "text" | "unknown";
|
||||
rows: string[];
|
||||
columns: number;
|
||||
/** Conservative visible capacity of the recognized Codex fullscreen composer. */
|
||||
pasteViewport?: { columns: number; rows: number };
|
||||
cursor?: ComposerSnapshot["cursor"];
|
||||
unknownReason?: string;
|
||||
suggestionRows?: string[];
|
||||
@@ -177,6 +179,34 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Conservative uncertainty before a protected Codex paste, never Enter authority.
|
||||
* Codex 0.164 collapses >1000 code points to [Pasted Content N chars], which
|
||||
* the current ownership reader cannot own. Only a strictly shorter logical
|
||||
* line has an exact one-row charge. Wrapped lines use a conservative pair bound
|
||||
* over all native semantic breaks, plus an insertion row; this can retain prose
|
||||
* that fits natively, without recreating UAX#14 or weakening Enter ownership.
|
||||
*/
|
||||
export function composerPasteUncertainty(input: ComposerInput, text: string): "controls" | "long" | "viewport" | null {
|
||||
const viewport = input.pasteViewport;
|
||||
if (!viewport) return null;
|
||||
const normalized = text.replace(/\r\n/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
if (lines.some(line => /[\x00-\x1f\x7f-\x9f]/.test(line))) return "controls";
|
||||
// rust-v0.164.0 chat_composer/paste_input.rs:184-192 (Unicode scalar count).
|
||||
if ([...normalized].length > 1000) return "long";
|
||||
if (viewport.columns < 1 || viewport.rows < 1) return "viewport";
|
||||
let rows = 0;
|
||||
for (const line of lines) {
|
||||
const cells = [...line].reduce((sum, ch) => sum + (ch >= " " && ch <= "~" ? 1 : 2), 0);
|
||||
// A native semantic fragment may move whole, even across an apparent space.
|
||||
// Bound the entire line rather than assuming character or ASCII-space breaks.
|
||||
// Equality is not the one-row fast path: a full line needs an insertion row.
|
||||
rows += cells < viewport.columns ? 1 : Math.ceil(2 * cells / viewport.columns) + 1;
|
||||
if (rows > viewport.rows) return "viewport";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recognize this lease's paste immediately after pasting or before its Enter
|
||||
* in the original composer. A later lease cannot acquire ownership from a label.
|
||||
*/
|
||||
|
||||
@@ -55,8 +55,16 @@ export interface SlackConnectorConfig {
|
||||
* every channel is handled here, as before. Not in DEFAULT_CONFIG, like `channelMap`.
|
||||
*/
|
||||
forwardChannels?: ForwardChannel[];
|
||||
/**
|
||||
* What a person's emoji reaction on an OpenRig post does. Absent or "route" = a row for the seat that asked, as before.
|
||||
* "off" = reactions are ignored, so an acknowledging emoji costs no agent turn. Not in DEFAULT_CONFIG, like `channelMap`.
|
||||
*/
|
||||
reactions?: SlackReactionsMode;
|
||||
}
|
||||
|
||||
export const SLACK_REACTIONS_MODES = ["route", "off"] as const;
|
||||
export type SlackReactionsMode = typeof SLACK_REACTIONS_MODES[number];
|
||||
|
||||
export const DEFAULT_CONFIG: SlackConnectorConfig = {
|
||||
enabled: false,
|
||||
inboundDestination: "operator-agent@kernel",
|
||||
@@ -82,6 +90,10 @@ function validateConfig(cfg: SlackConnectorConfig, opts: { reading?: boolean } =
|
||||
validateLevel("minimumLevelThatPosts", cfg.minimumLevelThatPosts);
|
||||
validateLevel("minimumLevelThatInterrupts", cfg.minimumLevelThatInterrupts);
|
||||
validateChannelMap(cfg, { allowUnknownFields: opts.reading });
|
||||
// A value a newer OpenRig wrote is read as the default rather than failing the connector.
|
||||
if (!opts.reading && cfg.reactions !== undefined && !SLACK_REACTIONS_MODES.includes(cfg.reactions)) {
|
||||
throw new Error(`reactions must be one of ${SLACK_REACTIONS_MODES.join(", ")} (got ${String(cfg.reactions)})`);
|
||||
}
|
||||
}
|
||||
|
||||
export function configPathFor(home?: string): string {
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface SlackBlockActions {
|
||||
actions?: Array<{ block_id?: string; action_id?: string; value?: string; action_ts?: string }>;
|
||||
}
|
||||
|
||||
export type RecordHumanAnswer = (input: { qitemId: string; actorSession: string; questionId: string; optionId: string }) => RecordHumanAnswerResult;
|
||||
export type RecordHumanAnswer = (input: { qitemId: string; actorSession: string; questionId: string; optionId: string; clickedTs?: string }) => RecordHumanAnswerResult;
|
||||
|
||||
/** What a person reads in the thread when OpenRig refuses a click or can't record it. Plain words: what happened, and
|
||||
* who may answer or what to do instead. The same clicks are refused as before; they just aren't silent. */
|
||||
@@ -78,6 +78,7 @@ export const CLICK_REPLIES = {
|
||||
unregistered: "This click wasn't recorded: only the person this decision was sent to can answer it, and your Slack account isn't registered with this OpenRig.",
|
||||
unmapped: "This click wasn't recorded: OpenRig can't find the decision this message belongs to. Reply in this thread in words instead.",
|
||||
closed: "This decision is no longer waiting for answers, so this click wasn't recorded.",
|
||||
parked: "This click wasn't recorded: this decision is parked right now, so it isn't taking this answer.",
|
||||
onRecord: "Your answer is recorded, and this decision is no longer waiting for answers.",
|
||||
someoneElse: "This click wasn't recorded: this decision was sent to someone else, and only they can answer it.",
|
||||
stale: "This click wasn't recorded: the button no longer matches this decision's questions. Reply in this thread in words instead.",
|
||||
@@ -93,6 +94,7 @@ export function decidedForThemReply(d: { decidedBy: string; modeName: string; ch
|
||||
|
||||
/** The reply for a click recordHumanAnswer didn't record, by its reason. */
|
||||
export function clickNotRecordedReply(reason: string): string {
|
||||
if (reason === "state-blocked") return CLICK_REPLIES.parked;
|
||||
if (reason.startsWith("state-")) return CLICK_REPLIES.closed;
|
||||
if (reason === "not-the-asked-human") return CLICK_REPLIES.someoneElse;
|
||||
if (reason === "unknown-option" || reason === "no-questions") return CLICK_REPLIES.stale;
|
||||
@@ -188,6 +190,8 @@ export interface InboundDeps {
|
||||
queue: InboundQueuePort;
|
||||
seen: SeenStore;
|
||||
deadLetter: DeadLetterStore<SlackEvent>;
|
||||
/** What a person's emoji reaction does: "route" (absent) makes a row for the seat that asked; "off" ignores it (#2233). */
|
||||
reactions?: "route" | "off";
|
||||
destination: string; // first-class config; default operator-agent@kernel
|
||||
/** A6 v3 registration gate. Resolves ev.user -> a registered human (or refuses). Injected so
|
||||
* this core stays pure/testable; the subsystem wires it via the daemon human-registry resolver. */
|
||||
@@ -527,7 +531,7 @@ export class InboundRouter {
|
||||
const existing = this.deps.queue.getHumanAnswer?.(qitemId, who.source);
|
||||
if (existing) {
|
||||
try {
|
||||
const resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: humanAnswerText(existing.body) });
|
||||
const resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: humanAnswerText(existing.body), messageTs: action?.action_ts });
|
||||
if (resolution === "not-applicable") {
|
||||
if (live) await acknowledge(CLICK_REPLIES.notClosed);
|
||||
return { status: "refused", reason: "resolve-not-applicable" };
|
||||
@@ -540,7 +544,7 @@ export class InboundRouter {
|
||||
return { status: "handler-failed", reason: "answer-continuation-failed" };
|
||||
}
|
||||
}
|
||||
const recorded = this.deps.recordHumanAnswer?.({ qitemId, actorSession: who.source, ...picked });
|
||||
const recorded = this.deps.recordHumanAnswer?.({ qitemId, actorSession: who.source, ...picked, clickedTs: action?.action_ts });
|
||||
if (!recorded || recorded.status !== "recorded") {
|
||||
const reason = recorded?.reason ?? "answers-unavailable";
|
||||
// A closed decision holding this person's clicked answer: typically Slack's redelivery of the click that closed
|
||||
@@ -572,7 +576,8 @@ export class InboundRouter {
|
||||
summary: `${who.source} via Slack: answered ${lines.length === 1 ? "1 question" : `${lines.length} questions`}`,
|
||||
body: `${lines.join("\n")}\n\n---\nAnswers (question id → option id): ${JSON.stringify(recorded.answers)}\nIn reply to: ${qitemId} (its humanAnswers field holds the same)\nRouted by openrig slack-inbound (button click).`,
|
||||
});
|
||||
resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: lines.join("; ") });
|
||||
// The click's own time, so a decision parked on this person resumes as a typed reply after its question would.
|
||||
resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: lines.join("; "), messageTs: action?.action_ts });
|
||||
} catch (e) {
|
||||
this.deps.log?.(`answer continuation failed qitem=${qitemId}: ${(e as Error).message}`);
|
||||
if (live) await acknowledge("Your answers are recorded, but handing them back failed. OpenRig will retry.");
|
||||
@@ -609,6 +614,9 @@ export class InboundRouter {
|
||||
}
|
||||
|
||||
private async attemptReaction(ev: SlackEvent): Promise<{ status: InboundDisposition; reason?: string }> {
|
||||
// With reactions off, a person's emoji stays in Slack and never becomes a row (#2233). That holds on the retry too, so a
|
||||
// reaction retained from before reactions were turned off is dropped as ignored.
|
||||
if (this.deps.reactions === "off") return { status: "ignored", reason: "reactions-off" };
|
||||
const eventId = `reaction:${ev.item?.channel ?? "-"}:${ev.item?.ts ?? "-"}:${ev.user ?? "-"}:${ev.reaction ?? "-"}:${ev.event_ts ?? "-"}`;
|
||||
if (this.deps.seen.load().has(eventId)) return { status: "ignored", reason: "dup" };
|
||||
if (this.inflight.has(eventId)) return { status: "ignored", reason: "inflight" };
|
||||
|
||||
@@ -412,7 +412,9 @@ export interface PostChatMessageInput {
|
||||
}
|
||||
|
||||
export type PostChatMessageResult =
|
||||
| { ok: true; status: number; ts: string }
|
||||
/** `threadTs`: where Slack placed the message, from the posted message in its answer: the thread's parent, or null for a
|
||||
* top-level message. Absent when the answer doesn't include the posted message. */
|
||||
| { ok: true; status: number; ts: string; threadTs?: string | null }
|
||||
| { ok: false; status: number; error?: string };
|
||||
|
||||
/** The longest Retry-After `postChatMessage` waits for inline. A 429 means Slack
|
||||
@@ -451,5 +453,10 @@ export async function postChatMessage(
|
||||
if (!ts) {
|
||||
return { ok: false, status: r.status, error: "chat.postMessage returned ok without a message ts" };
|
||||
}
|
||||
return { ok: true, status: r.status, ts };
|
||||
// #2234: Slack can accept a reply to a deleted thread root as a new top-level message, with no thread_ts.
|
||||
const message = (r.json as { message?: unknown }).message;
|
||||
if (!message || typeof message !== "object") return { ok: true, status: r.status, ts };
|
||||
const parent = (message as { thread_ts?: unknown }).thread_ts;
|
||||
const threadTs = typeof parent === "string" && parent.trim() && parent.trim() !== ts ? parent.trim() : null;
|
||||
return { ok: true, status: r.status, ts, threadTs };
|
||||
}
|
||||
@@ -74,6 +74,9 @@ export interface SubsystemSlackDeliveryOpts {
|
||||
* reaction on that message can reach them. A throw retains the
|
||||
* delivery like any receipt failure; the replay reconciles the message by marker and records it again. */
|
||||
onPostedPart?: (payload: OutboundPostPayload, messageTs: string, threadTs: string, channel: string) => void;
|
||||
/** #2234: Slack posted a reply to `threadTs` as a new top-level message, so that thread's root is gone. The message is
|
||||
* then recorded as a root through `onPostedRoot`. */
|
||||
onThreadMissing?: (payload: OutboundPostPayload, threadTs: string, channel: string) => void;
|
||||
/** OPR.0.5.6.14 — the transport-failure receipt hook: a failed post writes
|
||||
* the row's transport-failed ledger transition (class + API error), so a
|
||||
* delivery failure is as legible on the row as a success. `partlyPosted` marks an ask whose
|
||||
@@ -469,10 +472,14 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
|
||||
// write that throws after a successful post is a clean retained outcome
|
||||
// (never an escaped throw); the replay reconciles by marker — the message
|
||||
// IS in the channel — and retries the idempotent receipt without reposting.
|
||||
// #2234: record where Slack actually put the message, not where it was asked to. Without the posted message in its
|
||||
// answer, the requested placement stands.
|
||||
const placedThreadTs = res.threadTs === undefined ? threadTs : res.threadTs ?? undefined;
|
||||
try {
|
||||
if (threadTs === undefined) opts.onPostedRoot?.(q, res.ts, channel);
|
||||
else opts.onPostedPart?.(q, res.ts, threadTs, channel);
|
||||
opts.onPosted?.(q, res.ts, threadTs);
|
||||
if (threadTs !== undefined && placedThreadTs === undefined) opts.onThreadMissing?.(q, threadTs, channel);
|
||||
if (placedThreadTs === undefined) opts.onPostedRoot?.(q, res.ts, channel);
|
||||
else opts.onPostedPart?.(q, res.ts, placedThreadTs, channel);
|
||||
opts.onPosted?.(q, res.ts, placedThreadTs);
|
||||
} catch (e) {
|
||||
log(`receipt write FAILED after successful post for ${q.qitemId ?? decision.decisionId}: ${(e as Error).message} — retained; replay reconciles by marker and retries the idempotent receipt`);
|
||||
return { ok: false, class: "receipt-failed", detail: (e as Error).message };
|
||||
@@ -498,7 +505,7 @@ function deliverSinglePart(opts: SubsystemSlackDeliveryOpts, markEpisode = true)
|
||||
if (local && "skipped" in local) {
|
||||
log(`ATTACHMENT skipped for ${q.qitemId ?? decision.decisionId}: ${path.basename(String(q.evidenceRef))} ${local.skipped} (text delivered; attachment missing)`);
|
||||
} else if (local) {
|
||||
const intoThread = threadTs ?? res.ts;
|
||||
const intoThread = placedThreadTs ?? res.ts;
|
||||
const up = await getUploadURLExternal(opts.botToken, local.filename, local.bytes.length, opts.fetchImpl);
|
||||
if (up.ok && up.uploadUrl && up.fileId) {
|
||||
const put = await uploadBytesExternal(up.uploadUrl, local.bytes, opts.fetchImpl);
|
||||
@@ -772,7 +779,9 @@ export function subsystemSlackDeliver(opts: SubsystemSlackDeliveryOpts): Subsyst
|
||||
resolveChannel: index === 0
|
||||
? opts.resolveChannel
|
||||
: (part) => firstAttemptChannel(opts, opts.attempted.load(), partId(0)) ?? opts.resolveChannel?.(part) ?? opts.channel,
|
||||
onPostedRoot: index === 0 ? opts.onPostedRoot : undefined,
|
||||
// #2234: a later part has the primary's thread, so this fires only when Slack posted it top-level because that root was
|
||||
// deleted between parts. It then opens a root of its own for the same conversation, as a single part does.
|
||||
onPostedRoot: opts.onPostedRoot,
|
||||
onPosted: (_part, messageTs, threadTs) => {
|
||||
if (index !== 0) return;
|
||||
if (messageTs === "reconciled") throw new Error("Primary reconciliation has no Slack timestamp; multipart delivery remains incomplete.");
|
||||
|
||||
@@ -41,7 +41,7 @@ import { startSocketInbound, type SocketInboundHandle, type WsLike } from "./soc
|
||||
import { HUMAN_ALERT_TAG, loadHumanRegistry, resolveSlackHandle } from "../human-registry.js";
|
||||
import { sharedChannelAddresses, sharedChannelFor } from "../participants.js";
|
||||
import { hasLiveHumanGate, isBlockerLive, type QueueRepository } from "../../queue-repository.js";
|
||||
import { formatReplyToChoice, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js";
|
||||
import { formatReplyToChoice, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js";
|
||||
import { parseSessionName } from "../../session-name.js";
|
||||
import type { FetchImpl } from "./slack-api.js";
|
||||
import { ownerNotificationLevelAtLeast } from "../../queue-transition-log.js";
|
||||
@@ -112,11 +112,9 @@ export function makeHumanReplyResolver(
|
||||
// A park on a person by any address (a registered person's own address, as their Slack rows carry it, or a
|
||||
// legacy human seat) answers only from a reply written after its current question posted.
|
||||
if (source?.state === "blocked" && source.blockedOn && queueRepo.isPersonAddress(source.blockedOn)) {
|
||||
const episode = queueRepo.transitionLog.listForQitem(input.qitemId)
|
||||
.filter((transition) => transition.ownerNotificationKind === "human-required").at(-1);
|
||||
const episode = queueRepo.currentHumanRequiredEpisode(input.qitemId);
|
||||
if (!episode) return "not-applicable";
|
||||
const key = `${input.qitemId}:${episode.transitionId}`;
|
||||
const receipt = queueRepo.transitionLog.getOwnerNotificationReceipt(input.qitemId, key);
|
||||
const { key, receipt } = episode;
|
||||
// A final receipt attests delivery even when reconciliation lost its timestamp.
|
||||
// Without one, ONLY a unique exact current-episode primary proves anything.
|
||||
const primary = !receipt && home ? pendingPrimaryReceipt(home, input.qitemId, key) : null;
|
||||
@@ -611,6 +609,20 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
log(`reply stamp failed for ${p.qitemId}: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
// #2234: Slack posted a reply to this root as a new top-level message, so the root is gone. Its mapping is closed,
|
||||
// so later posts don't reuse it. A closed root still routes a reply to its seat.
|
||||
onThreadMissing: (p, threadTs, channel) => {
|
||||
threadMap.close(threadTs, channel);
|
||||
log(`thread ${threadTs} in ${channel} is gone: Slack posted the reply as a new message, so its mapping is closed`);
|
||||
// A --reply-to update's receipt says where it actually went; the recorded choice stays, for retries.
|
||||
if (threadReplyTo(p)) {
|
||||
try {
|
||||
opts.queueRepo.update({ qitemId: p.qitemId, actorSession: REPLY_TO_CHOICE_ACTOR, transitionNote: formatReplyToThreadMissing(threadTs) });
|
||||
} catch (e) {
|
||||
log(`reply-to placement for ${p.qitemId} not recorded: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
onTransportFailed: recordTransportFailed,
|
||||
onPosted: (p, messageTs, threadTs) => {
|
||||
// v3 digest branch: transport truth first — every member receipt is
|
||||
@@ -1021,6 +1033,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
seen: inboundSeen,
|
||||
deadLetter: dead,
|
||||
destination: cfg.inboundDestination,
|
||||
reactions: cfg.reactions === "off" ? "off" : "route",
|
||||
resolveSender: makeInboundSenderResolver(registry, opts.home),
|
||||
// S10 — deterministic thread routing: mapped thread → exactly the mapped seat; unmapped
|
||||
// or human-initiated → the configured orchestrator slot as an unrouted-signal row.
|
||||
|
||||
@@ -40,7 +40,7 @@ interface Deps {
|
||||
piRunner: (sessionName: string) => PiOccupantRecordInput["runner"];
|
||||
native: CurrentGenerationDeps;
|
||||
census: ProcessCensus;
|
||||
readAllPaneProcesses?: () => Promise<Map<string, { pid: number }> | null>;
|
||||
readAllPaneProcesses?: () => Promise<Map<string, { pid: number | null }> | null>;
|
||||
now?: () => number;
|
||||
}
|
||||
type InventoryNode = { nodeId: string; canonicalSessionName: string | null; runtime: string | null; sessionStatus: string | null };
|
||||
@@ -185,7 +185,7 @@ export class CurrentModelReader {
|
||||
} };
|
||||
const prepared = await Promise.all(nodes.map(n => this.prepare(n, native)));
|
||||
if (!prepared.some(p => "finish" in p)) return prepared as ModelObservation[];
|
||||
let after: Map<string, { pid: number }> | null = null;
|
||||
let after: Map<string, { pid: number | null }> | null = null;
|
||||
try { after = await this.deps.readAllPaneProcesses(); } catch { /* UNKNOWN, never a fallback subprocess storm. */ }
|
||||
return prepared.map(p => "finish" in p ? p.finish(after?.get(p.pane)?.pid ?? null, after === null ? "native-read-unavailable" : "pane-unavailable") : p);
|
||||
}
|
||||
|
||||
@@ -550,10 +550,26 @@ function rotationProcess(row: NativeProcessRow, rotation: ClaudeResumeRotation |
|
||||
export type NativeProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string; afterScript?: true };
|
||||
export type CodexProcessObservation = NativeProcessObservation;
|
||||
|
||||
/** The process still running the frozen `#!` script: the kernel starts it as `<interpreter> [one shebang option] <script>
|
||||
* args…`, so the script's path is its argv[1], or its argv[2] after a single option. */
|
||||
function runsFrozenScript(row: NativeProcessRow, selectedExecutable: string): boolean {
|
||||
const [, first, second] = tokens(row.command);
|
||||
return first === selectedExecutable || (first?.startsWith("-") === true && second === selectedExecutable);
|
||||
}
|
||||
|
||||
function nativeProcessCandidates(rows: NativeProcessRow[], panePid: number, runtime: NativeRuntime, selectedExecutable?: string, scriptLaunch?: boolean): NativeProcessObservation[] {
|
||||
const exact = candidatesMatching(rows, panePid, runtime, row => claudeProcess(row, selectedExecutable));
|
||||
if (exact.length > 0 || runtime !== "claude-code" || !selectedExecutable || scriptLaunch !== true) return exact;
|
||||
return candidatesMatching(rows, panePid, runtime, claudeProcessAfterScript).map(candidate => ({ ...candidate, afterScript: true as const }));
|
||||
const named = candidatesMatching(rows, panePid, runtime, claudeProcessAfterScript);
|
||||
if (named.length > 0) return named.map(candidate => ({ ...candidate, afterScript: true as const }));
|
||||
// #2341: a wrapper that starts Claude without `exec` keeps running the frozen script as the foreground parent, and
|
||||
// Claude can run under any name. That script's direct child naming a Claude conversation is the launched runtime;
|
||||
// the caller still requires the launch's own token, one candidate and a stable fingerprint.
|
||||
const byPid = new Map(rows.map(row => [row.pid, row]));
|
||||
return candidatesMatching(rows, panePid, runtime, row => {
|
||||
const parent = byPid.get(row.ppid);
|
||||
return !!parent && runsFrozenScript(parent, selectedExecutable) && claudeSessionToken(tokens(row.command).slice(1)) !== null;
|
||||
}).map(candidate => ({ ...candidate, afterScript: true as const }));
|
||||
}
|
||||
|
||||
function candidatesMatching(rows: NativeProcessRow[], panePid: number, runtime: NativeRuntime, claudeRow: (row: NativeProcessRow) => boolean): NativeProcessObservation[] {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -238,6 +238,8 @@ const CLAUDE_STATUS_WARNINGS = [
|
||||
/^Update available! Run: \S.*$/,
|
||||
// The context hint, as Claude prints it; reported as the last line below the mode bar on one seat.
|
||||
/^new task\? \/clear to save \d+(?:\.\d+)?k tokens$/,
|
||||
// After Claude installs an update: "/restart to apply" (a Claude seat, 2026-10-30) or "Restart to apply".
|
||||
/^✔ Update installed · (?:\/restart|Restart) to apply$/,
|
||||
];
|
||||
// Current Claude status rows need not end in "thinking)" or show "esc to interrupt".
|
||||
// Completed summaries such as "✻ Crunched for 2s" lack the live ellipsis/timer shape.
|
||||
@@ -457,7 +459,8 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
|
||||
// Claude replaces its mode hint after our paste; that noninteractive hint is not a new composer.
|
||||
const stableFooter = prompt.glyph === "❯" && (CLAUDE_MODE_FOOTER.test(footer) || isClaudePasteHint(footer)) ? "claude-composer" : footer;
|
||||
const frame = JSON.stringify([prompt.glyph, prefix, width, stableFooter]);
|
||||
const input: ComposerInput = { state: empty ? "empty" : "text", cursor: { ...snapshot.cursor }, suggestionRows, rows: visible, columns: width - prefix, cursorAtEnd, collapsedPaste: label, frame,
|
||||
const nativeCodex = prompt.glyph !== "❯" && codexFooterSeparator(lines) === end;
|
||||
const input: ComposerInput = { state: empty ? "empty" : "text", cursor: { ...snapshot.cursor }, suggestionRows, rows: visible, columns: width - prefix - (nativeCodex ? 1 : 0), cursorAtEnd, collapsedPaste: label, frame,
|
||||
...(ghost ? { ghost } : {}) };
|
||||
// A numbered body is allowed only as this operation's exact text in its
|
||||
// previously empty composer. A newly observed menu gains no such authority.
|
||||
@@ -467,6 +470,13 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
|
||||
input.collapsedPaste = visible.join("\n");
|
||||
input.pasteMatchesExpected = true;
|
||||
}
|
||||
if (nativeCodex) {
|
||||
// Codex 0.164 fullscreen: composer <= 2/3 of the pane (at least eight
|
||||
// rows, capped by the pane), less padding/status/hint rows. Leave the
|
||||
// one-column right inset out of the native printable width.
|
||||
input.pasteViewport = { columns: input.columns,
|
||||
rows: Math.min(height, Math.max(8, Math.floor(height * 2 / 3))) - 4 };
|
||||
}
|
||||
candidates.push(input);
|
||||
}
|
||||
return candidates.length === 1 ? candidates[0]! : unknown(candidates.length ? "ambiguous_composer" : "unrecognized_composer", snapshot.cursor);
|
||||
@@ -1730,6 +1740,8 @@ export interface SendOpts {
|
||||
onInputEffect?: (phase: "pasted" | "submitted") => void;
|
||||
/** Internal managed lifecycle prerequisite; never accepted from HTTP send options. */
|
||||
beforeWrite?: () => void;
|
||||
/** Internal: managed lifecycle input must preserve a person's draft even when the optional typing guard is off. */
|
||||
preserveInput?: true;
|
||||
/** The caller's request. Aborted while the send still waits, before any input starts, the send ends with
|
||||
* `caller_cancelled` and never writes, then or later. Once input may have started it is ignored: the result stays
|
||||
* attempted or written. Queue wakes and committed outbox deliveries have no caller and ignore it. */
|
||||
@@ -3193,6 +3205,15 @@ export class SessionTransport {
|
||||
const cancel = callerSignal(opts);
|
||||
if (cancel?.aborted) return observe({ ...callerCancelled(sessionName), ...(waitMode ? waitEvidence : {}) });
|
||||
const beforePaste = cancel ? () => { opts?.beforeWrite?.(); if (cancel.aborted) throw new DeliveryGuardError(CALLER_CANCELLED, "caller cancelled before the paste"); } : opts?.beforeWrite;
|
||||
const preserveInput = opts?.preserveInput ? async () => {
|
||||
const box = runtime === "claude-code" || runtime === "codex"
|
||||
? await this.captureInputBox(readTarget, runtime, { remainingMs: INPUT_BOX_READ_DEADLINE_MS }) : null;
|
||||
if (bindingChanged()) throw new DeliveryGuardError("draft_input_changed", "The managed input target changed; no input was written.");
|
||||
if (box?.status !== "empty" && !(box?.status === "likely-suggestion" && box.confidence === "high")) {
|
||||
throw new DeliveryGuardError(box?.status === "typed" ? "draft_input_busy" : "draft_input_unknown",
|
||||
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`);
|
||||
}
|
||||
} : undefined;
|
||||
// Set once the text write has been handed to the adapter: from then on a throw is not "nothing sent".
|
||||
let writeIssued = false;
|
||||
const tagIssued = (error: unknown): never => {
|
||||
@@ -3204,7 +3225,9 @@ export class SessionTransport {
|
||||
() => {
|
||||
opts?.beforeWrite?.();
|
||||
writeIssued = true;
|
||||
if (promptOverride) return this.tmuxAdapter.sendText(sessionName, text, beforePaste, { bracketed: false, beforePaste: overrideBeforePaste });
|
||||
if (promptOverride) return this.tmuxAdapter.sendText(sessionName, text, beforePaste, { bracketed: false,
|
||||
beforePaste: preserveInput ? async () => { await overrideBeforePaste?.(); await preserveInput(); } : overrideBeforePaste });
|
||||
if (preserveInput) return this.tmuxAdapter.sendText(sessionName, text, beforePaste, { beforePaste: preserveInput });
|
||||
return beforePaste ? this.tmuxAdapter.sendText(sessionName, text, beforePaste) : this.tmuxAdapter.sendText(sessionName, text);
|
||||
},
|
||||
(result) => result.ok ? "ok" : "failed",
|
||||
|
||||
@@ -552,9 +552,12 @@ export class StartupOrchestrator {
|
||||
|
||||
// 5. Launch harness (unless skipped for legacy nodes)
|
||||
// A community harness's seat: record the selection on this session before the app starts. It's provenance for
|
||||
// later reads (identity, activity, labels, restore), not launch success or identity proof.
|
||||
// later reads (identity, activity, labels, restore), not launch success or identity proof. Whether this launch's
|
||||
// preset declares a runner is recorded with it, so activity follows the occupant as launched, not a later edit:
|
||||
// only a harness launch samples it, and a continuation that launches nothing keeps what was recorded.
|
||||
if (input.adapter instanceof CommunityHarnessRuntimeAdapter) {
|
||||
try { this.sessionRegistry.recordCommunityHarnessSelection(input.sessionId, input.adapter.runtime); }
|
||||
try { this.sessionRegistry.recordCommunityHarnessSelection(input.sessionId, input.adapter.runtime,
|
||||
input.skipHarnessLaunch ? undefined : input.adapter.runner()); }
|
||||
catch (err) { errors.push(`Couldn't record the community harness selection: ${(err as Error).message}`); return this.fail(input, "failed", errors); }
|
||||
}
|
||||
|
||||
@@ -595,6 +598,13 @@ export class StartupOrchestrator {
|
||||
sessionId: input.sessionId, ...launchResult.firstInput });
|
||||
warnings.push("First startup input passed at launch; submission is not confirmed.");
|
||||
}
|
||||
if (launchResult.modelInput?.dispatch === "confirmed") {
|
||||
this.eventBus.emit({ type: "node.startup_model_passed", rigId: input.rigId, nodeId: input.nodeId,
|
||||
sessionId: input.sessionId, model: launchResult.modelInput.model, resume: !!launchResumeToken });
|
||||
warnings.push(`Model "${launchResult.modelInput.model}" passed to the launcher at launch; which model runs is not confirmed.`);
|
||||
} else if (launchResult.modelInput) {
|
||||
warnings.push(`Model "${launchResult.modelInput.model}": the launch command's send is uncertain, so it isn't recorded as passed.`);
|
||||
}
|
||||
if (launchResult.ok) {
|
||||
appliedLaunch = launchResult.appliedLaunch;
|
||||
resumedProcess = launchResult.resumedProcess;
|
||||
|
||||
@@ -85,7 +85,7 @@ export interface StagedLaunch {
|
||||
canonicalSession: string;
|
||||
/** Target materials (seat storage, projection, pre-launch files), before the harness starts; returns notes. */
|
||||
prepare: (binding: NodeBinding, adapter: RuntimeAdapter) => Promise<string[]>;
|
||||
binding?: Partial<Pick<NodeBinding, "seatSkillsRoot" | "claudeManagedBlockFile" | "runtimeSwitchFrom">>;
|
||||
binding?: Partial<Pick<NodeBinding, "seatSkillsRoot" | "claudeManagedBlockFile" | "runtimeSwitchFrom" | "accountHome" | "accountToken">>;
|
||||
}
|
||||
|
||||
export type SuccessorLaunchResult =
|
||||
|
||||
@@ -471,8 +471,8 @@ export class HerdrAdapter implements TerminalProvider {
|
||||
return this.deps.viewer?.lookup(key, pane) ?? { ok: false, code: "viewer_not_provisioned", error: "No qualified viewer mapping." };
|
||||
}
|
||||
|
||||
async followView(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>): Promise<OpenViewResult> {
|
||||
return this.deps.following?.follow(viewId, view, controls, stillCurrent)
|
||||
async followView(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>, membershipError?: string): Promise<OpenViewResult> {
|
||||
return this.deps.following?.follow(viewId, view, controls, stillCurrent, membershipError)
|
||||
?? { provider: "herdr", ok: false, opened: [], absent: [], degraded: [], pages: 0,
|
||||
code: "follow_unsupported", error: "This endpoint has no qualified following allocator." };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDefaultOpenRigPath } from "../../openrig-compat.js";
|
||||
import { HerdrViewer, findAttributedViewerPanes, readHerdrViewerBody as body, parseHerdrViewerPane as pane,
|
||||
import { HerdrViewer, ViewerCreateNotDispatchedError, findAttributedViewerPanes, readHerdrViewerBody as body, parseHerdrViewerPane as pane,
|
||||
type HerdrViewerRun, type InspectViewerSeat, type ViewerKey, type ViewerMapping, type ViewerPane } from "./herdr-viewer.js";
|
||||
import { DEFAULT_FOLLOW_POLICY, planFollowingTiles, type FollowControls, type FollowPolicy, type FollowTile, type FollowingStatus } from "./terminal-follow-policy.js";
|
||||
import type { ComposedPane, ComposedView, OpenViewResult } from "./terminal-provider.js";
|
||||
@@ -37,11 +37,11 @@ export class HerdrFollowView {
|
||||
private pending: Promise<unknown> = Promise.resolve();
|
||||
constructor(private readonly deps: HerdrFollowDeps) {}
|
||||
|
||||
follow(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>): Promise<OpenViewResult> {
|
||||
const result = this.pending.then(() => this.apply(viewId, view, controls, stillCurrent));
|
||||
follow(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>, membershipError?: string): Promise<OpenViewResult> {
|
||||
const result = this.pending.then(() => this.apply(viewId, view, controls, stillCurrent, membershipError));
|
||||
this.pending = result.catch(() => {}); return result;
|
||||
}
|
||||
private async apply(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>): Promise<OpenViewResult> {
|
||||
private async apply(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>, membershipError?: string): Promise<OpenViewResult> {
|
||||
const id = createHash("sha256").update(JSON.stringify([this.deps.socketPath, viewId])).digest("hex");
|
||||
const root = path.join(this.deps.root ?? getDefaultOpenRigPath("private/terminal-following/provider"), id);
|
||||
const file = path.join(root, "canvas.json"), log = path.join(root, "events.log");
|
||||
@@ -74,8 +74,9 @@ export class HerdrFollowView {
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
if (!controls.enabled) return result(true, "disabled"); // Keep the fixed canvas exactly as it is.
|
||||
if (!view?.task?.complete) {
|
||||
if (state) { notice("Task membership is incomplete; keeping the previous view."); save(); }
|
||||
return result(false, "retained", "Task membership is incomplete; keeping the previous view.");
|
||||
const message = membershipError ?? "Task membership is incomplete; keeping the previous view.";
|
||||
if (state) { notice(message); save(); }
|
||||
return result(false, "retained", message);
|
||||
}
|
||||
if ((this.deps.platform ?? process.platform) !== "linux") throw new Error("Following is qualified on Linux Herdr 0.9.5/protocol 22 only.");
|
||||
const status = body(await this.deps.run(["status", "server", "--json"]));
|
||||
@@ -84,7 +85,7 @@ export class HerdrFollowView {
|
||||
}
|
||||
if (!await stillCurrent()) throw new Error("Task membership changed before reconciliation; keeping the previous view.");
|
||||
if (!state) {
|
||||
state = { version: 1, cwd: path.join(root, "strip"), phase: "creating", policy: { ...DEFAULT_FOLLOW_POLICY, pins: [], suppressed: {} },
|
||||
state = { version: 1, cwd: path.join(root, "strip"), phase: "creating", policy: { ...DEFAULT_FOLLOW_POLICY, ...(view.task.kind === "slice" || view.task.kind === "mission" ? { maxTiles: 4 } : {}), pins: [], suppressed: {} },
|
||||
tiles: [], events: [`Following ${view.task.name}. Opening a tile can change an agent's terminal size.`] };
|
||||
mkdirSync(state.cwd, { recursive: true, mode: 0o700 }); save();
|
||||
// Persist first. An uncertain response is enumerated on the next call,
|
||||
@@ -160,9 +161,12 @@ export class HerdrFollowView {
|
||||
throw new Error(`${tile.seat}: Core's current attachment differs from the retained viewer; keeping the previous view unconfirmed.`);
|
||||
}
|
||||
}
|
||||
const current = await currentPane(tile.viewer);
|
||||
let current: ViewerPane;
|
||||
try { current = await currentPane(tile.viewer); }
|
||||
catch (error) { throw new Error(`${tile.seat}: unknown: ${error instanceof Error ? error.message : String(error)}`); }
|
||||
const verified = await viewers.lookup(tile.member, tile.target);
|
||||
if (!verified.ok || JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error("A retained viewer is unconfirmed; keeping the whole view.");
|
||||
if (!verified.ok) throw new Error(`${tile.seat}: ${verified.phase ?? "unknown"}: ${verified.error} Keeping the whole view.`);
|
||||
if (JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error(`${tile.seat}: Viewer mapping changed; keeping the whole view.`);
|
||||
observations.set(tile.key, { focused: current.focused!, input: await this.deps.input?.(tile.target) ?? "unknown" });
|
||||
}
|
||||
const plan = planFollowingTiles(state.tiles, members, true, observations, policy, now());
|
||||
@@ -173,19 +177,24 @@ export class HerdrFollowView {
|
||||
if (!target || !member.key.hostId || !member.key.nodeId) { notice(`${join.seat}: ${member.reason ?? "Terminal is unavailable; the member remains in this task."}`); continue; }
|
||||
const memberKey = { hostId: member.key.hostId, nodeId: member.key.nodeId };
|
||||
let verified = await viewers.ensure(memberKey, target, stillCurrent, async (cwd) => {
|
||||
await currentPane(state!.pane!);
|
||||
if (!await stillCurrent()) throw new Error("Membership changed before the split.");
|
||||
const anchor = state!.tiles.at(-1)?.viewer;
|
||||
if (anchor) await currentPane(anchor);
|
||||
try {
|
||||
await currentPane(state!.pane!);
|
||||
if (!await stillCurrent()) throw new Error("Membership changed before the split.");
|
||||
if (anchor) await currentPane(anchor);
|
||||
} catch (error) {
|
||||
throw new ViewerCreateNotDispatchedError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
const split = body(await this.deps.run(["pane", "split", "--pane", anchor?.paneId ?? state!.pane!.pane_id,
|
||||
"--direction", anchor && state!.tiles.length % 2 ? "right" : "down", "--no-focus", "--cwd", cwd]));
|
||||
return JSON.stringify({ root_pane: split.pane });
|
||||
});
|
||||
const originalFailure = !verified.ok ? `${verified.phase ?? "unknown"}: ${verified.error}` : undefined;
|
||||
for (let attempt = 0; !verified.ok && attempt < 5; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
verified = await viewers.lookup(memberKey, target); // read-only: never repeats a create/run.
|
||||
}
|
||||
if (!verified.ok) { notice(`${join.seat}: ${verified.error}`); continue; }
|
||||
if (!verified.ok) { notice(`${join.seat}: ${originalFailure}; confirmation: ${verified.phase ?? "unknown"}: ${verified.error}`); continue; }
|
||||
state.tiles.push({ key: join.key, seat: join.seat, target, member: { hostId: member.key.hostId, nodeId: member.key.nodeId }, viewer: verified.viewer });
|
||||
notice(`${join.seat}: ${join.reason}`); save();
|
||||
}
|
||||
@@ -252,13 +261,15 @@ export class HerdrFollowView {
|
||||
}
|
||||
for (const member of view.task.members) if (member.availability !== "available") notice(`${member.seat}: ${member.reason ?? member.availability}`);
|
||||
for (const overflow of plan.overflow) notice(`${overflow.seat}: ${overflow.reason}`);
|
||||
if (!members.length && !state.tiles.length) notice("No open obligation remains. Automatic following has stopped; the workspace is kept. Use --follow to resume it.");
|
||||
if (!members.length && !state.tiles.length) notice(view.task.kind === "qitem"
|
||||
? "No open obligation remains. Automatic following has stopped; the workspace is kept. Use --follow to resume it."
|
||||
: "No active work; waiting for associated work while following this slice.");
|
||||
save();
|
||||
const unavailableJoins = plan.joins.filter(join => !state!.tiles.some(tile => tile.key === join.key)
|
||||
&& view.task!.members.some(m => keyOf(m.key.hostId, m.key.nodeId) === join.key && m.availability === "available"));
|
||||
const answer = result(unavailableJoins.length === 0, members.length === 0 && state.tiles.length === 0 ? "empty" : "following",
|
||||
unavailableJoins.length ? "Some current members have unconfirmed viewers; see the explanation strip. Inspect before refreshing." : undefined, plan.nextAt);
|
||||
if (answer.following!.state === "empty") answer.following!.enabled = false;
|
||||
if (view.task.kind === "qitem" && answer.following!.state === "empty") answer.following!.enabled = false;
|
||||
answer.following!.overflow = plan.overflow;
|
||||
answer.following!.tiles = state.tiles.map(t => ({ key: t.key, seat: t.seat, departedAt: t.departedAt,
|
||||
...(plan.retained.find(r => r.key === t.key) ? { reason: plan.retained.find(r => r.key === t.key)!.reason } : {}) }));
|
||||
|
||||
@@ -10,9 +10,11 @@ import type { ComposedPane } from "./terminal-provider.js";
|
||||
export interface ViewerKey { hostId: string; nodeId: string }
|
||||
export interface ViewerMapping { workspaceId: string; paneId: string; terminalId: string; readOnly: boolean }
|
||||
export type ViewerResult = { ok: true; viewer: ViewerMapping; created: boolean }
|
||||
| { ok: false; code: string; error: string };
|
||||
| { ok: false; code: string; error: string; phase?: Entry["phase"] };
|
||||
export type InspectViewerSeat = (argv: string[]) => Promise<{ sessionId: string; nodeId: string }>;
|
||||
export type HerdrViewerRun = (argv: string[]) => Promise<string>;
|
||||
/** Only the caller which has not dispatched a create may assert this. */
|
||||
export class ViewerCreateNotDispatchedError extends Error {}
|
||||
export type CreateViewerPane = (cwd: string, label: string) => Promise<string>;
|
||||
export interface ViewerPane { workspace_id: string; pane_id: string; terminal_id: string; cwd: string; focused?: boolean }
|
||||
type Pane = ViewerPane;
|
||||
@@ -21,7 +23,6 @@ interface Entry {
|
||||
phase: "creating" | "created" | "starting"; pane?: Pane;
|
||||
}
|
||||
interface Journal { version: 1; entries: Record<string, Entry> }
|
||||
const unavailable = (error: string): ViewerResult => ({ ok: false, code: "viewer_unconfirmed", error });
|
||||
const runFile = promisify(execFile);
|
||||
|
||||
/** The supported CLI, never guessed socket RPC method parity. Its reported
|
||||
@@ -103,16 +104,18 @@ export class HerdrViewer {
|
||||
return findAttributedViewerPanes(this.run, entry.cwd);
|
||||
}
|
||||
private async ensureOnce(key: ViewerKey, target: ComposedPane, provision = true, stillCurrent: () => Promise<boolean> = async () => true, createPane?: CreateViewerPane): Promise<ViewerResult> {
|
||||
let entry: Entry | undefined;
|
||||
const unconfirmed = (error: string): ViewerResult => ({ ok: false, code: "viewer_unconfirmed", error, ...(entry ? { phase: entry.phase } : {}) });
|
||||
try {
|
||||
if (this.platform !== "linux") return { ok: false, code: "viewer_unsupported", error: "Ensure-viewer is qualified on Linux Herdr 0.9.5/protocol 22 only." };
|
||||
const status = body(await this.run(["status", "server", "--json"]));
|
||||
if (status.version !== "0.9.5" || status.protocol !== 22 || status.running !== true || status.socket !== this.socketPath) {
|
||||
return unavailable("The qualified Herdr version/protocol or selected socket was not confirmed.");
|
||||
return unconfirmed("The qualified Herdr version/protocol or selected socket was not confirmed.");
|
||||
}
|
||||
if (provision && !await stillCurrent()) return unavailable("Task membership changed while waiting to provision; refresh its preview.");
|
||||
if (!target.attachArgv) return unavailable("Only verified local tmux bindings support ensure-viewer.");
|
||||
if (provision && !await stillCurrent()) return unconfirmed("Task membership changed while waiting to provision; refresh its preview.");
|
||||
if (!target.attachArgv) return unconfirmed("Only verified local tmux bindings support ensure-viewer.");
|
||||
const binding = await this.inspect(target.attachArgv);
|
||||
if (binding.nodeId !== key.nodeId) return unavailable("The current tmux binding does not match this member’s node identity.");
|
||||
if (binding.nodeId !== key.nodeId) return unconfirmed("The current tmux binding does not match this member’s node identity.");
|
||||
if (provision) mkdirSync(this.root, { recursive: true, mode: 0o700 });
|
||||
const endpoint = createHash("sha256").update(this.socketPath).digest("hex");
|
||||
const file = path.join(this.root, `${endpoint}.json`);
|
||||
@@ -122,36 +125,49 @@ export class HerdrViewer {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
journal = { version: 1, entries: {} };
|
||||
}
|
||||
if (journal.version !== 1 || !journal.entries || typeof journal.entries !== "object" || Array.isArray(journal.entries)) return unavailable("Viewer provenance is unreadable; no provider mutation was attempted.");
|
||||
if (journal.version !== 1 || !journal.entries || typeof journal.entries !== "object" || Array.isArray(journal.entries)) return unconfirmed("Viewer provenance is unreadable; no provider mutation was attempted.");
|
||||
const save = () => {
|
||||
const tmp = `${file}.${randomUUID()}.tmp`;
|
||||
writeFileSync(tmp, `${JSON.stringify(journal)}\n`, { mode: 0o600 }); renameSync(tmp, file);
|
||||
};
|
||||
const id = JSON.stringify([key.hostId, key.nodeId]);
|
||||
let entry = journal.entries[id];
|
||||
entry = journal.entries[id];
|
||||
let created = false;
|
||||
if (entry && (entry.command !== target.paneCommand || entry.readOnly !== target.readOnly)) {
|
||||
return unavailable("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
|
||||
return unconfirmed("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
|
||||
}
|
||||
if (!entry) {
|
||||
if (!provision) return { ok: false, code: "viewer_not_provisioned", error: "No owned viewer has been provisioned for this member." };
|
||||
if (Object.keys(journal.entries).length >= 256) return unavailable("This endpoint has 256 retained viewer records; inspect its viewer provenance before adding more.");
|
||||
if (Object.keys(journal.entries).length >= 256) return unconfirmed("This endpoint has 256 retained viewer records; inspect its viewer provenance before adding more.");
|
||||
const token = randomUUID();
|
||||
const cwd = path.join(this.root, endpoint, token); mkdirSync(cwd, { recursive: true, mode: 0o700 });
|
||||
entry = { cwd, label: `openrig-viewer-${token}`, command: target.paneCommand, argv: target.attachArgv, readOnly: target.readOnly, phase: "creating" };
|
||||
journal.entries[id] = entry; save();
|
||||
// No retry here. A timeout is reconciled by enumeration on the next request.
|
||||
const reply = body(await (createPane ? createPane(cwd, entry.label)
|
||||
: this.run(["workspace", "create", "--cwd", cwd, "--label", entry.label, "--no-focus"])));
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await (createPane ? createPane(cwd, entry.label)
|
||||
: this.run(["workspace", "create", "--cwd", cwd, "--label", entry.label, "--no-focus"]));
|
||||
} catch (error) {
|
||||
if (createPane && error instanceof ViewerCreateNotDispatchedError) {
|
||||
// This invocation positively refused BEFORE dispatch, not an empty
|
||||
// enumeration or a timeout after a possible provider effect.
|
||||
delete journal.entries[id]; save();
|
||||
try { rmdirSync(cwd); } catch { /* An unused directory must not prevent a later join. */ }
|
||||
return unconfirmed(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const reply = body(raw);
|
||||
const made = pane(reply.root_pane);
|
||||
if (!made || made.cwd !== cwd) return unavailable("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
|
||||
if (!made || made.cwd !== cwd) return unconfirmed("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
|
||||
entry.pane = made; entry.phase = "created"; save(); created = true;
|
||||
}
|
||||
if (!entry.pane) {
|
||||
// A unique private cwd attributes a previously initiated create, not a
|
||||
// caller ID or mutable display label. Empty/unreadable is still unknown.
|
||||
const found = await this.ownedPanes(entry);
|
||||
if (found.length !== 1) return unavailable("Uncertain viewer creation has no unique private-cwd mapping.");
|
||||
if (found.length !== 1) return unconfirmed("Uncertain viewer creation has no unique private-cwd mapping.");
|
||||
entry.pane = found[0]!; entry.phase = "created"; if (provision) save();
|
||||
}
|
||||
let current: Pane | null;
|
||||
@@ -160,13 +176,13 @@ export class HerdrViewer {
|
||||
if (!current || current.pane_id !== entry.pane.pane_id || current.terminal_id !== entry.pane.terminal_id
|
||||
|| current.workspace_id !== entry.pane.workspace_id || current.cwd !== entry.cwd) {
|
||||
if (!provision || (await this.ownedPanes(entry)).length !== 0) {
|
||||
return unavailable("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
|
||||
return unconfirmed("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
|
||||
}
|
||||
// Only a complete empty readback retires provenance. Remove our empty
|
||||
// attribution directory, never provider panes or arbitrary journal paths.
|
||||
if (!/^openrig-viewer-[0-9a-f-]{36}$/.test(entry.label)
|
||||
|| entry.cwd !== path.join(this.root, endpoint, entry.label.slice("openrig-viewer-".length))) {
|
||||
return unavailable("Absent viewer provenance has no owned attribution directory.");
|
||||
return unconfirmed("Absent viewer provenance has no owned attribution directory.");
|
||||
}
|
||||
try { rmdirSync(entry.cwd); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
@@ -174,8 +190,10 @@ export class HerdrViewer {
|
||||
return { ok: false, code: "viewer_not_provisioned", error: "The old viewer is confirmed absent; ensure again to create a verified replacement." };
|
||||
}
|
||||
if (entry.phase === "created") {
|
||||
if (!provision) return unavailable("The created viewer process is not yet confirmed.");
|
||||
if (!await stillCurrent()) return unavailable("Task membership changed before the viewer process started.");
|
||||
if (!provision) return unconfirmed("The created viewer process is not yet confirmed.");
|
||||
if (!await stillCurrent()) return unconfirmed("Task membership changed before the viewer process started.");
|
||||
const beforeRun = await this.inspect(target.attachArgv);
|
||||
if (beforeRun.nodeId !== key.nodeId || beforeRun.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed before the viewer process started.");
|
||||
entry.phase = "starting"; save();
|
||||
// pane run rc0/empty stdout is not proof. It is sent ONCE then checked.
|
||||
await this.run(["pane", "run", current.pane_id, entry.command]);
|
||||
@@ -195,11 +213,11 @@ export class HerdrViewer {
|
||||
&& (actual[expected.length] === `=${targetName}` || actual[expected.length] === binding.sessionId);
|
||||
});
|
||||
const afterBinding = await this.inspect(target.attachArgv);
|
||||
if (afterBinding.nodeId !== key.nodeId || afterBinding.sessionId !== binding.sessionId) return unavailable("The seat binding changed while provisioning its viewer.");
|
||||
if (!verified) return unavailable("The viewer process is not confirmed; no pane run was repeated.");
|
||||
if (afterBinding.nodeId !== key.nodeId || afterBinding.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed while provisioning its viewer.");
|
||||
if (!verified) return unconfirmed("The viewer process is not confirmed; no pane run was repeated.");
|
||||
return { ok: true, viewer: { workspaceId: current.workspace_id, paneId: current.pane_id, terminalId: current.terminal_id, readOnly: entry.readOnly }, created };
|
||||
} catch (error) {
|
||||
return unavailable(`Viewer effect is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return unconfirmed(`Viewer effect is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { parseFrontmatter, type SliceIndexer } from "../slices/slice-indexer.js";
|
||||
import { parseScopeTags } from "../slices/qitem-membership.js";
|
||||
import { belongsToProject } from "../workspace/project-catalog.js";
|
||||
import { NODE_FILE_PRECEDENCE } from "../scope/node-file.js";
|
||||
import { projectTaskOwners, type TaskSeat, type TaskTerminalView } from "./task-terminal-view.js";
|
||||
|
||||
export interface ScopeViewError { code: "scope_unsupported" | "scope_not_found" | "scope_ambiguous" | "scope_unavailable"; error: string }
|
||||
type IndexContext = Pick<SliceIndexer, "slicesRoot" | "additionalSliceRoots" | "projectId">;
|
||||
interface Scope { name: string; id?: string; title: string; source: string; mission?: string; missionSource?: string }
|
||||
const MAX_ENTRIES = 4096, MAX_HEADER_BYTES = 16384, MAX_SOURCE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_ROWS = 50000, MAX_TAG_BYTES = 16384, MAX_TOTAL_TAG_BYTES = 8 * 1024 * 1024;
|
||||
class ScopeLimit extends Error {}
|
||||
const unavailable = (error: string): ScopeViewError => ({ code: "scope_unavailable", error });
|
||||
|
||||
/** Strict bounded identity read over the configured index roots. The review
|
||||
* listing's first-match and unreadable-as-empty fallbacks are not authority
|
||||
* for retiring viewers. No inferred project, substring match or remote I/O. */
|
||||
function readScopeIndex(index: IndexContext): Scope[] | ScopeViewError {
|
||||
let entries = 0, bytes = 0;
|
||||
const scopes: Scope[] = [], seenRoots = new Set<string>(), seenScopes = new Map<string, Scope>();
|
||||
const list = (dir: string, visit: (name: string) => void) => {
|
||||
const handle = fs.opendirSync(dir);
|
||||
try {
|
||||
for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
|
||||
if (++entries > MAX_ENTRIES) throw new ScopeLimit(`Scope index exceeds its ${MAX_ENTRIES}-entry work limit.`);
|
||||
if (entry.isDirectory() && !entry.name.startsWith(".")) visit(entry.name);
|
||||
}
|
||||
} finally { handle.closeSync(); }
|
||||
};
|
||||
const add = (dir: string, name: string, mission?: string, missionSource?: string) => {
|
||||
const source = fs.realpathSync(dir), existing = seenScopes.get(source);
|
||||
if (existing) {
|
||||
if (missionSource && !existing.missionSource) { existing.mission = mission; existing.missionSource = missionSource; }
|
||||
return;
|
||||
}
|
||||
let raw = "";
|
||||
for (const file of NODE_FILE_PRECEDENCE) {
|
||||
let fd: number;
|
||||
try { fd = fs.openSync(path.join(dir, file), "r"); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
||||
try {
|
||||
const buffer = Buffer.alloc(MAX_HEADER_BYTES);
|
||||
const size = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
||||
bytes += size;
|
||||
if (bytes > MAX_SOURCE_BYTES) throw new ScopeLimit("Scope index exceeds its 8-MiB source-byte work limit.");
|
||||
raw = buffer.subarray(0, size).toString("utf8");
|
||||
if (raw.startsWith("---") && !raw.slice(3).includes("\n---")) {
|
||||
if (size === MAX_HEADER_BYTES) throw new ScopeLimit("Scope frontmatter exceeds its 16-KiB header work limit.");
|
||||
throw new Error("Unterminated scope frontmatter.");
|
||||
}
|
||||
} finally { fs.closeSync(fd); }
|
||||
break; // SPEC.md wins; an unreadable selected node never falls back.
|
||||
}
|
||||
const fields = parseFrontmatter(raw);
|
||||
const id = typeof fields.id === "string" && fields.id ? fields.id : undefined;
|
||||
const title = typeof fields.title === "string" && fields.title ? fields.title
|
||||
: typeof fields.slice === "string" && fields.slice ? fields.slice : raw.match(/^# (.+)$/m)?.[1]?.trim() || name;
|
||||
const scope = { name, id, title, source, mission, missionSource };
|
||||
seenScopes.set(source, scope); scopes.push(scope);
|
||||
};
|
||||
try {
|
||||
for (const [position, root] of [index.slicesRoot, ...index.additionalSliceRoots].entries()) {
|
||||
let real: string;
|
||||
try { real = fs.realpathSync(root); }
|
||||
catch (error) {
|
||||
// Explicit legacy compatibility roots may not exist. The primary
|
||||
// configured index and every existing root must remain readable.
|
||||
if (position > 0 && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
||||
throw error;
|
||||
}
|
||||
if (seenRoots.has(real)) continue;
|
||||
seenRoots.add(real);
|
||||
list(root, name => {
|
||||
const dir = path.join(root, name), nested = path.join(dir, "slices");
|
||||
let isMission = false;
|
||||
try { isMission = fs.statSync(nested).isDirectory(); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
if (isMission) {
|
||||
const missionSource = fs.realpathSync(dir);
|
||||
list(nested, child => add(path.join(nested, child), child, name, missionSource));
|
||||
}
|
||||
else add(dir, name);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return unavailable(error instanceof ScopeLimit ? `${error.message} This index is too large for this bounded read; use a smaller configured index or a qitem view.`
|
||||
: "Scope index sources are unavailable or malformed; inspect the configured sources and refresh the view.");
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
function selectSlice(scopes: Scope[], key: string): Scope | ScopeViewError {
|
||||
if (!key || key.trim() !== key || /[\\/\x00-\x1f\x7f]/.test(key)) return { code: "scope_not_found", error: "Invalid slice name or id." };
|
||||
const matches = scopes.filter(scope => scope.name === key || scope.id === key);
|
||||
if (!matches.length) return { code: "scope_not_found", error: `No indexed slice exactly matches '${key}'.` };
|
||||
if (matches.length !== 1) return { code: "scope_ambiguous", error: `Multiple indexed slices match '${key}'; choose an unambiguous scope.` };
|
||||
const selected = matches[0]!;
|
||||
// Flat legacy slices have no mission context to place a colliding alias.
|
||||
if (!selected.missionSource && scopes.some(other => other !== selected && (other.name === selected.name || other.id === selected.name
|
||||
|| (selected.id && (other.id === selected.id || other.name === selected.id))))) {
|
||||
return { code: "scope_ambiguous", error: `Slice '${key}' has a colliding name or id association.` };
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
/** Active typed-tag owners only, with one bounded local queue projection.
|
||||
* A cap or malformed record is incomplete, never authoritative empty. */
|
||||
export function readScopedTaskTerminalView(db: Database.Database, view: string, index: IndexContext,
|
||||
resolveSeat: (address: string) => TaskSeat | null, readIndex = () => readScopeIndex(index)): TaskTerminalView | ScopeViewError {
|
||||
if (!view.startsWith("slice:")) return { code: "scope_unsupported", error: "Authoritative following for this scope kind is not implemented." };
|
||||
const identities = readIndex();
|
||||
if ("code" in identities) return identities;
|
||||
const scope = selectSlice(identities, view.slice(6));
|
||||
if ("code" in scope) return scope;
|
||||
// At most two linear index passes per resolve, never a scan per queue row.
|
||||
const claims = new Map([...new Set([scope.name, scope.id].filter((key): key is string => !!key))].map(key => {
|
||||
const candidates = identities.filter(candidate => candidate.name === key || candidate.id === key);
|
||||
const byMission = new Map<string, Scope[]>();
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.mission) continue;
|
||||
const sameMission = byMission.get(candidate.mission) ?? [];
|
||||
sameMission.push(candidate); byMission.set(candidate.mission, sameMission);
|
||||
}
|
||||
return [key, { count: candidates.length, byMission }] as const;
|
||||
}));
|
||||
return db.transaction(() => {
|
||||
const issues = new Set<string>();
|
||||
const selected: Array<{ qitem_id: string; destination_session: string; state: string }> = [];
|
||||
const tagSize = "coalesce(length(CAST(tags AS BLOB)),0)";
|
||||
const rows = db.prepare(`SELECT qitem_id,destination_session,state,${tagSize} AS tag_bytes,
|
||||
CASE WHEN ${tagSize}<=${MAX_TAG_BYTES} THEN tags ELSE NULL END AS tags
|
||||
FROM queue_items INDEXED BY idx_queue_items_state WHERE state IN ('pending','in-progress','blocked') LIMIT ?`)
|
||||
.iterate(MAX_ROWS + 1) as Iterable<{ qitem_id: string; destination_session: string; state: string; tags: string | null; tag_bytes: number }>;
|
||||
let scanned = 0, bytes = 0;
|
||||
for (const row of rows) {
|
||||
if (++scanned > MAX_ROWS || (bytes += row.tag_bytes) > MAX_TOTAL_TAG_BYTES) {
|
||||
issues.add("Scoped queue enumeration reached its row/encoded-byte work limit; membership is incomplete."); break;
|
||||
}
|
||||
if (row.tag_bytes > MAX_TAG_BYTES) { issues.add("Some active scope tags exceed their per-record work limit; membership is incomplete."); continue; }
|
||||
let tags: unknown;
|
||||
try { tags = row.tags === null ? [] : JSON.parse(row.tags); } catch { tags = null; }
|
||||
if (!Array.isArray(tags) || tags.some(tag => typeof tag !== "string")) {
|
||||
issues.add("Some active scope tags are malformed; membership is incomplete."); continue;
|
||||
}
|
||||
if (index.projectId && !belongsToProject(row.tags, index.projectId)) continue;
|
||||
const parsed = parseScopeTags(row.tags);
|
||||
let here = false, elsewhere = false, unplaced = false;
|
||||
const mission = parsed.missions.size === 1 ? parsed.missions.values().next().value : undefined;
|
||||
for (const [key, claim] of claims) {
|
||||
if (!parsed.slices.has(key)) continue;
|
||||
if (claim.count === 1) { here = true; continue; }
|
||||
// A reused name/id requires exactly one mission tag and one indexed
|
||||
// claimant in that mission. Never assign it by first-match or absence.
|
||||
const placed = mission === undefined ? [] : claim.byMission.get(mission) ?? [];
|
||||
if (placed.length !== 1) { unplaced = true; continue; }
|
||||
if (placed[0]!.source === scope.source) here = true; else elsewhere = true;
|
||||
}
|
||||
if (unplaced || (here && elsewhere)) {
|
||||
issues.add(`Queue row '${row.qitem_id}' has unplaceable or contradictory colliding slice tags; membership is incomplete.`);
|
||||
} else if (here) selected.push(row);
|
||||
}
|
||||
selected.sort((a, b) => a.qitem_id.localeCompare(b.qitem_id));
|
||||
return projectTaskOwners({ kind: "slice", id: scope.id ?? scope.name, name: scope.title,
|
||||
complete: issues.size === 0, issues: [...issues] }, selected, resolveSeat);
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
/** Cache identities only under live primary-root notification coverage.
|
||||
* Queue ownership is always read anew. Missing legacy roots are checked on
|
||||
* every call; an appearing/unwatched root disables generation reuse. */
|
||||
export function createScopedTaskResolver(db: Database.Database, index: IndexContext,
|
||||
resolveSeat: (address: string) => TaskSeat | null, observed: () => boolean) {
|
||||
let cached: Scope[] | undefined;
|
||||
return {
|
||||
invalidate() { cached = undefined; },
|
||||
resolve(view: string): TaskTerminalView | ScopeViewError {
|
||||
let reusable = false;
|
||||
try {
|
||||
const primary = fs.realpathSync(index.slicesRoot);
|
||||
reusable = observed() && index.additionalSliceRoots.every(root => {
|
||||
try { const real = fs.realpathSync(root); return real === primary || real.startsWith(primary + path.sep); }
|
||||
catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
|
||||
});
|
||||
} catch { /* Unconfirmed coverage always takes a fresh bounded read. */ }
|
||||
if (!reusable) cached = undefined;
|
||||
try {
|
||||
const result = readScopedTaskTerminalView(db, view, index, resolveSeat, () => {
|
||||
if (cached) return cached;
|
||||
const next = readScopeIndex(index);
|
||||
if (reusable && Array.isArray(next)) cached = next;
|
||||
return next;
|
||||
});
|
||||
if ("code" in result) cached = undefined;
|
||||
return result;
|
||||
} catch (error) { cached = undefined; throw error; }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,10 @@ export interface TaskTerminalMember {
|
||||
viewer?: import("./herdr-viewer.js").ViewerMapping;
|
||||
}
|
||||
export interface TaskMembership {
|
||||
qitemId: string;
|
||||
kind: "qitem" | "slice" | "mission";
|
||||
id: string;
|
||||
/** Present only for a genuine queue episode. */
|
||||
qitemId?: string;
|
||||
name: string;
|
||||
complete: boolean;
|
||||
issues: string[];
|
||||
@@ -160,6 +163,16 @@ function readTaskTerminalSnapshot(
|
||||
};
|
||||
for (const row of selected) visit(row.qitem_id);
|
||||
|
||||
return projectTaskOwners({ kind: "qitem", id: qitemId, qitemId, name: selected[0]!.summary?.trim() || qitemId,
|
||||
complete: issues.length === 0, issues: [...new Set(issues)] }, selected, resolveSeat);
|
||||
}
|
||||
|
||||
/** Shared owner/availability fold; the caller owns authoritative work enumeration. */
|
||||
export function projectTaskOwners(
|
||||
task: Omit<TaskMembership, "members">,
|
||||
selected: readonly { qitem_id: string; destination_session: string; state: string }[],
|
||||
resolveSeat: (address: string) => TaskSeat | null,
|
||||
): TaskTerminalView {
|
||||
const owners = new Map<string, TaskTerminalMember>();
|
||||
const inputs = new Map<string, ViewMemberInput>();
|
||||
const resolved = new Map<string, TaskSeat | null>();
|
||||
@@ -189,7 +202,7 @@ function readTaskTerminalSnapshot(
|
||||
});
|
||||
}
|
||||
const members = [...owners.values()].sort((a, b) => JSON.stringify(a.key).localeCompare(JSON.stringify(b.key)) || a.seat.localeCompare(b.seat));
|
||||
return { task: { qitemId, name: selected[0]!.summary?.trim() || qitemId, complete: issues.length === 0, issues: [...new Set(issues)], members }, members: [...inputs.values()] };
|
||||
return { task: { ...task, members }, members: [...inputs.values()] };
|
||||
}
|
||||
|
||||
/** Preserve local stopped/unlaunched owners as well as current registry aliases.
|
||||
|
||||
@@ -25,14 +25,14 @@ export class TerminalFollowing {
|
||||
const saved = JSON.parse(readFileSync(file, "utf8")) as { version: number; selections: Selection[] };
|
||||
if (saved.version !== 1 || !Array.isArray(saved.selections) || saved.selections.length > 64
|
||||
|| saved.selections.some(s => typeof s.enabled !== "boolean" || s.selector?.provider !== "herdr"
|
||||
|| typeof s.selector.view !== "string" || !s.selector.view.startsWith("qitem:")
|
||||
|| typeof s.selector.view !== "string" || !/^(qitem|slice):.+/.test(s.selector.view)
|
||||
|| (s.selector.session !== undefined && typeof s.selector.session !== "string"))) throw new Error("Invalid following selectors.");
|
||||
for (const selected of saved.selections) this.selections.set(this.key(selected.selector), selected);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") this.loadError = "Saved following selectors are unreadable; their file was preserved.";
|
||||
}
|
||||
this.unsubscribe = bus.subscribe(event => {
|
||||
if (/^(queue|session|node)\./.test(event.type)) this.changed();
|
||||
if (/^(queue|session|node)\./.test(event.type) || event.type === "scope.sources_changed") this.changed();
|
||||
});
|
||||
if (this.selections.size) this.changed();
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export class TerminalFollowing {
|
||||
// A complete empty task has finished. Keep its provider workspace, but
|
||||
// retire automatic work and release the active-selector slot. Explicit
|
||||
// --follow can resume the same journaled workspace if work returns.
|
||||
if (result.ok && result.following?.state === "empty") {
|
||||
if (selected.selector.view.startsWith("qitem:") && result.ok && result.following?.state === "empty") {
|
||||
this.selections.delete(key); selected.enabled = false;
|
||||
result.following.enabled = false;
|
||||
this.save();
|
||||
|
||||
@@ -149,5 +149,5 @@ export interface TerminalProvider {
|
||||
openView(view: ComposedView): Promise<OpenViewResult>;
|
||||
ensureViewer?(key: ViewerKey, pane: ComposedPane, stillCurrent?: () => Promise<boolean>): Promise<ViewerResult>;
|
||||
lookupViewer?(key: ViewerKey, pane: ComposedPane): Promise<ViewerResult>;
|
||||
followView?(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>): Promise<OpenViewResult>;
|
||||
followView?(viewId: string, view: ComposedView | null, controls: FollowControls, stillCurrent: () => Promise<boolean>, membershipError?: string): Promise<OpenViewResult>;
|
||||
}
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user