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
151 changed files with 3851 additions and 548 deletions

No files matched your search

+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.3/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.3/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.3` 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.3/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.3/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.3/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.3/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.3`. 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
+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.
+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();
@@ -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",
@@ -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
+2 -2
View File
@@ -899,7 +899,7 @@ export class TmuxAdapter {
const identity = this.freshManaged.get(target)?.nodeId ?? target;
const checkDraft = guard?.needsInputCheck(identity);
const policy = checkDraft || options?.beforePaste ? {
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity); await options?.beforePaste?.(); },
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity, false, text); await options?.beforePaste?.(); },
afterPaste: async () => { if (checkDraft) await guard!.notePasted(identity, text); },
} : undefined;
return this.sendTextUnchecked(pane, text, () => { beforeWrite(); beforeInput?.(); }, options?.bracketed, policy);
@@ -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 };
@@ -137,6 +137,8 @@ export interface ComposerInput {
state: "empty" | "text" | "unknown";
rows: string[];
columns: number;
/** Conservative visible capacity of the recognized Codex fullscreen composer. */
pasteViewport?: { columns: number; rows: number };
cursor?: ComposerSnapshot["cursor"];
unknownReason?: string;
suggestionRows?: string[];
@@ -177,6 +179,30 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
return false;
}
/** Conservative uncertainty before a protected Codex paste, never Enter authority.
* Codex 0.164 collapses >1000 code points to [Pasted Content N chars], which
* the current ownership reader cannot own. 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;
if (!viewport) return null;
const normalized = text.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
if (lines.some(line => /[\x00-\x1f\x7f-\x9f]/.test(line))) return "controls";
// rust-v0.164.0 chat_composer/paste_input.rs:184-192 (Unicode scalar count).
if ([...normalized].length > 1000) return "long";
if (viewport.columns < 1 || viewport.rows < 1) return "viewport";
let rows = 0;
for (const line of lines) {
// Two cells per non-ASCII code point conservatively covers wide glyphs.
const cells = [...line].reduce((sum, ch) => sum + (ch >= " " && ch <= "~" ? 1 : 2), 0);
rows += cells <= viewport.columns ? 1 : Math.ceil(2 * cells / viewport.columns) + 1;
if (rows > viewport.rows) return "viewport";
}
return null;
}
/** Recognize this lease's paste immediately after pasting or before its Enter
* in the original composer. A later lease cannot acquire ownership from a label.
*/
@@ -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) {
@@ -2,7 +2,7 @@ import { hasOtherSeatHistory, isStoppedSeatPredecessor, resolveCurrentSessionAdd
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
import { composerContainsOwnedText, ownCollapsedPaste, type ComposerInput, type ComposerOwnership } from "./composer-prompts.js";
import { composerPasteUncertainty, composerContainsOwnedText, ownCollapsedPaste, type ComposerInput, type ComposerOwnership } from "./composer-prompts.js";
/** A binding is captured before waiting. Never rebind an old operation to a new occupant. */
export interface GuardTarget {
@@ -414,7 +414,7 @@ export class SeatDeliveryGuard {
}
/** Called at the paste/key boundary after file and buffer preparation. */
async beforeInput(name: string, submit = false): Promise<void> {
async beforeInput(name: string, submit = false, pasteText?: string): Promise<void> {
if (!this.needsInputCheck(name)) return;
const target = this.target(name);
const lease = this.scope.getStore()!.get(target.nodeId)!;
@@ -433,6 +433,15 @@ export class SeatDeliveryGuard {
} else if (input.state !== "empty") {
throw new DeliveryGuardError("draft_input_busy", "The seat has an unsubmitted input. Automatic delivery is held.");
} else {
const uncertainty = pasteText === undefined ? null : composerPasteUncertainty(input, pasteText);
if (uncertainty) {
const message = uncertainty === "controls"
? "OpenRig couldn't confirm the layout of tabs or control characters. Send the message without them; nothing was pasted."
: uncertainty === "long"
? "OpenRig couldn't confirm ownership of Codex's collapsed long paste. Send a shorter message, 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);
}
lease.inputFrame = input.frame;
}
}
@@ -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;
}
@@ -467,6 +467,13 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
input.collapsedPaste = visible.join("\n");
input.pasteMatchesExpected = true;
}
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: width - prefix - 1,
rows: Math.min(height, Math.max(8, Math.floor(height * 2 / 3))) - 4 };
}
candidates.push(input);
}
return candidates.length === 1 ? candidates[0]! : unknown(candidates.length ? "ambiguous_composer" : "unrecognized_composer", snapshot.cursor);
+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;
@@ -237,7 +237,7 @@ describe("Codex runtime adapter", () => {
});
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), listProcesses: () => [
{ pid: 900, ppid: 1, command: paneCommand === "bash" ? "/bin/bash" : "node launcher", pgid: 900, tpgid: 901, executableName: paneCommand, startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
{ pid: 901, ppid: 900, command: "codex", argv: ["codex"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
] });
expect(await adapter.checkReady(makeBinding()), fixture).toMatchObject({ ready: false, code: "login_required" });
expect(tmux.capturePaneContent).toHaveBeenCalledTimes(2);
@@ -271,7 +271,7 @@ describe("Codex runtime adapter", () => {
fsOps: mockFs(),
listProcesses: () => [
{ pid: 900, ppid: 1, command: "/bin/zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex --model anthropic/claude-3.5-sonnet", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
{ pid: 901, ppid: 900, command: "codex --model anthropic/claude-3.5-sonnet", argv: ["codex", "--model", "anthropic/claude-3.5-sonnet"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
],
});
@@ -295,7 +295,7 @@ describe("Codex runtime adapter", () => {
fsOps: mockFs(),
listProcesses: () => [
{ pid: 900, ppid: 1, command: "/bin/zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex --model anthropic/claude-3.5-sonnet", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
{ pid: 901, ppid: 900, command: "codex --model anthropic/claude-3.5-sonnet", argv: ["codex", "--model", "anthropic/claude-3.5-sonnet"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:01 2000" },
],
});
@@ -337,7 +337,7 @@ describe("Codex runtime adapter", () => {
fsOps: mockFs(),
listProcesses: () => [
{ pid: 900, ppid: 1, command: "/bin/bash", pgid: 900, tpgid: 901, executableName: "bash", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex", argv: ["codex"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
],
});
@@ -1082,7 +1082,7 @@ describe("Codex runtime adapter", () => {
fsOps: mockFs(),
listProcesses: () => [
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex", argv: ["codex"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
],
readThreadIdByPid: (pid) => pid === 901 ? "019d45bc-117d-78a3-a4ad-6fb186e5a86d" : undefined,
sleep: async () => {},
@@ -1119,7 +1119,7 @@ describe("Codex runtime adapter", () => {
fsOps: mockFs(),
listProcesses: () => [
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex", argv: ["codex"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
],
readThreadIdByPid: () => "019d45bc-117d-78a3-a4ad-6fb186e5a86d",
sleep: async () => {},
@@ -1173,7 +1173,7 @@ describe("Codex runtime adapter", () => {
fsOps: mockFs(),
listProcesses: () => [
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex", pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 901, ppid: 900, command: "codex", argv: ["codex"], pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
],
readThreadIdByPid: (pid) => pid === 901 ? "019d45bc-117d-78a3-a4ad-6fb186e5a86d" : undefined,
sleep: async () => {},
@@ -1256,7 +1256,7 @@ describe("Codex runtime adapter", () => {
it("a skip key sent to an update prompt that stays on screen is reported as sent, with the choice-bearing update screen", async () => {
const tmux = screens(() => SKIPPABLE);
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {},
listProcesses: () => [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex" }] });
listProcesses: () => [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex", argv: ["codex"] }] });
const result = await adapter.verifyResumeInPane("impl", "tok");
expect(tmux.sendKeys).toHaveBeenCalledTimes(1);
expect(tmux.sendKeys).toHaveBeenCalledWith("impl", ["3"]);
@@ -1268,7 +1268,7 @@ describe("Codex runtime adapter", () => {
// Round 3: the newest observation wins in the order it was taken, including the helper's current-screen recheck, and
// a screen OpenRig acted on afterwards is reported as the screen before that input.
const RESUMED = "OpenAI Codex (v0.120.0)\n› Ask Codex to do anything";
const CODEX_ROW = [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex" }];
const CODEX_ROW = [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex", argv: ["codex"] }];
/** How many screen captures a run on an unchanging, unskippable update screen makes. */
async function updatingCaptures(): Promise<number> {
const counter = screens(() => UPDATING);
@@ -1643,7 +1643,7 @@ describe("Codex runtime adapter", () => {
.mockResolvedValue("OpenAI Codex (v0.120.0)\n› Ask Codex to do anything"),
});
const adapter = new CodexRuntimeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {},
listProcesses: () => [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex resume sess-456" }],
listProcesses: () => [{ pid: 901, ppid: 1, pgid: 901, tpgid: 901, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "codex resume sess-456", argv: ["codex", "resume", "sess-456"] }],
readResumeEvidence: () => ({ threadId: "sess-456" }),
});
@@ -22,8 +22,11 @@ const binding = {
// DontRemind immediately; :74-85 persists dismissal or returns RunUpdate.
// Drive real TmuxAdapter serialization into this model, not a sequence of
// ready screenshots that would pass even if the wrong input were sent.
type ProcessRow = { pid: number; ppid: number; command: string; pgid?: number; tpgid?: number; executableName?: string; startedAt?: string };
type ProcessRow = { pid: number; ppid: number; command: string; argv?: string[]; pgid?: number; tpgid?: number; executableName?: string; startedAt?: string };
const native = "/opt/codex/vendor/aarch64-apple-darwin/bin/codex";
const ordinaryNative = "/opt/test-provider/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex";
const ordinaryArgs = ["-s", "workspace-write", "-C", "/workspace/sample-project", "--add-dir", "/workspace/sample-project/.git",
"--add-dir", "/tmp/test-instance/shared-docs/rigs/first-project/state/dev", "-m", "fixture-model"];
// Synthetic shell -> Node -> native ancestry, including a background helper.
// Paths, PIDs and start times are invented; only the process relationships matter.
// The receiver below models the update menu; this is not native execution.
@@ -31,19 +34,20 @@ const ordinaryRows: ProcessRow[] = [
{ pid: 2001, ppid: 2000, pgid: 2001, tpgid: 2002, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000", command: "-zsh" },
{ pid: 2002, ppid: 2001, pgid: 2002, tpgid: 2002, executableName: "bash", startedAt: "Sat Jan 1 12:00:00 2000", command: "/bin/sh /tmp/test-launch/openrig-tmux-send.txt" },
{ pid: 2003, ppid: 2002, pgid: 2002, tpgid: 2002, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /opt/test-provider/bin/codex -s workspace-write -C /workspace/sample-project --add-dir /workspace/sample-project/.git --add-dir /tmp/test-instance/shared-docs/rigs/first-project/state/dev -m fixture-model" },
{ pid: 2004, ppid: 2003, pgid: 2002, tpgid: 2002, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/test-provider/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex -s workspace-write -C /workspace/sample-project --add-dir /workspace/sample-project/.git --add-dir /tmp/test-instance/shared-docs/rigs/first-project/state/dev -m fixture-model" },
{ pid: 2004, ppid: 2003, pgid: 2002, tpgid: 2002, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000",
command: `${ordinaryNative} ${ordinaryArgs.join(" ")}`, argv: [ordinaryNative, ...ordinaryArgs] },
{ pid: 2005, ppid: 2004, pgid: 2005, tpgid: 2002, executableName: "codex-code-mode-", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/test-provider/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex-code-mode-host" },
];
const processRows = (shape: "wrapper" | "exec-wrapper" | "native"): ProcessRow[] => {
if (shape === "native") return [{ pid: 101, ppid: 1, command: native, pgid: 101, tpgid: 101, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" }];
if (shape === "native") return [{ pid: 101, ppid: 1, command: native, argv: [native], pgid: 101, tpgid: 101, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" }];
if (shape === "exec-wrapper") return [
{ pid: 101, ppid: 1, command: "node /opt/bin/codex", pgid: 101, tpgid: 101, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 102, ppid: 101, command: native, pgid: 101, tpgid: 101, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 102, ppid: 101, command: native, argv: [native], pgid: 101, tpgid: 101, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
];
return [
{ pid: 101, ppid: 1, command: "-zsh", pgid: 101, tpgid: 102, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 102, ppid: 101, command: "node /opt/bin/codex", pgid: 102, tpgid: 102, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 103, ppid: 102, command: native, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 103, ppid: 102, command: native, argv: [native], pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000" },
];
};
function fixture(options: { menu?: string; delay?: number; beforeMenu?: number; failInput?: boolean; screen?: string; command?: string; panePid?: number; processes?: ProcessRow[]; shape?: "wrapper" | "exec-wrapper" | "native" } = {}) {
@@ -90,8 +94,10 @@ function fixture(options: { menu?: string; delay?: number; beforeMenu?: number;
// #1524 (b): a resume's Codex processes carry `resume <token>`, and Codex's records for them name that token once ready.
const resumeArgs = () => / resume '?([^' ]+)'?/.exec(launched)?.[1];
const listProcesses = vi.fn(() => (options.processes ?? processRows(options.shape ?? "wrapper")).map(row =>
forkArgs() && /codex/.test(row.command) && !/codex-code-mode/.test(row.command) ? { ...row, command: `${row.command} fork ${forkArgs()}` }
: resumeArgs() && /codex/.test(row.command) && !/codex-code-mode/.test(row.command) ? { ...row, command: `${row.command} resume ${resumeArgs()}` } : row));
forkArgs() && /codex/.test(row.command) && !/codex-code-mode/.test(row.command) ? { ...row, command: `${row.command} fork ${forkArgs()}`,
...(row.argv ? { argv: [...row.argv, "fork", forkArgs()!] } : {}) }
: resumeArgs() && /codex/.test(row.command) && !/codex-code-mode/.test(row.command) ? { ...row, command: `${row.command} resume ${resumeArgs()}`,
...(row.argv ? { argv: [...row.argv, "resume", resumeArgs()!] } : {}) } : row));
const adapter = new CodexRuntimeAdapter({
tmux, fsOps, sleep: async () => { ticks++; },
listProcesses,
@@ -227,7 +233,7 @@ describe.each(paths)("Codex update input: $name", ({ opts }) => {
it.each([
{ name: "no process rows", rows: [] },
{ name: "native name with unrelated executable argument zero", rows: [{pid:101,ppid:1,command:"/bin/echo codex",pgid:101,tpgid:101,executableName:"codex", startedAt: "Sat Jan 1 12:00:00 2000"}] },
{ name: "native name with unrelated executable argument zero", rows: [{pid:101,ppid:1,command:"/bin/echo codex",argv:["/bin/echo","codex"],pgid:101,tpgid:101,executableName:"codex", startedAt: "Sat Jan 1 12:00:00 2000"}] },
{ name: "argument named codex", rows: [{pid:101,ppid:1,command:"node other.js --label codex",pgid:101,tpgid:101,executableName:"node", startedAt: "Sat Jan 1 12:00:00 2000"}] },
{ name: "native name only in arguments", rows: [{pid:101,ppid:1,command:"/bin/echo codex",pgid:101,tpgid:101,executableName:"echo", startedAt: "Sat Jan 1 12:00:00 2000"}] },
{ name: "background Codex", rows: processRows("wrapper").map(r=>({...r,tpgid:500})) },
@@ -236,7 +242,7 @@ describe.each(paths)("Codex update input: $name", ({ opts }) => {
{ name: "conflicting terminal group", rows: processRows("wrapper").map(r=>r.pid===103?{...r,tpgid:500}:r) },
{ name: "another pane", rows: processRows("wrapper").map(r=>r.pid===103?{...r,ppid:999}:r) },
{ name: "kernel name contradicts argv", rows: processRows("wrapper").map(r=>r.pid===103?{...r,executableName:"node", startedAt: "Sat Jan 1 12:00:00 2000"}:r) },
{ name: "ambiguous native processes", rows: [...processRows("wrapper"),{pid:104,ppid:102,command:native,pgid:102,tpgid:102,executableName:"codex", startedAt: "Sat Jan 1 12:00:00 2000"}] },
{ name: "ambiguous native processes", rows: [...processRows("wrapper"),{pid:104,ppid:102,command:native,argv:[native],pgid:102,tpgid:102,executableName:"codex", startedAt: "Sat Jan 1 12:00:00 2000"}] },
])("does not choose Skip with $name", async ({rows}) => {
for (const command of ["node", "codex", "bash"]) {
const f=fixture({command,processes:rows});
@@ -54,6 +54,19 @@ describe("GET /library/resolve-address — file-level span serving (Atom 4c)", (
const resolve = (address: string) =>
app.request(`/api/context-packs/library/resolve-address?address=${encodeURIComponent(address)}`);
it("serves complete sections around commented headings and fences", async () => {
const welcome = "## Welcome\nhello\n<!--\n## Archived\n```\n-->\nstill welcome";
writeFileSync(join(tmp, "lib", "packs", "world", "walk.md"),
`${welcome}\n## Reference\nreference instructions\n`);
const first = await resolve("packs/world/walk.md#welcome");
expect(first.status).toBe(200);
expect(((await first.json()) as { text: string }).text).toBe(welcome);
const next = await resolve("packs/world/walk.md#reference");
expect(next.status).toBe(200);
expect(((await next.json()) as { text: string }).text).toBe("## Reference\nreference instructions\n");
expect((await resolve("packs/world/walk.md#archived")).status).toBe(422);
});
it("serves a requested section after an inline triple-backtick span", async () => {
writeFileSync(join(tmp, "lib", "packs", "world", "walk.md"),
"## Welcome\n```literal ` backticks```\n## Reference\nreference instructions\n");
+15 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import type Database from "better-sqlite3";
@@ -29,6 +29,7 @@ describe("0.3.0 conveyor starter workflow specs", () => {
let db: Database.Database;
let eventBus: EventBus;
let runtime: WorkflowRuntime;
let queueRepo: QueueRepository;
beforeEach(() => {
db = createDb();
@@ -44,7 +45,7 @@ describe("0.3.0 conveyor starter workflow specs", () => {
]);
eventBus = new EventBus(db);
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-conveyor', 'conveyor')`).run();
const queueRepo = new QueueRepository(db, eventBus, { validateRig: () => true });
queueRepo = new QueueRepository(db, eventBus, { validateRig: () => true });
// P34: the W1 seam is fail-closed (MF2) — a nudge-intended terminal
// close needs a SAME-DB intent store to make its wake durable.
queueRepo.attachOutbox(new OutboxHandler(db));
@@ -103,6 +104,18 @@ describe("0.3.0 conveyor starter workflow specs", () => {
}
});
it("workflow entry nudge is sent as the instantiating session", async () => {
const maybeNudge = vi.spyOn(queueRepo, "maybeNudge").mockResolvedValue();
const created = await runtime.instantiate({
specPath: CONVEYOR_SPEC,
rootObjective: "packet sender",
createdBySession: "intake-lead@conveyor",
});
expect(maybeNudge).toHaveBeenCalledTimes(1);
expect(maybeNudge.mock.calls[0]?.[0]).toBe(created.entryQitemId);
expect(maybeNudge.mock.calls[0]?.[3]).toBe("intake-lead@conveyor");
});
it("conveyor can run multiple active instances on the same rig", async () => {
const first = await runtime.instantiate({
specPath: CONVEYOR_SPEC,
@@ -6,6 +6,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { migrate } from "../src/db/migrate.js";
import nativePaste from "./fixtures/claude-paste-footer.json";
import { wrapPaneEnvelope } from "../src/lib/pane-envelope.js";
import type { ComposerSnapshot } from "../src/domain/composer-prompts.js";
import { EventBus } from "../src/domain/event-bus.js";
import { OutboxHandler } from "../src/domain/outbox-handler.js";
@@ -102,6 +103,127 @@ function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean }
}
// Retained D1 geometry: empty Codex 0.164 at 65x9; after paste the editor
// shows only the envelope's reply footer and an upward scroll indicator.
function codexViewport(body: string, width: number, height: number): ComposerSnapshot {
// rust-v0.164.0 uses this character-count label, not Claude's line-count label.
if ([...body].length > 1000) return codexViewport(`[Pasted Content ${[...body].length} chars]`, width, height);
if (!body) return { screen: ["• DONE", "", " Worked for 4s • 20:24", "", "",
"› \x1b[2mAsk Codex to do anything\x1b[0m", "", " GPT-6.1-Sol low · ~/project · Recover OpenRig identity context", " ? for shortcuts", ""].join("\n"),
cursor: { x: 2, y: 5, width, height }, inMode: false };
if (height === 9 && body.includes("From:")) return {
screen: ["", "", "› --- ↑",
" ↩ Reply if needed: rig queue create --destination host-",
' fixture-driver@external --summary "<one line>" --body "..."',
" --verify", "", " GPT-6.1-Sol low · ~/project · Recover OpenRig identity context", ""].join("\n"),
cursor: { x: 10, y: 5, width, height }, inMode: false };
const rows = body.split("\n");
return { screen: ["Ready", "", `› ${rows[0]}`, ...rows.slice(1).map(row => ` ${row}`), "",
" GPT-6.1-Sol low · ~/project", " ? for shortcuts", ""].join("\n"),
cursor: { x: 2 + stringWidth(rows.at(-1)!), y: 1 + rows.length, width, height }, inMode: false };
}
const d1Envelope = wrapPaneEnvelope("host-fixture-driver@external", "worker@test",
"Write exactly draft-aware to /home/t1s075/project/delivery-draft-aware.txt with one shell tool; reply DONE briefly and wait.");
async function codexDelivery(mode = "draft-aware", size = { width: 65, height: 9 }) {
const f = fixture(undefined, { runtime: "codex" });
await f.guard.set("a", { mode }, "human@test", "D1 protected delivery");
vi.mocked(f.tmux.captureComposerSnapshot).mockImplementation(async () => codexViewport(f.bodies.a ?? "", size.width, size.height));
return { ...f, size, options: { verify: false, ...(mode === "openrig-urgent" ? { urgent: { by: "human@test" } } : {}) } };
}
describe("Codex pre-paste observability through the real delivery path", () => {
it.each(["draft-aware", "openrig-urgent"])("retains the full envelope before writing into the native 65x9 frame (%s)", async mode => {
const f = await codexDelivery(mode);
expect(await f.send("D1", d1Envelope, f.options)).toMatchObject({ ok: true, outcome: "retained", sent: false,
inputPhase: "not_reached", reason: "draft_input_unknown" });
expect(f.writes).toEqual([]);
expect(f.submissions).toEqual([]);
expect(f.bodies.a).toBe("");
});
it.each(["draft-aware", "openrig-urgent"])("submits a fitting full envelope once (%s)", async mode => {
const f = await codexDelivery(mode, { width: 160, height: 45 });
expect(await f.send("fits", d1Envelope, f.options)).toMatchObject({ ok: true, sent: true });
expect(f.submissions).toEqual([d1Envelope]);
expect(f.writes).toHaveLength(2);
expect(f.bodies.a).toBe("");
});
it("delivers nine native-fitting 61-column lines plus their envelope at 65x45", async () => {
const f = await codexDelivery("draft-aware", { width: 65, height: 45 });
const text = wrapPaneEnvelope("sender@test", "worker@test", Array(9).fill("x".repeat(61)).join("\n"));
expect(await f.send("native-fit", text, f.options)).toMatchObject({ ok: true, sent: true });
expect(f.submissions).toEqual([text]);
});
it("owns the complete envelope after a native 62-column hard wrap at 65x45", async () => {
const f = await codexDelivery("draft-aware", { width: 65, height: 45 });
const body = "x".repeat(64);
const text = wrapPaneEnvelope("sender@test", "worker@test", body);
expect(text.split("\n").filter(row => row.length > 62)).toEqual([body]);
// Codex's two-column prompt and one-column right inset leave 62 cells.
// This ASCII word wraps without dropping or replacing any authored byte.
vi.mocked(f.tmux.captureComposerSnapshot).mockImplementation(async () =>
codexViewport((f.bodies.a ?? "").replace(body, `${body.slice(0, 62)}\n${body.slice(62)}`), 65, 45));
expect(await f.send("native-wrap", text, f.options)).toMatchObject({ ok: true, sent: true });
expect(f.submissions).toEqual([text]);
expect(f.bodies.a).toBe("");
});
it("retains a raw-fitting 1200-code-point envelope before Codex collapses it into an unowned label", async () => {
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
const empty = wrapPaneEnvelope("sender@test", "worker@test", "");
const text = wrapPaneEnvelope("sender@test", "worker@test", "x".repeat(1200 - [...empty].length));
expect([...text]).toHaveLength(1200);
const result = await f.send("native-long", text, f.options);
expect(result).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
expect(result.error).toMatch(/shorter message.*guard.*off/);
expect(f.writes).toEqual([]);
expect(f.bodies.a).toBe("");
});
it.each(["\t", "\x1b"])("gives control-specific recovery at every size (%j)", async control => {
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
for (const text of [`a${control}b`, `${"x".repeat(1200)}${control}`]) {
const result = await f.send("control", text, f.options);
expect(result).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
expect(result.error).toMatch(/without them/);
expect(result.error).not.toMatch(/larger terminal/);
}
expect(f.writes).toEqual([]);
});
it("leaves default-off delivery unchanged in the small viewport", async () => {
const f = await codexDelivery("off");
expect(await f.send("off", d1Envelope, f.options)).toMatchObject({ ok: true, sent: true });
expect(f.submissions).toEqual([d1Envelope]);
});
it("rechecks the actual viewport after buffer preparation, before any paste", async () => {
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
f.hooks.load = () => { f.size.width = 65; f.size.height = 9; };
expect(await f.send("resized", d1Envelope, f.options)).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
expect(f.writes).toEqual([]);
});
it("retains a wrapped line which cannot stay visible without writing it", async () => {
const f = await codexDelivery();
expect(await f.send("wraps", "one-long-word-".repeat(40), f.options)).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
expect(f.writes).toEqual([]);
});
it("keeps a concurrent real draft and the human-input epoch check", async () => {
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
f.hooks.load = () => f.guard.humanInput("a", async () => { f.bodies.a = "my real draft"; });
expect(await f.send("human", d1Envelope, f.options)).toMatchObject({ outcome: "retained", sent: false,
inputPhase: "not_reached", reason: "draft_input_changed" });
expect(f.writes).toEqual([]);
expect(f.bodies.a).toBe("my real draft");
});
});
describe("current draft-aware delivery through the real transport", () => {
it.each(["ordinary request ", "1. Review the change"])("attempts each ordinary authored request, even with a reused caller ID: %j", async text => {
const f = fixture(); await f.enable();
@@ -715,3 +715,62 @@ describe("Effort precedence, latest wins: a choice saved after the snapshot (OPR
ctx.db.close();
});
});
// #2224: `rig seat set-model` saves the model on the seat's live node, and every managed resume reads it at call time,
// so a restore from a snapshot taken before the change launches the saved model, and says so.
describe("A model saved with rig seat set-model after the snapshot wins on restore (#2224)", () => {
// Claude's resume takes the model sixth, Codex's seventh.
const cases = [
{ runtime: "claude-code", resumeType: "claude_name", before: "claude-fable-5-1", after: "claude-opus-5-5", modelArg: 5 },
{ runtime: "codex", resumeType: "codex_id", before: "gpt-6.1-terra", after: "gpt-6.1-sol", modelArg: 6 },
] as const;
async function restoreAfter(c: (typeof cases)[number], saved: string | null) {
const ctx = setup();
const rig = ctx.rigRepo.createRig(`model-${c.runtime}`);
const node = ctx.rigRepo.addNode(rig.id, "analyst", { runtime: c.runtime, model: c.before, cwd: "/workspace" });
const session = ctx.sessionRegistry.registerSession(node.id, `analyst@model-${c.runtime}`);
ctx.sessionRegistry.updateStatus(session.id, "running");
ctx.sessionRegistry.updateStartupStatus(session.id, "ready", new Date().toISOString());
ctx.db.prepare("UPDATE sessions SET resume_type = ?, resume_token = 'tok-model' WHERE id = ?").run(c.resumeType, session.id);
const snapshot = ctx.snapshotCapture.captureSnapshot(rig.id, "manual");
expect(snapshot.data.nodes[0]!.model).toBe(c.before);
if (saved) ctx.rigRepo.setNodeModel(node.id, saved);
const resume = vi.fn(async () => ({ ok: true, code: "resumed" }));
const mockTmux = {
createSession: vi.fn(async () => ({ ok: true })), killSession: vi.fn(async () => ({ ok: true })),
listSessions: vi.fn(async () => []), hasSession: vi.fn(async () => false),
sendText: vi.fn(async () => ({ ok: true })), sendKeys: vi.fn(async () => ({ ok: true })),
listWindows: vi.fn(async () => []), listPanes: vi.fn(async () => []),
getPaneCommand: vi.fn(async () => (c.runtime === "codex" ? "codex" : "claude")),
} as any;
const nodeLauncher = new NodeLauncher({ db: ctx.db, rigRepo: ctx.rigRepo, sessionRegistry: ctx.sessionRegistry, eventBus: ctx.eventBus, tmuxAdapter: mockTmux });
const restoreOrch = new RestoreOrchestrator({
db: ctx.db, rigRepo: ctx.rigRepo, sessionRegistry: ctx.sessionRegistry, eventBus: ctx.eventBus,
snapshotRepo: ctx.snapshotRepo, snapshotCapture: ctx.snapshotCapture, checkpointStore: ctx.checkpointStore,
nodeLauncher, tmuxAdapter: mockTmux,
claudeResume: { canResume: vi.fn(() => c.runtime === "claude-code"), resume: c.runtime === "claude-code" ? resume : vi.fn() } as unknown as ClaudeResumeAdapter,
codexResume: { canResume: vi.fn(() => c.runtime === "codex"), resume: c.runtime === "codex" ? resume : vi.fn() } as unknown as CodexResumeAdapter,
});
const result = await restoreOrch.restore(snapshot.id);
ctx.db.close();
return { ok: result.ok, launched: (resume.mock.calls[0] as unknown[] | undefined)?.[c.modelArg], text: JSON.stringify(result) };
}
for (const c of cases) {
it(`${c.runtime}: restore launches the saved model, not the snapshot's, and says so`, async () => {
const restored = await restoreAfter(c, c.after);
expect(restored.ok).toBe(true);
expect(restored.launched).toBe(c.after);
expect(restored.text).toContain(`this restore launched the seat's saved model ${c.after}, not ${c.before} from the snapshot`);
});
it(`${c.runtime}: with no change since the snapshot, restore launches the snapshot's model and says nothing about it`, async () => {
const restored = await restoreAfter(c, null);
expect(restored.ok).toBe(true);
expect(restored.launched).toBe(c.before);
expect(restored.text).not.toContain("saved model");
});
}
});
@@ -7,7 +7,8 @@ export function provenCodexResume(token: string, opts: { panePid?: number; codex
const panePid = opts.panePid ?? 900;
const codexPid = opts.codexPid ?? 901;
const startedAt = opts.startedAt ?? "Tue Oct 20 05:00:01 2026";
const codex = { pid: codexPid, ppid: panePid, command: `codex resume ${token}`, pgid: codexPid, tpgid: codexPid, executableName: "codex", startedAt };
const codex = { pid: codexPid, ppid: panePid, command: `codex resume ${token}`, argv: ["codex", "resume", token],
pgid: codexPid, tpgid: codexPid, executableName: "codex", startedAt };
return {
getPanePid: vi.fn(async () => panePid),
listProcesses: () => panePid === codexPid ? [{ ...codex, ppid: 1 }] : [
+4 -2
View File
@@ -16,6 +16,8 @@ export interface FakeProcess {
startedAt: string;
/** The command line, its arguments joined by spaces; empty for a kernel thread. */
command: string;
/** Exact cmdline arguments when a test needs argv boundaries. */
argv?: string[];
}
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
@@ -26,13 +28,13 @@ export function writeFakeProc(processes: FakeProcess[]): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "fake-proc-"));
roots.push(root);
fs.writeFileSync(path.join(root, "stat"), "cpu 1 2 3\nbtime 0\n");
for (const { pid, ppid, pgid, tpgid, name, startedAt, command } of processes) {
for (const { pid, ppid, pgid, tpgid, name, startedAt, command, argv } of processes) {
const ticks = Date.parse(startedAt) / 10;
if (!Number.isInteger(ticks)) throw new Error(`not an lstart: ${startedAt}`);
fs.mkdirSync(path.join(root, String(pid)));
fs.writeFileSync(path.join(root, String(pid), "stat"),
`${pid} (${name}) S ${ppid} ${pgid} ${pgid} 34816 ${tpgid} 4194560 1 0 0 0 0 0 0 0 20 0 1 0 ${ticks} 1000 200\n`);
fs.writeFileSync(path.join(root, String(pid), "cmdline"), command ? `${command}\0` : "");
fs.writeFileSync(path.join(root, String(pid), "cmdline"), argv ? `${argv.join("\0")}\0` : command ? `${command}\0` : "");
}
return root;
}
+6 -11
View File
@@ -27,6 +27,7 @@ import {
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { writeRuntimeStubs } from "../../../../test/stub-runtimes.js";
import { shellQuote } from "../../src/adapters/shell-quote.js";
/**
* Inherited environment variables that redirect the CLI/daemon client at a
@@ -336,20 +337,14 @@ export function prepareHermeticEnv(opts: PrepareHermeticEnvOptions = {}): Hermet
// Test-only command seam: every child `tmux` call gets one explicit private
// socket. TMUX_TMPDIR remains for compatibility, but is never the selector.
// Exec directly so each terminal operation does not start another Node process.
const originalPath = baseEnv.PATH ?? process.env.PATH ?? "";
writeFileSync(
tmuxWrapperPath,
`#!/usr/bin/env node
const { spawnSync } = require("node:child_process");
const socketPath = ${JSON.stringify(tmuxSocketPath)};
const originalPath = ${JSON.stringify(originalPath)};
const env = { ...process.env, PATH: originalPath };
const result = spawnSync("tmux", ["-S", socketPath, ...process.argv.slice(2)], { env, stdio: "inherit" });
if (result.error) {
console.error(result.error.message);
process.exit(127);
}
process.exit(result.status ?? 1);
`#!/bin/sh
PATH=${shellQuote(originalPath)}
export PATH
exec tmux -S ${shellQuote(tmuxSocketPath)} "$@"
`,
{ mode: 0o755 },
);
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more