Compare commits

..
Author SHA1 Message Date
dev-guard 71389bef06 test(daemon): cover complete message ownership at native Codex width 2026-10-28 21:39:36 +00:00
dev-guard ebf20ae616 fix(daemon): refine protected Codex paste admission and recovery 2026-10-28 21:35:18 +00:00
dev-guard 4c14ca76c4 test(daemon): cover native Codex paste limits and fitting envelopes 2026-10-28 21:35:17 +00:00
dev-guard 682bede406 fix(daemon): retain protected Codex messages before an unobservable paste 2026-10-28 21:13:18 +00:00
dev-guard db45c6bcb8 test(daemon): cover Codex paste visibility before input 2026-10-28 21:13:18 +00:00
Mike Schwarz 03a2828ce7 fix(daemon): keep prompt alerts routable with nonlocal senders (#2237)
When a seat sits at a choice prompt and its row came from another host, the alert about it now still reaches someone, and one such row no longer stops the whole pass. The wake ladder used the row's original sender as a local destination for the alert's carrier row; a sender on another host failed local destination validation and threw, aborting the shared prompt pass, and the same choice in the ordinary ladder failed the whole tick. A shared carrier choice now picks an admissible configured operator or the original sender, otherwise the blocked seat itself, at the prompt pass, its consumed-hop continuation, and the ordinary ladder's continuation and operator-floor alert. The carrier is never nudged, the original sender stays as the row's source, and the existing human-alert policy (including explicit-only) is unchanged.

3 files: source (queue-wake-ladder.ts, +13 -6) and tests (+51). No new refusal, error code, timer or setting.
2026-10-28 13:47:57 -07:00
Mike Schwarz c57f0173ab feat(tui): open the terminal scope being viewed (#2232)
The TUI's terminal button now opens the scope you're looking at: an agent's page opens just that agent, a pod's page gets an "Open terminals ▸ pod <pod>" button, and the rig's page opens the rig. An agent row's term link and the attention card's chat line now open that one seat instead of its whole pod. The daemon gains a seat:<rig>/<logical-id> terminal view beside pod:, built from the rig's own seat list; an unknown or malformed seat gets the existing view_not_found error, and rig terminal open's help lists the pod: and seat: forms.

11 files: daemon terminal view (+18 -1) with its test (+21), TUI render, attention and link styling, CLI help, and TUI tests; +112 -24 in total. No new refusal or error code.
2026-10-28 13:37:52 -07:00
Mike Schwarz 72086329ae docs: v0.7.4 install pins, current release-notes steps, and openrig-operator references routed to the help guide (#2238)
The README, getting-started and quickstart now install and add skills from the current release, v0.7.4, instead of v0.7.3. docs/releases/README.md says it holds the per-release notes for v0.1.12 through v0.6.3 plus v0.6.12, points to CHANGELOG.md and GitHub Releases for later ones, and its release steps match how releases are published now. Shipped skills and the kernel culture no longer route to a skill that isn't shipped: rig-lifecycle points to rig context get help, the kernel culture's restart case points to getting-started's "Incomplete setup and restart", and the dead sibling entries are dropped. Both skill copies stay identical and their digests are updated.

12 files, +28 -56: docs, skill text and the digest file only; no code change.
2026-10-28 13:31:55 -07:00
Mike Schwarz 8684c981a6 fix(daemon): retry original queue wakes after prompt clear (#2235)
A queue item created while its recipient sits at a selection prompt now gets its original wake retried once the prompt clears, even after a daemon restart or when an idle hook masked the pane's attention state. A definite no-write prompt refusal arms recovery; the first positive clear can retry the durable failed wake intents, reusing the same row and frozen wake body. Delivered and indeterminate wakes are never re-sent. Each clear pass holds per-row reservations, so overlapping passes can't release each other's rows or re-arm a refusal. For #2219.

3 files: source (queue-repository.ts, startup.ts; +44 -16) and tests (+107 -5). No new refusal, error code, timer or schema change.
2026-10-28 12:57:05 -07:00
Mike Schwarz e5c4cba385 perf(inventory): reuse node identity for activity reads (#2236)
Node inventory now reads each node's activity by the node ID it already has, alongside the current session name, instead of resolving the name back to a node, and attention enrichment no longer repeats a hook read whose result was discarded. A newer same-named session on another node can no longer supply this node's activity or attention state. No-session rows and the standalone attention fallback keep their behavior. In one matched measurement (15 rigs, 75 nodes) the change removed 900 SQL calls per command and cut the CLI median from about 1,028 ms to 622 ms; no general speedup is claimed.

3 files: source +6 -5, tests +71 -1. No cache, schema or API change.
2026-10-28 12:50:17 -07:00
Jay 10b5f155b3 fix(daemon): send wake-ladder retries as OpenRig and pass workflow entry sender (#2229)
Queue wakes from the daemon no longer arrive as "From: <unknown sender>". A wake-ladder retry now names OpenRig (daemon@kernel) as its sender and keeps the row's own source visible in the body ("Row source: <seat>."), and a workflow's first entry wake names the session that started the workflow. Retry timing, caps and admission are unchanged. For #2223.

4 files: source (queue-wake-ladder.ts, workflow-runtime.ts; +7 -2) and tests (+54 -3). No new refusal.
2026-10-28 12:27:11 -07:00
Brad Vincent 5700c6c2ff fix(daemon): preserve OS argv boundaries in Codex identity checks (#2212)
Codex resume identity stays correct when a supported profile or working directory contains spaces. The daemon now reads each foreground Codex process's exact arguments from the OS (Linux's existing /proc sample, bounded macOS KERN_PROCARGS2) instead of splitting flattened ps text, so a valid managed resume isn't rejected and a UUID inside an option value isn't taken for the resumed conversation. Resume and fork proof, restart in place, permission recovery, effort and claim still require exact arguments, and report unknown or unverified when they can't be read. Ordinary sends, queue nudges and watchdog wakes still reach a stable foreground Codex whose arguments can't be read, without treating it as identity proof.

35 files: 9 daemon source files (+286 -52) and 26 tests and helpers (+639 -147). No new public refusal code, migration, dependency or public API change. The macOS argument helper runs once per foreground sample, uncached.
2026-10-28 12:06:34 -07:00
Mike Schwarz d44cf3199a fix(daemon): a restore keeps the model saved with rig seat set-model (#2228)
A seat whose model was changed with `rig seat set-model` keeps that model when it's launched from a snapshot restore: restore now reads the seat's one saved model, as the live profile already does, for both the resume and the fresh restore paths, instead of the snapshot's older model. A seat with no saved model still gets the snapshot's. For #2224.

2 files: source and tests (restore-orchestrator.ts and its end-to-end test).
2026-10-28 11:47:58 -07:00
Mike Schwarz c2378fa315 fix(daemon): rig expand keeps a member's explicit effort (#2227)
`rig expand` with an explicit effort for a new member now keeps it, instead of silently launching at the default: the expansion route and the expansion spec both carry the member's effort as written, and the canonical RigSpec schema still trims a text effort and ignores any other value with an advisory, as before. No new refusal.

4 files: source +5 -0, tests +58 -0.
2026-10-28 11:38:05 -07:00
Mike SchwarzandRudy Celekli 884a5bfb6b fix(cli): keep chatroom watch --tmux rig names literal (#2218)
`rig chatroom watch --tmux` works for any rig name: the watcher goes to tmux as separate arguments after `--`, with no shell; a final `;` is escaped so tmux doesn't split the command; and the session name is encoded for tmux's format. The command prints the session name tmux reports, since tmux versions differ in how they rewrite characters such as `:` and `.` in a name. Finishes #1415 by @rudycelekli.

2 files: source +23 -4, tests +85 -0.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-28 11:11:11 -07:00
Mike Schwarz 9ba9abe858 perf(inventory): scope startup proof reads to rig nodes (#2226)
Per-rig node inventory now reads startup-proof history only for that rig's current nodes, scoped by node identity rather than the events' own rig id, since a node's history can predate its current rig. The per-node fold and the inventory it produces are unchanged, and other callers keep reading all history. In one matched private measurement (75 nodes, 30,000 proof events, 15 rigs) proof rows read fell from 480,000 to 60,000 and the CLI median from about 1,973 ms to 1,135 ms; a single-rig control was slower, so no general speedup is claimed.

3 files: source +10 -5, tests +35 -5.
2026-10-28 10:58:27 -07:00
Mike Schwarz 2648083e96 perf(inventory): skip discarded rig graph hydration (#2225)
The nodes inventory route checks that the rig exists with one bound query instead of loading the rig's whole graph and discarding it. A missing rig still returns the same 404, and the inventory and its enrichment are unchanged. In one matched private measurement the route's SQL statements fell from 478 to 401 (1 rig) and from 688 to 583 (15 rigs); no general speedup is claimed.

1 file: +2 -1.
2026-10-28 10:51:13 -07:00
Mike Schwarzandakushonkamen 76c0170ec8 fix(daemon): let git over SSH use the daemon's SSH agent in managed seats (#2222)
Git over SSH inside a managed seat can now reach the daemon's ssh-agent for a passphrase-protected key: the daemon forwards its own SSH_AUTH_SOCK through the shared launch environment to every managed launch, and Pi and OMP pass it to the agent process. An empty daemon value counts as unset, so a socket only the seat's terminal has stays out. rig-spec.md says the socket is forwarded and how to check it with ssh-add -l. Finishes #1162 by @akushonkamen, for #1159.

5 files: source +7 -1, tests +18 -10, docs +3 -3.

Co-authored-by: akushonkamen <[email protected]>
2026-10-28 10:33:20 -07:00
Devulapalli Naga Sri Vaishnavi 0224e7710d fix(context): ignore HTML comment blocks in section addresses (#2211)
Context section addresses now ignore headings and code fences inside HTML comment blocks, following CommonMark's comment blocks. Commenting out an old section no longer truncates the section before it, creates a duplicate address, or hides every later section. None of the 245 commented markdown files in our own packs changes.

3 files: source and tests +61 -1.
2026-10-28 10:29:23 -07:00
Mike SchwarzandRudy Celekli 8efb5a59c1 fix(scope): find a slice's mission when the missions root ends in a slash (#2221)
A slice's mission is now found when the missions root is given with a trailing slash or as a relative path: the slice path is resolved once against the root, and a path outside the root falls back to the main walk as before. Nothing else in scope resolution changes. Finishes #1468 by @rudycelekli.

2 files: source +14 -4, tests +37 -0.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-28 10:28:29 -07:00
Nash Aschenbath 36f971fa2c fix(daemon): clear Claude permission marks after tool batches (#2209)
A Claude permission notification whose dialog was never shown no longer leaves the seat marked as needing input until the next `Stop` or `UserPromptSubmit`. The openrig-core plugin now also relays `PostToolBatch`, and a batch settles only its own actor's strictly older permission marks once every tool in it has run, so a real pending prompt from another actor or a later batch is untouched. A Claude Code without `PostToolBatch` behaves as before.

11 files: source +144 -29, tests +431 -2, docs +21 -1.
2026-10-28 10:15:58 -07:00
Dery Ferdika b51500f024 test(scenarios): reject unbound step verbs at schema load (#2217)
A scenario that uses a step verb with no runtime binding (`restore`, `mutate` or `policy`) is now rejected when the schema loads, with `UNBOUND_STEP_VERB` guidance, instead of running its earlier steps (which can launch real seats) and then failing partway. `emit` stays separately gated and `seed_regression` stays accepted; the runtime check remains as a floor. Test system only. Three parked library scenarios now refuse at load; none is selected by CI.

4 files: test helper and test +37 -9 approx, docs; +62 -9 total.
2026-10-28 10:13:02 -07:00
Mike SchwarzandRudy Celekli 4926be0c0c fix(cli): treat a trailing slash in OPENRIG_URL as the same daemon (#2220)
`OPENRIG_URL=http://host:7433/` and `http://host:7433` now reach and identify the same daemon. The CLI reads the URL once through one shared reader that trims a trailing slash, so client requests, `rig send`, crash-cart, startup proof and the daemon-lifecycle identity checks all agree. Removing a trailing slash never changes a URL's origin, so the stop identity check still can't match a different daemon. Finishes #1462 by @rudycelekli.

8 files: source +62 -18, tests +169 -0.

Co-authored-by: Rudy Celekli <[email protected]>
2026-10-28 10:07:54 -07:00
Mike Schwarz 413504a029 fix(host): rig host doctor names the entry replies actually reach (#2214)
`rig host doctor` now checks where a reply addressed to a machine's self-id actually goes, using the same lookup `rig send` uses (the registry id, then a declared `hostId`, then the first matching `hosts.yaml` entry), instead of assuming it reaches the entry being checked. A stale alias that wins over a working entry now shows up in the doctor's output. Routing is unchanged, and the new rows don't change the doctor's exit codes: the failing row appears only when the transport has already failed.

3 files: source +68 -14, tests +94 -0.
2026-10-28 09:42:58 -07:00
Dery Ferdika a70bf71d5f fix(daemon): bound the Slack queue-access active-row scan (#2183)
Slack alert selection now reads the latest owner-notification episode for each active row in one query, and leaves out episodes already posted before it loads full queue rows, instead of two lookups per active row. Bounded by eligibility, not by count: there is no row cap, so no pending ask is dropped, and queue creation order is unchanged. Open asks keep their posted episodes. A failed transition or receipt read now propagates instead of reading as an empty result, and the backlog seeding when a channel is enabled sees the same episodes. No live Slack or large-backlog timing result is claimed.

4 files: source +64 -15, tests +153 -3.
2026-10-28 09:41:36 -07:00
Mike Schwarz 3f17e625af fix: preserve configured Herdr session endpoints (#2205)
`rig terminal open ... --session <name>` now reaches the Herdr session the daemon is configured for, and never silently another one. A name that matches the configured session keeps its explicit socket; other names resolve separately with no fallback; Herdr's config home and session paths come from the daemon, not the caller. The resolved endpoint is carried through the CLI's follow and ensure paths too, which earlier stopped at the daemon's RPC. The Linux-only wiring test doesn't run in the macOS daemon CI job.

9 files: source +24 -19, tests +174 -10, docs +19 -4.
2026-10-28 09:39:10 -07:00
Mike Schwarz 470f55241e docs(cli): explain deferred escalation targets in edge help (#2204)
`rig edge add --help` no longer says both endpoints must be existing logical IDs. It says only `escalates_to` accepts a same-daemon canonical address that resolves when used, with `review-advisor@coord` as an example, and that cross-host and synthetic targets stay unsupported. Help text only; validation and routing are unchanged. Refs #1772.

1 file: +8 -4.
2026-10-28 09:35:54 -07:00
Mike Schwarz 3874c743df perf(test): exec tmux directly in scenario fixtures (#2216)
Scenario test fixtures no longer start a Node process for every tmux command. The fixture's tmux wrapper is now a POSIX shell script that keeps the private socket, the saved PATH and literal arguments, and execs tmux, so its exit codes and signals pass straight through. A new forwarding test covers both an exit and a signal with no Node on the saved PATH. Test helpers only; no product code changes, and no production speedup is claimed.

2 files: test helper +6 -11, tests +33 -1.
2026-10-28 09:33:55 -07:00
Mike Schwarz b0bc678833 fix(test): honor send verification in scenario adapter (#2215)
A scenario action that sets `send.verify: true` now runs `rig send --verify`, so scenario tests check what they say they check. Before, the adapter dropped the flag and ran an unverified send. False or absent values keep the existing arguments. Test helpers only; no product code changes.

2 files: test helper +2 -2, tests +5 -3.
2026-10-28 09:29:36 -07:00
Mike Schwarz 26a490938c fix(daemon): route a terminal seat's stuck-sweep finding to a person (#2192)
When work owned by a bound terminal seat (`runtime: terminal`, such as a plain shell) is stuck, the stuck sweep's finding now goes to someone who can act on it instead of becoming the terminal seat's own row. It joins the fallback the sweep already uses for an address with no wake route and for an unclaimed hosted member: the operator seat when it's a person, then the row's creator, then the operator agent, and a terminal seat is never chosen. When none of them can be reached, the finding stays with the terminal seat and says why. The typing guard is unchanged. This covers the stuck sweep only; parked-owner watchdog reminders are separate.

2 files: source +11 -5, tests +34 -0.
2026-10-28 09:27:37 -07:00
Mike Schwarz afee489108 fix(provider): retain the launcher and bound target during relaunch (#2196)
A same-conversation account switch no longer leaves a Claude seat stopped when its running executable is outside the daemon's PATH. Before the takeover ends the old process, in-place restore reads the executable the seat is actually running, through the existing native witness, and carries it through the classic and managed resume builders; when it can't be read, launch resolution stays as it is today. The takeover also keeps the selected node identity through the guarded TERM, KILL and respawn instead of resolving a bare pane id again, so it can't act on another seat that holds the same pane id. The guard still checks the bound session, pane and occupant before writing. Config-dir selection and the resume token step are unchanged.

13 files: source +58 -25, tests +54 -6.
2026-10-28 09:16:42 -07:00
Mike Schwarz 898b39ea5c fix(cli): verify cross-host queue receipts on the destination (#2193)
A cross-host `rig queue create --verify` now reads its delivery receipt from the destination daemon, so a successful remote create no longer reports a 404 and an indeterminate result. Verification reuses the destination reader that create reconciliation already selects, including the registered HTTP endpoint and its bearer, and a host naming the current daemon stays local. The receipt must match the created item's ID, destination, sender and body, so an older row reached by a reused ID can't read as verified. An unreadable or mismatched receipt stays indeterminate, and neither the create nor the wake is resent.

2 files: source +38 -11, tests +90 -0.
2026-10-28 09:13:13 -07:00
162 changed files with 3895 additions and 1069 deletions

No files matched your search

-223
View File
@@ -8,229 +8,6 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.5]
OpenRig 0.7.5 adds `rig terminal open qitem:<id> --follow`: keep one terminal
workspace as a task's current owners join and leave. Joining viewers do not
request focus; departures remove viewers, not the agents or conversations.
Follow mode is scoped to Linux Herdr 0.9.5 / protocol 22. The exercised journey
used shell stand-ins; real-agent follow qualification and active render cost
are not established by that result. Changes since 0.7.4:
### Before you upgrade
- **Restart the daemon after installing** to run the new code and database
update. Migration 126 adds deferred `escalates_to` target addresses to the
edges table, preserving existing edges. Previously inert escalation edges
now affect recovery for seats without a delegation parent.
```bash
npm install -g @openrig/cli@0.7.5
rig daemon stop && rig daemon start
```
- **The bundled openrig-core plugin goes from 0.1.21 to 0.1.22.** Its
`queue-handoff` skill teaches agents to park waits on CI or a long job with
a continuation and wake. As before, managed refresh preserves replaced
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply any local
edits you still need from that copy. No setting is added or removed.
- **Codex hook refresh has a known limit.** The earlier remedy
`rig seat restart <seat> --keep-conversation --reason "refresh hooks"`
(preview with `--plan`) refuses a Codex seat launched by a 0.6.x
`openrig-tmux-send` script when Codex is a grandchild of the pane's shell.
Nothing is stopped by that refusal. This release does not fix
[#2200](https://github.com/mvschwarz/openrig/issues/2200); do not treat the
refused restart as a completed hook refresh. 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 Codex restart limitation in #2200 remains,** 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.
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/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.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.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:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
```
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
+5 -5
View File
@@ -158,7 +158,7 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
| Piece | Where |
|---|---|
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `emit`, `mutate`, `policy` parse but are unbound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `mutate`, `policy` are rejected at load time (`UNBOUND_STEP_VERB`) because the runner has no shipped binding for them; `emit` parses but only `refocus` is bound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario library | `packages/test-system/scenarios/`: 20 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
@@ -278,7 +278,7 @@ means:
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
| Step-time restore, compaction, mutation, policy | `restore`, `emit`, `mutate` and `policy` parse, but the runner throws `UnboundActionError` when it reaches them. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Step-time restore, compaction, mutation, policy | `restore`, `mutate` and `policy` steps are refused at load time (`UNBOUND_STEP_VERB`); `emit` parses but a non-`refocus` behavior throws `UnboundActionError` when the runner reaches it. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects a team seat's skills into the seat's generated plugin in OpenRig's state, and into `<cwd>/.claude/skills/` only for a seat without one. | A stub green says nothing about provider-specific projection (the class behind #159). |
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
@@ -319,9 +319,9 @@ of them. The other nine scenarios are authored but not yet runnable as meaningfu
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
| `policy-posture-survives-structural-edit` | `policy` and `mutate` are unbound | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | `policy` and `restore` are unbound | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`), and `mutate` is unbound | Declare the comparable fields and real mutations |
| `policy-posture-survives-structural-edit` | The validator refuses its `policy` and `mutate` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | The validator refuses its `policy` and `restore` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`) and refuses its `mutate` steps at load time (`UNBOUND_STEP_VERB`) | Declare the comparable fields and real mutations |
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
In most of these files, `seed_regression` steps that come *after* the assertions are
+21 -1
View File
@@ -102,7 +102,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
- **r3 — Claude `sessions/<pid>.json` self-report** (busy/shell/idle/waiting, since
v2.1.139): standing, above hooks for working/idle. Undocumented internal — unreadable
means fall down the ladder, never error.
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/Stop/Notification;
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/Notification;
Codex SessionStart/UserPromptSubmit/Stop/PermissionRequest): standing for Claude; Codex's
hook rung starts at trial and is promoted after 50 agreeing observations over at least an
hour. Exactly-once turn boundaries; SubagentStop filtered (it
@@ -139,6 +139,26 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
idle. Silence never clears a pending attention prompt, and a runner that states no interval (omp, an older pi
runner) keeps the hold-until-the-next-boundary behaviour.
Claude permission notifications raise needs-input immediately. A visible permission can clear
after it has been observed and then disappears. A notification whose dialog was never observed
can also clear when Claude reports `PostToolBatch`: every tool in that actor's batch has resolved,
so its earlier permission requests are no longer pending. The relay carries the native actor
identity; a subagent or in-process teammate finishing its batch cannot clear the main session's
request, and overlapping actors remain separate. An individual `PostToolUse` is insufficient
because another tool in the batch may still be waiting for permission.
Batch completion must come from the current occupant and have a later source timestamp than
the matching notification. Missing actor attribution from an older relay, equal or older
timestamps, and another actor's completion retain attention. An unrecognized or missing screen
does not expire attention by age. `Stop` and `UserPromptSubmit` remain the turn-boundary fallback.
The oracle remembers each actor's latest completed batch for the current occupant, so a strictly
earlier notification arriving late cannot restore the mark, even if the completion arrived first.
A notification with an equal or later timestamp remains eligible; swapping or retiring the
occupant discards its remembered completions.
Completing a batch changes the needs-input signal without inferring that the seat is idle or
overwriting a later elicitation. The hook contract is documented in
[Claude's hooks reference](https://code.claude.com/docs/en/hooks#posttoolbatch).
Partial-coverage honesty: a source with partial lifecycle coverage gets identity-only
trust, unless its inventory declares it positive-only. A positive-only source can report
working and nothing else; its silence reads unknown, never idle. The ladder is also the migration path — new oracles slot in above existing
+2 -2
View File
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/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
+1 -1
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.4`. 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.
+3 -3
View File
@@ -511,9 +511,9 @@ pods:
**Where the declared names reach, by runtime.** Declared values are handed to the seat's terminal as tmux environment, so most seats take them as given:
- Plain Claude Code (no permission mode selected), Codex and terminal seats take a spec `HOME`, `PATH` or `CLAUDE_CONFIG_DIR` as given.
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE` and `XDG_CURRENT_DESKTOP` are not forwarded. The SSH agent socket (`SSH_AUTH_SOCK`) is forwarded when OpenRig itself was started with one, so git over SSH works in the seat; a socket only the seat's terminal has stays out. To check, run `ssh-add -l` inside the seat. If it can't reach an SSH agent, start the OpenRig daemon from a terminal where `ssh-add -l` works. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
- Codex seats run OpenRig's own Codex executable whatever a spec `PATH` says. They use the seat's own `CODEX_HOME` when its `env:` declares an absolute one, else the daemon's when the daemon has one.
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
**A seat's own account.** Claude Code keeps its login in its config dir and Codex in its home, so a seat whose `env:`
declares an absolute `CLAUDE_CONFIG_DIR` (Claude Code) or `CODEX_HOME` (Codex) runs on whatever login that directory
@@ -653,7 +653,7 @@ own defaults/settings apply. Other runtimes ignore this field.
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
+19 -4
View File
@@ -30,10 +30,25 @@ With neither a provider nor a session, the daemon's open and preview APIs try
a live Herdr first, then a live cmux. An explicit session always selects Herdr
and skips that fallback, including when the daemon's default Herdr endpoint is down.
An explicit session resolves under the daemon user's Herdr session directory;
it takes precedence over that daemon's default socket override. The selection
belongs to the request. Saved views still describe membership and layout, so
pass `--session` again when reopening the view in that session.
Session paths use the daemon's `XDG_CONFIG_HOME` when set, or `HOME/.config`
when unset. Relative paths (including an empty value) resolve from the daemon's
startup directory, matching Herdr's configuration selection. Named sessions live under
`herdr/sessions/<name>/herdr.sock`; `default` uses `herdr/herdr.sock` directly.
These settings are captured when the daemon starts.
If the requested name equals the daemon's `HERDR_SESSION`, it keeps that
session's explicit `HERDR_SOCKET_PATH`. A different requested name uses the
configuration-tree path, without reusing the override or inferring a session
from an arbitrary socket path. With an explicit socket but no configured
session name, omit `--session` to keep that exact endpoint. A missing selected
socket never falls back to another session. Viewer and follow operations carry
that exact socket to the CLI through `HERDR_SOCKET_PATH`, without an explicit
CLI `--session` that would override it. They check that the CLI reports the
selected socket before changing its panes. Daemon-side CLI calls retain the
startup environment; desktop follow uses the socket confirmed by the daemon.
The selection belongs to the request. Saved views still describe membership
and layout, so pass `--session` again when reopening the view in that session.
The existing view rules apply: saved views, rig and pod views, read-only mission
and slice views, pagination, absent seats and partial results all use the same
+177 -153
View File
@@ -1,168 +1,192 @@
# What changed in OpenRig 0.7.5
# What changed in OpenRig 0.7.1
This note is for an agent on an install that was just upgraded to 0.7.5.
It lists changes since 0.7.4. For the whole capability map, read
`rig context get onboarding-width/public-what-you-can-do.md`.
Each release replaces this note: `rig context get reference/whats-new.md`
describes the installed version once the upgraded daemon is running. Without
a daemon, read `$OPENRIG_HOME/reference/whats-new.md`; that copy refreshes at
daemon start and can still describe the previous version just after an upgrade.
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.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). The new
migration 126 stores deferred `escalates_to` addresses alongside node-bound
edges. Existing escalation edges that were inert now route recovery for
seats without a delegation parent, including to another rig on this daemon.
- **openrig-core goes from 0.1.21 to 0.1.22.** Its queue-handoff skill teaches
parking waits on CI or long jobs with a continuation and wake. Managed
refresh still preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you need.
No setting is added or removed.
- **Keep the Codex hook-refresh limit in mind.** The earlier remedy
`rig seat restart <seat> --keep-conversation --reason "refresh hooks"`
(preview with `--plan`) refuses a Codex seat launched by a 0.6.x
`openrig-tmux-send` script when Codex is a grandchild of the pane's shell.
Nothing is stopped, and the hook refresh has not happened. Issue
[#2200](https://github.com/mvschwarz/openrig/issues/2200) is not fixed in
0.7.5. If Codex asks for hook review, review the hooks you intend to enable;
upgrading does not restore approvals an earlier OpenRig removed.
- **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.
## New things you can do
- **Follow a task in one terminal workspace:**
`rig terminal open qitem:<id> --follow --provider herdr`. Current obligation
owners join as verified viewer tiles without requesting focus. Departures
close viewers after a grace period, not agents or conversations. This is
scoped to **Linux Herdr 0.9.5 / protocol 22**.
- **Adjust the view:** `--pin` / `--unpin`, `--suppress` / `--unsuppress`,
`--suppression-ms`, `--max-tiles`, `--grace-ms` and `--focus-retention-ms`.
The defaults are eight tiles, four seconds of departure grace and twelve
more seconds for a focused leaver. `--no-follow` keeps the fixed layout.
A completely empty task drains removable tiles; when none remain, following
stops. Run `--follow` again to resume the same workspace.
- **Inspect following:** `rig terminal views --json` and
`rig terminal status --json` include saved active selectors and their latest
status. Ordinary `rig terminal open qitem:<id>` remains a one-shot open.
A client can use `POST /api/terminal/viewer/ensure` to add or reuse a verified
missing viewer without relaying out existing panes. Membership grants no input
authority. See `rig context get reference/terminal-sessions.md`.
- **Use the `pi-tui` example for Pi's own terminal UI as a community harness.**
Copy its preset and launcher from `docs/reference/community-harness-examples/pi-tui/`
and maintain them as your own. It supplies a per-seat Pi agent folder and
startup input. One Pi 1.4.0 / OpenRig 0.7.4 run exercised a parked wake to
the same session and an unattended handoff to a second seat. Resume is not
declared; the limitations below matter.
- **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.
## Behaviour and output that changed
## Behaviour that changed
- **`rig send` waits longer for its result:** 60 seconds locally and over HTTP,
plus any requested `--wait-for-idle` duration. A longer send or lost response
is still unconfirmed, with no automatic resend. A busy agent can hold delivered
input off-screen: missing pane text does not prove non-delivery. Reconcile
before another send or filing the same work as a queue item.
- **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))
- **Unconfirmed panes are not counted as opened.** Terminal CLI and TUI results
name `pane_unconfirmed` separately from skipped seats; an all-unconfirmed open
exits nonzero. Inspect before retrying rather than assuming nothing opened.
- **A wake recorded as unroutable can resume once its seat is pane-bound.** The
sweep uses the row's existing wake intent so the outbox does not send the same
settled intent again. Existing quiet hours and retry rules still apply.
- **A parentless seat's `escalates_to` destination is used for recovery,**
including another rig on this daemon. Delegation parents still take
precedence; an open recovery keeps its recorded owner. A stuck finding closes
on source evidence, not because a reporting threshold or unreadable registry
made it disappear from a report.
- **Fresh Claude handovers wait for the new process and current drawn input
frame.** Retained scrollback and an echoed launch command are not readiness.
- **Runtime send refusals return 409 instead of 500.** A stale Claude pane
names `rig reconcile-session <seat>` as the repair and types nothing.
- **`rig down` records an explicit stop after a rolled-back launch.**
`rig start --last` then keeps it down. `rig ps` marks periodic snapshots active
only for rigs the scheduler snapshots.
- **The web Overview shows the selected rig's current configuration,** not a
same-named library template. The TUI shows the configured local host name;
host registration explains the optional HTTP Feed subscription.
- **`attach --self` handles tmux's rewritten separators;** capture and transcript
requests use normalized line counts; stdio MCP warns on out-of-range daemon
ports while continuing to serve without replacing the supplied port.
- **The scope audit route accepts slice folders numbered 100 and above.**
Moving a slice still reopens its proof judgments: structural validity does
not mean an earlier judgment applies at the new path.
- **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.
## Keep using the 0.7.4 capabilities
## Output that changed shape
- **Hosted assistants can remain team members over MCP**, with roster entries,
per-seat grants and optional webhook rings. Their reach comes from the grant,
not the tool list. Without a wake trigger they check work when their owner
next starts a session; finished work closes with `no-follow-on`.
Internet-facing hosted members remain **experimental, unsupported, off by
default and at your own risk**. This release's terminal view needs no internet
exposure. See `rig context get reference/hosted-members.md` for narrow grants
and token revocation.
- **External grant checks, OAuth discovery and MCP protocol 2026-07-28** remain
available beside the older MCP handshake. A recorded principal is attribution,
not authentication. A webhook 2xx accepts the ring, not the task; a queue claim
is pickup, and activity between calls remains unknown.
- **`rig seat history --rig <id> --node-id <id>`** still lists eligible recorded
conversations. Resuming a person's reviewed history remains a client operation
through startup review, not a CLI command.
- **Daemon start restores a kernel only when this start newly detects it went
down.** A kernel already recorded down stays down; use `rig up kernel --existing` when you want it back.
That restore evidence still lasts only one daemon process.
- **`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.
## Limits to carry into your work
## What to stop doing
- **Follow mode and ensure-viewer are limited to Linux Herdr 0.9.5 / protocol 22.**
Other platforms, providers and selectors retain one-shot open. The exercised
follow journey used shell stand-ins, not native agents; real-agent follow
qualification is not claimed. Active render cost is unmeasured.
- **Unreadable composers keep departed tiles.** Pi, terminal and community
harness input may be unconfirmed. Unpinning does not make it safe to close;
typed drafts are retained too. Each input-retained departure gets one delayed
recheck, then an event or explicit follow command can reconsider it. Failed
provider or membership reads retain the old view with an explanation.
- **A viewer can resize its agent's terminal.** `ignore-size` is not isolation.
The first workspace on an empty Herdr server may gain focus. An SSH tile still
needs reopening after a Herdr restart, and native cmux remains unqualified.
- **Use `pi-tui` unattended, not alongside a person's typing.** A wake can submit
a half-typed draft, and an unrecognized dialog can receive a send. Draft-aware
mode is not a known fix. Busy-turn wakes, other Pi versions, native resume and
Pi choosing the park/handoff sequence for itself are not established by the
example's one run. Plain terminal seats do not receive queue wakes; setting
their typing guard to `off` can put notices into a shell. A preset's first
startup file must use `delivery_hint: send_text` to reach its first input.
- **Cross-rig escalation is same-daemon only;** upgrade followed by snapshot
restore of deferred targets has not been qualified.
- **The Codex restart refusal in #2200 remains.** Codex permission settings were
qualified on native 0.164.0 in 0.7.3, not every version; isolated account-home
switching is not part of this release.
- **OpenClaw interrupted local turns remain unrecovered;** inspect the pane and
use its Gateway preset for unattended work. 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.
- **Shared channels remain Slack only.** TUI primary-user authority is attribution,
not authentication. Earlier Slack-thread, browser-sizing and other published
0.7.4 limits remain unless a change above explicitly names them fixed.
- **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.
## Contributors
## Known gaps in 0.7.1
Thanks to @icey-zzz, @Aschenbath, @DeryFerd, @Nikhi00718 and @dajiaohuang for
merged contributions, and @rudycelekli for the work completed in #2160.
- **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`.
+8 -33
View File
@@ -1,38 +1,15 @@
# Release Notes
This directory is the lightweight release history for OpenRig.
This directory holds one note per release for OpenRig v0.1.12 through v0.6.3, plus v0.6.12 (for example `v0.1.12.md`).
It is intentionally simpler than a monolithic `CHANGELOG.md`.
Each shipped release gets its own note:
- `v0.1.12.md`
- `v0.2.0.md`
- `v0.3.0.md`
- and so on
## Why This Exists
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
- npm package release
- optional Git tag
- optional GitHub Release
- short, human-written summary of what is included
This keeps release notes:
- easy to author
- easy to link in GitHub Releases
- easy to paste into announcements
- durable in the repo
Notes for later releases are in [`CHANGELOG.md`](../../CHANGELOG.md) and on the [GitHub Releases page](https://github.com/mvschwarz/openrig/releases).
## Minimal Process
For each release:
1. Copy `_template.md` to `vX.Y.Z.md`.
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
1. Add the release's section to [`CHANGELOG.md`](../../CHANGELOG.md).
2. Write the release's GitHub Release notes. No new file is added to this directory.
3. Run the **substance gate** at the exact release cut. The gate derives every
file under the shipped pack sources, verifies a hash-bound human judgment
and an individual disposition for each mechanical candidate, scans the
@@ -79,16 +56,16 @@ For each release:
git push origin vX.Y.Z
```
5. Create a GitHub Release using the same file:
5. Create the GitHub Release with the notes from step 2:
```bash
gh release create vX.Y.Z \
--repo mvschwarz/openrig \
--title "OpenRig vX.Y.Z" \
--notes-file docs/releases/vX.Y.Z.md
--title "OpenRig X.Y.Z" \
--notes-file <release-notes.md>
```
6. Publish the npm package if that is part of the release flow.
6. Publish the npm package.
## Guidance
@@ -101,5 +78,3 @@ For each release:
## Scope
This directory is a release-note archive, not a full historical changelog taxonomy.
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.5",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.5",
"version": "0.7.1",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7346,7 +7346,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.5",
"version": "0.7.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7384,7 +7384,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.5",
"version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7426,7 +7426,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.5",
"version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.5",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.5",
"version": "0.7.1",
"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": [
+3 -3
View File
@@ -3,7 +3,7 @@ import { assessLocalDaemonOwner, ForeignLocalDaemonError, type LocalDaemonOwner
export { ForeignLocalDaemonError } from "./local-daemon-owner.js";
import path from "node:path";
import { ConfigStore } from "./config-store.js";
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
@@ -230,7 +230,7 @@ export class DaemonClient {
const localHeaders = senderIdentityHeaders(undefined, this.sessionName);
const session = localHeaders[SENDER_IDENTITY_HEADER];
if (!session || session.split("@").length >= 3) return { headers: localHeaders, local: false };
const directTarget = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const directTarget = readOpenRigUrl();
if (!this.remoteTarget && !directTarget) return { headers: localHeaders, local: true };
// Imported here, not at the top: it loads better-sqlite3 and yaml, which only a seat on a direct endpoint needs.
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
@@ -256,7 +256,7 @@ export class DaemonClient {
if (baseUrl) {
this.baseUrl = baseUrl;
} else {
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const envUrl = readOpenRigUrl();
if (envUrl) {
this.baseUrl = envUrl;
} else {
+23 -4
View File
@@ -4,7 +4,24 @@ import { readOpenRigEnv } from "../openrig-compat.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
* reading as an option. tmux reads the `-s` value as a format, where `##` is one `#` and a run of `#` before `[` is a
* style it keeps as written, so every other `#` is doubled and the session is `chatroom@<rig>`. Some tmux versions
* still change a session name (3.5a writes `_` for `:` and `.`, and escapes a backslash or a control character such
* as a newline or tab), so `-P -F` prints the name tmux used. */
export function chatroomWatchTmuxArgs(rig: string): string[] {
const session = `chatroom@${rig}`.replace(/#+(\[)?/g, (run, style?: string) => style ? run : run + run);
return ["new-session", "-d", "-P", "-F", "#{session_name}", "-s", tmuxArg(session),
"rig", "chatroom", "watch", "--", tmuxArg(rig)];
}
/** tmux ends a command at an argument that ends in `;`, unless a `\` comes before it, so a final `;` gets that `\`. */
function tmuxArg(value: string): string {
return value.endsWith(";") ? `${value.slice(0, -1)}\\;` : value;
}
interface RigSummary {
id: string;
@@ -175,9 +192,11 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
if (opts.tmux) {
const sessionName = `chatroom@${rig}`;
try {
execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} "rig chatroom watch ${JSON.stringify(rig)}"`, { stdio: "ignore" });
console.log(`Started watch in tmux session: ${sessionName}`);
console.log(`Attach with: tmux attach -t ${sessionName}`);
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.trim() || sessionName;
console.log(`Started watch in tmux session: ${created}`);
console.log(`Attach with: tmux attach -t ${created}`);
} catch {
console.error(`Failed to create tmux session '${sessionName}'. It may already exist.`);
process.exitCode = 1;
+11 -5
View File
@@ -1,7 +1,7 @@
import { Command } from "commander";
import { copyFileSync, lstatSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { getOpenRigHome } from "../openrig-compat.js";
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { formatDaemonHostForUrl } from "../client.js";
@@ -54,6 +54,14 @@ function existsOrThrow(path: string): boolean {
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
}
/** The evidence probe's URL: OPENRIG_URL with one trailing slash trimmed, as the detector trims it, else daemon.json's
* host and port. The shared reader leaves a query or fragment URL as written, so the trim stays here. */
export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { host?: string; port: number } | undefined): string {
return openrigUrl
? `${openrigUrl.replace(/\/$/, "")}/healthz`
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
}
/**
* The real verdict: LAZY-imports the narrow @openrig/daemon/crash-cart subpath at invocation (dep
* rail 2 — rig startup + other verbs never load the daemon module) and composes the shipped detector
@@ -63,7 +71,7 @@ function existsOrThrow(path: string): boolean {
async function realEmit(): Promise<CrashCartEmit> {
const cc = await import("@openrig/daemon/crash-cart");
const home = getOpenRigHome();
const openrigUrl = process.env.OPENRIG_URL;
const openrigUrl = readOpenRigUrl();
const probeClassified = (url: string) =>
cc.probeHealthz(url, { fetch: (u, init) => fetch(u, init as RequestInit), timeoutMs: 700 });
const detectDeps = {
@@ -83,9 +91,7 @@ async function realEmit(): Promise<CrashCartEmit> {
? `alive (pid ${state.pid})`
: `dead (pid ${state.pid})`
: "no daemon.json";
const url = openrigUrl
? `${openrigUrl.replace(/\/$/, "")}/healthz`
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
const url = crashCartEvidenceUrl(openrigUrl, state);
const probeResult = await probeClassified(url);
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
},
+8 -4
View File
@@ -125,10 +125,10 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
cmd
.command("add")
.description("Ensure one edge between two existing seats; an existing edge is unchanged")
.description("Ensure one typed edge; an existing edge is unchanged")
.argument("<rig>", "Target rig name or ID")
.argument("[from]", "Source seat logical ID, such as orch.lead")
.argument("[to]", "Target seat logical ID, such as review.reviewer")
.argument("[from]", "Existing source seat logical ID, such as orch.lead")
.argument("[to]", "Target logical ID, or a same-daemon address for escalates_to")
.option("--kind <kind>", "delegates_to, spawned_by, can_observe, collaborates_with or escalates_to")
.option("--plan", "Validate the request and show the change without writing")
.option("--json", "JSON output for agents")
@@ -136,8 +136,12 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
Examples:
rig edge add my-rig orch.lead review.reviewer --kind delegates_to
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --plan --json
rig edge add my-rig work.lead review-advisor@coord --kind escalates_to
Endpoints are fully qualified logical IDs for both same-pod and cross-pod edges.
Use existing, fully qualified logical IDs in <rig> for both same-pod and
cross-pod edges. For escalates_to only, the target may instead be a canonical
same-daemon seat address (pod-member@rig), resolved when used; that rig or seat
need not exist yet. Cross-host and synthetic destinations are unsupported.
Run it once per edge. A repeat reports the existing edge and its ID, and writes
nothing. No seats or sessions are changed.`)
.action(async (rig: string, from: string | undefined, to: string | undefined, opts: { kind?: string; plan?: boolean; json?: boolean }) => {
+68 -14
View File
@@ -72,6 +72,46 @@ export interface DoctorDeps {
* sidecar (TOFU + loud-conflict). Optional so existing fixtures keep compiling AND unit runs never
* write the real sidecar — absent means learning is skipped, which is the fail-open contract. */
learnHostBinding?: (alias: string, observedHostId: string, observedUrl: string) => HostObservationOutcome;
/** The registry entry a reply addressed `@<hostId>` resolves to, by the lookup `rig send` makes.
* Optional like the learner: absent means doctor makes no claim about where replies go. */
replyRoute?: (hostId: string) => HostEntry | null;
/** The self-id learned for a registry alias, if any. */
learnedHostId?: (alias: string) => string | null;
}
/** Where `rig send` takes a target ending `@<hostId>`: registry id, then a declared hostId, then the
* first hosts.yaml entry with that learned binding (cross-host-target.ts). Null when none matches. */
export function replyRouteFromDisk(hostId: string): HostEntry | null {
const loaded = loadHostRegistry();
if (!loaded.ok) return null;
const resolved = resolveHost(loaded.registry, hostId, loadHostBindings().bindings);
return resolved.ok ? resolved.host : null;
}
const REPLY_ROUTE_ORDER = "rig send resolves it by registry id, then a declared hostId, then the first hosts.yaml entry that learned it";
/** A reachable entry whose self-id another entry answers for. Unknown, not fail: doctor can't tell
* from here whether that other entry still reaches the machine. */
function replyRouteElsewhereRow(host: HostEntry, hostId: string, winner: HostEntry): CheckRow {
return {
step: "reply-route",
status: "unknown",
detail: `replies addressed @${hostId} go to '${winner.id}', not '${host.id}' (${REPLY_ROUTE_ORDER})`,
fix: `run rig host doctor ${winner.id}; if it no longer reaches this machine, delete its entry from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any)`,
};
}
/** An unreachable entry that replies still resolve to: the failure a stale entry causes, named on
* the entry itself. Null when the entry has no self-id or another entry answers for it. */
function replyRouteThroughDeadEntryRow(host: HostEntry, deps: DoctorDeps): CheckRow | null {
const hostId = host.hostId ?? deps.learnedHostId?.(host.id) ?? null;
if (!hostId || deps.replyRoute?.(hostId)?.id !== host.id) return null;
return {
step: "reply-route",
status: "fail",
detail: `replies addressed @${hostId} resolve to '${host.id}', so they fail while it is unreachable`,
fix: `if another entry reaches this machine, delete '${host.id}' from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any), then run rig host doctor on that entry; otherwise restore this entry's connectivity`,
};
}
function nestedBoolean(obj: unknown, path: readonly string[]): boolean | undefined {
@@ -115,6 +155,8 @@ function defaultDoctorDeps(): DoctorDeps {
sock.on("error", () => { sock.destroy(); resolve("closed"); });
}),
learnHostBinding: (alias, observedHostId, observedUrl) => recordHostObservation({ alias, observedHostId, observedUrl }),
replyRoute: replyRouteFromDisk,
learnedHostId: (alias) => loadHostBindings().bindings[alias]?.hostId ?? null,
};
}
@@ -164,6 +206,8 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
? "SSH auth/permission gate — check the key, agent, and remote authorized_keys"
: "verify the ssh target/alias, tailnet connectivity, and that sshd is running",
});
const routeRow = reach.failedStep === "ssh-unreachable" ? replyRouteThroughDeadEntryRow(host, deps) : null;
if (routeRow) rows.push(routeRow);
return rows; // later legs are meaningless without the shell
}
rows.push({ step: "transport-reachability", status: "pass", detail: `ssh to ${host.target} ok` });
@@ -256,20 +300,28 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
const parsed = JSON.parse(health.body) as { selfHostId?: unknown };
if (typeof parsed.selfHostId === "string" && parsed.selfHostId.length > 0) {
const obs = deps.learnHostBinding(host.id, parsed.selfHostId, (host as HttpHostEntry).url);
rows.push(obs.outcome === "conflict"
? {
step: "host-identity-binding",
status: "fail",
detail: describeBindingConflict(host.id, obs.binding),
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
}
: {
step: "host-identity-binding",
status: "pass",
detail: obs.outcome === "bound"
? `learned host identity '${obs.binding.hostId}' (first contact) — reply hints ending @${obs.binding.hostId} now resolve to '${host.id}'`
: `host identity '${obs.binding.hostId}' confirmed`,
});
if (obs.outcome === "conflict") {
rows.push({
step: "host-identity-binding",
status: "fail",
detail: describeBindingConflict(host.id, obs.binding),
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
});
} else {
// Ask the route rig send takes rather than assume it: an entry listed earlier in
// hosts.yaml with the same identity keeps the replies, even when it no longer answers.
const id = obs.binding.hostId;
const winner = deps.replyRoute?.(id) ?? null;
const routedHere = winner?.id === host.id;
rows.push({
step: "host-identity-binding",
status: "pass",
detail: obs.outcome === "bound"
? `learned host identity '${id}' (first contact)${routedHere ? ` — reply hints ending @${id} now resolve to '${host.id}'` : ""}`
: `host identity '${id}' confirmed`,
});
if (winner && !routedHere) rows.push(replyRouteElsewhereRow(host, id, winner));
}
}
} catch {
// body not JSON — an older or non-daemon endpoint; learning is skipped, nothing breaks.
@@ -277,6 +329,8 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
}
} catch (err) {
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon unreachable at ${(host as HttpHostEntry).url}: ${(err as Error).message}`, fix: "check tailnet connectivity and that the remote daemon is running (rig daemon start on the host)" });
const routeRow = replyRouteThroughDeadEntryRow(host, deps);
if (routeRow) rows.push(routeRow);
return rows;
}
rows.push({
+38 -11
View File
@@ -515,13 +515,10 @@ function notThisCreate(row: Record<string, unknown>, sent: SentCreate, storedSou
return null;
}
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
* was sent with, from the daemon that holds it: this one for a local row, the destination's registered daemon for a
* cross-host one. Only a row that is this create counts as committed; anything else leaves the outcome unknown. It never
* sends the create again. */
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
const remote = remoteHostOf(hostId);
const where = remote ? ` on host '${remote}'` : "";
/** Both reconciliation and --verify read the daemon that holds the row, with its own bearer. */
async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: string | undefined) {
let remote = remoteHostOf(hostId);
if (remote && await daemonSelfHostId(client) === remote) remote = undefined;
let reader = client;
let options: { timeoutMs: number; headers?: Record<string, string> } = { timeoutMs: CREATE_READ_BACK_TIMEOUT_MS };
if (remote) {
@@ -529,15 +526,45 @@ async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: str
const match = registry.ok ? resolveHost(registry.registry, remote, loadHostBindings().bindings) : undefined;
if (!match?.ok || match.host.transport !== "http") {
const why = match?.ok ? "its entry in this CLI's host registry is SSH, not a daemon URL" : "it isn't in this CLI's host registry";
return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${why}).` };
return { error: `from host '${remote}' (${why})` };
}
const bearer = resolveRemoteBearer(match.host);
if (!bearer.ok) return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${bearer.error}).` };
if (!bearer.ok) return { error: `from host '${remote}' (${bearer.error})` };
reader = deps.clientFactory(match.host.url);
if (bearer.token) options = { ...options, headers: { Authorization: `Bearer ${bearer.token}` } };
}
return { reader, options };
}
/** A successful forward isn't a delivery receipt. Never accept a different remote row as this create's receipt. */
function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Pick<DaemonClient, "get"> {
if (!remoteHostOf(hostId)) return client;
let target: ReturnType<typeof queueReadTarget> | undefined;
let source: ReturnType<typeof storedSourceOf> | undefined;
return {
get: async <T>(path: string) => {
const selected = await (target ??= queueReadTarget(deps, client, hostId));
if ("error" in selected) throw new Error(`receipt could not be read ${selected.error}`);
const response = await selected.reader.get<T>(path, selected.options);
const row = response.data as Record<string, unknown> | undefined;
if (response.status === 200 && (!row || row.qitemId !== qitemId
|| notThisCreate(row, sent, await (source ??= storedSourceOf(client, sent))) !== null)) {
throw new Error("receipt row does not match the created qitem");
}
return response;
},
};
}
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
* was sent with, from the daemon that holds it. Only this create's row counts; it never sends the create again. */
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
const remote = remoteHostOf(hostId);
const where = remote ? ` on host '${remote}'` : "";
const target = await queueReadTarget(deps, client, hostId);
if ("error" in target) return { row: null, note: `This CLI couldn't read the row back ${target.error}.` };
try {
const res = await reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, options);
const res = await target.reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, target.options);
if (res.status === 200 && res.data?.qitemId === qitemId) {
const differs = notThisCreate(res.data, sent, await storedSourceOf(client, sent));
if (differs === "unread") {
@@ -1079,7 +1106,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
if (opts.verify && status < 400) {
const createdId = typeof created.qitemId === "string" ? created.qitemId : null;
const delivery = createdId
? await waitForDeliveryOutcome(client, createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
? await waitForDeliveryOutcome(createReceiptReader(deps, client, hostResolved.hostId, qitemId, sent), createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
: unreadableDeliveryResult(null, hostResolved.destination, {}, "create response did not include a qitem id; delivery cannot be correlated");
await printWriteResult(client, opts.json ?? false, { ...created, qitemId: createdId, persisted: true, delivery }, status, receipt);
return;
+2 -2
View File
@@ -9,7 +9,7 @@ import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host
import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
import { resolveCrossHostTarget } from "../cross-host-target.js";
import { requestMayHaveArrived, runRemoteHttpOp } from "../remote-host-ops.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { readOpenRigUrl } from "../openrig-compat.js";
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
import { COMPOSER_PROMPT_CLASS } from "@openrig/daemon/composer-prompts";
import { resolveContextRef, walkSizedWarning } from "../context-resolve.js";
@@ -186,7 +186,7 @@ export function refuseEmptyMessage(body: string, verb: "send" | "broadcast", jso
* keeps the single-seat and fan-out callers from having to sequence it
* identically in two places. */
async function resolveLocalDaemonUrl(deps: SendDeps): Promise<string> {
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const envUrl = readOpenRigUrl();
if (envUrl) return envUrl;
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state === "running" && status.port !== undefined) return getDaemonUrl(status);
+4 -1
View File
@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { formatDaemonHostForUrl } from "../client.js";
import { readOpenRigUrl } from "../openrig-compat.js";
export interface StartupProofDeps {
env?: NodeJS.ProcessEnv;
@@ -33,7 +34,9 @@ export function resolveStartupProofEndpoint(deps: StartupProofDeps = {}): Endpoi
const env = deps.env ?? process.env;
const readFile = deps.readFile ?? ((p: string) => fs.readFileSync(p, "utf8"));
const home = first(env["OPENRIG_HOME"], env["RIGGED_HOME"]) ?? path.join(os.homedir(), ".openrig");
let baseUrl = first(env["OPENRIG_URL"], env["RIGGED_URL"]);
// Startup proof has always trimmed these and skipped blank ones; keep that, then pick and
// normalize through the shared reader like every other OPENRIG_URL reader.
let baseUrl = readOpenRigUrl({ OPENRIG_URL: env["OPENRIG_URL"]?.trim(), RIGGED_URL: env["RIGGED_URL"]?.trim() }) ?? null;
let token = first(env["OPENRIG_ACTIVITY_HOOK_TOKEN"], env["RIGGED_ACTIVITY_HOOK_TOKEN"]);
if (!baseUrl || !token) {
+1 -1
View File
@@ -126,7 +126,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
cmd
.command("open")
.argument("<view>", "a rig name, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
.argument("<view>", "a rig name, pod:<rig>/<pod>, seat:<rig>/<logical-id>, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
.description("Open a desktop terminal showing the view's live agents as interactive tiles")
.option("--provider <name>", "herdr, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one); tmux requires --window. Inside Rex, the default opens in Rex")
.option("--session <name>", "Herdr session on the daemon's host; --session default selects Herdr's default session")
+22 -4
View File
@@ -7,7 +7,7 @@ export { daemonMayAnswer } from "./daemon-reachability.js";
import type { DaemonStartLock } from "./daemon-start-lock.js";
import { DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
import { ConfigStore } from "./config-store.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
// Types only: the module itself (with the daemon's yaml-reading system world) is imported where an
// instance is initialized, so a command that only talks to the daemon never loads it.
import type {
@@ -72,6 +72,18 @@ export interface GetDaemonStatusOptions {
cleanupStaleState?: boolean;
}
/** True for `http://host:port` with an explicit, non-default port and no path, query or
* fragment: the only OPENRIG_URL shape getDaemonUrl's host-and-port rebuild reaches unchanged
* (without a port, or with :80, which URL reports as no port, status falls back to 7433). */
function isPlainHttpOrigin(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === "http:" && u.port !== "" && u.pathname === "/" && !u.search && !u.hash && !url.endsWith("/");
} catch {
return false;
}
}
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
export function getDaemonUrl(status: DaemonStatus): string {
return `http://${formatDaemonHostForUrl(status.host ?? DEFAULT_HOST)}:${status.port}`;
@@ -905,7 +917,7 @@ function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: Daem
export async function stopDaemon(deps: LifecycleDeps): Promise<"stopped" | "no-target"> {
const state = readState(deps);
const configured = resolveConfiguredDaemonTarget();
const explicitUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const explicitUrl = readOpenRigUrl();
const stateUrl = state ? `http://${formatDaemonHostForUrl(state.host ?? DEFAULT_HOST)}:${state.port}` : undefined;
const target = stateUrl ?? explicitUrl?.replace(/\/+$/, "") ?? `http://${formatDaemonHostForUrl(configured.host)}:${configured.port}`;
if (stateUrl && explicitUrl && new URL(explicitUrl).origin !== new URL(stateUrl).origin) {
@@ -1028,8 +1040,14 @@ export async function getDaemonStatus(
deps: LifecycleDeps,
options: GetDaemonStatusOptions = {},
): Promise<DaemonStatus> {
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly
const openrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly. Commands reach
// it through getDaemonUrl's http://host:port rebuild, so only a plain http origin uses
// the normalized form; any other URL is probed as written, exactly as before.
const rawOpenrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
const normalizedOpenrigUrl = readOpenRigUrl();
const openrigUrl = normalizedOpenrigUrl !== undefined && isPlainHttpOrigin(normalizedOpenrigUrl)
? normalizedOpenrigUrl
: rawOpenrigUrl;
if (openrigUrl) {
// An unverified daemon keeps the endpoint it was probed at, so a command can still make its request.
const endpoint = (() => {
+14 -4
View File
@@ -554,8 +554,9 @@ export function findSlice(
continue;
}
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
const sliceRoot = path.dirname(candidate);
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
// The directory and the name both come from the resolved path, so `<slice>/.` and `<slice>/sub/..` select the slice.
const resolved = path.resolve(candidate);
return buildSliceInfo(mission, path.dirname(resolved), path.basename(resolved));
}
}
if (firstContextError) throw firstContextError;
@@ -567,9 +568,18 @@ export function findSlice(
}
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
// path.dirname never ends in a separator, so compare against the normalized root
// (a configured or --workspace root may be written as `/w/missions/`); resolve the
// slice the same way so a relative root and a relative candidate stay comparable.
// A path that leaves the root, such as `<root>/../other/m1/slices/x`, then falls back
// to the walk as written, where it reads as before.
return walkToMission(path.resolve(missionsRoot), path.resolve(slicePath)) ?? walkToMission(missionsRoot, slicePath);
}
function walkToMission(root: string, slicePath: string): string | null {
let dir = path.dirname(slicePath);
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
if (path.dirname(dir) === missionsRoot) return dir;
while (dir.startsWith(root) && dir !== root) {
if (path.dirname(dir) === root) return dir;
dir = path.dirname(dir);
}
return null;
+20 -3
View File
@@ -55,12 +55,12 @@ function warnOnce(key: string, message: string): void {
console.warn(message);
}
export function readOpenRigEnv(primary: string, legacy?: string): string | undefined {
const primaryValue = process.env[primary];
export function readOpenRigEnv(primary: string, legacy?: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
const primaryValue = env[primary];
if (primaryValue !== undefined && primaryValue !== "") return primaryValue;
if (legacy) {
const legacyValue = process.env[legacy];
const legacyValue = env[legacy];
if (legacyValue !== undefined && legacyValue !== "") {
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
return legacyValue;
@@ -70,6 +70,23 @@ export function readOpenRigEnv(primary: string, legacy?: string): string | undef
return undefined;
}
/** An explicit daemon URL with trailing slashes removed, so `http://h:7433/` addresses the
* same daemon as `http://h:7433`. A URL with a query or fragment is returned exactly as
* written, and so is a value that would strip down to no host (such as `/`), which stays
* an explicit (if invalid) target. */
export function normalizeOpenRigUrl(raw: string): string {
if (/[?#]/.test(raw)) return raw;
const stripped = raw.replace(/\/+$/, "");
return /:\/\/[^/]/.test(stripped) ? stripped : raw;
}
/** OPENRIG_URL (legacy RIGGED_URL), normalized by normalizeOpenRigUrl. `env` defaults to
* process.env; callers with an injected environment pass theirs. */
export function readOpenRigUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
const raw = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL", env);
return raw === undefined ? undefined : normalizeOpenRigUrl(raw);
}
export function getPreferredOpenRigHome(): string {
const openrigHome = getOpenRigHome();
const legacyRiggedHome = getLegacyRiggedHome();
+1 -3
View File
@@ -401,9 +401,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
...(session !== undefined ? { session } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}) },
{ timeoutMs: 45_000, headers: terminalAuthHeaders() });
if (!answer.data.ok || !answer.data.workspaceId) return { ...answer.data, window, notes: [...(answer.data.notes ?? []), ...windowNotes] };
const selectedSession = session ?? endpoint.session;
const selectedArgs = ["-u", "TMUX", "-u", "HERDR_SESSION", "-u", "HERDR_SOCKET_PATH", herdr,
...(selectedSession && selectedSession !== "default" ? ["--session", selectedSession] : [])];
const selectedArgs = herdrArgs;
const selectedStatus = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "status", "server", "--json"]));
const selectedBody = selectedStatus?.result ?? selectedStatus;
if (selectedStatus.error || selectedBody?.running !== true || selectedBody.socket !== endpoint.socketPath) {
@@ -0,0 +1,85 @@
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { chatroomWatchTmuxArgs } from "../src/commands/chatroom.js";
const sessionArg = (rig: string) => { const args = chatroomWatchTmuxArgs(rig); return args[args.indexOf("-s") + 1]; };
describe("chatroom watch --tmux arguments", () => {
it("hands tmux the watcher as separate arguments, with the rig name after --, and asks for the name it used", () => {
expect(chatroomWatchTmuxArgs("my rig")).toEqual(["new-session", "-d", "-P", "-F", "#{session_name}", "-s", "chatroom@my rig",
"rig", "chatroom", "watch", "--", "my rig"]);
});
// tmux reads -s as a format: `##` is one `#`, and a run of `#` before `[` is a style it keeps as written.
it.each([
["my-rig", "chatroom@my-rig"],
["a#b", "chatroom@a##b"],
["a##b", "chatroom@a####b"],
["a#", "chatroom@a##"],
["a#{b}", "chatroom@a##{b}"],
["a#[fg=red]b", "chatroom@a#[fg=red]b"],
["a##[b", "chatroom@a##[b"],
])("encodes %j for tmux's session-name format as %j", (rig, session) => {
expect(sessionArg(rig)).toBe(session);
});
// tmux ends a command at an argument ending in `;` unless a `\` comes before it.
it.each([
["a;", "chatroom@a\\;", "a\\;"],
[";", "chatroom@\\;", "\\;"],
["a\\;", "chatroom@a\\\\;", "a\\\\;"],
["a;b", "chatroom@a;b", "a;b"],
])("puts a \\ before a final ; in %j", (rig, session, watcherArg) => {
const args = chatroomWatchTmuxArgs(rig);
expect(sessionArg(rig)).toBe(session);
expect(args[args.length - 1]).toBe(watcherArg);
});
});
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",
"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)", () => {
let dir = "";
let recorder = "";
beforeAll(() => {
dir = mkdtempSync(path.join(tmpdir(), "openrig-chatroom-watch-"));
// Stands in for `rig`: records the arguments the watcher gets, then stays up so the session lives.
recorder = path.join(dir, "record.cjs");
writeFileSync(recorder, "require('node:fs').writeFileSync(process.argv[2], JSON.stringify(process.argv.slice(3)));\nsetTimeout(() => {}, 30000);\n");
});
afterAll(() => {
spawnSync("tmux", ["-L", TMUX_SOCKET, "kill-server"]);
rmSync(dir, { recursive: true, force: true });
});
// The exact arguments, with only the `rig` program swapped for the recorder; tmux prints the session it made.
function start(rig: string, out: string): string {
const args = chatroomWatchTmuxArgs(rig);
const program = args.indexOf("-s") + 2;
return execFileSync("tmux", ["-L", TMUX_SOCKET, "-f", "/dev/null", ...args.slice(0, program), process.execPath, recorder, out,
...args.slice(program + 1)], { encoding: "utf8" }).trim();
}
it.each(names.map((rig, i) => [rig, i] as const))("keeps the session chatroom@%s and hands the watcher the name as one argument", async (rig, i) => {
const out = path.join(dir, `argv-${i}.json`);
expect(start(rig, out)).toBe(`chatroom@${rig}`);
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);
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 });
});
});
+74
View File
@@ -232,6 +232,80 @@ describe("doctorLegs — Source-1 learning from the healthz body already in hand
} as never);
expect(rows.find((r) => r.step === "host-identity-binding")).toBeUndefined();
});
it("first contact where replies do resolve here keeps the claim and adds no route row", async () => {
const { doctorLegs } = await import("../src/commands/host.js");
const rows = await doctorLegs(anonHost, {
...baseDeps,
httpGet: async () => ({ status: 200, body: JSON.stringify({ selfHostId: "host-84c37990" }) }),
learnHostBinding: (_alias: string, id: string) => ({ outcome: "bound", binding: { hostId: id, firstObservedAt: "x", lastObservedAt: "x" } }),
replyRoute: () => anonHost,
} as never);
expect(rows.find((r) => r.step === "host-identity-binding")!.detail).toContain("now resolve to 'mm2-host'");
expect(rows.find((r) => r.step === "reply-route")).toBeUndefined();
});
// An earlier hosts.yaml entry that learned the same identity keeps the replies, even after its
// address stops answering. Real learner and real resolver over one sidecar file.
it("an earlier entry with the same identity: doctor names it on the working entry and fails it when dead", async () => {
const { doctorLegs } = await import("../src/commands/host.js");
const p = tmpPath();
const stale = { id: "old-box-b", transport: "http", url: "http://10.0.0.9:7433" } as const;
const working = { id: "box-b", transport: "http", url: "http://10.0.0.5:7433" } as const;
const registry: HostRegistry = { hosts: [stale, working] };
recordHostObservation({ alias: stale.id, observedHostId: "host-0b0b0b0b", observedUrl: stale.url, path: p });
const deps = {
...baseDeps,
learnHostBinding: (alias: string, id: string, url: string) => recordHostObservation({ alias, observedHostId: id, observedUrl: url, path: p }),
replyRoute: (id: string) => {
const r = resolveHost(registry, id, loadHostBindings(p).bindings);
return r.ok ? r.host : null;
},
learnedHostId: (alias: string) => loadHostBindings(p).bindings[alias]?.hostId ?? null,
};
const live = await doctorLegs(working, { ...deps, httpGet: async () => ({ status: 200, body: JSON.stringify({ selfHostId: "host-0b0b0b0b" }) }) } as never);
const identity = live.find((r) => r.step === "host-identity-binding")!;
expect(identity.status).toBe("pass");
expect(identity.detail).not.toContain("now resolve to");
const route = live.find((r) => r.step === "reply-route")!;
expect(route.status).toBe("unknown");
expect(route.detail).toContain("go to 'old-box-b', not 'box-b'");
expect(route.fix).toContain("rig host doctor old-box-b");
// The entry doctor names is the one rig send's own resolver picks.
const sent = resolveCrossHostTarget("pm@some-rig@host-0b0b0b0b", undefined, () => ({ ok: true as const, registry }), undefined, () => loadHostBindings(p));
expect(sent).toMatchObject({ ok: true, sugarHost: "old-box-b" });
const dead = await doctorLegs(stale, { ...deps, httpGet: async () => { throw new Error("read ECONNRESET"); } } as never);
expect(dead.find((r) => r.step === "transport-reachability")!.status).toBe("fail");
const deadRoute = dead.find((r) => r.step === "reply-route")!;
expect(deadRoute.status).toBe("fail");
expect(deadRoute.detail).toContain("@host-0b0b0b0b resolve to 'old-box-b'");
expect(deadRoute.fix).toContain("delete 'old-box-b'");
});
it("an unreachable entry that replies don't resolve to adds no route row", async () => {
const { doctorLegs } = await import("../src/commands/host.js");
const rows = await doctorLegs(anonHost, {
...baseDeps,
httpGet: async () => { throw new Error("connect ECONNREFUSED"); },
learnedHostId: () => "host-84c37990",
replyRoute: () => ({ id: "other-host", transport: "http", url: "http://y:7433" }),
} as never);
expect(rows.map((r) => r.step)).toEqual(["transport-reachability"]);
});
it("an unreachable ssh entry whose declared hostId replies resolve to gets the route row too", async () => {
const { doctorLegs } = await import("../src/commands/host.js");
const sshHost = { id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig", hostId: "host-84c37990" } as const;
const rows = await doctorLegs(sshHost, {
...baseDeps,
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "connect refused" }),
httpGet: async () => ({ status: 200, body: "" }),
replyRoute: (id: string) => (id === "host-84c37990" ? sshHost : null),
} as never);
expect(rows.find((r) => r.step === "reply-route")).toMatchObject({ status: "fail" });
});
});
describe("F2 — concurrency guard: conflict records survive concurrent writers", () => {
+20
View File
@@ -376,6 +376,26 @@ describe("rig host doctor — stepwise distinct errors + three-valued posture",
expect(err.join("\n")).toContain("unknown host id 'nope'");
});
it("doctor's reply route reads the same hosts.yaml and host-bindings.json rig send does", async () => {
const { replyRouteFromDisk } = await import("../src/commands/host.js");
const { resolveCrossHostTarget } = await import("../src/cross-host-target.js");
fs.writeFileSync(path.join(dir, "hosts.yaml"), [
"hosts:",
" - id: old-box-b",
" transport: http",
" url: http://10.0.0.9:7433",
" - id: box-b",
" transport: http",
" url: http://10.0.0.5:7433",
].join("\n") + "\n");
// Both aliases learned one identity; hosts.yaml order, not sidecar order, picks the entry.
const learned = { hostId: "host-0b0b0b0b", firstObservedAt: "x", lastObservedAt: "x" };
fs.writeFileSync(path.join(dir, "host-bindings.json"), JSON.stringify({ version: 1, bindings: { "box-b": learned, "old-box-b": learned } }));
expect(replyRouteFromDisk("host-0b0b0b0b")?.id).toBe("old-box-b");
expect(resolveCrossHostTarget("pm@some-rig@host-0b0b0b0b", undefined)).toMatchObject({ ok: true, sugarHost: "old-box-b" });
expect(replyRouteFromDisk("host-unknown")).toBeNull();
});
// seed a registry for the doctor CLI test above
async function capturePrelude(): Promise<void> {
const prog = new Command();
@@ -0,0 +1,156 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DaemonClient } from "../src/client.js";
import { getDaemonStatus, stopDaemon, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { normalizeOpenRigUrl } from "../src/openrig-compat.js";
import { resolveStartupProofEndpoint } from "../src/commands/startup-proof.js";
import { crashCartEvidenceUrl } from "../src/commands/crash-cart.js";
// OPENRIG_URL=http://host:port/ (trailing slash) addresses the same daemon as
// http://host:port; the CLI must not request //healthz or //api/... from it.
describe("OPENRIG_URL with a trailing slash", () => {
afterEach(() => vi.unstubAllEnvs());
function probeDeps(ok: (url: string) => boolean) {
const urls: string[] = [];
const fetch = vi.fn(async (url: string) => {
urls.push(url);
return { ok: ok(url) };
});
const exists = vi.fn(() => false);
const readFile = vi.fn(() => null);
return { urls, exists, readFile, deps: { fetch, exists, readFile } as unknown as LifecycleDeps };
}
it.each([
["http://127.0.0.1:7433/", "http://127.0.0.1:7433"],
["http://127.0.0.1:7433//", "http://127.0.0.1:7433"],
["http://h:7433/p/", "http://h:7433/p"],
["https://h/", "https://h"],
["http://h:7433/?a=b/", "http://h:7433/?a=b/"],
["http://h:7433/p/?x", "http://h:7433/p/?x"],
["http://h:7433/p/#x/", "http://h:7433/p/#x/"],
["http://h:7433", "http://h:7433"],
["/", "/"],
["//", "//"],
["http://", "http://"],
])("normalizeOpenRigUrl(%s) is %s (a query or fragment URL stays as written)", (raw, normalized) => {
expect(normalizeOpenRigUrl(raw)).toBe(normalized);
});
it("getDaemonStatus probes <origin>/healthz, not <origin>//healthz", async () => {
vi.stubEnv("OPENRIG_URL", "http://127.0.0.1:7555/");
const { urls, deps } = probeDeps((url) => url === "http://127.0.0.1:7555/healthz");
const status = await getDaemonStatus(deps);
expect(urls).toEqual(["http://127.0.0.1:7555/healthz"]);
expect(status).toMatchObject({ state: "running", healthy: true, port: 7555, host: "127.0.0.1" });
});
// Commands reach OPENRIG_URL through getDaemonUrl's http://host:port rebuild, so the probe
// only changes for a plain http origin; a prefix or https URL is probed exactly as before.
it.each([
["http://127.0.0.1:7555/p/", "http://127.0.0.1:7555/p//healthz"],
["https://127.0.0.1:7555/", "https://127.0.0.1:7555//healthz"],
["http://127.0.0.1:7555/?x=1", "http://127.0.0.1:7555/?x=1/healthz"],
// No explicit port (or :80, which URL reports as none): status would hand commands 7433,
// so the probe must not succeed on port 80 for them.
["http://127.0.0.1/", "http://127.0.0.1//healthz"],
["http://127.0.0.1:80/", "http://127.0.0.1:80//healthz"],
])("getDaemonStatus probes %s as main does (%s)", async (url, probed) => {
vi.stubEnv("OPENRIG_URL", url);
const { urls, deps } = probeDeps(() => false);
await getDaemonStatus(deps);
expect(urls).toEqual([probed]);
});
it("a slash-only OPENRIG_URL stays an explicit target and never selects the local daemon", async () => {
vi.stubEnv("OPENRIG_URL", "/");
const { urls, exists, readFile, deps } = probeDeps(() => true);
await getDaemonStatus(deps);
expect(urls).toEqual(["//healthz"]);
expect(exists).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(new DaemonClient().baseUrl).toBe("/");
});
it("a default DaemonClient requests <url>/api/..., not <url>//api/...", async () => {
vi.stubEnv("OPENRIG_URL", "http://127.0.0.1:7555//");
const urls: string[] = [];
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
urls.push(String(url));
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
}) as unknown as typeof fetch;
const client = new DaemonClient(undefined, { fetchImpl });
await client.get("/api/ps");
expect(urls).toContain("http://127.0.0.1:7555/api/ps");
expect(urls.some((url) => url.includes("7555//"))).toBe(false);
});
it("a default DaemonClient keeps a path prefix and scheme", () => {
vi.stubEnv("OPENRIG_URL", "https://h:7433/p/");
expect(new DaemonClient().baseUrl).toBe("https://h:7433/p");
});
it("the startup-proof endpoint reads OPENRIG_URL through the same normalization", () => {
const endpoint = resolveStartupProofEndpoint({
env: { OPENRIG_URL: "http://127.0.0.1:7555/", OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
readFile: () => { throw new Error("no endpoint file"); },
});
expect(endpoint).toEqual({ baseUrl: "http://127.0.0.1:7555", token: "tok" });
});
it.each([
["http://127.0.0.1:7555/p/?x=1", "http://127.0.0.1:7555/p/?x=1/api/ps"],
["http://127.0.0.1:7555/p/#frag", "http://127.0.0.1:7555/p/#frag/api/ps"],
])("a default DaemonClient keeps main's base for %s", async (url, requested) => {
vi.stubEnv("OPENRIG_URL", url);
const urls: string[] = [];
const fetchImpl = vi.fn(async (u: string | URL | Request) => {
urls.push(String(u));
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
}) as unknown as typeof fetch;
const client = new DaemonClient(undefined, { fetchImpl });
expect(client.baseUrl).toBe(url);
await client.get("/api/ps");
expect(urls).toContain(requested);
});
it("the startup-proof endpoint picks RIGGED_URL like every other reader when OPENRIG_URL is empty", () => {
const endpoint = resolveStartupProofEndpoint({
env: { OPENRIG_URL: "", RIGGED_URL: "http://127.0.0.1:7556/", OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
readFile: () => { throw new Error("no endpoint file"); },
});
expect(endpoint).toEqual({ baseUrl: "http://127.0.0.1:7556", token: "tok" });
});
it.each([
[{ OPENRIG_URL: " ", RIGGED_URL: "http://127.0.0.1:7556" }, "http://127.0.0.1:7556"],
[{ OPENRIG_URL: " http://127.0.0.1:7555/ " }, "http://127.0.0.1:7555"],
])("the startup-proof endpoint trims and skips blank URLs as before (%o)", (urls, baseUrl) => {
const endpoint = resolveStartupProofEndpoint({
env: { ...urls, OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
readFile: () => { throw new Error("no endpoint file"); },
});
expect(endpoint).toEqual({ baseUrl, token: "tok" });
});
// The shared reader leaves a query or fragment URL as written, so these two callers keep main's own trim before
// /healthz: rig daemon stop trims every trailing slash, and crash-cart's evidence probe one, as its detector does.
it.each([
["http://127.0.0.1:7555/?x=1/", "http://127.0.0.1:7555/?x=1/healthz"],
["http://127.0.0.1:7555/?x=1///", "http://127.0.0.1:7555/?x=1/healthz"],
["http://127.0.0.1:7555/#frag/", "http://127.0.0.1:7555/#frag/healthz"],
])("rig daemon stop with OPENRIG_URL=%s checks %s", async (url, check) => {
vi.stubEnv("OPENRIG_URL", url);
const { urls, deps } = probeDeps(() => true);
await expect(stopDaemon(deps)).rejects.toThrow(`Checked ${check};`);
expect(urls).toEqual([check]);
});
it.each([
["http://127.0.0.1:7555/?x=1/", "http://127.0.0.1:7555/?x=1/healthz"],
["http://127.0.0.1:7555/?x=1//", "http://127.0.0.1:7555/?x=1//healthz"],
["http://127.0.0.1:7555/#frag/", "http://127.0.0.1:7555/#frag/healthz"],
])("crash-cart's evidence probe for OPENRIG_URL=%s is %s", (url, probe) => {
expect(crashCartEvidenceUrl(normalizeOpenRigUrl(url), undefined)).toBe(probe);
});
});
@@ -103,6 +103,96 @@ describe("a queue create that got no answer is read back once by its ID", () =>
const posts = (calls: Call[]) => calls.filter((c) => c.method === "post");
const readBacks = (calls: Call[]) => calls.filter((c) => c.method === "get" && c.path === `/api/queue/${ID}`);
it.each([
["an explicit host", "dev@rig-b", ["--host", "vps-b"]],
["a host-qualified destination", "dev@rig-b@vps-b", []],
])("create --verify reads the remote receipt with %s and its bearer, never the local row", async (_case, destination, extra) => {
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n bearer_env: XH_AUTH\n`);
vi.stubEnv("XH_AUTH", AUTH_VALUE);
const { calls, error } = await run(destination, [{ status: 201, data: REMOTE_ROW }], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${LOCAL}/api/queue/${ID}`]: { status: 404, data: { error: "qitem_not_found" } },
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
}, ["--verify", ...extra]);
expect(error).toBeNull();
expect(posts(calls)).toEqual([expect.objectContaining({ base: LOCAL, path: "/api/queue/create" })]);
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: REMOTE,
options: { timeoutMs: 10_000, headers: { Authorization: `Bearer ${AUTH_VALUE}` } } })]);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ qitemId: ID, persisted: true,
delivery: { receiptSource: "terminal", outcome: "verified", wakeResult: "verified" } });
expect(stdout.join("\n") + stderr.join("\n")).not.toContain(AUTH_VALUE);
});
it.each([
["missing", { status: 404, data: { error: "qitem_not_found" } }],
["unreachable", new DaemonTimeoutError("remote receipt timed out")],
["wrong id", { status: 200, data: { ...REMOTE_ROW, qitemId: "another-id", lastNudgeResult: "verified" } }],
["wrong destination", { status: 200, data: { ...REMOTE_ROW, destinationSession: "other@rig-b", lastNudgeResult: "verified" } }],
["wrong source", { status: 200, data: { ...REMOTE_ROW, sourceSession: "other@rig-a", lastNudgeResult: "verified" } }],
["wrong body", { status: 200, data: { ...REMOTE_ROW, body: "different", lastNudgeResult: "verified" } }],
] satisfies Array<[string, Reply]>)("create --verify keeps a %s remote receipt indeterminate without resending", async (_case, receipt) => {
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 201, data: REMOTE_ROW }], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
[`${REMOTE}/api/queue/${ID}`]: receipt,
}, ["--verify"]);
expect(error).toBeNull();
expect(posts(calls)).toHaveLength(1);
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: REMOTE })]);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
});
it("create --verify does not guess a receipt host missing from this CLI's registry", async () => {
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 201, data: REMOTE_ROW }], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
}, ["--verify"]);
expect(error).toBeNull();
expect(posts(calls)).toHaveLength(1);
expect(readBacks(calls)).toHaveLength(0);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
});
it("does not verify an older stored body returned by a successful colliding create", async () => {
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
const oldRow = { ...REMOTE_ROW, body: "older body", lastNudgeResult: "verified" };
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 200, data: { ...oldRow,
createWarning: { code: "qitem_body_not_saved", message: "The supplied body was not saved." } } }], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: oldRow },
}, ["--verify"]);
expect(error).toBeNull();
expect(posts(calls)).toHaveLength(1);
expect(readBacks(calls)).toHaveLength(1);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
expect(stderr.join("\n")).toContain("not saved");
});
it("create --verify reads locally when --host names this daemon, without requiring a registry entry", async () => {
const { calls, error } = await run("dev@rig-b", [{ status: 201, data: ROW }], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...ROW, lastNudgeResult: "verified" } },
}, ["--host", "host-a", "--verify"]);
expect(error).toBeNull();
expect(posts(calls)).toHaveLength(1);
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: LOCAL })]);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "verified" } });
});
it("a reconciled remote create still verifies its remote receipt without another write", async () => {
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
const { calls, error } = await run("dev@rig-b@vps-b", [FORWARD_UNKNOWN], {
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
}, ["--verify"]);
expect(error).toBeNull();
expect(posts(calls)).toHaveLength(1);
expect(readBacks(calls)).toHaveLength(2);
expect(readBacks(calls).every(c => c.base === REMOTE)).toBe(true);
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "verified" } });
});
it("a local create that committed after the CLI stopped waiting reads back as created, and isn't sent again", async () => {
const { calls, error } = await run("dev@rig-b", [new DaemonTimeoutError(`Request to ${LOCAL}/api/queue/create timed out after 5000ms`)],
{ [`${LOCAL}/api/queue/${ID}`]: { status: 200, data: ROW } });
+37
View File
@@ -356,6 +356,43 @@ describe("findSlice + resolution variants", () => {
expect(slice.missionName).toBe("release-0.3.2");
});
it("finds the owning mission when the missions root ends in a separator", () => {
const withSlash = missionsRoot + path.sep;
expect(findSlice(withSlash, "07-target", "release-0.3.2").missionName).toBe("release-0.3.2");
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
expect(findSlice(withSlash, abs).missionName).toBe("release-0.3.2");
});
it("finds the owning mission for a relative missions root with a bare name and --mission, or a mission path", () => {
const prev = process.cwd();
process.chdir(root);
try {
const hinted = findSlice("missions", "07-target", "release-0.3.2");
expect(hinted.missionName).toBe("release-0.3.2");
expect(hinted.absPath).toBe(path.join(fs.realpathSync(root), "missions", "release-0.3.2", "slices", "07-target"));
expect(findSlice("missions/", "07-target", "release-0.3.2").missionName).toBe("release-0.3.2");
expect(findSlice("missions", "release-0.3.2/slices/07-target").missionName).toBe("release-0.3.2");
} finally {
process.chdir(prev);
}
});
it.each(["/.", "/sub/.."])("resolves an absolute slice path ending in %s to the slice itself", (suffix) => {
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
fs.mkdirSync(path.join(abs, "sub"));
const slice = findSlice(missionsRoot, abs + suffix.replace(/\//g, path.sep));
expect(slice.absPath).toBe(abs);
expect(slice.name).toBe("07-target");
expect(slice.missionName).toBe("release-0.3.2");
});
it("keeps an absolute slice path that leaves the missions root pointing at the requested folder", () => {
const outside = path.join(root, "other", "m1", "slices", "x");
fs.mkdirSync(outside, { recursive: true });
const slice = findSlice(missionsRoot, [missionsRoot, "..", "other", "m1", "slices", "x"].join(path.sep));
expect(slice.absPath).toBe(outside);
});
it.each(["workspace", "missions", "declared mission"])(
"resolves a hinted bare slice past an unrelated %s directory",
(collision) => {
+13
View File
@@ -1325,6 +1325,19 @@ describe("Send CLI", () => {
expect(String(lastSendBody?.text)).toContain("To: dev-impl@my-rig");
});
it("single-seat send with a trailing-slash OPENRIG_URL reaches <url>/api/..., not <url>//api/...", async () => {
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${port}/`);
vi.stubEnv("RIGGED_URL", "");
lastSendBody = null;
const rc = recordingRealClient();
const { logs } = await captureLogs(async () => {
await makeCmd(probeFailNoStateDeps(rc.factory)).parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello"]);
});
expect(logs.join("\n")).toContain("Sent to dev-impl@my-rig");
expect(rc.seen()).toBe(`http://127.0.0.1:${port}`);
expect(String(lastSendBody?.text)).toContain("To: dev-impl@my-rig");
});
it("R2 RED: fan-out --to + OPENRIG_URL + probe-stopped -> actual /broadcast lands with per-recipient results", async () => {
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${port}`);
vi.stubEnv("RIGGED_URL", "");
@@ -73,24 +73,26 @@ describe("reopening a Herdr view in a desktop window", () => {
});
afterEach(() => { process.exitCode = originalExit; vi.restoreAllMocks(); });
it.each([true, false])("following binds default named daemon endpoint before tab selection (matches=%s)", async matches => {
const f = fixture([{ workspace_id: "follow-w", tab_id: "follow-t", label: "following" }], "kernel");
const namedSocket = "/daemon home/sessions/daemon-named/herdr.sock";
it.each([[undefined, true], ["daemon-named", true], [undefined, false], ["daemon-named", false]] as const)("following binds the exact custom endpoint before tab selection (session=%s matches=%s)", async (session, matches) => {
const f = fixture([{ workspace_id: "follow-w", tab_id: "follow-t", label: "following" }], "kernel", session);
const namedSocket = "/run private/custom.sock";
f.get.mockImplementation(async (url: string) => ({ status: 200, data: url.includes("preview")
? { planId, composed: f.composed, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }
: { providers: [{ liveness: { alive: true }, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }] } }) as never);
? { ...(session ? { session } : {}), planId, composed: f.composed, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }
: { ...(session ? { session } : {}), providers: [{ liveness: { alive: true }, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }] } }) as never);
f.post.mockResolvedValue({ status: 200, data: { provider: "herdr", ok: true, workspaceId: "follow-w", opened: ["operator"], absent: [], degraded: [], pages: 1,
...(session ? { session } : {}),
following: { id: "same", enabled: true, state: "following", maxTiles: 8, tiles: [], events: [], overflow: [] } } } as never);
const original = f.exec.getMockImplementation()!;
f.exec.mockImplementation(async (file, args) => args.includes("status") && args.includes("server")
? JSON.stringify({ result: { running: true, socket: matches ? namedSocket : "/wrong/default.sock" } }) : original(file, args));
await f.run(["--follow", "--json"]);
await f.run(["--follow", "--json", ...(session ? ["--session", session] : [])]);
const parsed = JSON.parse(logs[0]!);
if (matches) {
expect(parsed).toMatchObject({ ok: true, workspaceId: "follow-w" });
expect(f.focus).toHaveBeenCalledExactlyOnceWith("follow-t");
for (const [, args] of f.exec.mock.calls.filter(([, args]) => args.includes("status") || args.includes("tab"))) {
expect(args).toContain("--session"); expect(args).toContain("daemon-named");
expect(args).toContain(`HERDR_SOCKET_PATH=${namedSocket}`);
expect(args).not.toContain("--session");
}
} else {
expect(parsed.ok).toBe(false); expect(f.focus).not.toHaveBeenCalled(); expect(f.list).not.toHaveBeenCalled();
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.22",
"version": "0.1.21",
"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.22",
"version": "0.1.21",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
@@ -81,6 +81,17 @@
]
}
],
"PostToolBatch": [
{
"hooks": [
{
"type": "command",
"command": "\"${OPENRIG_NODE:-node}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\" --from-plugin",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
@@ -84,6 +84,11 @@ function buildOpenRigPayload(providerPayload, env = process.env, now = () => new
generation,
hookEvent,
subtype,
// Claude's main session omits agent_id; subagents and in-process teammates carry it.
// Keep that distinction for permission completion, without forwarding tool inputs/results.
...(runtime === "claude-code" && (hookEvent === "Notification" || hookEvent === "PostToolBatch")
&& (providerPayload.agent_id == null || firstString(providerPayload.agent_id))
? { hookAgentId: firstString(providerPayload.agent_id) } : {}),
turnId: firstString(providerPayload.turn_id, providerPayload.turnId),
occurredAt: now().toISOString(),
relayRoute: argv.includes("--from-plugin") ? "plugin" : "managed",
@@ -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.5`). This marker describes the pack's teaching, not publication or live
`capability-delta-v0.7.4`). This marker describes the pack's teaching, not publication or live
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
What changed in the installed version, and what to do about it, is one read away:
`rig context get reference/whats-new.md`.
@@ -211,24 +211,14 @@ 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. Ordinary opens add a new
workspace, so to come back to a fixed view, focus its open space; task follow mode reuses one.
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.
**`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. Ordinary
open is one-shot. Add **`--follow --provider herdr`** to keep one workspace as the task's owners
join and leave (Linux Herdr 0.9.5 / protocol 22 only). Joining viewers do not request focus;
departures close viewers, not agents or conversations. Typed drafts and unreadable composers
retain departed tiles, including unconfirmed Pi, terminal and community-harness input.
`--pin`, `--suppress` and `--max-tiles` adjust the view; `--no-follow` keeps the fixed layout.
A complete empty task drains removable tiles and stops following once none remain; an explicit
`--follow` resumes that workspace. `rig terminal views --json` shows saved active selectors.
The exercised journey used shell stand-ins, not native agents, and active render cost remains
unmeasured. See `reference/terminal-sessions.md` for controls and limits. Preview and open
responses carry `viewDescriptor` (`kind`, `name`); membership grants no input authority.
An unconfirmed pane is reported as `pane_unconfirmed`, not counted as opened. Inspect before
retrying: an unconfirmed result does not prove that nothing opened.
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`).
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
@@ -1329,10 +1319,6 @@ your circumstances is configuration, and the ones that are not, another agent ca
with secret names, never values): OpenRig then POSTs a pointer to the row when work lands, and a
2xx records only that the ring was accepted, not that anything woke. When work waits for a
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
**Internet-facing hosted members are experimental, unsupported, off by default and at your
own risk.** Local assistants can connect locally. Internet access needs your own TLS tunnel or
proxy and a narrowly scoped, revocable token; see `reference/hosted-members.md`. Task terminal
following does not require internet exposure.
- **`rig skill loadout --runtime <claude-code|codex>`** — inspect the exact catalog revision,
selectors, target, and current/missing/shadowed/conflicting state for one working directory.
`--apply` writes only the managed ownership set, is idempotent, and refuses local edits or
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.7.5",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -16,7 +16,6 @@ metadata:
stage: factory-approved
sibling_skills:
- openrig-user
- openrig-operator
- developing-openrig
- openrig-upgrade
- forming-an-openrig-mental-model
@@ -16,7 +16,6 @@ metadata:
stage: factory-approved
sibling_skills:
- openrig-user
- openrig-operator
- forming-an-openrig-mental-model
---
@@ -48,7 +48,7 @@ states.
## Don't use this when
- The operation is single-command and deterministic — use `openrig-user` skill for CLI surface
- The work is operator-level configuration of OpenRig itself — use `openrig-operator`
- The work is operator-level configuration or troubleshooting of OpenRig itself — run `rig context get help`
- The work is rig spec authoring — use `openrig-architect`
## Failure modes (4)
@@ -120,7 +120,7 @@ different claims; don't conflate.
## See also
- `openrig-user` skill — CLI surface for `rig up / down / restore / etc.`
- `openrig-operator` skill — operator-level discipline for OpenRig itself
- `rig context get help` — the help guide for the installed version, for when OpenRig itself isn't working
- `seat-continuity-and-handover` skill — sub-primitive: occupant-creation modes for restore (`resume` / `rebuild` / `fresh` / `failed`)
- `session-source-fork` skill — `forked` continuity outcome for fork-based restore
- `permission-and-capability-preflight` skill — provider-auth blocker → escalation path
@@ -47,7 +47,8 @@ machine and have things happen.
marks them healthy. A HOST reboot drops tmux entirely; the
reconciler then marks the kernel's member sessions detached, and
the operator pod owns the agent-restart workflow ("bring my
sessions back online") per the openrig-operator skill.
sessions back online") per "Incomplete setup and restart":
`rig context get reference/getting-started.md#incomplete-setup-and-restart`.
Other rigs are NOT auto-instantiated by the daemon at any point —
they require explicit operator-initiated `rig up` / `rig restore`
via the agent-driven workflow.
@@ -259,6 +259,7 @@ 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 };
const claudeCommand = binding.claudeExecutable ? `exec ${shellQuote(binding.claudeExecutable)}` : "claude";
this.autoLaunches.delete(binding.nodeId);
opts = { ...opts, ...(opts.forkSource ? { forkSource: { ...opts.forkSource } } : {}) };
if (!binding.tmuxSession) {
@@ -277,6 +278,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
try {
if (!this.claudeManagedLaunch) await unresolvedClaudePermissionModes();
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.runtimeSwitchFrom ? { runtimeSwitch: { fromRuntime: binding.runtimeSwitchFrom.runtime, session: binding.runtimeSwitchFrom.session,
pane: binding.runtimeSwitchFrom.pane, generation: binding.runtimeSwitchFrom.generation } } : {}) }, binding.permissionMode);
@@ -356,7 +358,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
// The non-managed command text is parsed by the pane shell, so the
// parent id needs the same quoting as --model/--effort above (and as
// the codex fork path) — a hostile id must not leave --resume.
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(parentId)} --fork-session --session-id ${forkSessionId} --name ${opts.name}`;
: `${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(parentId)} --fork-session --session-id ${forkSessionId} --name ${opts.name}`;
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, managed.assertCurrent)
: await sendClassic(cmd);
if (!textResult.ok) {
@@ -377,8 +379,8 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const cmd = managed ? managed.command(["--permission-mode", binding.permissionMode!, ...launchArgs, ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), ...(advisor ? ["--advisor", advisor] : []), ...additionArgs,
...(opts.resumeToken ? ["--resume", opts.resumeToken] : ["--session-id", generatedSessionId!]), "--name", opts.name],
opts.resumeToken ? claudeResumeLaunchEnv(opts.resumeToken) : undefined) : opts.resumeToken
? `${claudeResumeLaunchPrefix(opts.resumeToken)}${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(opts.resumeToken)} --name ${opts.name}`
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --session-id ${generatedSessionId} --name ${opts.name}`;
? `${claudeResumeLaunchPrefix(opts.resumeToken)}${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(opts.resumeToken)} --name ${opts.name}`
: `${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --session-id ${generatedSessionId} --name ${opts.name}`;
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Selected history changed during Claude launch preparation; no native command was sent.", recovery: "attention_required" };
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, beforeManagedInput)
@@ -79,6 +79,7 @@ export class ClaudeResumeAdapter {
launchAdditions?: SeatLaunchAdditions,
claudePermissions?: ClaudePermissionRules,
beforeNativeDispatch?: () => boolean,
executable?: string,
): Promise<ResumeResult> {
if (!this.canResume(resumeType, resumeToken)) {
return { ok: false, code: "no_resume", message: "Claude resume not available" };
@@ -97,7 +98,8 @@ export class ClaudeResumeAdapter {
if (selectedPermissionMode !== undefined) {
try {
if (!nodeId || !this.options.claudeManagedLaunch) await unresolvedClaudePermissionModes();
managed = await this.options.claudeManagedLaunch!.prepare({ nodeId: nodeId!, cwd, session: tmuxSessionName }, selectedPermissionMode);
managed = await this.options.claudeManagedLaunch!.prepare({ nodeId: nodeId!, cwd, session: tmuxSessionName,
...(executable ? { executable } : {}) }, selectedPermissionMode);
} 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).
@@ -112,7 +114,7 @@ export class ClaudeResumeAdapter {
});
const permissionMode = posture + launchArgs.map(arg => ` ${shellQuote(arg)}`).join("");
const cmd = managed ? managed.command(["--permission-mode", selectedPermissionMode!, ...launchArgs, ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), ...(advisor ? ["--advisor", advisor] : []), ...additionArgs, "--resume", resumeToken!], claudeResumeLaunchEnv(resumeToken!))
: `${claudeResumeLaunchPrefix(resumeToken!)}${claudeClassicRendererEnvPrefix(process.env)}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(resumeToken!)}`;
: `${claudeResumeLaunchPrefix(resumeToken!)}${claudeClassicRendererEnvPrefix(process.env)}${executable ? `exec ${shellQuote(executable)}` : "claude"} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(resumeToken!)}`;
const assertSelected = beforeNativeDispatch ? () => { if (!beforeNativeDispatch()) throw new Error("Selected history changed before Claude resume input"); } : undefined;
const beforeManagedInput = beforeNativeDispatch ? () => { assertSelected?.(); managed?.assertCurrent?.(); } : managed?.assertCurrent;
@@ -174,7 +174,9 @@ export const OMP_PROVIDER_EXTRA_ENV_VARS: Record<string, readonly string[]> = {
};
// Baseline process needs. No host credential families or shell customization.
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "USER", "LOGNAME", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
// SSH_AUTH_SOCK lets Pi's shell tools run git over SSH (#1159): the path isn't key material, but the socket lets the
// agent use the keys loaded in ssh-agent.
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "USER", "LOGNAME", "SSH_AUTH_SOCK", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
// NodeLauncher supplies identity and instance routing on both fresh and resumed
// seats. Pi's shell tools inherit this child env: dropping these values makes
+1 -1
View File
@@ -1194,7 +1194,7 @@ export class TmuxAdapter {
beforeWrite();
const result = await this.respawnPaneUnchecked(pane, command, opts);
const guard = this.deliveryGuard;
const target = guard?.maybeTarget(pane);
const target = guard?.maybeTarget(paneTarget);
if (result.ok && target && guard!.ownsLifecycle(target.nodeId)) guard!.noteFreshLifecyclePane(target.nodeId, pane);
return result;
}, false, "respawn");
@@ -134,7 +134,7 @@ export interface ActivityEvidence {
livenessSeconds?: number;
activity?: ActivityValue;
needsInput?: NeedsInput;
permissionRequest?: { id: string };
permissionRequest?: { id: string; hookAgentId?: string | null };
permissionResolved?: boolean;
/** Which relay route posted hook evidence, when it said. One event can arrive by more than one route (a Codex seat's
* plugin hook and its managed hook), so promotion counts agreements per route. Absent for an untagged post. */
@@ -8,6 +8,7 @@ export const AGENT_ACTIVITY_FRESHNESS_MS = 5 * 60 * 1000;
export interface HookActivityInput {
turnId?: string | null;
hookAgentId?: string | null;
runtime: string | null;
sessionName?: string | null;
@@ -120,6 +121,7 @@ export class AgentActivityStore {
generation,
});
activity.turnId = input.turnId ?? null;
if (input.hookAgentId !== undefined) activity.hookAgentId = input.hookAgentId;
if (input.sourceSequence) activity.sourceSequence = input.sourceSequence;
if (input.livenessSeconds) activity.livenessSeconds = input.livenessSeconds;
+1 -1
View File
@@ -462,7 +462,7 @@ export class ClaimService {
const input = { target, tmux: this.tmuxAdapter, listProcesses: this.listProcesses };
try {
if (runtime === "claude-code") return !!await verifyClaudePaneRuntime(input);
if (runtime === "codex") return !!await verifyCodexPaneProcess(input);
if (runtime === "codex") return !!await verifyCodexPaneProcess({ ...input, requireArgv: true });
} catch { /* unproven */ }
return false;
}
@@ -18,6 +18,8 @@ import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, claudeSeatTo
import { 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. */
executable?: string;
nodeId: string;
cwd?: string;
session?: string;
@@ -221,7 +223,7 @@ export class ClaudeManagedLaunch {
return this.selectedConfigDir(nodeId) ? this.configPaths(cwd, nodeId).statePath : undefined;
}
private context(cwd: string, nodeId: string) {
private context(cwd: string, nodeId: string, selectedExecutable?: string) {
const { PATH, HOME } = this.sessionEnv;
const CLAUDE_CONFIG_DIR = this.selectedConfigDir(nodeId);
if (!PATH || !HOME || !path.isAbsolute(HOME)) throw new Error("Claude managed launch context is unresolved: managed PATH and absolute HOME are required.");
@@ -235,8 +237,9 @@ export class ClaudeManagedLaunch {
if (CLAUDE_CONFIG_DIR !== undefined) env.CLAUDE_CONFIG_DIR = configDir;
if (claudeClassicRendererEnvPrefix(this.rendererEnv)) env.CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN = "1";
let executable: string | undefined;
for (const dir of search) {
const candidate = path.join(dir, "claude");
const candidates = selectedExecutable ? [selectedExecutable] : search.map(dir => path.join(dir, "claude"));
for (const candidate of candidates) {
if (!path.isAbsolute(candidate)) continue;
try { accessSync(candidate, constants.X_OK); if (!statSync(candidate).isFile()) continue; }
catch { continue; }
executable = realpathSync(candidate); break;
@@ -272,7 +275,7 @@ 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);
const context = this.context(cwd, target.nodeId, 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
@@ -280,7 +283,7 @@ export class ClaudeManagedLaunch {
if (inherited.some(key => !/^[A-Z_][A-Z0-9_]*$/.test(key))) throw new Error("Invalid managed environment key.");
const assertCurrent = () => {
if (JSON.stringify(this.target(target.nodeId, switching?.fromRuntime)) !== JSON.stringify(before)
|| JSON.stringify(this.context(cwd, target.nodeId)) !== JSON.stringify(context)
|| JSON.stringify(this.context(cwd, target.nodeId, 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)) {
throw new Error("Claude managed launch context changed during capability discovery/input; retry explicitly.");
@@ -1,7 +1,7 @@
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
import { isAbsolute } from "node:path";
import type { SessionRegistry } from "./session-registry.js";
import { observeCodexPaneProcess, codexCommandResumeToken, processArgv, type NativeProcessLister } from "./native-process-lineage.js";
import { observeCodexPaneProcess, codexProcessArgv, codexProcessResumeToken, type NativeProcessLister } from "./native-process-lineage.js";
import { lstartToMinTs } from "./codex-thread-id.js";
import { parseSqliteUtcMs } from "./sqlite-time.js";
import { shellQuote } from "../adapters/shell-quote.js";
@@ -106,13 +106,15 @@ export async function observeCodexRebuildPermissions(input: {
return unverified("The predecessor's current occupant and pane binding could not be established.");
}
try {
const probe = { target: binding.tmuxPane, tmux: input.tmux, listProcesses: input.listProcesses };
const probe = { target: binding.tmuxPane, tmux: input.tmux, listProcesses: input.listProcesses, requireArgv: true };
const first = await observeCodexPaneProcess(probe);
const start = lstartToMinTs(first?.process.startedAt);
const boot = parseSqliteUtcMs(tenure.bootAt);
if (!first || start === undefined || !Number.isFinite(boot)) return unverified("The predecessor's native process identity or start time was not established.");
const argv = codexProcessArgv(first.process);
if (!argv) return unverified("The predecessor's exact native arguments could not be read.");
const thread = await input.readThreadId(first.process.pid, first.process.startedAt!);
const argvThread = codexCommandResumeToken(first.process.command);
const argvThread = codexProcessResumeToken(first.process);
if (!thread || (argvThread !== undefined && argvThread !== thread)) return unverified("The predecessor's process and native conversation could not be joined.");
const path = input.rollout(thread);
const record = path ? readCodexPermissionRecord(path, thread, input.cwd, Math.max(start * 1000 + 999, boot)) : null;
@@ -124,7 +126,6 @@ export async function observeCodexRebuildPermissions(input: {
|| current?.tmuxPane !== binding.tmuxPane || current?.tmuxSession !== binding.tmuxSession) {
return unverified("The predecessor changed while its native permission policy was read.");
}
const argv = processArgv(first.process.command);
const profileAt = argv.findIndex(arg => arg === "-p" || arg === "--profile");
return { state: "observed", ...record, processStartedAt: first.process.startedAt!,
profile: profileAt >= 0 ? argv[profileAt + 1] ?? null : null };
@@ -181,10 +181,8 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
/** 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.
* the current ownership reader cannot own. Other wrapped text gets a row
* upper bound; a line fitting the native width needs only one row.
*/
export function composerPasteUncertainty(input: ComposerInput, text: string): "controls" | "long" | "viewport" | null {
const viewport = input.pasteViewport;
@@ -197,11 +195,9 @@ export function composerPasteUncertainty(input: ComposerInput, text: string): "c
if (viewport.columns < 1 || viewport.rows < 1) return "viewport";
let rows = 0;
for (const line of lines) {
// Two cells per non-ASCII code point conservatively covers wide glyphs.
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;
rows += cells <= viewport.columns ? 1 : Math.ceil(2 * cells / viewport.columns) + 1;
if (rows > viewport.rows) return "viewport";
}
return null;
@@ -205,26 +205,19 @@ export function makeQueuePorts(
opts: { loadHumanRegistry?: () => LoadResult; sharedChannels?: () => ReadonlyArray<{ address: string }> } = {},
): InboundQueuePort & OutboundQueuePort & { listOpenAsks(filter: AlertFilterOpts): Promise<OpenAskEpisode[]> } {
/** Each active row's current owner-notification episode, projected to its person or shared channel. Posted episodes
* are skipped unless asked for (the alert sweep never wants them). Null when nobody is registered. */
* are skipped unless asked for (the alert sweep never wants them). Null when nobody is registered.
*
* Eligibility lives in the query now: an active row must carry an owner notification, and — for the alert sweep —
* it must still be unposted to Slack. Only eligible rows reach the per-row lookup, so a pathological backlog of
* active rows without any notification costs a single read instead of one latest-transition and one receipt lookup
* per row. There is no row cap: an eligible item older than any window still comes back in both views. */
const currentEpisodes = (includePosted: boolean): Array<{ item: QueueItem; posted: boolean }> | null => {
// Nobody registered yet isn't a broken registry: rows to a registered shared channel still post (0.7.3).
const people = registeredPeople((opts.loadHumanRegistry ?? (() => loadHumanRegistry()))());
if (!people) return null;
const channels = (opts.sharedChannels ?? (() => sharedChannelAddresses()))();
// Avoid building waiting/recovery views for rows with no unposted owner notification.
// Keep list(activeOnly)'s selection, ordering and cap; this remains an O(N) ID scan.
const ids = queueRepo.db.prepare(
`SELECT qitem_id FROM queue_items
WHERE state IN ('pending', 'in-progress', 'blocked')
ORDER BY CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC
LIMIT ?`,
).all(1000000) as Array<{ qitem_id: string }>;
return ids.flatMap(({ qitem_id: qitemId }) => {
const transition = queueRepo.transitionLog.latestOwnerNotificationForQitem(qitemId);
if (!transition) return [];
const notificationKey = `${qitemId}:${transition.transitionId}`;
const posted = queueRepo.transitionLog.hasOwnerNotificationReceipt(qitemId, notificationKey);
if (posted && !includePosted) return [];
const episodes = queueRepo.transitionLog.activeOwnerNotificationEpisodes(includePosted);
return episodes.flatMap(({ qitemId, transition, posted }) => {
const row = queueRepo.getById(qitemId);
if (!row) return [];
const item = project(row, transition, people, channels);
+13 -1
View File
@@ -103,13 +103,25 @@ interface HeaderScan {
unterminatedFenceLine: number | null;
}
/** Scan for real headers, skipping fenced code blocks (``` or ~~~, any info string;
/** Scan for real headers, skipping HTML comment blocks and fenced code blocks (``` or ~~~, any info string;
* a fence closes only on the same marker at the same or greater length). */
function scanHeaders(lines: string[]): HeaderScan {
const hits: HeaderHit[] = [];
let fence: { marker: string; length: number; line: number } | null = null;
let comment = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
// HTML comment blocks end on the line containing --> (or at EOF). Their
// headings and fences are literal content, just as comment markers inside
// a fenced code block are literal content. Keep the original span bytes.
if (comment) {
if (line.includes("-->")) comment = false;
continue;
}
if (!fence && /^ {0,3}<!--/.test(line)) {
comment = !line.includes("-->");
continue;
}
const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})/);
if (fenceMatch) {
const marker = fenceMatch[1]![0]!;
@@ -0,0 +1,168 @@
import { execFile } from "node:child_process";
import { createReadStream } from "node:fs";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const MAX_PIDS = 128;
const MAX_BYTES = 1024 * 1024;
const MAX_ARGS = 4096;
const MAX_OUTPUT = 2 * MAX_BYTES;
// KERN_PROCARGS2 begins with argc and the executable path, followed by argc NUL-delimited
// arguments and then the environment. Only the arguments are returned by this helper.
const MAC_ARGUMENTS = `ObjC.import("Foundation");
ObjC.bindFunction("sysctl", ["int", ["void *", "uint32_t", "void *", "void *", "void *", "size_t"]]);
function number(bytes, count) {
var value = 0;
for (var i = count - 1; i >= 0; i--) value = value * 256 + bytes.charCodeAt(i);
return value;
}
function parse(data, used) {
if (used < 7 || used > 1048576) return null;
var exact = data.subdataWithRange({location: 0, length: used});
var bytes = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(exact, $.NSISOLatin1StringEncoding));
if (typeof bytes !== "string" || bytes.length !== used) return null;
var argc = number(bytes, 4);
if (argc < 1 || argc > 4096) return null;
var pathEnd = bytes.indexOf("\\u0000", 4);
if (pathEnd <= 4) return null;
// XNU aligns ip_startargv after the saved path to the target's pointer size.
// KERN_PROCARGS2 strips the 16-byte executable_path= prefix. Supported macOS
// processes are 64-bit, so the path and its padding occupy whole 8-byte units.
// Skipping every zero would also skip an empty argv[0] and read env as argv.
var offset = 4 + Math.ceil((pathEnd - 4 + 1) / 8) * 8;
if (offset >= used) return null;
for (var padding = pathEnd + 1; padding < offset; padding++) {
if (bytes.charCodeAt(padding) !== 0) return null;
}
var args = [];
for (var index = 0; index < argc; index++) {
if (offset >= used) return null;
var end = bytes.indexOf("\\u0000", offset);
if (end < 0 || end >= used) return null;
var argument = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(exact.subdataWithRange({location: offset, length: end - offset}), $.NSUTF8StringEncoding));
if (typeof argument !== "string") return null;
// NSString consumes a UTF-8 BOM at the start of each decoded unit. Restore
// it so a BOM-prefixed token cannot become an exact conversation UUID.
if (end - offset >= 3 && bytes.charCodeAt(offset) === 239 && bytes.charCodeAt(offset + 1) === 187
&& bytes.charCodeAt(offset + 2) === 191) argument = String.fromCharCode(65279) + argument;
args.push(argument);
offset = end + 1;
}
return args[0] ? args : null;
}
function run(inputs) {
var data = $.NSMutableData.dataWithLength(1048576);
var outputSize = 0;
return JSON.stringify(inputs.map(function(input) {
var request = JSON.parse(input);
if (outputSize > 2097152) return [request.pid, null];
if (request.fixture) {
var fixture = $.NSData.alloc.initWithBase64EncodedStringOptions(request.fixture, 0);
return [request.pid, parse(fixture, Number(fixture.length))];
}
var mib = $.NSData.alloc.initWithBase64EncodedStringOptions(request.mib, 0);
var size = $.NSData.alloc.initWithBase64EncodedStringOptions(request.size, 0).mutableCopy;
if ($.sysctl(mib.bytes, 3, data.mutableBytes, size.mutableBytes, null, 0) !== 0) return [request.pid, null];
var sizeBytes = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(size, $.NSISOLatin1StringEncoding));
var used = typeof sizeBytes === "string" && sizeBytes.length === 8 ? number(sizeBytes, 8) : 0;
var args = parse(data, used);
if (args) {
var answerSize = JSON.stringify(args).length;
if (outputSize + answerSize > 2097152) return [request.pid, null];
outputSize += answerSize;
}
return [request.pid, args];
}));
}`;
/** Parse a Linux /proc cmdline without ever treating malformed bytes as an argument boundary. */
export function parseLinuxProcessArguments(bytes: Buffer): string[] | null {
if (bytes.length < 2 || bytes.length > MAX_BYTES || bytes.at(-1) !== 0) return null;
const args: string[] = [];
// TextDecoder strips an initial BOM by default, even when decoding each argv
// unit separately. Keep that byte sequence as U+FEFF so it cannot become a UUID.
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
let start = 0;
try {
for (let index = 0; index < bytes.length; index++) {
if (bytes[index] !== 0) continue;
args.push(decoder.decode(bytes.subarray(start, index)));
if (args.length > MAX_ARGS) return null;
start = index + 1;
}
} catch { return null; }
return args[0] ? args : null;
}
async function boundedLinuxCmdline(pid: number, signal: AbortSignal): Promise<string[] | null> {
const chunks: Buffer[] = [];
let length = 0;
try {
for await (const chunk of createReadStream(`/proc/${pid}/cmdline`, { highWaterMark: 64 * 1024, signal })) {
length += chunk.length;
if (length > MAX_BYTES) return null;
chunks.push(chunk);
}
return parseLinuxProcessArguments(Buffer.concat(chunks, length));
} catch { return null; }
}
/** Exercise the same macOS KERN_PROCARGS2 parser with a crafted buffer, without reading any process. */
export async function parseMacProcessArgumentsForTest(bytes: Buffer): Promise<string[] | null> {
if (process.platform !== "darwin") return null;
try {
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_ARGUMENTS,
JSON.stringify({ pid: 0, fixture: bytes.toString("base64") })], { encoding: "utf8", timeout: 2000, maxBuffer: MAX_OUTPUT });
const parsed: unknown = JSON.parse(stdout);
return Array.isArray(parsed) && Array.isArray(parsed[0]) && Array.isArray(parsed[0][1]) ? parsed[0][1] as string[] : null;
} catch { return null; }
}
/** Exact OS argv for bounded native Codex candidates. Unavailable or malformed reads have no witness. */
export async function readNativeProcessArguments(requested: number[]): Promise<Map<number, string[]>> {
const pids = [...new Set(requested.filter(pid => Number.isInteger(pid) && pid > 0 && pid <= 2147483647))].slice(0, MAX_PIDS);
const result = new Map<number, string[]>();
if (pids.length === 0) return result;
let entries: unknown;
try {
if (process.platform === "linux") {
const found: Array<[number, string[] | null]> = [];
// Bounded concurrency and one overall deadline keep a slow /proc from holding observation indefinitely.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
try {
for (let start = 0; start < pids.length; start += 16) {
const batch = await Promise.all(pids.slice(start, start + 16).map(async (pid): Promise<[number, string[] | null]> =>
[pid, await boundedLinuxCmdline(pid, controller.signal)]));
found.push(...batch);
if (controller.signal.aborted) break;
}
} finally { clearTimeout(timer); }
entries = found;
} else if (process.platform === "darwin") {
const size = Buffer.alloc(8);
size.writeBigUInt64LE(BigInt(MAX_BYTES));
const requests = pids.map(pid => {
const mib = Buffer.alloc(12);
mib.writeInt32LE(1, 0); // CTL_KERN
mib.writeInt32LE(49, 4); // KERN_PROCARGS2
mib.writeInt32LE(pid, 8);
return JSON.stringify({ pid, mib: mib.toString("base64"), size: size.toString("base64") });
});
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_ARGUMENTS, ...requests], {
encoding: "utf8", timeout: 2000, maxBuffer: MAX_OUTPUT,
});
entries = JSON.parse(stdout);
} else return result;
if (!Array.isArray(entries)) return result;
const requestedPids = new Set(pids);
for (const entry of entries) {
if (!Array.isArray(entry) || !requestedPids.has(entry[0])) continue;
const args: unknown = entry[1];
if (Array.isArray(args) && args.length > 0 && args.length <= MAX_ARGS && typeof args[0] === "string" && args[0].length > 0
&& args.every(arg => typeof arg === "string" && !arg.includes("\0"))) result.set(entry[0], args);
}
} catch { /* No OS argv is unknown, never a fallback to flattened ps text. */ }
return result;
}
@@ -1,12 +1,15 @@
import { isShellForeground } from "./shell-classifier.js";
import { runAsyncSite } from "./sync-site-wrap.js";
import { readNativeExecutablePaths } from "./native-process-executable.js";
import { readNativeProcessArguments } from "./native-process-arguments.js";
import { readProcessTable } from "./process-table.js";
export interface NativeProcessRow {
pid: number;
ppid: number;
command: string;
/** Exact OS argv for native Codex. Missing, malformed, or unreadable argv never proves Codex identity. */
argv?: string[] | null;
pgid?: number;
tpgid?: number;
executableName?: string;
@@ -128,21 +131,18 @@ function mayBeClaudeAfterScript(row: NativeProcessRow): boolean {
}
function commandUsesExpectedToken(command: string, runtime: NativeRuntime, expectedToken: string): boolean {
// Codex takes the exact-argv selector in findExactNativeResumeProcess before this Claude-only search.
if (runtime !== "claude-code") return false;
const argv = tokens(command);
const executable = runtime === "claude-code" ? "claude" : "codex";
const executableIndex = argv.findIndex((token) => runtime === "claude-code" ? claudeExecutable(token) : executableName(token) === executable);
const executableIndex = argv.findIndex((token) => claudeExecutable(token));
if (executableIndex < 0) return false;
const args = argv.slice(executableIndex + 1);
if (runtime === "claude-code") {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if ((arg === "--resume" || arg === "--session-id") && args[index + 1] === expectedToken) return true;
if (arg === `--resume=${expectedToken}` || arg === `--session-id=${expectedToken}`) return true;
}
return false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if ((arg === "--resume" || arg === "--session-id") && args[index + 1] === expectedToken) return true;
if (arg === `--resume=${expectedToken}` || arg === `--session-id=${expectedToken}`) return true;
}
return codexConversationToken(args, "resume") === expectedToken;
return false;
}
// Interactive options can appear before or after `resume`/`fork`. Their values
@@ -167,7 +167,7 @@ function codexOptionEnd(args: string[], index: number): number {
}
// undefined is another command; null is the requested command without an exact token.
function codexConversationToken(args: string[], subcommand: "resume" | "fork"): string | null | undefined {
function codexConversationToken(args: string[], subcommand: "resume" | "fork", exactArgv = false): string | null | undefined {
let resumeIndex = -1;
for (let index = 0; index < args.length;) {
const arg = args[index]!;
@@ -186,10 +186,10 @@ function codexConversationToken(args: string[], subcommand: "resume" | "fork"):
if (arg === "--") { conversation ??= resumeArgs[index + 1] ?? null; break; }
if (arg === "--last") last = true;
if (arg.startsWith("-")) { index = codexOptionEnd(resumeArgs, index); continue; }
// ps has lost argv boundaries: after a session and the first prompt word,
// later option-looking words belong to the prompt. A real --last conflicts
// with a session plus a prompt in Codex's interactive grammar.
if (conversation !== null) break;
// A flattened ps command cannot tell a quoted prompt from several argv entries. Exact
// argv can: keep scanning for a real --last after a prompt, while a literal --last
// inside one prompt argument never equals the option.
if (conversation !== null) { if (!exactArgv) break; index += 1; continue; }
conversation = arg;
index += 1;
}
@@ -215,6 +215,25 @@ export function codexCommandResumeToken(command: string): string | null | undefi
return codexConversationToken(tokens(command).slice(1), "resume");
}
/** The native process's own resume identity. Flattened ps text is never positive proof. */
export function codexProcessResumeToken(row: Pick<NativeProcessRow, "argv">): string | null | undefined {
const argv = codexProcessArgv(row);
return argv ? codexConversationToken(argv.slice(1), "resume", true) : undefined;
}
function validCodexArgv(row: Pick<NativeProcessRow, "argv">): row is { argv: string[] } {
let bytes = 0;
return Array.isArray(row.argv) && row.argv.length > 0 && row.argv.length <= 4096
&& typeof row.argv[0] === "string" && row.argv[0].length > 0
&& row.argv.every(arg => typeof arg === "string" && !arg.includes("\0")
&& (bytes += Buffer.byteLength(arg)) <= 1024 * 1024);
}
/** Exact, bounded argv from a process whose argv[0] also names Codex. No ps-text fallback. */
export function codexProcessArgv(row: Pick<NativeProcessRow, "argv">): string[] | null {
return validCodexArgv(row) && executableName(row.argv[0]!) === "codex" ? row.argv : null;
}
type ClaudeArgvIds = { resume: string | null; "session-id": string | null };
// OPR.0.7.0.9: a seat's launch additions, read the same way by both selectors. The options OpenRig already emitted
@@ -496,6 +515,19 @@ export async function listNativeProcesses(options: { claudeLaunchPaths?: boolean
const paths = await readNativeExecutablePaths(candidates.map(row => row.pid));
for (const row of candidates) row.executablePath = paths.get(row.pid);
}
// Linux argv was captured with this same /proc table sample; never read real /proc
// again after a caller has selected a fixture or alternate process-table root.
// Only foreground rows can be selected below. Skip macOS helper reads for
// background Codex processes; every foreground seat still gets fresh argv.
const codexCandidates = process.platform === "darwin"
? rows.filter(row => row.executableName === "codex" && row.pgid !== undefined && row.pgid > 0 && row.pgid === row.tpgid)
: [];
if (codexCandidates.length > 0) {
// A failed or timed-out helper read loses argv evidence, not the process table.
// The foreground Codex still participates in delivery's uniqueness check.
const argumentsByPid = await readNativeProcessArguments(codexCandidates.map(row => row.pid)).catch(() => new Map<number, string[]>());
for (const row of codexCandidates) row.argv = argumentsByPid.get(row.pid) ?? null;
}
return rows;
} catch { return []; }
}
@@ -532,24 +564,30 @@ function candidatesMatching(rows: NativeProcessRow[], panePid: number, runtime:
const executable = runtime === "claude-code" ? "claude" : "codex";
for (const row of rows) {
const osExecutable = runtime === "claude-code" ? executableName(row.executableName ?? "") : row.executableName;
if ((runtime === "claude-code" ? !claudeRow(row)
: osExecutable !== executable || executableName(tokens(row.command)[0] ?? "") !== executable)
|| row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
if (row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
if (runtime === "claude-code" ? !claudeRow(row) : osExecutable !== executable) continue;
// OS-named Codex with unreadable argv still competes for the foreground.
// A readable argv[0] naming a different executable is positive contrary evidence.
if (runtime === "codex" && validCodexArgv(row) && !codexProcessArgv(row)) return [];
const chain: NativeProcessRow[] = [];
const visited = new Set<number>();
let current: NativeProcessRow | undefined = row;
let belongsToPane = false;
while (current && !visited.has(current.pid) && current.startedAt) {
visited.add(current.pid);
chain.push(current);
if (current.pid === panePid) { matches.push({ process: row, chain }); break; }
if (current.pid === panePid) { matches.push({ process: row, chain }); belongsToPane = true; break; }
current = byPid.get(current.ppid);
}
if (runtime === "codex" && !belongsToPane) return [];
}
return matches.map(({ process, chain }) => ({ panePid, process,
fingerprint: JSON.stringify(chain.map(row => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command, row.executablePath])) }));
fingerprint: JSON.stringify(chain.map(row => runtime === "codex"
? [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command, row.executablePath, row.argv]
: [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command, row.executablePath])) }));
}
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex", selectedExecutable?: string, rotation?: ClaudeResumeRotation | null, scriptLaunch?: boolean): NativeProcessObservation | null {
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex", selectedExecutable?: string, rotation?: ClaudeResumeRotation | null, scriptLaunch?: boolean, requireArgv = false): NativeProcessObservation | null {
const matches = nativeProcessCandidates(rows, panePid, runtime, selectedExecutable, scriptLaunch);
// #1079: a Codex launcher that spawns Codex is one runtime on one chain.
const chain = runtime === "codex" ? launcherChain(matches, rows) : matches;
@@ -565,8 +603,11 @@ function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expected
// links naming different conversations stay refused. Exact resume proof
// needs the deepest process's own argv: a launcher's token is never
// inherited by a child that names nothing, which stays deliverable but unproved.
const identities = chain.map((link) => codexConversationToken(tokens(link.process.command).slice(1), "resume"));
const identities = chain.map((link) => codexProcessResumeToken(link.process));
if (new Set(identities.filter((value) => typeof value === "string")).size > 1) return null;
// Exact resume proof needs readable argv across the whole foreground chain:
// an opaque launcher may name another conversation even when its child agrees.
if ((requireResume || requireArgv) && chain.some((link) => !codexProcessArgv(link.process))) return null;
if (requireResume && (!expectedToken || identities[0] !== expectedToken)) return null;
if (expectedToken !== undefined && identities.some((value) => value !== undefined
&& (!expectedToken || value !== expectedToken))) return null;
@@ -620,10 +661,11 @@ export function explainNativeRejection(rows: NativeProcessRow[], panePid: number
if (launched === rotation?.token) return rotationProcess(process, rotation) ? { kind: "passes" } : { kind: "rotation_process_mismatch", conversation: launched };
return { kind: "other_conversation", conversation: launched };
}
const identities = chain.map((link) => codexConversationToken(tokens(link.process.command).slice(1), "resume"));
const identities = chain.map((link) => codexProcessResumeToken(link.process));
if (new Set(identities.filter((value) => typeof value === "string")).size > 1) return { kind: "conflicting_conversations" };
if (requireResume) {
if (!expectedToken) return { kind: "no_recorded_conversation" };
if (chain.some((link) => !validCodexArgv(link.process))) return { kind: "not_a_resume" };
if (identities[0] === undefined) return { kind: "not_a_resume" };
if (identities[0] === null) return { kind: "names_no_conversation" };
if (identities[0] !== expectedToken) return { kind: "other_conversation", conversation: identities[0] };
@@ -642,6 +684,8 @@ async function observeNativePaneProcess(input: {
listProcesses?: NativeProcessLister;
expectedToken?: string | null;
requireResume?: boolean;
/** Codex only: require exact argv on every chain link without requiring a resume (fresh launches remain eligible). */
requireArgv?: boolean;
/** Canonical executable frozen by the managed launch, never re-resolved at observation time. */
selectedExecutable?: string;
/** Claude only: the frozen executable is a `#!` script, so a process whose OS executable path is named `claude` is
@@ -656,7 +700,7 @@ async function observeNativePaneProcess(input: {
if (!pid) return null;
const rows = await (input.listProcesses ?? (() => listNativeProcesses({ claudeLaunchPaths: input.scriptLaunch === true })))();
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime, input.selectedExecutable,
runtime === "claude-code" ? input.rotation : undefined, input.scriptLaunch);
runtime === "claude-code" ? input.rotation : undefined, input.scriptLaunch, input.requireArgv);
} catch { return null; }
}
@@ -678,11 +722,18 @@ export async function observeCodexForkProcess(input: {
const rows = await (input.listProcesses ?? listNativeProcesses)();
const chain = launcherChain(nativeProcessCandidates(rows, pid, "codex"), rows);
if (!chain?.length) return null;
const args = chain.map(link => tokens(link.process.command).slice(1));
const parents = args.map(argv => codexConversationToken(argv, "fork"));
// A fork is positive history proof. Every chain link must have exact argv,
// including the selected child and any launcher that could name a conflict.
const args: string[][] = [];
for (const link of chain) {
const argv = codexProcessArgv(link.process);
if (!argv) return null;
args.push(argv.slice(1));
}
const parents = args.map(argv => codexConversationToken(argv, "fork", true));
if (!parents.includes(input.parentToken)
|| parents.some(parent => parent !== undefined && parent !== input.parentToken)
|| args.some(argv => codexConversationToken(argv, "resume") !== undefined)) return null;
|| args.some(argv => codexConversationToken(argv, "resume", true) !== undefined)) return null;
return chain[0]!;
} catch { return null; }
}
+7 -6
View File
@@ -588,9 +588,9 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
const identityVerdicts = new SeatIdentityStore(db).getForRig(rigId);
// FS-1 W1.3 S1/S2 — the per-node restore-outcome + oriented reads built ONCE
// for the rig (was a query per node inside the rows.map). rig-scoped restore
// map = the prior WHERE rig_id=? filter; oriented map is node-scoped/global.
// map = the prior WHERE rig_id=? filter; oriented history follows the rig's nodes.
const restoreOutcomes = buildRestoreOutcomeMap(db, rigId);
const orienteds = buildOrientedMap(db);
const orienteds = buildOrientedMap(db, rigId);
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts();
const rows = queryInventoryRows(db, rigId);
@@ -1284,8 +1284,8 @@ export function attachTerminalActivityAndWork(
}
const work = countAssignedWorkForEntry(entry, assignedByDest);
const arbitratedNeedsInput = arbitratedNeedsInputSignal(arb);
const hook = entry.canonicalSessionName
? deps.agentActivity?.getLatestForNode({ sessionName: entry.canonicalSessionName, now,
const hook = 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 {
...entry,
@@ -1603,11 +1603,12 @@ export async function attachAgentActivity(
};
};
const hookActivity = deps.activityStore?.getLatestForNode({
const hookActivity = entry.canonicalSessionName ? deps.activityStore?.getLatestForNode({
nodeId: entry.nodeId,
sessionName: entry.canonicalSessionName,
now: sampledAt,
order: entry.runtime === "claude-code" ? "source" : "arrival",
});
}) : null;
// Reuse the existing cached capture. Only a complete supported idle frame, bound to this
// target and started in a later second than the last write, distinguishes idle redraw from resumed work.
// window_activity has second precision: same-second ordering is unknown. Poll/completion time is not write time.
+6 -4
View File
@@ -218,7 +218,7 @@ export class NodeLauncher {
return { ok: false, code: "selection_changed", message: "The selected node or history changed before launch." };
}
const tmuxResult = opts?.keepPane
? await this.takeOverPane(sessionName, opts.keepPane, sessionCwd, openRigEnv)
? await this.takeOverPane(sessionName, opts.keepPane, sessionCwd, openRigEnv, node.id)
: await this.tmuxAdapter.createSession(sessionName, sessionCwd, openRigEnv);
if (!tmuxResult.ok) {
return { ok: false, code: tmuxResult.code, message: tmuxResult.message };
@@ -364,17 +364,19 @@ export class NodeLauncher {
/** A kept pane's takeover, as a handover's cutover does it: its process ends in place (the pane survives it, with its
* scrollback), then a shell starts in it with this launch's environment, for the runtime to launch into. Nothing is
* touched when the pane isn't in the session. */
private async takeOverPane(sessionName: string, pane: string, cwd: string | undefined, env: Record<string, string>):
private async takeOverPane(sessionName: string, pane: string, cwd: string | undefined, env: Record<string, string>, nodeId: string):
Promise<{ ok: true; warnings?: string[] } | { ok: false; code: string; message: string }> {
let panes: Array<{ id: string }> = [];
try { panes = await this.tmuxAdapter.listPanes(sessionName); } catch { /* an unreadable session holds no pane to keep */ }
if (!panes.some((candidate) => candidate.id === pane)) {
return { ok: false, code: "pane_not_found", message: `Pane ${pane} isn't in tmux session "${sessionName}", so nothing was stopped.` };
}
const ended = await terminatePaneInPlace(this.tmuxAdapter, pane, this.paneExit, "The seat's process");
// The guard resolves the selected seat before writing to its immutable pane id.
const inputTarget = this.tmuxAdapter.deliveryGuard ? nodeId : pane;
const ended = await terminatePaneInPlace(this.tmuxAdapter, pane, this.paneExit, "The seat's process", inputTarget);
if (!ended.ok) return { ok: false, code: "pane_process_not_ended", message: ended.message };
const shell = (await this.tmuxAdapter.getDefaultShell()) ?? "/bin/sh";
const respawned = await this.tmuxAdapter.respawnPane(pane, shell, { ...(cwd ? { cwd } : {}), env });
const respawned = await this.tmuxAdapter.respawnPane(inputTarget, shell, { ...(cwd ? { cwd } : {}), env });
if (!respawned.ok) {
return { ok: false, code: "pane_not_respawned", message: `The seat's process in pane ${pane} ended, but a shell couldn't start there: ${respawned.message}` };
}
+3 -2
View File
@@ -24,15 +24,16 @@ export async function terminatePaneInPlace(
paneId: string,
timing: PaneCutoverTiming,
label = "Retiree",
inputTarget = paneId,
): Promise<PaneTerminationResult> {
const failures: string[] = [];
const remain = await tmux.setRemainOnExit(paneId, true);
if (!remain.ok) failures.push(`remain-on-exit: ${remain.message}`);
const term = await tmux.signalPaneProcess(paneId, "TERM");
const term = await tmux.signalPaneProcess(inputTarget, "TERM");
if (!term.ok) failures.push(`TERM: ${term.message}`);
if (await waitPaneDead(tmux, paneId, timing)) return { ok: true, path: "graceful" };
// Graceful window elapsed — force-kill (the pinned degraded path).
const kill = await tmux.signalPaneProcess(paneId, "KILL");
const kill = await tmux.signalPaneProcess(inputTarget, "KILL");
if (!kill.ok) failures.push(`KILL: ${kill.message}`);
if (await waitPaneDead(tmux, paneId, timing)) return { ok: true, path: "forced" };
const detail = failures.length > 0 ? ` Tmux failures: ${failures.join("; ")}.` : "";
@@ -1,6 +1,7 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { Worker } from "node:worker_threads";
import { parseLinuxProcessArguments } from "./native-process-arguments.js";
const execFileAsync = promisify(execFile);
@@ -14,6 +15,8 @@ export interface ProcessTableRow {
/** lstart, in local time: the identity half of pid + start time. */
startedAt: string;
command: string;
/** Linux Codex's exact argv from this same /proc cmdline sample; null when unreadable or malformed. */
argv?: string[] | null;
}
let inFlight: Promise<ProcessTableRow[]> | null = null;
@@ -207,6 +210,8 @@ function procRow(pid: string, stat: ProcFile, cmdlineFile: ProcFile | null, boot
executableName: printable(comm.slice(0, 15)),
startedAt: lstart(bootTime + Math.floor(Number(fields[19]) / TICKS_PER_SECOND)),
command,
...(name === "codex" ? { argv: typeof cmdlineFile === "string"
? parseLinuxProcessArguments(Buffer.from(cmdlineFile, "latin1")) : null } : {}),
};
}
+1 -1
View File
@@ -288,7 +288,7 @@ export class PsProjectionService {
? this.seatActivity.getSeatStateBySession(session)
: null;
const hook = this.agentActivity && session
? this.agentActivity.getLatestForNode({ sessionName: session, now: nowDate,
? this.agentActivity.getLatestForNode({ nodeId: node.nodeId, sessionName: session, now: nowDate,
order: node.runtime === "claude-code" ? "source" : "arrival" })
: null;
// The row's served activity: a live hook, else the cached pane reading. A confirmed-missing session serves
+41 -13
View File
@@ -922,7 +922,10 @@ export class QueueRepository {
* best-effort {@link maybeNudge} when no store is attached (P34 made that
* fallback real code rather than a promise in a comment). */
private outbox: OutboxHandler | undefined;
private readonly promptBlockedSessions = new Set<string>();
// A first clear may recover durable refusals observed before startup. Later clears need
// a new positive refusal/attention episode, so a returning prompt cannot spin retries.
private readonly promptRecoveryState = new Map<string, boolean>();
private readonly promptClearRetries = new Map<string, number>();
private resolveOccupantGeneration?: (sessionName: string) => string | null;
private readonly resolveSessionAddresses: (name: string) => string[];
private readonly wakeRepo: QueueWakeRepository;
@@ -1638,11 +1641,18 @@ export class QueueRepository {
}
/** One recovery pass on a positive attention-clear transition; the existing sweep remains the backstop. */
async retryPromptWakesOnClear(sessionName: string, state: AgentActivity["state"] | undefined): Promise<void> {
if (state === "needs_input") { this.promptBlockedSessions.add(sessionName); return; }
if ((state !== "running" && state !== "idle") || !this.promptBlockedSessions.delete(sessionName)) return;
async retryPromptWakesOnClear(sessionName: string, state: AgentActivity["state"] | undefined, paneNeedsInput = false): Promise<void> {
const addresses = this.resolveSessionAddresses(sessionName);
if (!addresses.length) return;
// A fresh idle hook must not hide the pane's positive attention observation here.
// Inventory keeps its own precedence; this only arms recovery, never admits a send.
if (paneNeedsInput || state === "needs_input") {
for (const address of addresses) this.promptRecoveryState.set(address, true);
return;
}
if (state !== "running" && state !== "idle") return;
if (addresses.every(address => this.promptRecoveryState.get(address) === false)) return;
for (const address of addresses) this.promptRecoveryState.set(address, false);
// Correlation may expand the group or release members for separate sends. Each claim must
// independently qualify for this clear-triggered pass; general recovery keeps its broader policy.
const eligible = (entry: OutboxEntry): boolean => {
@@ -1656,15 +1666,26 @@ export class QueueRepository {
AND (last_nudge_result LIKE 'failed:Refused:%interactive prompt%' OR last_nudge_result = 'failed:target_needs_input')`)
.all(...addresses) as Array<{ qitem_id: string; destination_session: string; source_session: string }>;
const attempted = new Set<string>();
for (const row of rows) {
const intents = this.outbox ? this.db.prepare(`SELECT outbox_id, delivery_state FROM outbox_entries
WHERE audit_pointer = ? AND substr(outbox_id, 1, ?) = ?`).all(row.qitem_id, WAKE_INTENT_PREFIX.length, WAKE_INTENT_PREFIX) as Array<{ outbox_id: string; delivery_state: string }> : [];
if (intents.length) {
// Claims, known delivery, uncertainty and deliberate holds are not a prompt-refused wake.
for (const intent of intents) if (intent.delivery_state === "failed") await this.deliverWakeIntent(intent.outbox_id, attempted, eligible);
} else if (this.getById(row.qitem_id)?.state === "pending") {
// Older/non-precommitted nudges retain the existing best-effort path.
await this.maybeNudge(row.qitem_id, row.destination_session, undefined, row.source_session);
// Reserve the whole selection before awaiting: a correlated send can include several rows.
// Its refusal stays durable, but must not re-arm this same clear-triggered pass.
for (const row of rows) this.promptClearRetries.set(row.qitem_id, (this.promptClearRetries.get(row.qitem_id) ?? 0) + 1);
try {
for (const row of rows) {
const intents = this.outbox ? this.db.prepare(`SELECT outbox_id, delivery_state FROM outbox_entries
WHERE audit_pointer = ? AND substr(outbox_id, 1, ?) = ?`).all(row.qitem_id, WAKE_INTENT_PREFIX.length, WAKE_INTENT_PREFIX) as Array<{ outbox_id: string; delivery_state: string }> : [];
if (intents.length) {
// Claims, known delivery, uncertainty and deliberate holds are not a prompt-refused wake.
for (const intent of intents) if (intent.delivery_state === "failed") await this.deliverWakeIntent(intent.outbox_id, attempted, eligible);
} else if (this.getById(row.qitem_id)?.state === "pending") {
// Older/non-precommitted nudges retain the existing best-effort path.
await this.maybeNudge(row.qitem_id, row.destination_session, undefined, row.source_session);
}
}
} finally {
for (const row of rows) {
const remaining = this.promptClearRetries.get(row.qitem_id)! - 1;
if (remaining) this.promptClearRetries.set(row.qitem_id, remaining);
else this.promptClearRetries.delete(row.qitem_id);
}
}
}
@@ -4601,6 +4622,13 @@ export class QueueRepository {
WHERE qitem_id = ?`
)
.run(ts, result, qitemId);
// The transport's definite no-write refusal establishes the episode even when a
// fresh hook hid needs_input. Uncertain delivery never arms clear-triggered replay.
if (!this.promptClearRetries.has(qitemId) && (result === "failed:target_needs_input"
|| (result.startsWith("failed:Refused:") && result.includes("interactive prompt")))) {
const row = this.getById(qitemId);
if (row?.state === "pending") this.promptRecoveryState.set(row.destinationSession, true);
}
}
/**
@@ -27,6 +27,7 @@ import { resolvePickupThresholdMinutes } from "./queue-pickup.js";
import { resolveOperatorSeat, resolveRecoveryAgentSeat, resolveSessionNodeId } from "./queue-owner.js";
import { nodeSessionConfirmedMissing } 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";
import { stalledPickupFinding } from "./queue-pickup.js";
@@ -865,21 +866,26 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
// OpenRig rings, whose work is still unclaimed (its ring failed, or it didn't pick the work up by the unclaimed
// age), goes the same way; one that claimed and stalled keeps its own finding, which rings it. Only a person can
// make a hosted member check its work, so the person goes first: the operator seat when it's a person's address,
// else the row's creator, else the operator agent, skipping any that is unset, this seat, confirmed missing or
// unroutable itself.
// else the row's creator, else the operator agent, skipping any that is unset, this seat, confirmed missing,
// unroutable or a terminal seat itself. A bound terminal seat goes the same way: the queue wake types nothing into
// it, and nothing there can claim work.
const classOf = (seat: string) => deps.queueRepo.classifyDestinationOf(seat).class;
for (const c of candidates) {
if (c.route !== c.row.destinationSession) continue;
const destination = classOf(c.row.destinationSession);
const noRoute = destination === "unroutable" && !!c.row.lastNudgeResult?.startsWith("unroutable:");
if (!noRoute && !(destination === "hosted" && !c.row.claimedAt)) continue;
const terminalSeat = destination === "pane-bound" && isBoundTerminalSeat(deps.db, c.row.destinationSession);
if (!noRoute && !terminalSeat && !(destination === "hosted" && !c.row.claimedAt)) continue;
const operatorSeat = (deps.resolveOperatorSeat ?? resolveOperatorSeat)();
const person = operatorSeat && classOf(operatorSeat) === "gateway-routable" ? operatorSeat : null;
const choices = [person, c.row.sourceSession, (deps.resolveRecoveryAgentSeat ?? resolveRecoveryAgentSeat)()];
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !sessionMissing(seat) && classOf(seat) !== "unroutable");
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !sessionMissing(seat) && classOf(seat) !== "unroutable"
&& !isBoundTerminalSeat(deps.db, seat));
c.why += noRoute
? `; ${c.route} has no wake route on this daemon (no terminal, not a person). If it's a hosted member, it sees this work only when it next checks, so ask it to; otherwise fix the address`
: `; ${c.route} is a hosted member. OpenRig rang its trigger (${c.row.lastNudgeResult ?? "no ring recorded"}), and it hasn't claimed this work. It sees work only when it checks, so ask it to`;
: terminalSeat
? `; ${c.route} is a terminal seat; nothing there can claim this work, and no wake is typed into it. Route the work to an agent seat`
: `; ${c.route} is a hosted member. OpenRig rang its trigger (${c.row.lastNudgeResult ?? "no ring recorded"}), and it hasn't claimed this work. It sees work only when it checks, so ask it to`;
if (chosen < 0) {
c.why += `. No person, creator or operator agent that can be reached is available, so this finding stays addressed to it`;
continue;
@@ -458,6 +458,62 @@ export class QueueTransitionLog {
return this.getOwnerNotificationReceipt(qitemId, notificationKey) !== null;
}
/** The latest owner-notification episode per ACTIVE queue row, in queue order. One query
* joins every eligibility rule into the selection — the row is active, it carries an owner
* notification, and (when `includePosted` is false, the alert sweep's path) no posted
* receipt exists for that notification's key — so rows that cannot matter never reach a
* per-row lookup. There is no row cap: an eligible item older than any window still comes
* back, and `posted` reports the receipt state for callers that list both.
*
* The eligible set is restricted to active queue items before any ranking happens, so a
* large notification archive never adds scan or ranking work to a sweep with no active item. */
activeOwnerNotificationEpisodes(includePosted: boolean): Array<{ qitemId: string; transition: QueueTransition; posted: boolean }> {
if (!this.hasOwnerNotificationColumns) return [];
// Exact token match with no wildcard risk: pad both ends so a key at the note's
// start or end still matches, and instr() compares literally (qitem ids may
// contain characters LIKE would treat as wildcards).
const receipt = (qitemIdExpr: string, transitionIdExpr: string) =>
`EXISTS (
SELECT 1 FROM ${this.historySource} r
WHERE r.qitem_id = ${qitemIdExpr}
AND r.transition_note LIKE 'slack-owner-notification-posted %'
AND instr(' ' || r.transition_note || ' ', ' notification_key=' || ${qitemIdExpr} || ':' || ${transitionIdExpr} || ' ') > 0
)`;
const postedFilter = includePosted ? "" : `AND NOT ${receipt("latest.qitem_id", "latest.transition_id")}`;
const rows = this.db
.prepare(
`WITH active_ids AS (
SELECT qitem_id FROM queue_items WHERE state IN ('pending', 'in-progress', 'blocked')
),
active AS (
SELECT qitem_id, ts_created FROM queue_items WHERE state IN ('pending', 'in-progress', 'blocked')
),
history_active AS (
SELECT h.* FROM ${this.historySource} h
WHERE h.qitem_id IN (SELECT qitem_id FROM active_ids)
AND h.owner_notification_level IS NOT NULL
),
ranked AS (
SELECT h.*, ROW_NUMBER() OVER (PARTITION BY h.qitem_id ORDER BY h.transition_id DESC) AS rn
FROM history_active h
),
latest AS (
SELECT * FROM ranked WHERE rn = 1
)
SELECT latest.*, ${receipt("latest.qitem_id", "latest.transition_id")} AS posted
FROM active
JOIN latest ON latest.qitem_id = active.qitem_id
${postedFilter}
ORDER BY active.ts_created DESC`,
)
.all() as Array<QueueTransitionRow & { posted: 0 | 1 }>;
return rows.map((row) => ({
qitemId: row.qitem_id,
transition: this.rowToTransition(row),
posted: row.posted === 1,
}));
}
getOwnerNotificationReceipt(qitemId: string, notificationKey: string): { messageTs: string | null; threadTs: string | null; digest: string | null } | null {
const rows = this.db
.prepare(
@@ -1,6 +1,8 @@
import { resolveEscalationOwner, ESCALATION_HOP_TAG, hasConsumedEscalationHop } from "./escalation-owner.js";
import type { ArbitratedSeatState } from "./activity-taxonomy.js";
import { seatRuntimeHomeBySession } from "./seat-runtime-home.js";
import { OPENRIG_SENDER } from "../lib/pane-envelope.js";
import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
import { quietHoldCustody } from "./quiet-hold-custody.js";
import { latestHookMovedOn, latestHookPermissionPending, latestHookWaitsOnPerson } from "./agent-activity-store.js";
import type { AgentActivity } from "./types.js";
@@ -640,7 +642,10 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
deps.attemptWake ??
(async (qitemId: string, target: string): Promise<string> => {
// A ladder retry is OpenRig's own scheduling on the row, whoever created it.
await deps.queueRepo.maybeNudge(qitemId, target, true, undefined, undefined, "openrig");
// It is sent as OpenRig (daemon@kernel); the row's original source stays visible in the body.
const row = deps.queueRepo.getById(qitemId);
const bodyOverride = `${renderQueueHandoffNudge(qitemId, null)}${row?.sourceSession ? ` Row source: ${row.sourceSession}.` : ""}`;
await deps.queueRepo.maybeNudge(qitemId, target, true, OPENRIG_SENDER, bodyOverride, "openrig");
return deps.queueRepo.getById(qitemId)?.lastNudgeWireResult ?? "indeterminate:no transport available";
});
@@ -842,7 +847,7 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
if (!due || suspended) continue;
const consumedReason = `${reason}; escalates_to one-hop delivery already used; existing fallback retained`;
// Keep this recovery visible without sharing the ordinary destination aggregate or forwarding another hop.
await ensureEscalationRow(deps, row.destinationSession, resolveOperatorSeat() ?? row.sourceSession,
await ensureEscalationRow(deps, row.destinationSession, humanAlertCarrier(deps, row.destinationSession, row.sourceSession),
[{ row, reason: consumedReason }], consumedReason, `consumed-hop:${row.qitemId}`, [ESCALATION_HOP_TAG]);
if (!view.orchRung) appendMarker(deps.queueRepo, row, `${LADDER_RUNG_PREFIX} orchestrator self-skip reason=${consumedReason}`);
if (await operatorRung(deps, row, consumedReason, actions, view)) {
@@ -878,10 +883,10 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
// F4: rung 1 self-skips when it resolves to the destination itself (or nowhere)
// — never escalate INTO the dead seat; fall through to the operator rung now.
// The operator floor must be a VISIBLE OBJECT, not markers alone: ensure the
// per-destination escalation row exists (addressed to the operator seat, else
// the obligation's own creator) so the escalations view and the health count
// per-destination escalation row has an admissible carrier so the
// escalations view and the health count
// expose it — it stays open past the batons' exhaustion.
const floorDest = resolveOperatorSeat() ?? needsOrchRung[0]!.row.sourceSession;
const floorDest = humanAlertCarrier(deps, dest, needsOrchRung[0]!.row.sourceSession);
await ensureEscalationRow(deps, dest, floorDest, needsOrchRung, reason);
for (const m of needsOrchRung) {
appendMarker(
@@ -1073,6 +1078,13 @@ function waitsOnPostedPromptAlert(deps: WakeLadderDeps, row: QueueItem, choiceOn
return true;
}
// The operator engine owns actual human delivery. Its non-nudged queue carrier
// must still be locally admissible; cross-host source provenance is not a local destination.
function humanAlertCarrier(deps: WakeLadderDeps, dest: string, source?: string): string {
const candidates = [resolveOperatorSeat(), source];
return candidates.find((candidate): candidate is string => Boolean(candidate && deps.queueRepo.acceptsDestination(candidate))) ?? dest;
}
/** Positive refusals enter the existing rungs without ever retrying the blocked
* seat. One aggregate carries the episode's obligations and notification key;
* original work remains owned by its current recipient. */
@@ -1282,7 +1294,7 @@ async function advancePromptRefusals(
const choiceOnly = Boolean(group.choice && !group.other);
const promptTags = choiceOnly ? [PROMPT_ALERT_TAG, PROMPT_CHOICE_TAG, `consumed-prompt-destination:${dest}`,
...(group.rowKeys.get(member.row.qitemId) ?? [])] : [];
const continuation = await ensureEscalationRow(deps, dest, resolveOperatorSeat() ?? member.row.sourceSession,
const continuation = await ensureEscalationRow(deps, dest, humanAlertCarrier(deps, dest, member.row.sourceSession),
[{ row: member.row, reason }], reason, `consumed-hop:${member.row.qitemId}`, [ESCALATION_HOP_TAG, ...promptTags]);
if (!view.orchRung) appendMarker(deps.queueRepo, member.row, `${LADDER_RUNG_PREFIX} orchestrator self-skip reason=${reason}`);
// A choice alert's tags and dispatch marker must live on the same durable row the shared gate reads.
@@ -1322,7 +1334,7 @@ async function advancePromptRefusals(
const kindTags = choiceOnly ? [PROMPT_CHOICE_TAG] : [];
const keys = group.keys.filter(key => group.jobKeys.has(key) || members.some(m => group.rowKeys.get(m.row.qitemId)?.has(key)));
const aggregate = await ensureEscalationRow(deps, dest,
toOperator ? resolveOperatorSeat() ?? members[0]?.row.sourceSession ?? [...group.jobs.values()][0]! : orch!, members, reason,
toOperator ? humanAlertCarrier(deps, dest, members[0]?.row.sourceSession ?? [...group.jobs.values()][0]) : orch!, members, reason,
`prompt-destination:${dest}`, [PROMPT_ALERT_TAG, ...kindTags, ...keys], group.jobs);
const row = deps.queueRepo.getById(aggregate.qitemId);
if (!row) continue;
@@ -6,8 +6,8 @@ import { assessSnapshotCurrentOccupants, stoppedSnapshotReason } from "./rehydra
import { nonInterruptiveNotice, nonInterruptiveSummary } from "../adapters/non-interruptive.js";
import { planModelAliasNotice } from "../adapters/yolo-mode.js";
import { effortPrecedence, readSeatEffort } from "./seat-effort.js";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
import { isAbsolute, join } from "node:path";
import { ensureSeatSkillPlugin, hasSeatSkillRecords, rehydrateSeatSkills, seatSkillPaths, seatSkillPathsForNode, seatCatalogKeptNote, seatSkillReplay, writeSeatSkillCatalog, type SeatSkillRehydration, writeSeatSkillRouter, seatRouterKeptNote } from "./seat-skill-storage.js";
import type Database from "better-sqlite3";
import { NativePermissionStore } from "./native-permission-store.js";
@@ -29,7 +29,8 @@ import type { OmpResumeAdapter } from "../adapters/omp-resume.js";
import type { TranscriptStore } from "./transcript-store.js";
import { assessNativeResumeProbe } from "./native-resume-probe.js";
import { resumeProbeEvidence, type NativeResumeProbeEvidence } from "./native-resume-probe-evidence.js";
import { observeClaudeDelivery, observeClaudeResumeLaunch, verifyClaudePaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess } from "./native-process-lineage.js";
import { observeClaudeDelivery, observeClaudeResumeLaunch, processArgv, verifyClaudePaneProcess, verifyClaudePaneRuntime, verifyPaneIdleShell, type ClaudeLaunchedProcess } from "./native-process-lineage.js";
import { readNativeExecutablePaths } from "./native-process-executable.js";
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
import { applySeatSkillsLineRule, seatGuidance, restoreMissingGuidance, missingGuidanceBlocks } from "./restore-guidance.js";
import { readFootprintAnswer } from "./footprint-choice.js";
@@ -1200,6 +1201,8 @@ export class RestoreOrchestrator {
if (opts?.keepPane && (!keptPane || !priorState.binding?.tmuxSession)) {
return { nodeId, logicalId: node.logicalId, status: "failed", error: `${node.logicalId} has no bound tmux pane to keep, so nothing was stopped.` };
}
// Read before takeover ends the process. An unavailable witness keeps normal launch resolution unchanged.
const claudeExecutable = keptPane && node.runtime === "claude-code" ? await this.claudeContinuationExecutable(keptPane) : undefined;
// Clear stale state so NodeLauncher doesn't see already_bound. A kept pane keeps its binding through the takeover: the
// delivery guard resolves the pane through it.
@@ -1346,11 +1349,26 @@ export class RestoreOrchestrator {
warnings?.push(...launchResult.warnings);
}
const restored = await this.postLaunchRestore(entry, rigId, data, launchResult.sessionName, launchResult, opts, warnings, priorState, seatRehydration, resumeSession);
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);
return selectedHistory ? { ...checked, sourceSessionId: selectedHistory.historySessionId, resultSessionId: launchResult.session.id } : checked;
}
private async claudeContinuationExecutable(pane: string): Promise<string | undefined> {
const native = await verifyClaudePaneRuntime({ target: pane, tmux: this.tmuxAdapter,
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}) });
if (!native) return undefined;
const argv0 = processArgv(native.process.command)[0];
const executable = argv0 && isAbsolute(argv0) ? argv0 : native.process.executablePath
?? (await readNativeExecutablePaths([native.process.pid])).get(native.process.pid);
try {
if (!executable || !isAbsolute(executable)) return undefined;
accessSync(executable, constants.X_OK);
return statSync(executable).isFile() ? realpathSync(executable) : undefined;
} catch { return undefined; }
}
/** A seat that came back running should also have its role, culture and SOP guidance. If its
* guidance file still lacks a managed block it should hold, the seat keeps its true status (a
* live resumed session is still running) and the gap is disclosed on the node and in the
@@ -1447,7 +1465,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 },
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; selectedHistory?: SelectedRestoreHistory; claudeExecutable?: string },
warnings?: string[],
priorState?: { binding: import("./types.js").Binding | null; sessions: { id: string; status: string }[] },
seatRehydration?: SeatSkillRehydration,
@@ -1471,6 +1489,17 @@ export class RestoreOrchestrator {
}
return restoreEffort.effort;
};
// The model `rig seat set-model` saved after the snapshot is the newer choice, and it says so when it differs.
let restoreModel: string | null | undefined;
const modelForRestore = (): string | null => {
if (restoreModel === undefined) {
restoreModel = this.liveModel(rigId, node, selected?.nodeId);
if (node.model && restoreModel !== node.model) {
warnings?.push(`Restore: ${node.logicalId}: this restore launched the seat's saved model ${restoreModel}, not ${node.model} from the snapshot.`);
}
}
return restoreModel;
};
// OPR.0.5.7.1 D1 — the active occupant is RESOLVED, never inferred from
// row ordering (cite site 2 of 2; "latest = max id" was the incident's
// defect: a superseded row with a newer ULID defeated the real occupant).
@@ -1597,8 +1626,8 @@ export class RestoreOrchestrator {
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
}
if (selected && !selected.beforeNative(launchedSessionId, launchResult?.binding.id)) return { nodeId: node.id, logicalId: node.logicalId, status: "attention_required", error: "The selected seat, settings or history changed after shell creation; native resume was not dispatched." };
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", this.liveCodexProfile(rigId, node, selected?.nodeId), node.model, this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
selected ? () => selected.beforeNative(launchedSessionId, launchResult?.binding.id) : undefined);
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", this.liveCodexProfile(rigId, node, selected?.nodeId), modelForRestore(), this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
selected ? () => selected.beforeNative(launchedSessionId, launchResult?.binding.id) : undefined, opts?.claudeExecutable);
if (resumeOutcome.kind === "resumed") {
baseStatus = "resumed";
if (launchedSessionId && this.claudeResume.canResume(resumeType, resumeToken)) {
@@ -1819,6 +1848,7 @@ export class RestoreOrchestrator {
const binding = {
...launchResult.binding,
...(opts?.claudeExecutable ? { claudeExecutable: opts.claudeExecutable } : {}),
cwd: node.cwd ?? ".",
codexConfigProfile: this.liveCodexProfile(rigId, node, selected?.nodeId) ?? undefined,
// OPR.0.4.8.3 Seam B: the pod-aware restore path binds the restored posture too
@@ -1830,7 +1860,7 @@ export class RestoreOrchestrator {
// the declared provider) passed nothing through ("No API key
// found" on every resumed Pi seat). Claude/Codex silently lost
// their -m/--model on restore the same way.
model: node.model ?? undefined,
model: modelForRestore() ?? undefined,
effort: effortForRestore() ?? undefined,
advisor: node.advisor ?? undefined,
};
@@ -2141,6 +2171,18 @@ export class RestoreOrchestrator {
return "floor";
}
/** The live seat's current model: `rig seat set-model` saves it on the live node after the snapshot, and every
* managed resume reads it at call time, so an older snapshot can't bring back the model it replaced. Found by logical
* id, as effort's restore does; a seat with no live row, or no model on it, keeps the snapshot's. */
private liveModel(rigId: string, node: { logicalId: string; model?: string | null }, selectedNodeId?: string): string | null {
const row = this.db.prepare(selectedNodeId
? "SELECT model FROM nodes WHERE id = ? AND rig_id = ? AND logical_id = ?"
: "SELECT model FROM nodes WHERE rig_id = ? AND logical_id = ?")
.get(...(selectedNodeId ? [selectedNodeId, rigId, node.logicalId] : [rigId, node.logicalId])) as
{ model: string | null } | undefined;
return row?.model ?? node.model ?? null;
}
/** The live seat's current Codex profile: a setter change made after the snapshot is the newer choice, so an older
* snapshot can't bring back the profile it replaced. The seat is found by logical id, as effort's restore does; a
* seat with no live row keeps the snapshot's value. */
@@ -2168,6 +2210,7 @@ export class RestoreOrchestrator {
warnings?: string[],
advisor?: string | null,
beforeNative?: () => boolean,
claudeExecutable?: string,
): Promise<
| { kind: "resumed"; launchedProcess?: ClaudeLaunchedProcess }
| { kind: "retry_fresh" }
@@ -2207,10 +2250,11 @@ export class RestoreOrchestrator {
const tail: unknown[] = claudePermissions !== undefined
? [...claudeTail, ...Array(5 - claudeTail.length).fill(undefined), launch.additions, claudePermissions]
: launch.additions ? [...claudeTail, ...Array(5 - claudeTail.length).fill(undefined), launch.additions] : claudeTail;
const guardedTail = beforeNative ? [...tail, ...Array(Math.max(0, 7 - tail.length)).fill(undefined), beforeNative] : tail;
const guardedTail = beforeNative || claudeExecutable ? [...tail, ...Array(Math.max(0, 7 - tail.length)).fill(undefined), beforeNative,
...(claudeExecutable ? [claudeExecutable] : [])] : tail;
if (beforeNative && !beforeNative()) return { kind: "attention_required", message: "The selected seat, settings or history changed during native preparation; resume was not dispatched." };
const result = await this.claudeResume.resume(sessionName, resumeType, resumeToken, cwd, resolvedPosture, model, permissionMode, nodeId,
...(guardedTail as [...typeof launchTail, advisor?: string, launchAdditions?: SeatLaunchAdditions, claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules, beforeNativeDispatch?: () => boolean]));
...(guardedTail as [...typeof launchTail, advisor?: string, launchAdditions?: SeatLaunchAdditions, claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules, beforeNativeDispatch?: () => boolean, executable?: string]));
if (result.ok) {
// OPR.0.7.0.9: the resumed conversation's own start, as on the pod-aware path.
const promptNote = launch.additions?.appendSystemPromptFile && nodeId && resumeToken ? systemPromptPendingNoteForNode(this.db, nodeId, resumeToken) : undefined;
@@ -155,6 +155,7 @@ export class RigExpansionService {
: {}),
...(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 } : {}),
@@ -9,6 +9,8 @@ import type { ClaudeResumeRotation } from "./native-process-lineage.js";
export interface NodeBinding extends Binding {
cwd: string;
/** In-place Claude continuation only: executable observed before the old process exits. */
claudeExecutable?: string;
model?: string;
effort?: string;
/** Claude-only, session-scoped Advisor model. */
@@ -188,7 +188,9 @@ export class SeatActivityService {
let updated = false;
for (const [id, request] of snapshot) {
if (seat.permissions.get(id) !== request) continue;
request.seen ||= visible === true;
// A screen does not identify which Claude actor owns a dialog. With overlapping actors, only a
// scoped batch or turn boundary can settle their unseen notifications.
request.seen ||= visible === true && (!claude || seat.permissions.size === 1);
request.confirmed = visible !== false || (claude && !request.seen);
request.nextCheckMs = this.now().getTime()
+ (visible === false && this.now().getTime() - request.sinceMs >= 10_000 ? 5_000 : DEFAULT_POLL_INTERVAL_MS);
@@ -196,25 +198,62 @@ export class SeatActivityService {
updated = true;
}
if (!updated) return;
seat.permissionObservedAt = this.now().toISOString();
const count = [...seat.permissions.values()].filter(request => request.confirmed).length;
if (claude && count === 0) {
for (const source of seat.sources.values()) {
if (source.latest.permissionRequest) source.latest = { ...source.latest, needsInput: undefined };
}
}
const sourceId = `${runtime}:permission-prompt`;
this.reportEvidence({
seatNodeId: seatNodeId!, sessionName, rung: "needs-input-chrome", sourceId,
seq: (seat.sources.get(sourceId)?.latest.seq ?? 0) + 1,
observedAt: seat.permissionObservedAt,
needsInput: { count, reason: count ? (!claude && visible === null ? "permission_request_unverified" : "permission_prompt") : null },
});
this.reportPermissionObservation(seatNodeId!, seat, "needs-input-chrome", this.now().toISOString(),
!claude && visible === null ? "permission_request_unverified" : "permission_prompt");
} finally {
seat.permissionPolling = false;
}
}
/** A native Claude PostToolBatch resolves only this actor's earlier permission notifications. The route checks
* the emitting occupant first. Missing actor attribution and unordered timestamps retain the pane/turn fallback. */
completeClaudeToolBatch(sessionName: string, hookAgentId: string | null, observedAt: string): boolean {
const seatNodeId = this.sessionToSeat.get(sessionName);
const seat = seatNodeId ? this.ladder.get(seatNodeId) : undefined;
if (!seat || seat.retired || seat.inventory?.runtime !== "claude-code") return false;
const completedAt = Date.parse(observedAt);
if (!Number.isFinite(completedAt)) return false;
// A notification may arrive after its completion, including when no request was observed yet.
seat.claudeToolBatches.set(hookAgentId, Math.max(completedAt, seat.claudeToolBatches.get(hookAgentId) ?? -Infinity));
let settled = false;
for (const [id, request] of seat.permissions) {
if (request.hookAgentId === undefined || request.hookAgentId !== hookAgentId
|| !(Date.parse(request.observedAt) < completedAt)) continue;
seat.permissions.delete(id);
settled = true;
}
if (!settled) return false;
seat.permissionRevision++;
this.reportPermissionObservation(seatNodeId!, seat, "lifecycle-hooks", observedAt, "permission_prompt");
return true;
}
/** Whether this occupant's actor already completed a strictly later batch. Equal timestamps do not establish order. */
hasLaterClaudeToolBatch(sessionName: string, hookAgentId: string | null | undefined, observedAt: string): boolean {
if (hookAgentId === undefined) return false;
const seatNodeId = this.sessionToSeat.get(sessionName);
const seat = seatNodeId ? this.ladder.get(seatNodeId) : undefined;
if (!seat || seat.retired || seat.inventory?.runtime !== "claude-code") return false;
const completedAt = seat.claudeToolBatches.get(hookAgentId);
return completedAt !== undefined && Date.parse(observedAt) < completedAt;
}
private reportPermissionObservation(seatNodeId: string, seat: SeatLadderState, rung: EvidenceRungId,
observedAt: string, reason: string): void {
const runtime = seat.inventory?.runtime ?? "codex";
seat.permissionEvidence = { rung, observedAt };
const count = [...seat.permissions.values()].filter(request => request.confirmed).length;
if (runtime === "claude-code" && count === 0) {
for (const source of seat.sources.values()) {
if (source.latest.permissionRequest) source.latest = { ...source.latest, needsInput: undefined };
}
}
const sourceId = `${runtime}:permission-prompt`;
this.reportEvidence({ seatNodeId, sessionName: seat.sessionName, rung, sourceId,
seq: (seat.sources.get(sourceId)?.latest.seq ?? 0) + 1, observedAt,
needsInput: { count, reason: count ? reason : null } });
}
/**
* Return the latest stored observation for a seat, or null when no
* observation has been recorded yet (e.g. service hasn't polled this
@@ -237,7 +276,8 @@ export class SeatActivityService {
seat.retired = true;
seat.sources.clear();
seat.permissions.clear();
seat.permissionObservedAt = null;
seat.claudeToolBatches.clear();
seat.permissionEvidence = null;
seat.pendingIdle = null;
seat.contradictionSinceMs = null;
seat.promotion.clear();
@@ -387,16 +427,21 @@ export class SeatActivityService {
const claude = runtime === "claude-code";
const wasConfirmed = seat.arbitrated.needsInput.count > 0;
if (evidence.permissionRequest) {
seat.permissionRevision++;
const request = evidence.permissionRequest;
if (claude && this.hasLaterClaudeToolBatch(evidence.sessionName, request.hookAgentId, evidence.observedAt)) return;
const previousRequest = seat.permissions.get(request.id);
// Relay posts can arrive out of order. Keep the newer actor request so an older batch cannot settle it.
if (claude && previousRequest && Date.parse(evidence.observedAt) < Date.parse(previousRequest.observedAt)) return;
seat.permissionRevision++;
if (claude || !seat.permissions.has(request.id)) {
seat.permissions.set(request.id, { sinceMs: this.now().getTime(), confirmed: claude, seen: false, nextCheckMs: 0 });
seat.permissions.set(request.id, { sinceMs: this.now().getTime(), observedAt: evidence.observedAt,
hookAgentId: request.hookAgentId, confirmed: claude, seen: false, nextCheckMs: 0 });
}
} else if ((evidence.rung === "lifecycle-hooks" && (evidence.activity === "idle-at-prompt" || evidence.permissionResolved
|| (claude && evidence.needsInput?.count && evidence.needsInput.reason !== "permission_prompt")))
|| (claude && seat.permissions.size === 0 && evidence.needsInput?.count && evidence.needsInput.reason !== "permission_prompt")) {
seat.permissions.clear();
seat.permissionObservedAt = null;
seat.permissionEvidence = null;
seat.sources.delete(`${runtime}:permission-prompt`);
}
seat.sources.set(evidence.sourceId, {
@@ -423,7 +468,7 @@ export class SeatActivityService {
const seat = seatNodeId ? this.ladder.get(seatNodeId) : undefined;
if (!seat || seat.retired) return;
seat.permissions.clear();
seat.permissionObservedAt = null;
seat.permissionEvidence = null;
seat.sources.delete(`${seat.inventory?.runtime ?? "codex"}:permission-prompt`);
for (const source of seat.sources.values()) {
if (source.latest.needsInput?.count) source.latest = { ...source.latest, needsInput: undefined };
@@ -440,7 +485,8 @@ export class SeatActivityService {
if (!seat) return;
seat.sources.clear();
seat.permissions.clear();
seat.permissionObservedAt = null;
seat.claudeToolBatches.clear();
seat.permissionEvidence = null;
seat.pendingIdle = null;
seat.contradictionSinceMs = null;
seat.promotion.clear();
@@ -523,9 +569,10 @@ export class SeatActivityService {
inventory: null,
sources: new Map(),
permissions: new Map(),
claudeToolBatches: new Map(),
permissionRevision: 0,
permissionPolling: false,
permissionObservedAt: null,
permissionEvidence: null,
trust: new Map(),
promotion: new Map(),
contradictionSinceMs: null,
@@ -760,7 +807,7 @@ export class SeatActivityService {
}
// Codex permission hooks need probe confirmation after grace; Claude hooks latch
// attention until a seen prompt disappears. Unavailable capture fails safe.
// attention until a seen prompt disappears or the same actor's tool batch completes. Unavailable capture fails safe.
// Other prompts retain their existing rung arbitration.
const chrome = this.latestByRung(seat, "needs-input-chrome");
const hooksEv = this.latestByRung(seat, "lifecycle-hooks");
@@ -775,14 +822,14 @@ export class SeatActivityService {
: null;
const confirmedCount = [...seat.permissions.values()].filter(p => p.confirmed).length;
const permissionTracked = (seat.inventory?.runtime === "codex" || seat.inventory?.runtime === "claude-code")
&& (seat.permissions.size > 0 || seat.permissionObservedAt !== null);
&& (seat.permissions.size > 0 || seat.permissionEvidence !== null);
const needsInput: NeedsInputShape = permissionTracked
? { count: confirmedCount, reason: confirmedCount ? chrome?.needsInput?.reason ?? "permission_prompt" : null }
: decider?.needsInput ?? { count: 0, reason: null };
if (permissionTracked && Boolean(confirmedCount) !== Boolean(seat.arbitrated.needsInput.count)) {
this.permissionPromptChanged?.(seat.sessionName, Boolean(confirmedCount), seat.inventory!.runtime);
}
const needsInputEvidence = permissionTracked ? (seat.permissionObservedAt ? { rung: "needs-input-chrome" as const, observedAt: seat.permissionObservedAt } : null) : decider ? { rung: decider.rung, observedAt: decider.observedAt } : null;
const needsInputEvidence = permissionTracked ? seat.permissionEvidence : decider ? { rung: decider.rung, observedAt: decider.observedAt } : null;
// Which rung answers is part of the answer: default zero → observed zero changes what
// consumers may conclude, so it advances seq and pushes like a count change.
@@ -847,10 +894,12 @@ interface PromotionRun {
}
interface SeatLadderState {
permissions: Map<string, { sinceMs: number; confirmed: boolean; seen: boolean; nextCheckMs: number }>;
permissions: Map<string, { sinceMs: number; observedAt: string; hookAgentId?: string | null;
confirmed: boolean; seen: boolean; nextCheckMs: number }>;
claudeToolBatches: Map<string | null, number>;
permissionRevision: number;
permissionPolling: boolean;
permissionObservedAt: string | null;
permissionEvidence: ArbitratedSeatState["needsInputEvidence"];
sessionName: string;
retired: boolean;
inventory: AdapterRungInventory | null;
@@ -80,7 +80,7 @@ async function describeLineageFailure(input: {
const rows = pid ? await Promise.resolve((input.listProcesses ?? listNativeProcesses)()).catch((): NativeProcessRow[] => []) : [];
if (!pid || !rows.some((row) => row.pid === pid)) return "The pane's process could not be found in the process listing (the listing may have failed), so the runtime could not be verified";
const observation = { target: input.target, tmux: input.tmux, listProcesses: () => rows };
const passes = input.variant === "codex" ? !!await observeCodexPaneProcess({ ...observation, expectedToken: input.expectedToken, requireResume: input.exact })
const passes = input.variant === "codex" ? !!await observeCodexPaneProcess({ ...observation, expectedToken: input.expectedToken, requireResume: input.exact, requireArgv: true })
: input.variant === "claude_conversation" ? !!await observeClaudePaneProcess({ ...observation, expectedToken: input.expectedToken, rotation: input.rotation })
: input.variant === "claude_runtime" ? !!await observeClaudePaneRuntime(observation)
: !!(input.expectedToken && findExactNativeResumeProcess(rows, pid, "claude-code", input.expectedToken, input.rotation));
@@ -208,7 +208,7 @@ export async function rebindAndVerifyPaneIdentity(input: {
runtimeMatch = "match";
const native = await verifyCodexPaneProcess({ target: pane.id, tmux: input.tmux,
listProcesses: input.listProcesses, expectedToken: expectedResumeToken,
requireResume: input.requireExactResumeLineage === true });
requireResume: input.requireExactResumeLineage === true, requireArgv: true });
const currentPanes = await input.tmux.listPanes(input.sessionName).catch(() => []);
if (native?.panePid === pid && currentPanes.length === 1 && currentPanes[0]?.id === pane.id) lineageMatch = native.process;
} else if (claudeProcessProof) {
@@ -438,7 +438,7 @@ export class SeatDeliveryGuard {
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 ownership of Codex's collapsed long paste. Send a shorter message, or set this seat's typing guard to off for delivery without draft protection; nothing was pasted."
: "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);
}
@@ -235,6 +235,7 @@ export class SeatIdentityReconciler {
return Promise.all(nativeSeats.map((seat) => (seat.runtime === "codex" ? observeCodexPaneProcess
: seat.resume_token !== null && seat.resume_token !== undefined ? observeClaudePaneProcess : observeClaudePaneRuntime)({
target: seat.tmux_pane!, tmux, expectedToken: seat.resume_token, rotation: claudeResumeRotation(seat),
...(seat.runtime === "codex" ? { requireArgv: true } : {}),
listProcesses: () => snapshot ??= this.listProcesses(),
})));
};
@@ -39,7 +39,7 @@ import {
type EffortResolution, type NativeEffortObservation,
} from "./seat-effort.js";
import {
claudeCommandSessionToken, codexCommandResumeToken, observeClaudePaneRuntime, observeCodexPaneProcess, processArgv,
claudeCommandSessionToken, codexProcessArgv, codexProcessResumeToken, observeClaudePaneRuntime, observeCodexPaneProcess, processArgv,
} from "./native-process-lineage.js";
import { defaultInPlaceRestartIo } from "./seat-restart-in-place.js";
import nodePath from "node:path";
@@ -615,19 +615,26 @@ export class SeatLifecycleService {
({ emitted, observed } = await this.piEffort(seat.nodeId, session.session_name, saved));
} else {
const observe = runtime === "claude-code" ? observeClaudePaneRuntime : observeCodexPaneProcess;
const process = (await observe({ target, tmux: this.tmuxAdapter, ...(this.listProcesses ? { listProcesses: this.listProcesses } : {}) }))?.process;
const process = (await observe({ target, tmux: this.tmuxAdapter, ...(runtime === "codex" ? { requireArgv: true } : {}),
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}) }))?.process;
if (!process) {
emitted = { state: "unknown", detail: `No single ${runtime === "claude-code" ? "Claude" : "Codex"} process could be read in the seat's pane.` };
observed = { state: "not_verified", detail: "The running process could not be read, so no turn can be tied to its launch." };
} else {
const flag = emittedEffortFromArgv(runtime, processArgv(process.command));
const value = flag.flag === "present" ? flag.value : null;
emitted = { state: "observed", value, pid: process.pid, startedAt: process.startedAt ?? null, matchesSaved: value === saved.effective };
// Native effort is attributed only from the transcript of the conversation this process is on.
const joined = await this.joinedTranscript(runtime, session, process);
const native = "detail" in joined ? { state: "not_verified" as const, detail: joined.detail }
: readNativeEffort(runtime, joined.path, process.startedAt ?? null);
observed = native.state === "observed" ? { ...native, matchesEmitted: native.value === value } : native;
const argv = runtime === "codex" ? codexProcessArgv(process) : processArgv(process.command);
if (!argv) {
emitted = { state: "unknown", detail: "The running Codex's exact arguments could not be read, so its launch effort is unknown." };
observed = { state: "not_verified", detail: "The running Codex's exact arguments could not be read, so no native turn can be tied to its launch." };
} else {
const flag = emittedEffortFromArgv(runtime, argv);
const value = flag.flag === "present" ? flag.value : null;
emitted = { state: "observed", value, pid: process.pid, startedAt: process.startedAt ?? null, matchesSaved: value === saved.effective };
// Native effort is attributed only from the transcript of the conversation this process is on.
const joined = await this.joinedTranscript(runtime, session, process);
const native = "detail" in joined ? { state: "not_verified" as const, detail: joined.detail }
: readNativeEffort(runtime, joined.path, process.startedAt ?? null);
observed = native.state === "observed" ? { ...native, matchesEmitted: native.value === value } : native;
}
}
}
return { ok: true, seat, runtime, requested, saved, emitted, observed, precedence, summary: effortSummary(saved, emitted, observed, runtime) };
@@ -696,7 +703,7 @@ export class SeatLifecycleService {
/** The transcript of the conversation the observed process is on, or why that join can't be made.
* The recorded conversation (Codex: the session's resume token; Claude: the status sidecar's session)
* must be the one the process itself names, by argv or, for a Codex that names none, by its own logs. */
private async joinedTranscript(runtime: "claude-code" | "codex", session: LatestSessionRow, process: { pid: number; command: string; startedAt?: string }):
private async joinedTranscript(runtime: "claude-code" | "codex", session: LatestSessionRow, process: { pid: number; command: string; argv?: string[] | null; startedAt?: string }):
Promise<{ path: string | null } | { detail: string }> {
if (!this.transcripts) return { detail: "The native transcript for this session isn't known to OpenRig." };
const mismatch = "The process in the pane isn't on the conversation recorded for this seat (or its conversation couldn't be read), so no native turn can be attributed to it.";
@@ -704,7 +711,7 @@ export class SeatLifecycleService {
if (runtime === "codex") {
const recorded = this.sessionResumeToken(session.id);
if (!recorded) return { detail: "No Codex conversation is recorded for this session yet." };
let own = codexCommandResumeToken(process.command) ?? undefined;
let own = codexProcessResumeToken(process) ?? undefined;
if (own === undefined && process.startedAt) {
const io = { ...defaultInPlaceRestartIo(), ...this.inPlaceRestartIo };
const facts = await io.readProcess(process.pid);
@@ -10,7 +10,7 @@ import type { SessionRegistry } from "./session-registry.js";
import type { HarnessLaunchResult, NodeBinding } from "./runtime-adapter.js";
import type { PersistedEvent, RigEvent } from "./types.js";
import {
codexCommandResumeToken, listNativeProcesses, processArgv, verifyCodexPaneProcess,
codexProcessArgv, codexProcessResumeToken, listNativeProcesses, processArgv, verifyCodexPaneProcess,
type NativeProcessLister, type NativeProcessRow,
} from "./native-process-lineage.js";
import { CodexThreadIdResolver, resolveCodexDbPaths, type CodexThreadEvidence } from "./codex-thread-id.js";
@@ -405,6 +405,8 @@ export class CodexInPlaceRestart {
return { ok: false, code: "resume_binding_unverified", message: "No single Codex process in the seat's pane could be tied to the stored conversation (a running Codex that names another conversation is refused too). Nothing was stopped." };
}
const { process, panePid, rows } = codex;
const runningArgv = codexProcessArgv(process);
if (!runningArgv) return { ok: false, code: "settings_unverified", message: "The running Codex's exact arguments could not be read, so its launch settings are unverified. Nothing was stopped." };
const startedAt = process.startedAt ?? null;
const facts = await this.io.readProcess(process.pid);
if (!facts) return { ok: false, code: "settings_unverified", message: "The running Codex's working directory and Codex home could not be read (this observer reads /proc on Linux). Nothing was stopped." };
@@ -412,7 +414,7 @@ export class CodexInPlaceRestart {
if (!codexHome) return { ok: false, code: "settings_unverified", message: "The running Codex's home could not be determined (no CODEX_HOME or HOME). Nothing was stopped." };
// The conversation the PROCESS is on: argv for a resumed Codex, its own native logs for a fresh one.
const argvToken = codexCommandResumeToken(process.command);
const argvToken = codexProcessResumeToken(process);
let conversationProof: "argv" | "native_logs";
if (argvToken === token) conversationProof = "argv";
else if (argvToken === undefined && startedAt && (await this.io.readThreadId(process.pid, startedAt, codexHome)) === token) conversationProof = "native_logs";
@@ -468,8 +470,7 @@ export class CodexInPlaceRestart {
const relaunchBinding: NodeBinding = { ...binding, cwd };
// The probed and prepared launch choices come from the running Codex, so the relaunch keeps them and no
// Codex process is started to decide them again.
// The exact argv when /proc gives it: a --add-dir with a space (a cwd's .git) must not be cut at the space.
const runningArgv = facts.argv?.length ? facts.argv : processArgv(process.command);
// Keep the launch settings tied to the same OS argv used for the selected process's identity.
const running = runningLaunch(runningArgv);
const relaunch = await this.deps.codex.buildResumeCommand(relaunchBinding, { name: sessionName, resumeToken: token, running });
if (!relaunch.ok) return { ok: false, code: "relaunch_unavailable", message: `The resume command could not be built: ${relaunch.error}. Nothing was stopped.` };
@@ -568,7 +569,7 @@ export class CodexInPlaceRestart {
const panePid = await this.deps.tmux.getPanePid(pane);
if (!panePid) return null;
const observation = await verifyCodexPaneProcess({ target: pane, tmux: { getPanePid: async () => panePid },
listProcesses: () => rows, ...(token ? { expectedToken: token } : {}) });
listProcesses: () => rows, requireArgv: true, ...(token ? { expectedToken: token } : {}) });
return observation && observation.panePid === panePid ? { process: observation.process, panePid, rows } : null;
}
@@ -457,8 +457,7 @@ 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 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,
const input: ComposerInput = { state: empty ? "empty" : "text", cursor: { ...snapshot.cursor }, suggestionRows, rows: visible, columns: width - prefix, 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.
@@ -468,11 +467,11 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
input.collapsedPaste = visible.join("\n");
input.pasteMatchesExpected = true;
}
if (nativeCodex) {
if (prompt.glyph !== "❯" && codexFooterSeparator(lines) === end) {
// 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,
input.pasteViewport = { columns: width - prefix - 1,
rows: Math.min(height, Math.max(8, Math.floor(height * 2 / 3))) - 4 };
}
candidates.push(input);
+8 -3
View File
@@ -275,11 +275,16 @@ export function deriveOriented(db: Database.Database, nodeId: string): NodeOrien
* node-scoped, so the per-node subset of rows + the identical fold yield the
* identical verdict. Nodes with no proof events are simply absent (caller
* defaults to "n-a", matching the no-challenge branch).
* An optional rig scope reads only its current nodes' history, retaining the
* same per-node fold without materializing other rigs' proof events.
*/
export function buildOrientedMap(db: Database.Database): Map<string, NodeOriented> {
export function buildOrientedMap(db: Database.Database, rigId?: string): Map<string, NodeOriented> {
// Inventory only consumes the current rig's nodes. Scope by node identity,
// not events.rig_id: a node's proof history can predate its current rig.
const scope = rigId === undefined ? "" : " AND node_id IN (SELECT id FROM nodes WHERE rig_id = ?)";
const rows = db.prepare(
"SELECT node_id, type, payload, seq FROM events WHERE type IN ('node.startup_challenged','node.startup_proof_skipped','node.startup_proof_verified','node.startup_proof_rejected') ORDER BY node_id, seq DESC"
).all() as Array<{ node_id: string; type: string; payload: string; seq: number }>;
"SELECT node_id, type, payload, seq FROM events WHERE type IN ('node.startup_challenged','node.startup_proof_skipped','node.startup_proof_verified','node.startup_proof_rejected')" + scope + " ORDER BY node_id, seq DESC"
).all(...(rigId === undefined ? [] : [rigId])) as Array<{ node_id: string; type: string; payload: string; seq: number }>;
const byNode = new Map<string, OrientedEventRow[]>();
for (const r of rows) {
let list = byNode.get(r.node_id);
@@ -9,20 +9,21 @@ import { HerdrFollowView, type ReadFollowInput } from "./herdr-follow-view.js";
* request. No request mutates process.env or another request's transport.
*/
export function createHerdrProviders(env: NodeJS.ProcessEnv = process.env, input?: ReadFollowInput) {
env = { ...env };
if (env.XDG_CONFIG_HOME !== undefined) env.XDG_CONFIG_HOME = path.resolve(env.XDG_CONFIG_HOME);
const defaultSocket = resolveHerdrSocketPath(env);
const base = path.dirname(resolveHerdrSocketPath({}));
let launch = 0;
const viewers = new Map<string, HerdrViewer>();
const viewer = (endpoint: NonNullable<ProviderStatus["launch"]>) => {
let selected = viewers.get(endpoint.socketPath);
if (!selected) { selected = new HerdrViewer(endpoint.socketPath, createHerdrViewerRun(endpoint.session)); viewers.set(endpoint.socketPath, selected); }
if (!selected) { selected = new HerdrViewer(endpoint.socketPath, createHerdrViewerRun(endpoint, env)); viewers.set(endpoint.socketPath, selected); }
return selected;
};
const make = (endpoint: NonNullable<ProviderStatus["launch"]>) => {
const transportFactory = createHerdrSocketTransport(createHerdrSocketRpc(endpoint.socketPath));
return new HerdrAdapter({
transportFactory,
following: new HerdrFollowView({ socketPath: endpoint.socketPath, run: createHerdrViewerRun(endpoint.session), transport: transportFactory(), input }),
following: new HerdrFollowView({ socketPath: endpoint.socketPath, run: createHerdrViewerRun(endpoint, env), transport: transportFactory(), input }),
launch: endpoint,
viewer: viewer(endpoint),
newLaunchToken: () => `l${++launch}`,
@@ -34,8 +35,7 @@ export function createHerdrProviders(env: NodeJS.ProcessEnv = process.env, input
sessionProvider: (session: string) => {
let provider = sessions.get(session);
if (!provider) {
provider = make({ session, socketPath: session === "default"
? path.join(base, "herdr.sock") : path.join(base, "sessions", session, "herdr.sock") });
provider = make({ session, socketPath: resolveHerdrSocketPath(env, session) });
sessions.set(session, provider);
}
return provider;
@@ -52,12 +52,17 @@ export interface HerdrTransport {
export type HerdrTransportFactory = () => HerdrTransport;
/** Resolve the herdr control socket path: env override → per-session → default. */
export function resolveHerdrSocketPath(env: NodeJS.ProcessEnv = process.env): string {
if (env["HERDR_SOCKET_PATH"]) return env["HERDR_SOCKET_PATH"] as string;
const base = path.join(homedir(), ".config", "herdr");
const session = env["HERDR_SESSION"];
return session ? path.join(base, "sessions", session, "herdr.sock") : path.join(base, "herdr.sock");
/** A per-request session keeps the daemon's config home. An explicit socket
* belongs to its configured session, never to an unrelated requested session. */
export function resolveHerdrSocketPath(env: NodeJS.ProcessEnv = process.env, selectedSession?: string): string {
if (env["HERDR_SOCKET_PATH"] && (selectedSession === undefined || selectedSession === env["HERDR_SESSION"])) {
return env["HERDR_SOCKET_PATH"];
}
const configHome = env["XDG_CONFIG_HOME"] !== undefined
? path.resolve(env["XDG_CONFIG_HOME"]) : path.join(env["HOME"] || homedir(), ".config");
const base = path.join(configHome, "herdr");
const session = selectedSession ?? env["HERDR_SESSION"];
return session && session !== "default" ? path.join(base, "sessions", session, "herdr.sock") : path.join(base, "herdr.sock");
}
/** Extract a version token (e.g. from a ping result's `version`, or `herdr --version`). */
@@ -26,13 +26,15 @@ const runFile = promisify(execFile);
/** The supported CLI, never guessed socket RPC method parity. Its reported
* endpoint must match the adapter's selected socket before any mutation. */
export function createHerdrViewerRun(session?: string): HerdrViewerRun {
export function createHerdrViewerRun(endpoint: { socketPath: string }, sourceEnv: NodeJS.ProcessEnv = process.env): HerdrViewerRun {
// Herdr's explicit --session wins over the socket override. Select by the
// already resolved socket alone, including paths outside the config tree.
const env: NodeJS.ProcessEnv = { ...sourceEnv, HERDR_SOCKET_PATH: endpoint.socketPath };
delete env.HERDR_SESSION;
return async args => {
const binary = resolveOnPath("herdr", process.env.PATH);
const binary = resolveOnPath("herdr", env.PATH);
if (!binary) throw new Error("Herdr CLI is unavailable.");
const env = { ...process.env };
delete env.HERDR_SESSION; delete env.HERDR_SOCKET_PATH;
const { stdout } = await runFile(binary, [...(session && session !== "default" ? ["--session", session] : []), ...args],
const { stdout } = await runFile(binary, args,
{ env, encoding: "utf8", timeout: 5000, maxBuffer: 2 * 1024 * 1024 });
return stdout;
};
@@ -315,7 +315,7 @@ export class TerminalService {
if ("code" in resolved) return resolved;
const composed = composeView(resolved.id, await this.refineLiveness(resolved.members), { resolveHost: (id) => this.deps.resolveHost(id), panesPerPage: resolved.panesPerPage ?? panesPerPage, localTmux: await this.resolveLocalTmux(), localTmuxServer: daemonTmuxServerArgs() });
const saved = view.startsWith("saved:") ? this.deps.viewsStore.get(view.slice(6))
: !/^(qitem|mission|slice|pod|rig):/.test(view) ? this.deps.viewsStore.get(view) : null;
: !/^(qitem|mission|slice|pod|seat|rig):/.test(view) ? this.deps.viewsStore.get(view) : null;
composed.viewDescriptor = { kind: saved && saved.id === resolved.id ? "fixed" : "following",
name: resolved.task?.name ?? (saved?.id === resolved.id ? saved.name : resolved.id) };
if (resolved.task) {
@@ -495,6 +495,23 @@ export class TerminalService {
return { id: view, members: deriveViewMembers(rows, { readOnly: false }) };
}
// 2b. one seat within a rig — `seat:<rig-id-or-name>/<logicalId>` (the TUI's agent page opens just that agent).
// Interactive, like its rig. A logical id has no `/`, so the last `/` splits it from the rig.
if (view.startsWith("seat:")) {
const rest = view.slice("seat:".length);
const slash = rest.lastIndexOf("/");
if (slash <= 0 || slash === rest.length - 1) {
return { code: "view_not_found", error: `malformed seat view '${view}' — expected seat:<rig>/<logical-id>` };
}
const rigArg = rest.slice(0, slash);
const logicalId = rest.slice(slash + 1);
const rows = ((await this.deps.listRigSeats(rigArg)) ?? []).filter((row) => row.logicalId === logicalId);
if (rows.length === 0) {
return { code: "view_not_found", error: `unknown seat '${logicalId}' in rig '${rigArg}'` };
}
return { id: view, members: deriveViewMembers(rows, { readOnly: false }) };
}
// 3. a rig — either the bare name (the common case: `rig terminal open acme`)
// or the explicit `rig:<id-or-name>` form the rig-scoped route alias
// composes (arch R1: the alias just prefixes `rig:<rigId>` and delegates,
+4
View File
@@ -574,6 +574,8 @@ export type AgentActivityEvidenceSource =
export interface AgentActivity {
turnId?: string | null;
/** Native Claude hook actor: null is the main session; absent means an older or unrecognized relay. */
hookAgentId?: string | null;
state: AgentActivityState;
reason: string;
@@ -1487,6 +1489,8 @@ export interface ExpansionPodFragment {
profile?: string;
cwd?: string;
model?: string;
/** As written: the canonical RigSpec schema trims a text effort and ignores any other value with an advisory. */
effort?: unknown;
codexConfigProfile?: string;
claudePermissions?: unknown;
codexPermissions?: unknown;
@@ -866,7 +866,7 @@ export class WorkflowRuntime {
);
});
if (entryQitemId && entryQitemDestinationSession) {
await this.queueRepo.maybeNudge(entryQitemId, entryQitemDestinationSession, entryQitemNudge);
await this.queueRepo.maybeNudge(entryQitemId, entryQitemDestinationSession, entryQitemNudge, input.createdBySession);
}
const finalInstance = this.instanceStore.getByIdOrThrow(instanceId!);
+47 -1
View File
@@ -52,7 +52,11 @@ export function evidenceFromHookActivity(input: {
}
if (input.runtime === "claude-code" && input.activity.rawEvent === "Notification"
&& input.activity.reason === "permission_prompt") {
return { ...base, permissionRequest: { id: "claude-permission-prompt" }, needsInput: { count: 1, reason: "permission_prompt" } };
const hookAgentId = input.activity.hookAgentId;
return { ...base, permissionRequest: {
id: hookAgentId ? `claude-permission-prompt:${hookAgentId}` : "claude-permission-prompt",
...(hookAgentId !== undefined ? { hookAgentId } : {}),
}, needsInput: { count: 1, reason: "permission_prompt" } };
}
// A runner's exit report clears what its last turn said: the seat is no longer at a prompt or mid-turn.
if (input.activity.rawEvent === "Stop" && isRunnerExit(input.activity.rawSubtype, input.runtime)) {
@@ -448,6 +452,46 @@ activityRoutes.post("/hooks", async (c) => {
return c.json({ ok: true, oriented: "verified", nodeId: result.nodeId, challengeId: result.challengeId });
}
// All tools in this actor's batch have resolved. An individual PostToolUse can belong to a parallel tool,
// and a subagent's batch says nothing about the main session's permission. Only the current occupant can
// settle a matching, older episode. Do not record an unrelated batch as an unknown hook that hides attention.
const claudePermission = body.runtime === "claude-code" && body.hookEvent === "Notification" && body.subtype === "permission_prompt";
if (body.hookEvent === "PostToolBatch" || claudePermission) {
const oracle = c.get("seatActivityService" as never) as import("../domain/seat-activity-service.js").SeatActivityService | undefined;
const registry = c.get("sessionRegistry" as never) as SessionRegistry | undefined;
const sessionName = stringOrNull(body.sessionName);
const seat = store.resolveSession({ sessionName, nodeId: stringOrNull(body.nodeId) });
const hookAgentId = body.hookAgentId;
const generation = stringOrNull(body.generation);
const occurredAt = stringOrNull(body.occurredAt);
let current: ReturnType<SessionRegistry["currentOccupantTenure"]> | undefined;
try {
current = seat ? registry?.currentOccupantTenure(seat.nodeId) : null;
} catch {
return c.json({ ok: false, code: "generation_resolver_error", error: "Occupant generation is unavailable." }, 503);
}
const session = seat && store.db.prepare("SELECT status FROM sessions WHERE id = ?")
.get(seat.sessionId) as { status: string } | undefined;
const attributable = body.runtime === "claude-code" && seat?.runtime === "claude-code"
&& (!sessionName || sessionName === seat.sessionName) && session?.status === "running"
&& generation !== null && current?.generationUuid === generation
&& (hookAgentId === null || (typeof hookAgentId === "string" && hookAgentId.trim().length > 0))
&& occurredAt !== null && Number.isFinite(Date.parse(occurredAt));
if (body.hookEvent === "PostToolBatch") {
if (attributable && oracle && !oracle.hasRungInventory(seat!.nodeId, seat!.sessionName)) {
// The completion may be this occupant's first hook to reach the daemon.
oracle.declareRungInventory({ seatNodeId: seat!.nodeId, sessionName: seat!.sessionName }, runtimeRungInventory("claude-code"));
}
const settled = attributable && oracle
? oracle.completeClaudeToolBatch(seat!.sessionName, hookAgentId as string | null, occurredAt!) : false;
return c.json({ ok: true, permissionResolved: settled });
}
// Do not publish an already-completed request as fresh attention to the store's event consumers.
if (attributable && oracle?.hasLaterClaudeToolBatch(seat!.sessionName, hookAgentId as string | null, occurredAt!)) {
return c.json({ ok: true, permissionResolved: true });
}
}
const result = store.recordHookEvent({
runtime: stringOrNull(body.runtime),
sessionName: stringOrNull(body.sessionName),
@@ -460,6 +504,8 @@ activityRoutes.post("/hooks", async (c) => {
// (sound per-path absence; never false-fresh).
generation: stringOrNull(body.generation),
turnId: stringOrNull(body.turnId),
...(body.hookAgentId === null || (typeof body.hookAgentId === "string" && body.hookAgentId.trim().length > 0)
? { hookAgentId: body.hookAgentId } : {}),
sourceSequence: typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null,
livenessSeconds: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
+2
View File
@@ -171,6 +171,8 @@ function normalizeExpansionPodFragment(raw: Record<string, unknown>): ExpansionP
: "codex_permissions" in m ? { codexPermissions: m["codex_permissions"] } : {}),
cwd: typeof m["cwd"] === "string" ? m["cwd"] : undefined,
model: typeof m["model"] === "string" ? m["model"] : undefined,
// Raw presence: the canonical RigSpec schema trims a text effort and ignores any other value with an advisory.
...("effort" in m ? { effort: m["effort"] } : {}),
// R2 (4ac243c3): PRESERVE raw presence — a present-invalid value (null, number,
// object, …) must reach the canonical RigSpec validator as-is and reject there;
// only a truly ABSENT key stays absent. No route-local validation, no coercion.
+2 -1
View File
@@ -105,7 +105,8 @@ sessionsRoutes.get("/", (c) => {
nodesRoutes.get("/", observeInventoryHandler, async (c) => {
const rigId = c.req.param("rigId")!;
const deps = getDeps(c);
const rig = deps.rigRepo.getRig(rigId);
// Only existence is needed here; the inventory below builds the response.
const rig = deps.rigRepo.db.prepare("SELECT 1 FROM rigs WHERE id = ?").get(rigId);
if (!rig) return c.json({ error: `Rig "${rigId}" not found. List rigs with: rig ps` }, 404);
const refresh = c.req.query("refresh") === "true";
+7 -3
View File
@@ -578,10 +578,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// hook/pane resolution as status. No new captures, timer or delivery admission path.
const retryPromptWakes = (sessionName: string) => {
const runtime = sessionRegistry.findResumeContextByName(sessionName)?.runtime;
const structural = seatStructuralActivityService.getStructuralActivity(sessionName);
const state = hookOrPaneActivity(agentActivityStore.getLatestForNode({ sessionName,
order: runtime === "claude-code" ? "source" : "arrival" }),
seatStructuralActivityService.getStructuralActivity(sessionName), false, runtime)?.activity.state;
void queueRepoInstance.retryPromptWakesOnClear(sessionName, state)
order: runtime === "claude-code" ? "source" : "arrival" }), structural, false, runtime)?.activity.state;
void queueRepoInstance.retryPromptWakesOnClear(sessionName, state, structural?.state === "attention")
.catch(error => console.warn(`Prompt-clear wake recovery: ${error instanceof Error ? error.name : typeof error}`));
};
seatStructuralActivityService.attachActivityChanged(retryPromptWakes);
@@ -655,6 +655,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
HOME: daemonHome,
USER: process.env.USER,
LOGNAME: process.env.LOGNAME,
// The SSH agent socket, so git over SSH works in a seat (#1159). The path isn't key material, but the socket lets
// the seat's coding agent use the keys loaded in ssh-agent. An empty value is left out like an unset one, so a
// socket only the seat's terminal has never takes its place.
SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK || undefined,
CODEX_HOME: codexHome,
};
// OPR.0.4.6.02 S1 — ONE shared tmux option-defaults applier, injected into
@@ -11,18 +11,22 @@
// BOTH must pass — that's what proves the two primitives are computed
// independently. Either alone is symmetry-breakable.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type Database from "better-sqlite3";
import { createFullTestDb } from "./helpers/test-app.js";
import {
attachTerminalActivityAndWork,
attachAgentActivity,
getNodeInventory,
readAssignedWorkBySession,
} from "../src/domain/node-inventory.js";
import {
countAssignedWorkForSession,
countPendingWorkForSession,
PsProjectionService,
} from "../src/domain/ps-projection.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { EventBus } from "../src/domain/event-bus.js";
function seedRig(db: Database.Database, name: string): string {
const id = `rig-${name}`;
@@ -80,6 +84,72 @@ describe("attachTerminalActivityAndWork — slice 15 per-node enrichment", () =>
beforeEach(() => { db = createFullTestDb(); });
afterEach(() => { db.close(); });
function emitHook(rigId: string, nodeId: string, sessionName: string, state: "needs_input" | "idle") {
const eventAt = new Date().toISOString();
new EventBus(db).emit({
type: "agent.activity", rigId, nodeId, sessionName, runtime: "codex",
activity: { state, reason: state === "needs_input" ? "permission_prompt" : "stop",
evidenceSource: "runtime_hook", sampledAt: eventAt, eventAt, fallback: false, stale: false },
} as never);
}
it.each(["codex", "claude-code"])("keeps %s hooks on their inventory node when another rig reuses the session name", async runtime => {
const rig = seedRig(db, "a-owner");
const otherRig = seedRig(db, "z-reused");
const node = seedNode(db, rig, "dev");
const other = seedNode(db, otherRig, "dev");
db.prepare("UPDATE nodes SET runtime = ?").run(runtime);
seedSession(db, node, "reused-session");
seedSession(db, other, "reused-session"); // Later session ID wins the old global name lookup.
emitHook(rig, node, "reused-session", "needs_input");
emitHook(otherRig, other, "reused-session", "idle");
const store = new AgentActivityStore({ db, eventBus: new EventBus(db) });
const base = getNodeInventory(db, rig);
const [attached] = await attachAgentActivity(base, { tmuxAdapter: {} as never, activityStore: store });
expect(attached!.agentActivity?.state).toBe("needs_input");
expect(attachTerminalActivityAndWork(base, { db, agentActivity: store })[0]!.needsAttention).toBe(true);
const totals = new PsProjectionService({ db, agentActivity: store }).getEntries();
expect(totals.find(e => e.rigId === rig)!.attentionCount).toBe(1);
expect(totals.find(e => e.rigId === otherRig)!.attentionCount).toBe(0);
});
it.each(["codex", "claude-code"])("does not revive %s hooks from an old session or a node without a session", async runtime => {
const rig = seedRig(db, "absence");
const current = seedNode(db, rig, "current");
const orphan = seedNode(db, rig, "no-session");
db.prepare("UPDATE nodes SET runtime = ?").run(runtime);
seedSession(db, current, "current-session");
emitHook(rig, current, "old-session", "needs_input");
emitHook(rig, orphan, "orphan-session", "needs_input");
const store = new AgentActivityStore({ db, eventBus: new EventBus(db) });
const base = getNodeInventory(db, rig);
const attached = await attachAgentActivity(base, { tmuxAdapter: {} as never, activityStore: store });
expect(attached.map(e => e.agentActivity?.state)).toEqual(["unknown", "unknown"]);
expect(attachTerminalActivityAndWork(base, { db, agentActivity: store }).map(e => e.needsAttention))
.toEqual([false, false]);
expect(new PsProjectionService({ db, agentActivity: store }).getEntries()[0]!.attentionCount).toBe(0);
});
it("reuses attached unknown activity without another read, but keeps the standalone hook fallback", async () => {
const rig = seedRig(db, "reuse");
const node = seedNode(db, rig, "dev");
seedSession(db, node, "dev@reuse");
const store = new AgentActivityStore({ db, eventBus: new EventBus(db) });
const read = vi.spyOn(store, "getLatestForNode");
const base = getNodeInventory(db, rig);
const attached = await attachAgentActivity(base, { tmuxAdapter: {} as never, activityStore: store });
expect(attached[0]!.agentActivity?.state).toBe("unknown");
expect(read).toHaveBeenCalledTimes(1);
emitHook(rig, node, "dev@reuse", "needs_input");
expect(attachTerminalActivityAndWork(attached, { db, agentActivity: store })[0]!.needsAttention).toBe(false);
expect(read).toHaveBeenCalledTimes(1);
for (const agentActivity of [undefined, null]) {
const standalone = [{ ...base[0]!, agentActivity }];
expect(attachTerminalActivityAndWork(standalone, { db, agentActivity: store })[0]!.needsAttention).toBe(true);
}
expect(read).toHaveBeenCalledTimes(3);
});
it("HG-3 DIRECTION A — node producing output with NOTHING queued: terminalActive=true, hasAssignedWork=false", () => {
const rig = seedRig(db, "active-only");
const n = seedNode(db, rig, "dev");
@@ -135,7 +135,8 @@ async function sendAfterHook(mode: Mode, consumer: "send" | "wake" | "handoff" =
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh /fixture/launch", startedAt },
{ pid: 102, ppid: 101, pgid: 101, tpgid: 101, executableName: foreign ? "codex" : "claude",
command: foreign ? "codex resume other" : `/opt/claude --session-id ${original}`, startedAt },
command: foreign ? "codex resume other" : `/opt/claude --session-id ${original}`,
...(foreign ? { argv: ["codex", "resume", "other"] } : {}), startedAt },
];
if (mode === "shim") rows.push({ ...rows[2]!, pid: 103, ppid: 102,
command: `/child/claude --session-id ${original} --settings /fixture/settings.json` });
@@ -238,7 +238,8 @@ describe("fresh in-place readiness requires current launch evidence", () => {
const startedAt = "Mon Oct 19 21:30:00 2026";
f.listProcesses.mockReturnValue([
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "codex", command: "codex resume other-token", startedAt },
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "codex", command: "codex resume other-token",
argv: ["codex", "resume", "other-token"], startedAt },
]);
await launch(f.adapter);
expect(await f.adapter.checkReady(binding)).toMatchObject({ ready: false, code: "claude_launch_identity_conflict" });
@@ -13,11 +13,11 @@ const open: Database.Database[] = [];
afterEach(() => { for (const db of open.splice(0)) db.close(); });
// `file` is the exact bytes of `claude` on the daemon's PATH; it must be a script, which runs on every platform.
async function prepared(file: string, link?: boolean) {
async function prepared(file: string, link?: boolean, recordedExecutable?: boolean) {
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "claude-script-launch-")));
const cwd = path.join(root, "seat"); const bin = path.join(root, "bin");
mkdirSync(cwd); mkdirSync(bin); mkdirSync(path.join(root, "home"));
const target = path.join(link ? root : bin, link ? "real-claude" : "claude");
const target = path.join(link || recordedExecutable ? root : bin, link || recordedExecutable ? "real-claude" : "claude");
writeFileSync(target, file); chmodSync(target, 0o755);
if (link) symlinkSync(target, path.join(bin, "claude"));
const db = new Database(":memory:"); open.push(db);
@@ -27,7 +27,7 @@ async function prepared(file: string, link?: boolean) {
db.prepare("INSERT INTO nodes VALUES ('node','claude-code',?)").run(cwd);
db.exec("INSERT INTO bindings VALUES ('binding','node','seat','%1'); INSERT INTO occupant_tenures VALUES ('node','generation-1',1)");
const managed = new ClaudeManagedLaunch(db, { PATH: `${bin}:/usr/bin:/bin`, HOME: path.join(root, "home") }, {});
return { managed: await managed.prepare({ nodeId: "node" }, "auto"), target };
return { managed: await managed.prepare({ nodeId: "node", ...(recordedExecutable ? { executable: target } : {}) }, "auto"), target, resolver: managed };
}
const printsHelp = `printf '%s\\n' '${HELP}'\n`;
@@ -43,6 +43,15 @@ it("reports it through a symlink, whose realpath is the frozen executable", asyn
expect(managed.executableIsScript).toBe(true);
});
it("continues with the recorded executable outside the daemon PATH, freezing the same file", async () => {
const { managed, target, resolver } = await prepared(`#!/bin/sh\n${printsHelp}`, false, true);
expect(managed.executable).toBe(target);
expect(managed.command(["--resume", "same-conversation"])).toContain(target);
await expect(resolver.prepare({ nodeId: "node" }, "auto")).rejects.toThrow("unavailable on the intended PATH");
writeFileSync(target, `#!/bin/sh\n${printsHelp}\n# changed`);
expect(() => managed.assertCurrent()).toThrow("context changed");
});
// Not through prepare(): it runs `--help` on the file first, and a file with no #! at byte 0 can't be exec'd on every
// platform (Linux's execvp hands it to /bin/sh, macOS returns ENOEXEC), so such a fixture would pass only on some hosts.
// prepare() reports exactly this function's answer for the file it froze.
@@ -0,0 +1,429 @@
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { activityRoutes } from "../src/routes/activity.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
import { EventBus } from "../src/domain/event-bus.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { attachAgentActivity, attachTerminalActivityAndWork, getNodeInventory } from "../src/domain/node-inventory.js";
import { WAKE_INTENT_PREFIX } from "../src/domain/outbox-handler.js";
import { createDaemon } from "../src/startup.js";
import { createFullTestDb } from "./helpers/test-app.js";
const require = createRequire(import.meta.url);
const { buildOpenRigPayload } = require("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs") as {
buildOpenRigPayload: (native: Record<string, unknown>, env: Record<string, string>, now: () => Date) => Record<string, unknown>;
};
const databases: ReturnType<typeof createFullTestDb>[] = [];
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
function fixture(visible: boolean | null = false) {
const db = createFullTestDb();
databases.push(db);
const repo = new RigRepository(db);
const rig = repo.createRig("batch-permission");
const node = repo.addNode(rig.id, "worker", { runtime: "claude-code" });
const registry = new SessionRegistry(db);
const sessionName = "worker@batch-permission";
const session = registry.registerSession(node.id, sessionName);
registry.updateStatus(session.id, "running");
registry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: sessionName });
const generation = registry.currentOccupantTenure(node.id)!.generationUuid;
let nowMs = Date.parse("2026-10-28T00:00:00.000Z");
const now = () => new Date(nowMs);
const store = new AgentActivityStore({ db, eventBus: new EventBus(db), now,
resolveOccupantGeneration: id => registry.currentOccupantTenure(id)?.generationUuid ?? null,
isRegisteredOccupantGeneration: (id, value) => registry.isOccupantGenerationRegistered(id, value),
});
const reader = vi.fn(async (): Promise<boolean | null> => visible);
const changed = vi.fn((name: string, confirmed: boolean, runtime: string) => {
const latest = store.getLatestForNode({ sessionName: name });
store.recordHookEvent({ sessionName: name, runtime, generation: latest?.generation,
hookEvent: confirmed ? "PermissionPromptConfirmed" : "PermissionPromptCleared" });
});
const service = new SeatActivityService({ tmux: { readPaneLastActivity: async () => null },
defaultWindowSeconds: 3, now, permissionPromptReader: reader, permissionPromptChanged: changed });
const app = new Hono();
app.use("*", async (context, next) => {
context.set("agentActivityStore" as never, store as never);
context.set("activityHookToken" as never, "batch-test-token" as never);
context.set("seatActivityService" as never, service as never);
context.set("sessionRegistry" as never, registry as never);
await next();
});
app.route("/activity", activityRoutes);
const hook = async (event: string, native: Record<string, unknown> = {}, wire: Record<string, unknown> = {}) => {
nowMs += 10;
const payload = buildOpenRigPayload({ hook_event_name: event, session_id: "native-session", permission_mode: "auto",
...(event === "Notification" ? { notification_type: "permission_prompt" } : {}), ...native }, {
OPENRIG_SESSION_NAME: sessionName, OPENRIG_NODE_ID: node.id,
OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: generation,
}, now);
const response = await app.request("/activity/hooks", { method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer batch-test-token" },
body: JSON.stringify({ ...payload, ...wire }),
});
expect(response.status).toBe(200);
return payload;
};
return { app, db, node, registry, session, sessionName, generation, store, service, reader, changed, hook, now,
advance: (delta: number) => { nowMs += delta; },
count: () => service.getSeatState(node.id)!.needsInput.count,
latest: () => {
// sampledAt is the read's clock; the retained source event and its meaning must stay unchanged.
const { sampledAt: _, ...activity } = store.getLatestForNode({ nodeId: node.id, sessionName })!;
return activity;
},
};
}
describe("Claude tool-batch completion resolves its permission notification", () => {
it.each([false, null])("clears an unseen permission after the batch finishes, with capture=%s", async visible => {
const subject = fixture(visible);
await subject.hook("UserPromptSubmit");
await subject.hook("Notification");
await subject.service.pollSeat(subject.sessionName);
expect(subject.count()).toBe(1);
await subject.hook("PostToolBatch", { tool_calls: [{ tool_name: "Bash", tool_use_id: "tool-1", tool_response: "finished" }] });
expect(subject.count()).toBe(0);
expect(subject.service.getSeatState(subject.node.id)).toMatchObject({ activity: "working",
needsInputEvidence: { rung: "lifecycle-hooks" } });
expect(subject.latest()).toMatchObject({ rawEvent: "PermissionPromptCleared", reason: "permission_prompt_cleared" });
const calls = subject.changed.mock.calls.length;
await subject.hook("PostToolBatch");
expect(subject.changed).toHaveBeenCalledTimes(calls);
});
it.each([undefined, "child-1"])("carries native actor %s without sending tool inputs or results", async agentId => {
const subject = fixture();
const payload = await subject.hook("Notification", { agent_id: agentId, tool_input: { command: "private" } });
expect(payload).toHaveProperty("hookAgentId", agentId ?? null);
const recorded = subject.db.prepare("SELECT payload FROM events WHERE type = 'agent.activity' AND json_extract(payload, '$.activity.rawEvent') = 'Notification' ORDER BY seq DESC LIMIT 1")
.get() as { payload: string };
expect(JSON.parse(recorded.payload).activity).toHaveProperty("hookAgentId", agentId ?? null);
const batch = await subject.hook("PostToolBatch", { agent_id: agentId,
tool_calls: [{ tool_name: "Bash", tool_use_id: "tool-1", tool_response: "private result" }] });
expect(batch).toHaveProperty("hookAgentId", agentId ?? null);
expect(batch).not.toHaveProperty("tool_calls");
expect(payload).not.toHaveProperty("tool_input");
expect(subject.count()).toBe(0);
});
it("does not treat one parallel tool finishing as the whole batch finishing", async () => {
const subject = fixture();
await subject.hook("Notification");
await subject.hook("PostToolUse", { tool_use_id: "other-tool" });
expect(subject.count()).toBe(1);
await subject.hook("PostToolBatch");
expect(subject.count()).toBe(0);
});
it("settles only the completed actor when main and subagent permissions overlap", async () => {
const subject = fixture();
await subject.hook("Notification");
await subject.hook("Notification", { agent_id: "child-1" });
expect(subject.count()).toBe(2);
await subject.hook("PostToolBatch", { agent_id: "child-1" });
expect(subject.count()).toBe(1);
await subject.hook("PostToolBatch");
expect(subject.count()).toBe(0);
});
it("does not attribute the same visible dialog to two outstanding actors", async () => {
const subject = fixture();
await subject.hook("Notification");
await subject.hook("Notification", { agent_id: "child-1" });
subject.reader.mockResolvedValue(true);
subject.reader.mockClear();
subject.advance(1_000);
await subject.service.pollSeat(subject.sessionName);
expect(subject.reader).toHaveBeenCalledOnce();
subject.reader.mockResolvedValue(false);
subject.advance(1_000);
await subject.service.pollSeat(subject.sessionName);
expect(subject.count()).toBe(2);
await subject.hook("PostToolBatch");
expect(subject.count()).toBe(1);
await subject.hook("PostToolBatch", { agent_id: "child-1" });
expect(subject.count()).toBe(0);
});
it("uses the current node identity when a relay only carries nodeId", async () => {
const subject = fixture();
await subject.hook("Notification");
await subject.hook("PostToolBatch", {}, { sessionName: null });
expect(subject.count()).toBe(0);
});
it("does not let the completed batch mask later self-reported attention", async () => {
const subject = fixture();
await subject.hook("Notification");
await subject.hook("PostToolBatch");
subject.service.reportEvidence({ seatNodeId: subject.node.id, sessionName: subject.sessionName,
rung: "self-report", sourceId: "claude:pid-json", seq: 1, observedAt: subject.now().toISOString(),
needsInput: { count: 1, reason: "dialog owns input" } });
expect(subject.count()).toBe(1);
});
it.each(["child-1", "child-2"])("ignores actor %s finishing while the main request is pending", async agentId => {
const subject = fixture();
await subject.hook("Notification");
const before = subject.latest();
await subject.hook("PostToolBatch", { agent_id: agentId });
expect(subject.count()).toBe(1);
expect(subject.latest()).toEqual(before);
});
it.each([-1, 0])("does not resolve a notification with a batch timestamp offset of %s ms", async offset => {
const subject = fixture();
await subject.hook("Notification");
const before = subject.latest();
const occurredAt = new Date(subject.now().getTime() + offset).toISOString();
await subject.hook("PostToolBatch", {}, { occurredAt });
expect(subject.count()).toBe(1);
expect(subject.latest()).toEqual(before);
});
it("does not let a delayed older notification make a newer request eligible for an old batch", async () => {
const subject = fixture();
const at = (offset: number) => new Date(Date.parse("2026-10-28T00:00:00.000Z") + offset).toISOString();
await subject.hook("Notification", {}, { occurredAt: at(30) });
await subject.hook("Notification", {}, { occurredAt: at(10) });
await subject.hook("PostToolBatch", {}, { occurredAt: at(20) });
expect(subject.count()).toBe(1);
expect(subject.latest().state).toBe("needs_input");
await subject.hook("PostToolBatch", {}, { occurredAt: at(40) });
expect(subject.count()).toBe(0);
});
it("does not guess an actor for notifications from an older relay", async () => {
const subject = fixture();
await subject.hook("Notification", {}, { hookAgentId: undefined });
const before = subject.latest();
await subject.hook("PostToolBatch");
expect(subject.count()).toBe(1);
expect(subject.latest()).toEqual(before);
});
it.each([undefined, "child-1"])("does not revive actor %s after its completed notification arrives again", async agentId => {
const subject = fixture();
await subject.hook("UserPromptSubmit");
const notification = await subject.hook("Notification", { agent_id: agentId });
const batch = await subject.hook("PostToolBatch", { agent_id: agentId });
const before = subject.latest();
for (const occurredAt of [notification.occurredAt,
new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString()]) {
await subject.hook("Notification", { agent_id: agentId }, { occurredAt });
expect(subject.count()).toBe(0);
expect(subject.latest()).toEqual(before);
}
});
it.each([undefined, "child-1"])("remembers actor %s completing before its first notification arrives", async agentId => {
const subject = fixture();
const batch = await subject.hook("PostToolBatch", { agent_id: agentId });
await subject.hook("Notification", { agent_id: agentId },
{ occurredAt: new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString() });
expect(subject.count()).toBe(0);
expect(subject.store.getLatestForNode({ sessionName: subject.sessionName })?.state).not.toBe("needs_input");
expect(subject.changed).not.toHaveBeenCalled();
});
it.each([0, 1])("keeps a new notification whose timestamp is %s ms after the prior batch", async offset => {
const subject = fixture();
await subject.hook("Notification");
const batch = await subject.hook("PostToolBatch");
await subject.hook("Notification", {},
{ occurredAt: new Date(Date.parse(String(batch.occurredAt)) + offset).toISOString() });
expect(subject.count()).toBe(1);
expect(subject.latest().state).toBe("needs_input");
});
it("retains the latest completion per actor even when older batches arrive later", async () => {
const subject = fixture();
await subject.hook("UserPromptSubmit");
const completedAt = subject.now().getTime() + 100;
const at = (offset: number) => new Date(completedAt + offset).toISOString();
await subject.hook("PostToolBatch", { agent_id: "child-1" }, { occurredAt: at(0) });
await subject.hook("PostToolBatch", { agent_id: "child-1" }, { occurredAt: at(-20) });
await subject.hook("Notification", { agent_id: "child-1" }, { occurredAt: at(-10) });
expect(subject.count()).toBe(0);
await subject.hook("Notification", {}, { occurredAt: at(-10) });
expect(subject.count()).toBe(1);
expect(subject.latest().state).toBe("needs_input");
});
it("keeps an unattributed legacy notification even after the main actor completed", async () => {
const subject = fixture();
const batch = await subject.hook("PostToolBatch");
await subject.hook("Notification", {}, { hookAgentId: undefined,
occurredAt: new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString() });
expect(subject.count()).toBe(1);
expect(subject.latest().state).toBe("needs_input");
});
it.each(["forget", "swap"] as const)("discards the prior occupant's completion on %s", async action => {
const subject = fixture();
const batch = await subject.hook("PostToolBatch");
let generation = subject.generation;
if (action === "forget") subject.service.forgetSeat(subject.sessionName);
else {
generation = subject.registry.mintOccupantTenure(subject.node.id, "initial").generationUuid;
subject.service.declareOccupantSwap(subject.node.id, generation);
}
await subject.hook("Notification", {}, { generation,
occurredAt: new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString() });
expect(subject.count()).toBe(1);
expect(subject.latest().state).toBe("needs_input");
});
it.each(["PostToolBatch", "Notification"])("returns a named 503 for %s when the occupant ledger cannot be read", async hookEvent => {
const subject = fixture();
await subject.hook("Notification");
const before = subject.latest();
const resolver = vi.spyOn(subject.registry, "currentOccupantTenure")
.mockImplementation(() => { throw new Error("fixture occupant ledger unavailable"); });
try {
const response = await subject.app.request("/activity/hooks", { method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer batch-test-token" },
body: JSON.stringify({ runtime: "claude-code", hookEvent, subtype: "permission_prompt",
sessionName: subject.sessionName, nodeId: subject.node.id, generation: subject.generation,
hookAgentId: null, occurredAt: new Date(subject.now().getTime() + 1).toISOString() }),
});
expect(response.status).toBe(503);
expect(await response.json()).toMatchObject({ ok: false, code: "generation_resolver_error" });
} finally {
resolver.mockRestore();
}
expect(subject.count()).toBe(1);
expect(subject.latest()).toEqual(before);
await subject.hook(hookEvent);
expect(subject.count()).toBe(hookEvent === "PostToolBatch" ? 0 : 1);
});
it.each([{ generation: null }, { generation: "retired-generation" }, { hookAgentId: undefined },
{ hookAgentId: 42 }, { occurredAt: "invalid" }, { sessionName: "old@batch-permission" }])(
"preserves attention when batch attribution is unusable: %j", async wire => {
const subject = fixture();
await subject.hook("Notification");
const before = subject.latest();
await subject.hook("PostToolBatch", {}, wire);
expect(subject.count()).toBe(1);
expect(subject.latest()).toEqual(before);
});
it("does not clear an elicitation that replaced the permission request", async () => {
const subject = fixture();
await subject.hook("Notification");
await subject.hook("Notification", { notification_type: "elicitation_dialog" });
const before = subject.latest();
await subject.hook("PostToolBatch");
expect(subject.count()).toBe(1);
expect(subject.latest()).toEqual(before);
});
it("does not revive a completed request from an in-flight pane capture", async () => {
const subject = fixture();
let finish!: (visible: boolean) => void;
subject.reader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await subject.hook("Notification");
expect(finish).toBeTypeOf("function");
await subject.hook("PostToolBatch");
expect(subject.count()).toBe(0);
finish(true);
await subject.service.pollSeat(subject.sessionName);
expect(subject.count()).toBe(0);
});
it("does not apply a batch to a retired session", async () => {
const subject = fixture();
await subject.hook("Notification");
subject.registry.updateStatus(subject.session.id, "stopped");
const before = subject.latest();
await subject.hook("PostToolBatch");
expect(subject.count()).toBe(1);
expect(subject.latest()).toEqual(before);
});
it("clears displayed attention and retries the existing refused handoff once through daemon wiring", async () => {
const originalNoKernel = process.env.OPENRIG_NO_KERNEL;
process.env.OPENRIG_NO_KERNEL = "1";
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async input => {
expect(String(input)).toBe("https://github.com/mvschwarz/openrig-plugins/releases/latest/download/openrig-core.tar.gz");
return new Response(null, { status: 404 });
});
const captures = JSON.parse(readFileSync(new URL("./fixtures/claude-permission-2.1.300.json", import.meta.url), "utf8"));
let daemon: Awaited<ReturnType<typeof createDaemon>> | undefined;
try {
daemon = await createDaemon({ dbPath: ":memory:",
tmuxExec: async command => command.includes("capture-pane") ? captures.approvedBash : "", cmuxExec: async () => "" });
const { rigRepo, sessionRegistry, queueRepo, outboxHandler, agentActivityStore, seatActivityService,
seatStructuralActivityService, tmuxAdapter, activityHookToken } = daemon.deps;
const rig = rigRepo.createRig("batch-wake");
const node = rigRepo.addNode(rig.id, "worker", { runtime: "claude-code" });
const sessionName = "worker@batch-wake";
const session = sessionRegistry.registerSession(node.id, sessionName);
sessionRegistry.updateStatus(session.id, "running");
sessionRegistry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: sessionName });
const generation = sessionRegistry.currentOccupantTenure(node.id)!.generationUuid;
const hook = async (event: string, native: Record<string, unknown> = {}) => {
const payload = buildOpenRigPayload({ hook_event_name: event,
...(event === "Notification" ? { notification_type: "permission_prompt" } : {}), ...native }, {
OPENRIG_SESSION_NAME: sessionName, OPENRIG_NODE_ID: node.id,
OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: generation,
}, () => new Date(event === "PostToolBatch" ? Date.now() : Date.now() - 1_000));
const response = await daemon!.app.request("/api/activity/hooks", { method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${activityHookToken}` },
body: JSON.stringify(payload) });
expect(response.status).toBe(200);
};
const inventory = async () => attachTerminalActivityAndWork(await attachAgentActivity(getNodeInventory(daemon!.db, rig.id),
{ tmuxAdapter, activityStore: agentActivityStore, structuralActivity: seatStructuralActivityService }),
{ db: daemon!.db, seatActivity: seatActivityService, agentActivity: agentActivityStore })[0]!;
await seatStructuralActivityService!.pollSeat(sessionName, null, "claude-code");
await hook("UserPromptSubmit");
await hook("Notification");
await seatActivityService!.pollSeat(sessionName);
expect(await inventory()).toMatchObject({ needsAttention: true, agentActivity: { state: "needs_input" },
activityState: { display: "needs-input", needsInput: { count: 1 } } });
let refused = true;
const send = vi.fn(async () => refused
? { ok: false, sent: false, reason: "target_needs_input", error: `Refused: '${sessionName}' is at an interactive prompt (permission_prompt). No text was sent.` }
: { ok: true, verified: true });
queueRepo!.attachTransport({ send });
const source = await queueRepo!.create({ sourceSession: "planner@batch-wake", destinationSession: "builder@batch-wake",
body: "build", nudge: false });
const { created } = await queueRepo!.handoff({ qitemId: source.qitemId, fromSession: "builder@batch-wake",
toSession: sessionName, body: "review the finished work" });
const wakeId = WAKE_INTENT_PREFIX + created.qitemId;
await vi.waitFor(() => expect(outboxHandler!.getById(wakeId)?.deliveryState).toBe("failed"));
const original = outboxHandler!.getById(wakeId)!;
refused = false;
await hook("PostToolBatch", { agent_id: "other-actor" });
expect(send).toHaveBeenCalledOnce();
expect((await inventory()).needsAttention).toBe(true);
await hook("PostToolBatch");
await vi.waitFor(() => expect(outboxHandler!.getById(wakeId)?.deliveryState).toBe("delivered"));
expect(send).toHaveBeenCalledTimes(2);
expect(send.mock.calls[1][1]).toContain(original.body);
expect(agentActivityStore!.getLatestForNode({ sessionName })).toMatchObject({ rawEvent: "PermissionPromptCleared" });
expect(await inventory()).toMatchObject({ needsAttention: false, agentActivity: { state: "running" },
activityState: { display: "working", needsInput: { count: 0 } } });
await hook("PostToolBatch");
await seatStructuralActivityService!.pollSeat(sessionName, null, "claude-code");
await new Promise<void>(resolve => setImmediate(resolve));
expect(send).toHaveBeenCalledTimes(2);
expect(queueRepo!.list({ limit: 100 })).toHaveLength(2);
} finally {
daemon?.eventLoopMonitor.stop();
daemon?.db.close();
fetch.mockRestore();
if (originalNoKernel === undefined) delete process.env.OPENRIG_NO_KERNEL;
else process.env.OPENRIG_NO_KERNEL = originalNoKernel;
}
}, 10_000);
});
@@ -33,6 +33,15 @@ function mockTmux(overrides?: {
}
describe("ClaudeResumeAdapter", () => {
it("the legacy continuation uses the retained executable and original conversation", async () => {
const tmux = mockTmux();
const adapter = new ClaudeResumeAdapter(tmux, { maxWaitMs: 1, sleep: async () => {} });
await adapter.resume("seat@rig", "claude_id", "original", "/repo", undefined, undefined, undefined, "node",
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, "/native tools/claude");
expect(tmux.sendText).toHaveBeenCalledWith("seat@rig", expect.stringContaining("exec '/native tools/claude'"));
expect(tmux.sendText).toHaveBeenCalledWith("seat@rig", expect.stringContaining("--resume 'original'"));
});
describe("canResume", () => {
it("claude_name + token -> true", () => {
const adapter = new ClaudeResumeAdapter(mockTmux());
@@ -76,6 +76,13 @@ describe("launchHarness — per-agent --model reaches the claude launch (51-07 A
return (calls[calls.length - 1]?.[1] as string) ?? "";
};
it("uses the retained executable for a same-conversation continuation", async () => {
const tmux = mockTmux();
await adapterWith(tmux).launchHarness({ ...makeBinding(), claudeExecutable: "/native tools/claude" }, { name: "seat", resumeToken: "original" });
expect(lastCmd(tmux)).toContain("exec '/native tools/claude'");
expect(lastCmd(tmux)).toContain("--resume 'original'");
});
it("FRESH launch emits --model when the binding declares one", async () => {
const tmux = mockTmux();
await adapterWith(tmux).launchHarness(withModel(MODEL), { name: "seat" });
@@ -959,7 +966,8 @@ describe("Claude Code runtime adapter", () => {
describe("a different occupant keeps main's result (timed out, no token): only unknown observations get attention", () => {
const timedOut = { ok: false, probe: probeEvidence, error: "Claude resume failed: timed out waiting for Claude to become active" };
const codex = { pid: 13, ppid: 11, pgid: 11, tpgid: 11, executableName: "codex", startedAt, command: "codex resume some-codex-session" };
const codex = { pid: 13, ppid: 11, pgid: 11, tpgid: 11, executableName: "codex", startedAt,
command: "codex resume some-codex-session", argv: ["codex", "resume", "some-codex-session"] };
const otherClaude = { ...claude, command: "claude --permission-mode acceptEdits --resume some-other-session --name other" };
const booting = [{ ...pane, tpgid: 11 }, launchScript];
@@ -58,7 +58,8 @@ describe("late Codex fork identity", () => {
pidOnlyRecord = false;
processRows = [
{ pid: 900, ppid: 1, pgid: 900, tpgid: 901, executableName: "sh", startedAt: STARTED, command: "sh" },
{ pid: 901, ppid: 900, pgid: 901, tpgid: 901, executableName: "codex", startedAt: STARTED, command: `codex fork ${PARENT}` },
{ pid: 901, ppid: 900, pgid: 901, tpgid: 901, executableName: "codex", startedAt: STARTED,
command: `codex fork ${PARENT}`, argv: ["codex", "fork", PARENT] },
];
tmux = {
hasSession: vi.fn(async () => true),
@@ -177,12 +178,12 @@ describe("late Codex fork identity", () => {
await orch.startNode(input);
threadId = CHILD;
const child = processRows[1]!;
if (shape === "different parent") child.command = `codex fork ${CHILD}`;
if (shape === "resume") child.command = `codex resume ${PARENT}`;
if (shape === "fresh") child.command = "codex";
if (shape === "different parent") { child.command = `codex fork ${CHILD}`; child.argv = ["codex", "fork", CHILD]; }
if (shape === "resume") { child.command = `codex resume ${PARENT}`; child.argv = ["codex", "resume", PARENT]; }
if (shape === "fresh") { child.command = "codex"; child.argv = ["codex"]; }
if (shape === "background") child.pgid = 800;
if (shape === "sibling") processRows.push({ ...child, pid: 902 });
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.executableName = "helper"; }
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.argv = ["helper", "codex", "fork", PARENT]; child.executableName = "helper"; }
if (shape === "missing start") delete child.startedAt;
expect(await lifecycle().continueStartup("builder@fork-recovery")).toMatchObject({ ok: false, startupStatus: "attention_required" });
expect(tmux.sendText).not.toHaveBeenCalled();
@@ -193,9 +194,9 @@ describe("late Codex fork identity", () => {
threadId = CHILD;
const child = processRows[1]!;
if (shape === "reused PID") pidOnlyRecord = true;
if (shape === "different parent") child.command = `codex fork ${CHILD}`;
if (shape === "different parent") { child.command = `codex fork ${CHILD}`; child.argv = ["codex", "fork", CHILD]; }
if (shape === "background") child.pgid = 800;
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.executableName = "helper"; }
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.argv = ["helper", "codex", "fork", PARENT]; child.executableName = "helper"; }
readinessSeconds = 1;
const result = await orch.startNode(input);
expect(result).toMatchObject({ ok: false, startupStatus: "attention_required" });
@@ -209,7 +210,8 @@ describe("late Codex fork identity", () => {
await orch.startNode(input);
threadId = CHILD;
processRows[1]!.command = `codex -c example=fork -s workspace-write fork --add-dir /fixture/queue ${PARENT}`;
processRows.push({ ...processRows[1]!, pid: 902, ppid: 901, command: "codex" });
processRows[1]!.argv = ["codex", "-c", "example=fork", "-s", "workspace-write", "fork", "--add-dir", "/fixture/queue", PARENT];
processRows.push({ ...processRows[1]!, pid: 902, ppid: 901, command: "codex", argv: ["codex"] });
expect(await lifecycle().continueStartup("builder@fork-recovery")).toMatchObject({ ok: true, continuityOutcome: "forked" });
expect(identityReads.at(-1)).toEqual([902, STARTED]);
expect(tmux.sendText).toHaveBeenCalledTimes(1);
@@ -10,6 +10,7 @@ import { codexCommandResumeToken, observeCodexForkProcess, observeCodexPaneProce
import { prepareCodexSeatFolder } from "../src/domain/codex-team-workspace.js";
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import { resolveCodexGitAddDirs } from "../src/domain/codex-git-add-dirs.js";
import { shellWords } from "../src/domain/seat-restart-in-place.js";
// Issue #121: in a linked git worktree `<cwd>/.git` is a FILE ("gitdir: ..."), so passing it to Codex as
// `--add-dir` breaks the Linux sandbox for every command. These tests use real temporary Git repositories
@@ -154,7 +155,7 @@ describe("Codex resume and fork git add-dirs (a restored seat keeps .git writabl
const asPs = (command: string) => command.slice(command.indexOf("codex ")).replace(/'/g, "");
const pane = { getPanePid: async () => 4100 };
const row = (command: string) => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex",
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command) }];
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command), argv: shellWords(command.slice(command.indexOf("codex "))) }];
const resume = await launchCommand(repo, { resumeToken: "thread-1" });
expect(codexCommandResumeToken(asPs(resume))).toBe("thread-1");
expect(await observeCodexPaneProcess({ target: "r01-qa", tmux: pane, listProcesses: () => row(resume),
@@ -207,7 +208,8 @@ describe("Codex resume and fork git add-dirs (a restored seat keeps .git writabl
// The process line joins argv with spaces and no quoting, so a dir before the id would hide it.
const line = built.core.replace(/'/g, "");
expect(codexCommandResumeToken(line)).toBe("thread-1");
const rows = () => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: line }];
const rows = () => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000",
command: line, argv: shellWords(built.core) }];
const pane = { getPanePid: async () => 4100 };
// The restart's own proof, then the send path's check behind a shell.
expect(await observeCodexPaneProcess({ target: "r01-qa", tmux: pane, listProcesses: rows, expectedToken: "thread-1", requireResume: true })).not.toBeNull();
@@ -251,7 +253,7 @@ describe("Codex seat folder add-dir (a seat keeps its LEARNED.md and recap writa
const asPs = (command: string) => command.slice(command.indexOf("codex ")).replace(/'/g, "");
const pane = { getPanePid: async () => 4100 };
const row = (command: string) => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex",
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command) }];
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command), argv: shellWords(command.slice(command.indexOf("codex "))) }];
const resume = await launchCommand(repo, { resumeToken: "thread-1" });
expect(codexCommandResumeToken(asPs(resume))).toBe("thread-1");
expect(await observeCodexPaneProcess({ target: "r01-qa", tmux: pane, listProcesses: () => row(resume),
@@ -3,6 +3,13 @@ import os from "node:os";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { defaultListProcesses } from "../src/adapters/codex-runtime-adapter.js";
import { readNativeProcessArguments } from "../src/domain/native-process-arguments.js";
import { findExactNativeResumeProcess, verifyCodexPaneProcess } from "../src/domain/native-process-lineage.js";
// The fixture's synthetic PIDs must never inspect host processes.
vi.mock("../src/domain/native-process-arguments.js", async importOriginal => ({
...await importOriginal<typeof import("../src/domain/native-process-arguments.js")>(),
readNativeProcessArguments: vi.fn(async () => new Map()),
}));
import { restoreProc, serveProcRoot, writeFakeProc } from "./helpers/fake-proc.js";
let scratch: string | undefined;
@@ -11,6 +18,8 @@ afterEach(() => {
restoreProc();
Object.defineProperty(process, "platform", platform);
vi.unstubAllEnvs();
vi.mocked(readNativeProcessArguments).mockReset();
vi.mocked(readNativeProcessArguments).mockResolvedValue(new Map());
if (scratch) fs.rmSync(scratch, { recursive: true, force: true });
});
@@ -41,11 +50,19 @@ it("reads foreground identity through the real async executable and parser path"
expect(rows).toEqual([
{ pid: 101, ppid: 1, pgid: 101, tpgid: 102, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000", command: "-zsh" },
{ pid: 102, ppid: 101, pgid: 102, tpgid: 102, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /opt/bin/codex -C /project" },
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/vendor/bin/codex -C /project" },
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/vendor/bin/codex -C /project", argv: null },
{ pid: 104, ppid: 1, pgid: 104, tpgid: -1, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /tmp/other.js --label codex" },
{ pid: 106, ppid: 1, pgid: 106, tpgid: -1, executableName: "tmux: server", startedAt: "Sat Jan 1 12:00:00 2000", command: "tmux new-session -d" },
{ pid: 107, ppid: 1, pgid: 107, tpgid: -1, executableName: "node (vitest 1)", startedAt: "Sat Jan 1 12:00:00 2000", command: "node (vitest 1)" },
]);
// A rejected helper (including a policy timeout) must leave the table intact.
vi.mocked(readNativeProcessArguments).mockRejectedValueOnce(new Error("argv helper rejected"))
.mockRejectedValueOnce(new Error("policy timeout"));
const observation = await verifyCodexPaneProcess({ target: "%1", tmux: { getPanePid: async () => 101 },
listProcesses: defaultListProcesses, expectedToken: "recorded", requireResume: false });
expect(observation?.process.pid).toBe(103);
expect(observation?.process.argv).toBeNull();
expect(findExactNativeResumeProcess(await defaultListProcesses(), 101, "codex", "recorded")).toBeNull();
fs.writeFileSync(path.join(scratch, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
expect(await defaultListProcesses()).toEqual([]);
});
@@ -55,7 +72,8 @@ it("reads foreground identity through the real /proc reader and parser path", as
const listed = [
{ pid: 101, ppid: 1, pgid: 101, tpgid: 102, name: "zsh", startedAt: started, command: "-zsh" },
{ pid: 102, ppid: 101, pgid: 102, tpgid: 102, name: "node", startedAt: started, command: "node /opt/bin/codex -C /project" },
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, name: "codex", startedAt: started, command: "/opt/vendor/bin/codex -C /project" },
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, name: "codex", startedAt: started, command: "/opt/vendor/bin/codex -C /project",
argv: ["/opt/vendor/bin/codex", "-C", "/project"] },
{ pid: 104, ppid: 1, pgid: 104, tpgid: -1, name: "node", startedAt: started, command: "node /tmp/other.js --label codex" },
// Linux task names may contain spaces (prctl PR_SET_NAME / process.title).
{ pid: 106, ppid: 1, pgid: 106, tpgid: -1, name: "tmux: server", startedAt: started, command: "tmux new-session -d" },
@@ -71,12 +89,38 @@ it("reads foreground identity through the real /proc reader and parser path", as
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "codex-process-read-"));
fs.writeFileSync(path.join(scratch, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
vi.stubEnv("PATH", scratch);
vi.mocked(readNativeProcessArguments).mockClear();
const rows = await defaultListProcesses();
expect(rows.sort((a, b) => a.pid - b.pid)).toEqual(listed.map(({ name, ...row }) => ({ ...row, executableName: name })));
expect(readNativeProcessArguments).not.toHaveBeenCalled();
serveProcRoot(path.join(root, "gone"));
expect(await defaultListProcesses()).toEqual([]);
});
it("keeps the foreground Codex deliverable beyond the argv reader's 128-process budget", async () => {
Object.defineProperty(process, "platform", { value: "darwin" });
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "codex-process-budget-"));
const started = "Sat Jan 1 12:00:00 2000";
const output = [
"PID PPID PGID TPGID UCOMM LSTART COMMAND",
`101 1 101 102 zsh ${started} -zsh`,
...Array.from({ length: 128 }, (_, index) => `${200 + index} 1 ${200 + index} ${200 + index} codex ${started} codex resume recorded`),
`400 101 102 102 codex ${started} codex resume recorded`,
`401 1 401 -1 codex ${started} codex resume recorded`,
];
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
fs.writeFileSync(path.join(scratch, "ps"), `#!/bin/sh\nprintf '%s\\n' ${output.map(quote).join(" ")}\n`, { mode: 0o755 });
vi.stubEnv("PATH", scratch);
vi.mocked(readNativeProcessArguments).mockImplementation(async requested =>
new Map(requested.slice(0, 128).map(pid => [pid, ["codex", "resume", "recorded"]])));
const rows = await defaultListProcesses();
expect(readNativeProcessArguments).toHaveBeenCalledWith([...Array.from({ length: 128 }, (_, index) => 200 + index), 400]);
expect(rows.find(row => row.pid === 400)?.argv).toBeNull();
expect((await verifyCodexPaneProcess({ target: "%1", tmux: { getPanePid: async () => 101 },
listProcesses: defaultListProcesses, expectedToken: "recorded", requireResume: false }))?.process.pid).toBe(400);
expect(findExactNativeResumeProcess(rows, 101, "codex", "recorded")).toBeNull();
});
it("reads this test process with the installed ps columns, without starting a provider", async () => {
const rows = await defaultListProcesses();
const self = rows.find((r) => r.pid === process.pid);
@@ -41,7 +41,7 @@ it.each([
});
describe("the live predecessor record", () => {
function fixture(options: { wrongThread?: boolean; stale?: boolean; incompleteLatest?: boolean; turnover?: boolean } = {}) {
function fixture(options: { wrongThread?: boolean; stale?: boolean; incompleteLatest?: boolean; turnover?: boolean; argv?: string[] | null } = {}) {
const root = mkdtempSync(join(tmpdir(), "codex-rebuild-")); roots.push(root);
const path = join(root, "record.jsonl");
writeFileSync(path, [
@@ -52,13 +52,14 @@ describe("the live predecessor record", () => {
const native = provenCodexResume("fixture-thread");
let reads = 0;
const listProcesses = vi.fn(() => {
const rows = native.listProcesses();
const rows = native.listProcesses().map(row => row.executableName === "codex" && options.argv !== undefined
? { ...row, command: options.argv?.join(" ") ?? row.command, argv: options.argv } : row);
if (options.turnover && reads++ > 0) return rows.map(row => ({ ...row, startedAt: "Tue Oct 20 05:02:01 2026" }));
return rows;
});
return { nodeId: "seat", sessionName: "member@team", cwd: "/project", path,
tmux: { getPanePid: native.getPanePid }, listProcesses,
readThreadId: vi.fn(async () => "fixture-thread"), rollout: () => path,
readThreadId: vi.fn(async () => "fixture-thread"), rollout: vi.fn(() => path),
sessions: { currentOccupantTenure: () => ({ generationUuid: "generation", bootAt: "2026-10-20T05:00:00Z" }),
getBindingForNode: () => ({ tmuxSession: "member@team", tmuxPane: "%1" }),
currentOccupantGenerationForSession: () => "generation" } as never };
@@ -68,6 +69,25 @@ describe("the live predecessor record", () => {
expect(result).toMatchObject({ state: "observed", observedAt: "2026-10-20T05:01:00.000Z",
envelope: { approvalPolicy: "on-request", sandboxPolicy: payload.sandbox_policy } });
});
it("reports a managed profile containing spaces from the selected process's exact argv", async () => {
const argv = ["codex", "-p", "openrig desktop work", "resume", "fixture-thread"];
expect(await observeCodexRebuildPermissions(fixture({ argv }))).toMatchObject({ state: "observed", profile: "openrig desktop work" });
});
it("retains the native-log join for a valid fresh Codex process", async () => {
expect(await observeCodexRebuildPermissions(fixture({ argv: ["codex", "-p", "managed work"] })))
.toMatchObject({ state: "observed", profile: "managed work" });
});
it.each([null, [], ["codex", "resume", "fixture-thread", "bad\0argument"], ["node", "resume", "fixture-thread"]])(
"does not inspect native records for an opaque or malformed predecessor argv: %j", async argv => {
const input = fixture({ argv });
expect(await observeCodexRebuildPermissions(input)).toMatchObject({ state: "unverified" });
expect(input.readThreadId).not.toHaveBeenCalled();
expect(input.rollout).not.toHaveBeenCalled();
});
it("refuses a thread named only inside a spaced option value", async () => {
const argv = ["codex", "resume", "--add-dir", "/tmp/fixture-thread", "another-thread"];
expect(await observeCodexRebuildPermissions(fixture({ argv }))).toMatchObject({ state: "unverified" });
});
it.each(["wrongThread", "stale", "incompleteLatest", "turnover"] as const)(
"discloses unverified observation on %s, rather than using an older/different policy", async reason => {
expect(await observeCodexRebuildPermissions(fixture({ [reason]: true }))).toMatchObject({ state: "unverified" });
@@ -23,7 +23,8 @@ const errorFrame = `No saved session found with ID ${token}. Run \`codex resume\
const started = "Tue Oct 20 05:00:01 2026";
const rows = (startedAt = started) => [
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Tue Oct 20 04:59:00 2026" },
{ pid: 901, ppid: 900, command: `codex resume ${token}`, pgid: 901, tpgid: 901, executableName: "codex", startedAt },
{ pid: 901, ppid: 900, command: `codex resume ${token}`, argv: ["codex", "resume", token],
pgid: 901, tpgid: 901, executableName: "codex", startedAt },
];
type Evidence = (pid: number, identity: string) => Promise<CodexResumeEvidence> | CodexResumeEvidence;
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more