mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71389bef06 | ||
|
|
ebf20ae616 | ||
|
|
4c14ca76c4 | ||
|
|
682bede406 | ||
|
|
db45c6bcb8 | ||
|
|
03a2828ce7 | ||
|
|
c57f0173ab | ||
|
|
72086329ae | ||
|
|
8684c981a6 | ||
|
|
e5c4cba385 | ||
|
|
10b5f155b3 | ||
|
|
5700c6c2ff | ||
|
|
d44cf3199a | ||
|
|
c2378fa315 | ||
|
|
884a5bfb6b | ||
|
|
9ba9abe858 | ||
|
|
2648083e96 | ||
|
|
76c0170ec8 | ||
|
|
0224e7710d | ||
|
|
8efb5a59c1 | ||
|
|
36f971fa2c | ||
|
|
b51500f024 | ||
|
|
4926be0c0c | ||
|
|
413504a029 | ||
|
|
a70bf71d5f | ||
|
|
3f17e625af | ||
|
|
470f55241e | ||
|
|
3874c743df | ||
|
|
b0bc678833 | ||
|
|
26a490938c | ||
|
|
afee489108 | ||
|
|
898b39ea5c |
No files matched your search
-223
@@ -8,229 +8,6 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.5]
|
||||
|
||||
OpenRig 0.7.5 adds `rig terminal open qitem:<id> --follow`: keep one terminal
|
||||
workspace as a task's current owners join and leave. Joining viewers do not
|
||||
request focus; departures remove viewers, not the agents or conversations.
|
||||
Follow mode is scoped to Linux Herdr 0.9.5 / protocol 22. The exercised journey
|
||||
used shell stand-ins; real-agent follow qualification and active render cost
|
||||
are not established by that result. Changes since 0.7.4:
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Restart the daemon after installing** to run the new code and database
|
||||
update. Migration 126 adds deferred `escalates_to` target addresses to the
|
||||
edges table, preserving existing edges. Previously inert escalation edges
|
||||
now affect recovery for seats without a delegation parent.
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.5
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
- **The bundled openrig-core plugin goes from 0.1.21 to 0.1.22.** Its
|
||||
`queue-handoff` skill teaches agents to park waits on CI or a long job with
|
||||
a continuation and wake. As before, managed refresh preserves replaced
|
||||
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply any local
|
||||
edits you still need from that copy. No setting is added or removed.
|
||||
- **Codex hook refresh has a known limit.** The earlier remedy
|
||||
`rig seat restart <seat> --keep-conversation --reason "refresh hooks"`
|
||||
(preview with `--plan`) refuses a Codex seat launched by a 0.6.x
|
||||
`openrig-tmux-send` script when Codex is a grandchild of the pane's shell.
|
||||
Nothing is stopped by that refusal. This release does not fix
|
||||
[#2200](https://github.com/mvschwarz/openrig/issues/2200); do not treat the
|
||||
refused restart as a completed hook refresh. If Codex asks for hook review,
|
||||
review the hooks you intend to enable.
|
||||
|
||||
### Follow one task in one workspace
|
||||
|
||||
- **Follow the current owners of a queue item:**
|
||||
`rig terminal open qitem:<id> --follow --provider herdr`. The view derives
|
||||
membership from the task's open rows and explicitly linked successors.
|
||||
Joining seats get verified viewer tiles in the same workspace. A complete
|
||||
empty task drains removable tiles and keeps the workspace; after retained
|
||||
tiles are gone, following stops. Run `--follow` again to resume it.
|
||||
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
|
||||
- **Keep control of the view:** `--pin`, `--unpin`, `--suppress`,
|
||||
`--unsuppress`, `--suppression-ms` and `--max-tiles` adjust presentation.
|
||||
`--no-follow` leaves the layout in place. The default is eight tiles,
|
||||
four seconds of departure grace and twelve more seconds for a focused
|
||||
leaver. Pins can exceed the cap; input-retained leavers do not consume a
|
||||
joining seat's slot. See [terminal sessions](docs/reference/terminal-sessions.md).
|
||||
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
|
||||
- **Uncertain input keeps its tile.** Typed drafts and unreadable composers
|
||||
retain departed viewers, including Pi, terminal and community harness
|
||||
seats whose input cannot be confirmed. Unpinning does not override that.
|
||||
There is one delayed input recheck, then queue/session events or an explicit
|
||||
follow command can reconsider it. Failed or incomplete reads retain the
|
||||
previous view with an explanation. Activity hooks do not trigger renders.
|
||||
([#2199](https://github.com/mvschwarz/openrig/pull/2199))
|
||||
- **Protected Codex delivery no longer strands a message it can't submit.** On a
|
||||
Codex seat whose typing guard is draft-aware or openrig-urgent, a message
|
||||
whose complete text can't be confirmed visible before paste is retained, not
|
||||
sent, instead of being pasted where it can't be submitted. The default (guard
|
||||
off) is unchanged. Use a larger pane or a shorter message; turning the typing
|
||||
guard off turns draft protection off and is not a recovery. Pastes over 1,000
|
||||
code points are retained. Wrapped lines use a conservative bound, so most
|
||||
wrapped wakes in common four-tile views can be retained even when they fit
|
||||
natively; approximate standard-envelope examples are one-paragraph bodies over
|
||||
about 115 characters at 80×22, 192 at 80×24, or 526 at 120×30 (examples, not
|
||||
limits). A later resize is not atomic, and layouts with Codex's image-enabled
|
||||
ambient pet are unqualified.
|
||||
([#2242](https://github.com/mvschwarz/openrig/pull/2242))
|
||||
- **Codex 0.164's two-row composer footer is recognized.** A departed Codex
|
||||
agent's empty composer no longer reads as unconfirmed input that keeps its
|
||||
tile. Delivery's draft-aware and openrig-urgent typing guards use the same
|
||||
reader, so an empty Codex 0.164 composer there can now receive typed
|
||||
delivery. ([#2206](https://github.com/mvschwarz/openrig/pull/2206))
|
||||
- **Clients can ensure a missing viewer without relaying out existing panes**
|
||||
through `POST /api/terminal/viewer/ensure`. It rechecks task membership and
|
||||
existing viewer identity before creating or reusing a tile. Preview names
|
||||
`not-provisioned`, `verified` or `unconfirmed`; membership gives no input
|
||||
authority. This path has the same Linux Herdr 0.9.5 / protocol 22 scope.
|
||||
([#2133](https://github.com/mvschwarz/openrig/pull/2133))
|
||||
- **An unconfirmed pane no longer counts as opened.** CLI and TUI output name
|
||||
it as degraded with `pane_unconfirmed`, distinguish it from a skipped seat,
|
||||
and omit its restart-command report. An all-unconfirmed open exits nonzero.
|
||||
Inspect before retrying; this is not proof that nothing opened.
|
||||
([#2173](https://github.com/mvschwarz/openrig/pull/2173))
|
||||
|
||||
### Fixes by what you see
|
||||
|
||||
- **Slow successful sends have time to answer.** Local and HTTP `rig send`
|
||||
wait 60 seconds, plus an explicit idle wait, instead of five seconds locally
|
||||
or thirty over HTTP. A timeout still means unconfirmed. The advice now says
|
||||
that a busy recipient may hold input off-screen: missing pane text does not
|
||||
prove non-delivery, so reconcile before another send or duplicate queue item.
|
||||
No automatic resend is added. This does not diagnose the daemon's latency.
|
||||
([#2198](https://github.com/mvschwarz/openrig/pull/2198))
|
||||
- **A wake that was unroutable before its seat had a pane is retried once the
|
||||
binding exists,** through the row's existing wake intent. The outbox does
|
||||
not send that same intent again after it is settled as delivered.
|
||||
([#2170](https://github.com/mvschwarz/openrig/pull/2170))
|
||||
- **A parentless seat's recovery can follow its declared `escalates_to`
|
||||
address,** including another rig on the same daemon. A delegation parent
|
||||
still takes precedence. Unavailable or ambiguous targets use the explained
|
||||
fallback; an already-open recovery keeps its recorded owner. This is one
|
||||
escalation hop, not cross-host routing.
|
||||
([#1772](https://github.com/mvschwarz/openrig/pull/1772))
|
||||
- **A stuck finding needs evidence to close.** Delivery, pickup or explicit
|
||||
custody can resolve it; a changed reporting threshold, an unreadable
|
||||
registry or a failed wake becoming unconfirmed cannot silently clear it.
|
||||
([#2153](https://github.com/mvschwarz/openrig/pull/2153))
|
||||
- **Fresh Claude handovers wait for the current launch.** Retained scrollback
|
||||
or an echoed shell command cannot make a new process look ready. The context
|
||||
packet waits for the matching process and Claude's complete drawn input frame.
|
||||
([#2159](https://github.com/mvschwarz/openrig/pull/2159))
|
||||
- **Runtime send refusals return HTTP 409 rather than 500.** A stale recorded
|
||||
Claude pane names `rig reconcile-session <seat>` as the repair; nothing is
|
||||
typed on that refusal. ([#2157](https://github.com/mvschwarz/openrig/pull/2157))
|
||||
- **`rig down` keeps a rig down after a rolled-back start.** It records the
|
||||
explicit stop without rewriting the superseded session's history. The
|
||||
`rig start --last` help now explains which rigs return and how an explicit
|
||||
stop keeps them out; the kernel is not selected by `--last`.
|
||||
([#2166](https://github.com/mvschwarz/openrig/pull/2166))
|
||||
- **Status and configuration describe the selected rig.** `rig ps` reports
|
||||
`periodicSnapshotActive` only for rigs the scheduler actually snapshots
|
||||
([#2168](https://github.com/mvschwarz/openrig/pull/2168)); the web Overview
|
||||
reads the rig's current exported configuration instead of a same-named
|
||||
library template ([#2188](https://github.com/mvschwarz/openrig/pull/2188)).
|
||||
- **Host labels and next steps are clearer.** The TUI shows the configured
|
||||
local host name while retaining its stable identity
|
||||
([#2162](https://github.com/mvschwarz/openrig/pull/2162)). `rig host add` and
|
||||
`pair` explain how to opt an HTTP host into remote Feed attention; registration
|
||||
leaves that setting unchanged ([#2179](https://github.com/mvschwarz/openrig/pull/2179)).
|
||||
- **Small CLI corrections:** `attach --self` reads tmux's rewritten separators
|
||||
([#2178](https://github.com/mvschwarz/openrig/pull/2178)); transcript and
|
||||
capture line counts use their normalized values on local and remote paths
|
||||
([#2164](https://github.com/mvschwarz/openrig/pull/2164)); stdio MCP warns on
|
||||
out-of-range daemon ports while continuing to serve, without substituting a
|
||||
port ([#2160](https://github.com/mvschwarz/openrig/pull/2160)).
|
||||
- **Notification adapters release unread HTTP response bodies,** without
|
||||
changing their send result or waiting for cleanup. No performance improvement
|
||||
is claimed here. ([#2172](https://github.com/mvschwarz/openrig/pull/2172))
|
||||
- **Scope audits agree on slice folders numbered 100 and above.** The guide
|
||||
also explains that moving a slice reopens its proof judgments; a clean
|
||||
structural audit is not a proof judgment.
|
||||
([#2194](https://github.com/mvschwarz/openrig/pull/2194))
|
||||
|
||||
### Presets, hosted members and guides
|
||||
|
||||
- **The `pi-tui` example runs Pi's own terminal UI as a community harness.**
|
||||
It gives each seat its own Pi agent folder and passes startup input to Pi.
|
||||
One Pi 1.4.0 / OpenRig 0.7.4 run showed a parked wake returning to the same
|
||||
session and an unattended handoff to a second seat. It did not prove Pi
|
||||
chooses that coordination by itself. Resume is not declared. Use it
|
||||
unattended: a wake can submit a person's half-typed draft, and unrecognized
|
||||
dialogs can receive the send. Busy-turn wakes and other Pi versions remain
|
||||
unqualified. ([#2182](https://github.com/mvschwarz/openrig/pull/2182),
|
||||
[#2184](https://github.com/mvschwarz/openrig/pull/2184))
|
||||
- **Interactive Pi setup is explicit:** copy the coordination skills and use
|
||||
`delivery_hint: send_text` for a startup file that must reach a preset's
|
||||
first input. A plain terminal seat does not receive queue wakes. Setting its
|
||||
typing guard to `off` can type notices into a shell; keep the default for a
|
||||
seat that can return to one. These are documented limits, not new guards.
|
||||
([#2174](https://github.com/mvschwarz/openrig/pull/2174),
|
||||
[#2180](https://github.com/mvschwarz/openrig/pull/2180),
|
||||
[#2181](https://github.com/mvschwarz/openrig/pull/2181),
|
||||
[#2185](https://github.com/mvschwarz/openrig/pull/2185))
|
||||
- **Hosted-member guidance names its real reach.** A member without a wake
|
||||
trigger checks work when its owner next starts a session; a listed tool is
|
||||
not a grant, and completed work closes with `no-follow-on`. Hosted members
|
||||
exposed over the internet remain **experimental, unsupported, off by default
|
||||
and at your own risk**. The guide explains narrow grants and token revocation;
|
||||
this terminal-follow release requires no internet exposure.
|
||||
([#2187](https://github.com/mvschwarz/openrig/pull/2187),
|
||||
[#2189](https://github.com/mvschwarz/openrig/pull/2189))
|
||||
- **Publishing a team bundle:** add and declare a README after `rig export`,
|
||||
then run `rig bundle check` at the revision being submitted
|
||||
([#2176](https://github.com/mvschwarz/openrig/pull/2176)). Queue guidance
|
||||
teaches parking long waits with a continuation and wake
|
||||
([#2167](https://github.com/mvschwarz/openrig/pull/2167)).
|
||||
- **Contributor maintenance:** the test guide corrects installed-scenario
|
||||
counts and cases ([#2165](https://github.com/mvschwarz/openrig/pull/2165));
|
||||
the compaction receipt fixture shares one clock, with no product change
|
||||
([#2169](https://github.com/mvschwarz/openrig/pull/2169)). Published release
|
||||
notes, the capability map and roadmap records were carried forward
|
||||
([#2161](https://github.com/mvschwarz/openrig/pull/2161),
|
||||
[#2175](https://github.com/mvschwarz/openrig/pull/2175),
|
||||
[#2197](https://github.com/mvschwarz/openrig/pull/2197)).
|
||||
|
||||
### Limits carried forward
|
||||
|
||||
- **Follow mode is Linux Herdr 0.9.5 / protocol 22 only.** Other platforms,
|
||||
providers and selector kinds retain one-shot open. Its exercised journey
|
||||
used shell stand-ins, not native agents. Active render cost is unmeasured.
|
||||
The first workspace on an empty provider may gain focus, and a viewer can
|
||||
resize the agent's terminal; `ignore-size` is not resize isolation.
|
||||
- **Unreadable composers retain departed tiles.** This includes input the
|
||||
inspector cannot read on Pi, terminal or community harness seats; unpinning
|
||||
alone does not establish that closing is safe.
|
||||
- **Escalation across rigs is same-daemon only.** Upgrade followed by snapshot
|
||||
restore of deferred escalation targets has not been qualified.
|
||||
- **A webhook 2xx is acceptance of the ring, not pickup.** A hosted member's
|
||||
activity remains unknown between calls. Principals record attribution, not
|
||||
authentication. See [hosted members](docs/reference/hosted-members.md).
|
||||
- **Reviewed-history resume remains a client operation through startup review,**
|
||||
not a CLI command. Kernel restore evidence lasts one daemon process.
|
||||
- **OpenClaw interrupted local turns are not recovered; Hermes has no declared
|
||||
resume or measured idle gateway exit.** `rig down` stops a community harness's pane; survival of independently spawned
|
||||
children remains untested. Inspect actual processes before relying on their exit.
|
||||
- **The Codex restart limitation in #2200 remains,** as do the other 0.7.4
|
||||
limits unless a change above names them fixed: Codex permission qualification
|
||||
only on 0.164.0, earlier removed hook approvals, SSH tiles after a Herdr
|
||||
restart, unqualified native cmux, Slack-only shared channels and the existing
|
||||
browser-sizing and old Slack-thread limits. Isolated account-home switching
|
||||
is not included.
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @icey-zzz (#1772), @Aschenbath (#2153, #2159, #2162, #2169, #2179),
|
||||
@DeryFerd (#2164, #2165), @Nikhi00718 (#2172) and @dajiaohuang (#2188).
|
||||
Thanks also to @rudycelekli for the contribution from #1416 completed in #2160;
|
||||
to @AdamWhitehurst (#1544, #2049), @kevinhsykes (#2151), @aanestesia (#605),
|
||||
@todie (#1419) and @tgrundtvig (#21), whose reports led to fixes here; and to
|
||||
the other people whose reports and reviews led to these fixes.
|
||||
|
||||
## [0.7.4]
|
||||
|
||||
OpenRig 0.7.4 lets an assistant you already use work as a member of your team.
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
|
||||
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
|
||||
|
||||
### Install it yourself
|
||||
|
||||
This runs OpenRig's install script from the `v0.7.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
This runs OpenRig's install script from the `v0.7.4` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
|
||||
script will do and changes nothing:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh`, installs the
|
||||
latest published `@openrig/cli` with `npm install -g`,
|
||||
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
|
||||
Codex and may install a missing one, as described below. A failed step prints
|
||||
|
||||
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.4`. If OpenRig is already installed, use the tag
|
||||
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
|
||||
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
|
||||
you want built.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -30,10 +30,25 @@ With neither a provider nor a session, the daemon's open and preview APIs try
|
||||
a live Herdr first, then a live cmux. An explicit session always selects Herdr
|
||||
and skips that fallback, including when the daemon's default Herdr endpoint is down.
|
||||
|
||||
An explicit session resolves under the daemon user's Herdr session directory;
|
||||
it takes precedence over that daemon's default socket override. The selection
|
||||
belongs to the request. Saved views still describe membership and layout, so
|
||||
pass `--session` again when reopening the view in that session.
|
||||
Session paths use the daemon's `XDG_CONFIG_HOME` when set, or `HOME/.config`
|
||||
when unset. Relative paths (including an empty value) resolve from the daemon's
|
||||
startup directory, matching Herdr's configuration selection. Named sessions live under
|
||||
`herdr/sessions/<name>/herdr.sock`; `default` uses `herdr/herdr.sock` directly.
|
||||
These settings are captured when the daemon starts.
|
||||
|
||||
If the requested name equals the daemon's `HERDR_SESSION`, it keeps that
|
||||
session's explicit `HERDR_SOCKET_PATH`. A different requested name uses the
|
||||
configuration-tree path, without reusing the override or inferring a session
|
||||
from an arbitrary socket path. With an explicit socket but no configured
|
||||
session name, omit `--session` to keep that exact endpoint. A missing selected
|
||||
socket never falls back to another session. Viewer and follow operations carry
|
||||
that exact socket to the CLI through `HERDR_SOCKET_PATH`, without an explicit
|
||||
CLI `--session` that would override it. They check that the CLI reports the
|
||||
selected socket before changing its panes. Daemon-side CLI calls retain the
|
||||
startup environment; desktop follow uses the socket confirmed by the daemon.
|
||||
|
||||
The selection belongs to the request. Saved views still describe membership
|
||||
and layout, so pass `--session` again when reopening the view in that session.
|
||||
|
||||
The existing view rules apply: saved views, rig and pod views, read-only mission
|
||||
and slice views, pagination, absent seats and partial results all use the same
|
||||
|
||||
+177
-153
@@ -1,168 +1,192 @@
|
||||
# What changed in OpenRig 0.7.5
|
||||
# What changed in OpenRig 0.7.1
|
||||
|
||||
This note is for an agent on an install that was just upgraded to 0.7.5.
|
||||
It lists changes since 0.7.4. For the whole capability map, read
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`.
|
||||
Each release replaces this note: `rig context get reference/whats-new.md`
|
||||
describes the installed version once the upgraded daemon is running. Without
|
||||
a daemon, read `$OPENRIG_HOME/reference/whats-new.md`; that copy refreshes at
|
||||
daemon start and can still describe the previous version just after an upgrade.
|
||||
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
|
||||
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
|
||||
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
|
||||
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
|
||||
after an upgrade it can still hold the previous version's note.
|
||||
|
||||
## After the upgrade
|
||||
|
||||
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). The new
|
||||
migration 126 stores deferred `escalates_to` addresses alongside node-bound
|
||||
edges. Existing escalation edges that were inert now route recovery for
|
||||
seats without a delegation parent, including to another rig on this daemon.
|
||||
- **openrig-core goes from 0.1.21 to 0.1.22.** Its queue-handoff skill teaches
|
||||
parking waits on CI or long jobs with a continuation and wake. Managed
|
||||
refresh still preserves replaced files under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you need.
|
||||
No setting is added or removed.
|
||||
- **Keep the Codex hook-refresh limit in mind.** The earlier remedy
|
||||
`rig seat restart <seat> --keep-conversation --reason "refresh hooks"`
|
||||
(preview with `--plan`) refuses a Codex seat launched by a 0.6.x
|
||||
`openrig-tmux-send` script when Codex is a grandchild of the pane's shell.
|
||||
Nothing is stopped, and the hook refresh has not happened. Issue
|
||||
[#2200](https://github.com/mvschwarz/openrig/issues/2200) is not fixed in
|
||||
0.7.5. If Codex asks for hook review, review the hooks you intend to enable;
|
||||
upgrading does not restore approvals an earlier OpenRig removed.
|
||||
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
|
||||
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
|
||||
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
|
||||
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
|
||||
`daemon.db_conflict` (`warn`). None is removed.
|
||||
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
|
||||
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
|
||||
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
|
||||
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
|
||||
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
|
||||
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
|
||||
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
|
||||
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
|
||||
anyone edited the file; if someone did, reapply the change from the copy.
|
||||
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
|
||||
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
|
||||
next launch, restore or handover.
|
||||
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
|
||||
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
|
||||
doesn't repair it; ask the person which mode they want and set it once.
|
||||
|
||||
## New things you can do
|
||||
|
||||
- **Follow a task in one terminal workspace:**
|
||||
`rig terminal open qitem:<id> --follow --provider herdr`. Current obligation
|
||||
owners join as verified viewer tiles without requesting focus. Departures
|
||||
close viewers after a grace period, not agents or conversations. This is
|
||||
scoped to **Linux Herdr 0.9.5 / protocol 22**.
|
||||
- **Adjust the view:** `--pin` / `--unpin`, `--suppress` / `--unsuppress`,
|
||||
`--suppression-ms`, `--max-tiles`, `--grace-ms` and `--focus-retention-ms`.
|
||||
The defaults are eight tiles, four seconds of departure grace and twelve
|
||||
more seconds for a focused leaver. `--no-follow` keeps the fixed layout.
|
||||
A completely empty task drains removable tiles; when none remain, following
|
||||
stops. Run `--follow` again to resume the same workspace.
|
||||
- **Inspect following:** `rig terminal views --json` and
|
||||
`rig terminal status --json` include saved active selectors and their latest
|
||||
status. Ordinary `rig terminal open qitem:<id>` remains a one-shot open.
|
||||
A client can use `POST /api/terminal/viewer/ensure` to add or reuse a verified
|
||||
missing viewer without relaying out existing panes. Membership grants no input
|
||||
authority. See `rig context get reference/terminal-sessions.md`.
|
||||
- **Use the `pi-tui` example for Pi's own terminal UI as a community harness.**
|
||||
Copy its preset and launcher from `docs/reference/community-harness-examples/pi-tui/`
|
||||
and maintain them as your own. It supplies a per-seat Pi agent folder and
|
||||
startup input. One Pi 1.4.0 / OpenRig 0.7.4 run exercised a parked wake to
|
||||
the same session and an unattended handoff to a second seat. Resume is not
|
||||
declared; the limitations below matter.
|
||||
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
|
||||
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
|
||||
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
|
||||
readable (`rig seat held-messages`) and is never replayed.
|
||||
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
|
||||
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
|
||||
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
|
||||
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
|
||||
interventions are guidance only.
|
||||
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
|
||||
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
|
||||
one line to shared guidance files.
|
||||
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
|
||||
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
|
||||
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
|
||||
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
|
||||
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
|
||||
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
|
||||
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
|
||||
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
|
||||
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
|
||||
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
|
||||
--seat <session>` gives each caller its own seat.
|
||||
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
|
||||
`docs/reference/a2a-agents.md` covers A2A agents.
|
||||
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
|
||||
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
|
||||
`docs/reference/community-harness-examples/`.
|
||||
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
|
||||
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
|
||||
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
|
||||
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
|
||||
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
|
||||
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
|
||||
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
|
||||
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
|
||||
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
|
||||
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
|
||||
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
|
||||
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
|
||||
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
|
||||
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
|
||||
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
|
||||
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
|
||||
strands an accepted create or join whose effect hadn't landed.
|
||||
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
|
||||
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
|
||||
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
|
||||
`rig stream show <id>` says what became of an observation.
|
||||
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
|
||||
the kernel starts seats only for those.
|
||||
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
|
||||
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
|
||||
deliberate start.
|
||||
|
||||
## Behaviour and output that changed
|
||||
## Behaviour that changed
|
||||
|
||||
- **`rig send` waits longer for its result:** 60 seconds locally and over HTTP,
|
||||
plus any requested `--wait-for-idle` duration. A longer send or lost response
|
||||
is still unconfirmed, with no automatic resend. A busy agent can hold delivered
|
||||
input off-screen: missing pane text does not prove non-delivery. Reconcile
|
||||
before another send or filing the same work as a queue item.
|
||||
- **Protected Codex delivery no longer strands a message it can't submit.** On a
|
||||
Codex seat whose typing guard is draft-aware or openrig-urgent, a message
|
||||
whose complete text can't be confirmed visible before paste is retained, not
|
||||
sent, instead of being pasted where it can't be submitted. The default (guard
|
||||
off) is unchanged. Use a larger pane or a shorter message; turning the typing
|
||||
guard off turns draft protection off and is not a recovery. Pastes over 1,000
|
||||
code points are retained. Wrapped lines use a conservative bound, so most
|
||||
wrapped wakes in common four-tile views can be retained even when they fit
|
||||
natively; approximate standard-envelope examples are one-paragraph bodies over
|
||||
about 115 characters at 80×22, 192 at 80×24, or 526 at 120×30 (examples, not
|
||||
limits). A later resize is not atomic, and layouts with Codex's image-enabled
|
||||
ambient pet are unqualified.
|
||||
([#2242](https://github.com/mvschwarz/openrig/pull/2242))
|
||||
- **Codex 0.164's two-row composer footer is recognized.** A departed Codex
|
||||
agent's empty composer no longer reads as unconfirmed input that keeps its
|
||||
tile. Delivery's draft-aware and openrig-urgent typing guards use the same
|
||||
reader, so an empty Codex 0.164 composer there can now receive typed delivery.
|
||||
([#2206](https://github.com/mvschwarz/openrig/pull/2206))
|
||||
- **Unconfirmed panes are not counted as opened.** Terminal CLI and TUI results
|
||||
name `pane_unconfirmed` separately from skipped seats; an all-unconfirmed open
|
||||
exits nonzero. Inspect before retrying rather than assuming nothing opened.
|
||||
- **A wake recorded as unroutable can resume once its seat is pane-bound.** The
|
||||
sweep uses the row's existing wake intent so the outbox does not send the same
|
||||
settled intent again. Existing quiet hours and retry rules still apply.
|
||||
- **A parentless seat's `escalates_to` destination is used for recovery,**
|
||||
including another rig on this daemon. Delegation parents still take
|
||||
precedence; an open recovery keeps its recorded owner. A stuck finding closes
|
||||
on source evidence, not because a reporting threshold or unreadable registry
|
||||
made it disappear from a report.
|
||||
- **Fresh Claude handovers wait for the new process and current drawn input
|
||||
frame.** Retained scrollback and an echoed launch command are not readiness.
|
||||
- **Runtime send refusals return 409 instead of 500.** A stale Claude pane
|
||||
names `rig reconcile-session <seat>` as the repair and types nothing.
|
||||
- **`rig down` records an explicit stop after a rolled-back launch.**
|
||||
`rig start --last` then keeps it down. `rig ps` marks periodic snapshots active
|
||||
only for rigs the scheduler snapshots.
|
||||
- **The web Overview shows the selected rig's current configuration,** not a
|
||||
same-named library template. The TUI shows the configured local host name;
|
||||
host registration explains the optional HTTP Feed subscription.
|
||||
- **`attach --self` handles tmux's rewritten separators;** capture and transcript
|
||||
requests use normalized line counts; stdio MCP warns on out-of-range daemon
|
||||
ports while continuing to serve without replacing the supplied port.
|
||||
- **The scope audit route accepts slice folders numbered 100 and above.**
|
||||
Moving a slice still reopens its proof judgments: structural validity does
|
||||
not mean an earlier judgment applies at the new path.
|
||||
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
|
||||
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
|
||||
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
|
||||
what wasn't delivered.
|
||||
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
|
||||
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
|
||||
at `hold`.
|
||||
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
|
||||
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
|
||||
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
|
||||
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
|
||||
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
|
||||
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
|
||||
shows an MCP enablement menu, a question or a permission prompt.
|
||||
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
|
||||
`--yes` command.
|
||||
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
|
||||
and `--no-kernel`.
|
||||
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
|
||||
listener, such as Tailscale's, can't be verified.
|
||||
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
|
||||
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
|
||||
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
|
||||
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
|
||||
writable roots.
|
||||
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
|
||||
guidance files byte for byte.
|
||||
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
|
||||
nor actor gets the shared `actor_required` error.
|
||||
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
|
||||
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
|
||||
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
|
||||
which clears nothing. Keys for a recognized native menu are sent as before.
|
||||
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
|
||||
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
|
||||
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
|
||||
runs, so it doesn't relaunch them beside themselves.
|
||||
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
|
||||
handover. A launch whose staging fails stops instead of falling back.
|
||||
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
|
||||
request once.
|
||||
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
|
||||
longer stops health diagnoses from listing.
|
||||
|
||||
## Keep using the 0.7.4 capabilities
|
||||
## Output that changed shape
|
||||
|
||||
- **Hosted assistants can remain team members over MCP**, with roster entries,
|
||||
per-seat grants and optional webhook rings. Their reach comes from the grant,
|
||||
not the tool list. Without a wake trigger they check work when their owner
|
||||
next starts a session; finished work closes with `no-follow-on`.
|
||||
Internet-facing hosted members remain **experimental, unsupported, off by
|
||||
default and at your own risk**. This release's terminal view needs no internet
|
||||
exposure. See `rig context get reference/hosted-members.md` for narrow grants
|
||||
and token revocation.
|
||||
- **External grant checks, OAuth discovery and MCP protocol 2026-07-28** remain
|
||||
available beside the older MCP handshake. A recorded principal is attribution,
|
||||
not authentication. A webhook 2xx accepts the ring, not the task; a queue claim
|
||||
is pickup, and activity between calls remains unknown.
|
||||
- **`rig seat history --rig <id> --node-id <id>`** still lists eligible recorded
|
||||
conversations. Resuming a person's reviewed history remains a client operation
|
||||
through startup review, not a CLI command.
|
||||
- **Daemon start restores a kernel only when this start newly detects it went
|
||||
down.** A kernel already recorded down stays down; use `rig up kernel --existing` when you want it back.
|
||||
That restore evidence still lasts only one daemon process.
|
||||
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
|
||||
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
|
||||
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
|
||||
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
|
||||
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
|
||||
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
|
||||
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
|
||||
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
|
||||
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
|
||||
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
|
||||
`--since` to resume, instead of an empty list.
|
||||
|
||||
## Limits to carry into your work
|
||||
## What to stop doing
|
||||
|
||||
- **Follow mode and ensure-viewer are limited to Linux Herdr 0.9.5 / protocol 22.**
|
||||
Other platforms, providers and selectors retain one-shot open. The exercised
|
||||
follow journey used shell stand-ins, not native agents; real-agent follow
|
||||
qualification is not claimed. Active render cost is unmeasured.
|
||||
- **Unreadable composers keep departed tiles.** Pi, terminal and community
|
||||
harness input may be unconfirmed. Unpinning does not make it safe to close;
|
||||
typed drafts are retained too. Each input-retained departure gets one delayed
|
||||
recheck, then an event or explicit follow command can reconsider it. Failed
|
||||
provider or membership reads retain the old view with an explanation.
|
||||
- **A viewer can resize its agent's terminal.** `ignore-size` is not isolation.
|
||||
The first workspace on an empty Herdr server may gain focus. An SSH tile still
|
||||
needs reopening after a Herdr restart, and native cmux remains unqualified.
|
||||
- **Use `pi-tui` unattended, not alongside a person's typing.** A wake can submit
|
||||
a half-typed draft, and an unrecognized dialog can receive a send. Draft-aware
|
||||
mode is not a known fix. Busy-turn wakes, other Pi versions, native resume and
|
||||
Pi choosing the park/handoff sequence for itself are not established by the
|
||||
example's one run. Plain terminal seats do not receive queue wakes; setting
|
||||
their typing guard to `off` can put notices into a shell. A preset's first
|
||||
startup file must use `delivery_hint: send_text` to reach its first input.
|
||||
- **Cross-rig escalation is same-daemon only;** upgrade followed by snapshot
|
||||
restore of deferred targets has not been qualified.
|
||||
- **The Codex restart refusal in #2200 remains.** Codex permission settings were
|
||||
qualified on native 0.164.0 in 0.7.3, not every version; isolated account-home
|
||||
switching is not part of this release.
|
||||
- **OpenClaw interrupted local turns remain unrecovered;** inspect the pane and
|
||||
use its Gateway preset for unattended work. Hermes has no declared resume or
|
||||
measured idle gateway exit. `rig down` stops a community harness's pane;
|
||||
survival of independently spawned children remains untested.
|
||||
- **Shared channels remain Slack only.** TUI primary-user authority is attribution,
|
||||
not authentication. Earlier Slack-thread, browser-sizing and other published
|
||||
0.7.4 limits remain unless a change above explicitly names them fixed.
|
||||
- **Don't read `lastNudgeInputBox`.** It's gone.
|
||||
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
|
||||
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
|
||||
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
|
||||
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
|
||||
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
|
||||
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
|
||||
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
|
||||
|
||||
## Contributors
|
||||
## Known gaps in 0.7.1
|
||||
|
||||
Thanks to @icey-zzz, @Aschenbath, @DeryFerd, @Nikhi00718 and @dajiaohuang for
|
||||
merged contributions, and @rudycelekli for the work completed in #2160.
|
||||
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
|
||||
its prompt, press Enter in its pane, or ask the person to.
|
||||
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
|
||||
acting on it.
|
||||
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
|
||||
marks any alerts waiting to post to Slack as already seen.
|
||||
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
|
||||
(`tmux -V`).
|
||||
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
|
||||
everything" wait. On a slow host, start the daemon yourself first.
|
||||
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
|
||||
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
|
||||
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
|
||||
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
|
||||
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
|
||||
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
|
||||
gateway folder or database instead: that can lose newer people, bindings and delivery records.
|
||||
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
|
||||
--provider herdr`.
|
||||
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
|
||||
first. Pair a decision room with quiet hours, or decide before asking.
|
||||
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
|
||||
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
|
||||
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
|
||||
falling back to credential-bearing command-line arguments.
|
||||
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
|
||||
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
|
||||
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
|
||||
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
|
||||
only claude.ai subscription logins and reports `not_subscription_login`.
|
||||
+8
-33
@@ -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.
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7346,7 +7346,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7384,7 +7384,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7426,7 +7426,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.1",
|
||||
"type": "module",
|
||||
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
|
||||
"keywords": [
|
||||
|
||||
@@ -3,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}` };
|
||||
},
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = (() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1325,6 +1325,19 @@ describe("Send CLI", () => {
|
||||
expect(String(lastSendBody?.text)).toContain("To: dev-impl@my-rig");
|
||||
});
|
||||
|
||||
it("single-seat send with a trailing-slash OPENRIG_URL reaches <url>/api/..., not <url>//api/...", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${port}/`);
|
||||
vi.stubEnv("RIGGED_URL", "");
|
||||
lastSendBody = null;
|
||||
const rc = recordingRealClient();
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd(probeFailNoStateDeps(rc.factory)).parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("Sent to dev-impl@my-rig");
|
||||
expect(rc.seen()).toBe(`http://127.0.0.1:${port}`);
|
||||
expect(String(lastSendBody?.text)).toContain("To: dev-impl@my-rig");
|
||||
});
|
||||
|
||||
it("R2 RED: fan-out --to + OPENRIG_URL + probe-stopped -> actual /broadcast lands with per-recipient results", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${port}`);
|
||||
vi.stubEnv("RIGGED_URL", "");
|
||||
|
||||
@@ -73,24 +73,26 @@ describe("reopening a Herdr view in a desktop window", () => {
|
||||
});
|
||||
afterEach(() => { process.exitCode = originalExit; vi.restoreAllMocks(); });
|
||||
|
||||
it.each([true, false])("following binds default named daemon endpoint before tab selection (matches=%s)", async matches => {
|
||||
const f = fixture([{ workspace_id: "follow-w", tab_id: "follow-t", label: "following" }], "kernel");
|
||||
const namedSocket = "/daemon home/sessions/daemon-named/herdr.sock";
|
||||
it.each([[undefined, true], ["daemon-named", true], [undefined, false], ["daemon-named", false]] as const)("following binds the exact custom endpoint before tab selection (session=%s matches=%s)", async (session, matches) => {
|
||||
const f = fixture([{ workspace_id: "follow-w", tab_id: "follow-t", label: "following" }], "kernel", session);
|
||||
const namedSocket = "/run private/custom.sock";
|
||||
f.get.mockImplementation(async (url: string) => ({ status: 200, data: url.includes("preview")
|
||||
? { planId, composed: f.composed, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }
|
||||
: { providers: [{ liveness: { alive: true }, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }] } }) as never);
|
||||
? { ...(session ? { session } : {}), planId, composed: f.composed, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }
|
||||
: { ...(session ? { session } : {}), providers: [{ liveness: { alive: true }, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }] } }) as never);
|
||||
f.post.mockResolvedValue({ status: 200, data: { provider: "herdr", ok: true, workspaceId: "follow-w", opened: ["operator"], absent: [], degraded: [], pages: 1,
|
||||
...(session ? { session } : {}),
|
||||
following: { id: "same", enabled: true, state: "following", maxTiles: 8, tiles: [], events: [], overflow: [] } } } as never);
|
||||
const original = f.exec.getMockImplementation()!;
|
||||
f.exec.mockImplementation(async (file, args) => args.includes("status") && args.includes("server")
|
||||
? JSON.stringify({ result: { running: true, socket: matches ? namedSocket : "/wrong/default.sock" } }) : original(file, args));
|
||||
await f.run(["--follow", "--json"]);
|
||||
await f.run(["--follow", "--json", ...(session ? ["--session", session] : [])]);
|
||||
const parsed = JSON.parse(logs[0]!);
|
||||
if (matches) {
|
||||
expect(parsed).toMatchObject({ ok: true, workspaceId: "follow-w" });
|
||||
expect(f.focus).toHaveBeenCalledExactlyOnceWith("follow-t");
|
||||
for (const [, args] of f.exec.mock.calls.filter(([, args]) => args.includes("status") || args.includes("tab"))) {
|
||||
expect(args).toContain("--session"); expect(args).toContain("daemon-named");
|
||||
expect(args).toContain(`HERDR_SOCKET_PATH=${namedSocket}`);
|
||||
expect(args).not.toContain("--session");
|
||||
}
|
||||
} else {
|
||||
expect(parsed.ok).toBe(false); expect(f.focus).not.toHaveBeenCalled(); expect(f.list).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig-core",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.21",
|
||||
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig-core",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.21",
|
||||
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
|
||||
@@ -81,6 +81,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolBatch": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"${OPENRIG_NODE:-node}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\" --from-plugin",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -84,6 +84,11 @@ function buildOpenRigPayload(providerPayload, env = process.env, now = () => new
|
||||
generation,
|
||||
hookEvent,
|
||||
subtype,
|
||||
// Claude's main session omits agent_id; subagents and in-process teammates carry it.
|
||||
// Keep that distinction for permission completion, without forwarding tool inputs/results.
|
||||
...(runtime === "claude-code" && (hookEvent === "Notification" || hookEvent === "PostToolBatch")
|
||||
&& (providerPayload.agent_id == null || firstString(providerPayload.agent_id))
|
||||
? { hookAgentId: firstString(providerPayload.agent_id) } : {}),
|
||||
turnId: firstString(providerPayload.turn_id, providerPayload.turnId),
|
||||
occurredAt: now().toISOString(),
|
||||
relayRoute: argv.includes("--from-plugin") ? "plugin" : "managed",
|
||||
|
||||
@@ -11,7 +11,7 @@ not know exists.** So the point of what follows is not skill. It is recognition:
|
||||
lands, something rhymes, and you go check instead of building.
|
||||
|
||||
Ninety top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.5`). This marker describes the pack's teaching, not publication or live
|
||||
`capability-delta-v0.7.4`). This marker describes the pack's teaching, not publication or live
|
||||
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
|
||||
What changed in the installed version, and what to do about it, is one read away:
|
||||
`rig context get reference/whats-new.md`.
|
||||
@@ -211,24 +211,14 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
type. Only if the window cannot open, `rig tui --shared` is the dashboard-only fallback; explain
|
||||
the failure. In the window's `--json` output, `reusedWorkspace` with no new tiles means an
|
||||
existing view was reused, which is success. `--provider herdr` or `--provider cmux` opens the
|
||||
view in a provider that is already running, without opening a window. Ordinary opens add a new
|
||||
workspace, so to come back to a fixed view, focus its open space; task follow mode reuses one.
|
||||
view in a provider that is already running, without opening a window; each such open adds a new
|
||||
workspace, so to come back to a view, focus its open space.
|
||||
**`qitem:<id>` as the view** previews or opens the current local owners of that task: its
|
||||
pending, in-progress and blocked rows through explicitly linked successors, without closed
|
||||
predecessors or past senders. It's a read-only membership view: stopped, missing, external and
|
||||
unresolved owners are named, and incomplete lineage is reported rather than cut short. Ordinary
|
||||
open is one-shot. Add **`--follow --provider herdr`** to keep one workspace as the task's owners
|
||||
join and leave (Linux Herdr 0.9.5 / protocol 22 only). Joining viewers do not request focus;
|
||||
departures close viewers, not agents or conversations. Typed drafts and unreadable composers
|
||||
retain departed tiles, including unconfirmed Pi, terminal and community-harness input.
|
||||
`--pin`, `--suppress` and `--max-tiles` adjust the view; `--no-follow` keeps the fixed layout.
|
||||
A complete empty task drains removable tiles and stops following once none remain; an explicit
|
||||
`--follow` resumes that workspace. `rig terminal views --json` shows saved active selectors.
|
||||
The exercised journey used shell stand-ins, not native agents, and active render cost remains
|
||||
unmeasured. See `reference/terminal-sessions.md` for controls and limits. Preview and open
|
||||
responses carry `viewDescriptor` (`kind`, `name`); membership grants no input authority.
|
||||
An unconfirmed pane is reported as `pane_unconfirmed`, not counted as opened. Inspect before
|
||||
retrying: an unconfirmed result does not prove that nothing opened.
|
||||
unresolved owners are named, and incomplete lineage is reported rather than cut short. It doesn't
|
||||
follow the task as it moves, and an explicit open still has the provider's usual workspace
|
||||
effects. Preview and open responses carry `viewDescriptor` (`kind`, `name`).
|
||||
Add `--session <name>` to select a Herdr session for that request; `--session default`
|
||||
explicitly selects its default session. `rig terminal status --session <name>` inspects
|
||||
the same endpoint. Omitting the flag keeps the daemon's configured endpoint. Herdr tiles come
|
||||
@@ -1329,10 +1319,6 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
with secret names, never values): OpenRig then POSTs a pointer to the row when work lands, and a
|
||||
2xx records only that the ring was accepted, not that anything woke. When work waits for a
|
||||
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
|
||||
**Internet-facing hosted members are experimental, unsupported, off by default and at your
|
||||
own risk.** Local assistants can connect locally. Internet access needs your own TLS tunnel or
|
||||
proxy and a narrowly scoped, revocable token; see `reference/hosted-members.md`. Task terminal
|
||||
following does not require internet exposure.
|
||||
- **`rig skill loadout --runtime <claude-code|codex>`** — inspect the exact catalog revision,
|
||||
selectors, target, and current/missing/shadowed/conflicting state for one working directory.
|
||||
`--apply` writes only the managed ownership set, is idempotent, and refuses local edits or
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -16,7 +16,6 @@ metadata:
|
||||
stage: factory-approved
|
||||
sibling_skills:
|
||||
- openrig-user
|
||||
- openrig-operator
|
||||
- developing-openrig
|
||||
- openrig-upgrade
|
||||
- forming-an-openrig-mental-model
|
||||
|
||||
@@ -16,7 +16,6 @@ metadata:
|
||||
stage: factory-approved
|
||||
sibling_skills:
|
||||
- openrig-user
|
||||
- openrig-operator
|
||||
- forming-an-openrig-mental-model
|
||||
---
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ states.
|
||||
## Don't use this when
|
||||
|
||||
- The operation is single-command and deterministic — use `openrig-user` skill for CLI surface
|
||||
- The work is operator-level configuration of OpenRig itself — use `openrig-operator`
|
||||
- The work is operator-level configuration or troubleshooting of OpenRig itself — run `rig context get help`
|
||||
- The work is rig spec authoring — use `openrig-architect`
|
||||
|
||||
## Failure modes (4)
|
||||
@@ -120,7 +120,7 @@ different claims; don't conflate.
|
||||
## See also
|
||||
|
||||
- `openrig-user` skill — CLI surface for `rig up / down / restore / etc.`
|
||||
- `openrig-operator` skill — operator-level discipline for OpenRig itself
|
||||
- `rig context get help` — the help guide for the installed version, for when OpenRig itself isn't working
|
||||
- `seat-continuity-and-handover` skill — sub-primitive: occupant-creation modes for restore (`resume` / `rebuild` / `fresh` / `failed`)
|
||||
- `session-source-fork` skill — `forked` continuity outcome for fork-based restore
|
||||
- `permission-and-capability-preflight` skill — provider-auth blocker → escalation path
|
||||
|
||||
@@ -47,7 +47,8 @@ machine and have things happen.
|
||||
marks them healthy. A HOST reboot drops tmux entirely; the
|
||||
reconciler then marks the kernel's member sessions detached, and
|
||||
the operator pod owns the agent-restart workflow ("bring my
|
||||
sessions back online") per the openrig-operator skill.
|
||||
sessions back online") per "Incomplete setup and restart":
|
||||
`rig context get reference/getting-started.md#incomplete-setup-and-restart`.
|
||||
Other rigs are NOT auto-instantiated by the daemon at any point —
|
||||
they require explicit operator-initiated `rig up` / `rig restore`
|
||||
via the agent-driven workflow.
|
||||
|
||||
@@ -259,6 +259,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource; freshInPlace?: boolean; beforeNativeDispatch?: () => boolean },
|
||||
): Promise<HarnessLaunchResult> {
|
||||
binding = { ...binding };
|
||||
const claudeCommand = binding.claudeExecutable ? `exec ${shellQuote(binding.claudeExecutable)}` : "claude";
|
||||
this.autoLaunches.delete(binding.nodeId);
|
||||
opts = { ...opts, ...(opts.forkSource ? { forkSource: { ...opts.forkSource } } : {}) };
|
||||
if (!binding.tmuxSession) {
|
||||
@@ -277,6 +278,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
try {
|
||||
if (!this.claudeManagedLaunch) await unresolvedClaudePermissionModes();
|
||||
managed = await this.claudeManagedLaunch!.prepare({ nodeId: binding.nodeId, cwd: binding.cwd,
|
||||
...(binding.claudeExecutable ? { executable: binding.claudeExecutable } : {}),
|
||||
session: binding.tmuxSession, pane: binding.tmuxPane, generation: binding.launchGeneration,
|
||||
...(binding.runtimeSwitchFrom ? { runtimeSwitch: { fromRuntime: binding.runtimeSwitchFrom.runtime, session: binding.runtimeSwitchFrom.session,
|
||||
pane: binding.runtimeSwitchFrom.pane, generation: binding.runtimeSwitchFrom.generation } } : {}) }, binding.permissionMode);
|
||||
@@ -356,7 +358,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
// The non-managed command text is parsed by the pane shell, so the
|
||||
// parent id needs the same quoting as --model/--effort above (and as
|
||||
// the codex fork path) — a hostile id must not leave --resume.
|
||||
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(parentId)} --fork-session --session-id ${forkSessionId} --name ${opts.name}`;
|
||||
: `${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(parentId)} --fork-session --session-id ${forkSessionId} --name ${opts.name}`;
|
||||
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, managed.assertCurrent)
|
||||
: await sendClassic(cmd);
|
||||
if (!textResult.ok) {
|
||||
@@ -377,8 +379,8 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
const cmd = managed ? managed.command(["--permission-mode", binding.permissionMode!, ...launchArgs, ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), ...(advisor ? ["--advisor", advisor] : []), ...additionArgs,
|
||||
...(opts.resumeToken ? ["--resume", opts.resumeToken] : ["--session-id", generatedSessionId!]), "--name", opts.name],
|
||||
opts.resumeToken ? claudeResumeLaunchEnv(opts.resumeToken) : undefined) : opts.resumeToken
|
||||
? `${claudeResumeLaunchPrefix(opts.resumeToken)}${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(opts.resumeToken)} --name ${opts.name}`
|
||||
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --session-id ${generatedSessionId} --name ${opts.name}`;
|
||||
? `${claudeResumeLaunchPrefix(opts.resumeToken)}${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(opts.resumeToken)} --name ${opts.name}`
|
||||
: `${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --session-id ${generatedSessionId} --name ${opts.name}`;
|
||||
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Selected history changed during Claude launch preparation; no native command was sent.", recovery: "attention_required" };
|
||||
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, beforeManagedInput)
|
||||
|
||||
@@ -79,6 +79,7 @@ export class ClaudeResumeAdapter {
|
||||
launchAdditions?: SeatLaunchAdditions,
|
||||
claudePermissions?: ClaudePermissionRules,
|
||||
beforeNativeDispatch?: () => boolean,
|
||||
executable?: string,
|
||||
): Promise<ResumeResult> {
|
||||
if (!this.canResume(resumeType, resumeToken)) {
|
||||
return { ok: false, code: "no_resume", message: "Claude resume not available" };
|
||||
@@ -97,7 +98,8 @@ export class ClaudeResumeAdapter {
|
||||
if (selectedPermissionMode !== undefined) {
|
||||
try {
|
||||
if (!nodeId || !this.options.claudeManagedLaunch) await unresolvedClaudePermissionModes();
|
||||
managed = await this.options.claudeManagedLaunch!.prepare({ nodeId: nodeId!, cwd, session: tmuxSessionName }, selectedPermissionMode);
|
||||
managed = await this.options.claudeManagedLaunch!.prepare({ nodeId: nodeId!, cwd, session: tmuxSessionName,
|
||||
...(executable ? { executable } : {}) }, selectedPermissionMode);
|
||||
} catch (error) { return { ok: false, code: "permission_selection_refused", message: (error as Error).message }; }
|
||||
}
|
||||
// A seat's own subscription token, as on a fresh launch (claude-code-adapter.ts).
|
||||
@@ -112,7 +114,7 @@ export class ClaudeResumeAdapter {
|
||||
});
|
||||
const permissionMode = posture + launchArgs.map(arg => ` ${shellQuote(arg)}`).join("");
|
||||
const cmd = managed ? managed.command(["--permission-mode", selectedPermissionMode!, ...launchArgs, ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), ...(advisor ? ["--advisor", advisor] : []), ...additionArgs, "--resume", resumeToken!], claudeResumeLaunchEnv(resumeToken!))
|
||||
: `${claudeResumeLaunchPrefix(resumeToken!)}${claudeClassicRendererEnvPrefix(process.env)}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(resumeToken!)}`;
|
||||
: `${claudeResumeLaunchPrefix(resumeToken!)}${claudeClassicRendererEnvPrefix(process.env)}${executable ? `exec ${shellQuote(executable)}` : "claude"} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(resumeToken!)}`;
|
||||
|
||||
const assertSelected = beforeNativeDispatch ? () => { if (!beforeNativeDispatch()) throw new Error("Selected history changed before Claude resume input"); } : undefined;
|
||||
const beforeManagedInput = beforeNativeDispatch ? () => { assertSelected?.(); managed?.assertCurrent?.(); } : managed?.assertCurrent;
|
||||
|
||||
@@ -174,7 +174,9 @@ export const OMP_PROVIDER_EXTRA_ENV_VARS: Record<string, readonly string[]> = {
|
||||
};
|
||||
|
||||
// Baseline process needs. No host credential families or shell customization.
|
||||
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "USER", "LOGNAME", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
|
||||
// SSH_AUTH_SOCK lets Pi's shell tools run git over SSH (#1159): the path isn't key material, but the socket lets the
|
||||
// agent use the keys loaded in ssh-agent.
|
||||
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "USER", "LOGNAME", "SSH_AUTH_SOCK", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
|
||||
|
||||
// NodeLauncher supplies identity and instance routing on both fresh and resumed
|
||||
// seats. Pi's shell tools inherit this child env: dropping these values makes
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ export class ClaimService {
|
||||
const input = { target, tmux: this.tmuxAdapter, listProcesses: this.listProcesses };
|
||||
try {
|
||||
if (runtime === "claude-code") return !!await verifyClaudePaneRuntime(input);
|
||||
if (runtime === "codex") return !!await verifyCodexPaneProcess(input);
|
||||
if (runtime === "codex") return !!await verifyCodexPaneProcess({ ...input, requireArgv: true });
|
||||
} catch { /* unproven */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, claudeSeatTo
|
||||
import { OUTRANKING_TOKEN_ENV, projectRootForLocalSettings, settingsFileConflicts, type CredentialConflict, type CredentialPrecedence } from "./claude-credential-precedence.js";
|
||||
|
||||
export interface ClaudeLaunchTarget {
|
||||
/** A running seat's verified executable, carried through its in-place continuation. */
|
||||
executable?: string;
|
||||
nodeId: string;
|
||||
cwd?: string;
|
||||
session?: string;
|
||||
@@ -221,7 +223,7 @@ export class ClaudeManagedLaunch {
|
||||
return this.selectedConfigDir(nodeId) ? this.configPaths(cwd, nodeId).statePath : undefined;
|
||||
}
|
||||
|
||||
private context(cwd: string, nodeId: string) {
|
||||
private context(cwd: string, nodeId: string, selectedExecutable?: string) {
|
||||
const { PATH, HOME } = this.sessionEnv;
|
||||
const CLAUDE_CONFIG_DIR = this.selectedConfigDir(nodeId);
|
||||
if (!PATH || !HOME || !path.isAbsolute(HOME)) throw new Error("Claude managed launch context is unresolved: managed PATH and absolute HOME are required.");
|
||||
@@ -235,8 +237,9 @@ export class ClaudeManagedLaunch {
|
||||
if (CLAUDE_CONFIG_DIR !== undefined) env.CLAUDE_CONFIG_DIR = configDir;
|
||||
if (claudeClassicRendererEnvPrefix(this.rendererEnv)) env.CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN = "1";
|
||||
let executable: string | undefined;
|
||||
for (const dir of search) {
|
||||
const candidate = path.join(dir, "claude");
|
||||
const candidates = selectedExecutable ? [selectedExecutable] : search.map(dir => path.join(dir, "claude"));
|
||||
for (const candidate of candidates) {
|
||||
if (!path.isAbsolute(candidate)) continue;
|
||||
try { accessSync(candidate, constants.X_OK); if (!statSync(candidate).isFile()) continue; }
|
||||
catch { continue; }
|
||||
executable = realpathSync(candidate); break;
|
||||
@@ -272,7 +275,7 @@ export class ClaudeManagedLaunch {
|
||||
|| (!staged && target.pane != null && target.pane !== before.pane)) {
|
||||
throw new Error("Claude managed launch target disagrees with the current binding; no input or selection changed.");
|
||||
}
|
||||
const context = this.context(cwd, target.nodeId);
|
||||
const context = this.context(cwd, target.nodeId, target.executable);
|
||||
// This is the existing managed session channel, not arbitrary shell variables.
|
||||
// Filter out the explicit capability environment and overwrite launch identity.
|
||||
const inherited = Object.keys(this.sessionEnv).filter(key => this.sessionEnv[key] !== undefined
|
||||
@@ -280,7 +283,7 @@ export class ClaudeManagedLaunch {
|
||||
if (inherited.some(key => !/^[A-Z_][A-Z0-9_]*$/.test(key))) throw new Error("Invalid managed environment key.");
|
||||
const assertCurrent = () => {
|
||||
if (JSON.stringify(this.target(target.nodeId, switching?.fromRuntime)) !== JSON.stringify(before)
|
||||
|| JSON.stringify(this.context(cwd, target.nodeId)) !== JSON.stringify(context)
|
||||
|| JSON.stringify(this.context(cwd, target.nodeId, target.executable)) !== JSON.stringify(context)
|
||||
|| JSON.stringify(Object.keys(this.sessionEnv).filter(key => this.sessionEnv[key] !== undefined
|
||||
&& !["PATH", "HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"].includes(key))) !== JSON.stringify(inherited)) {
|
||||
throw new Error("Claude managed launch context changed during capability discovery/input; retry explicitly.");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
import { observeCodexPaneProcess, codexCommandResumeToken, processArgv, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import { observeCodexPaneProcess, codexProcessArgv, codexProcessResumeToken, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import { lstartToMinTs } from "./codex-thread-id.js";
|
||||
import { parseSqliteUtcMs } from "./sqlite-time.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
@@ -106,13 +106,15 @@ export async function observeCodexRebuildPermissions(input: {
|
||||
return unverified("The predecessor's current occupant and pane binding could not be established.");
|
||||
}
|
||||
try {
|
||||
const probe = { target: binding.tmuxPane, tmux: input.tmux, listProcesses: input.listProcesses };
|
||||
const probe = { target: binding.tmuxPane, tmux: input.tmux, listProcesses: input.listProcesses, requireArgv: true };
|
||||
const first = await observeCodexPaneProcess(probe);
|
||||
const start = lstartToMinTs(first?.process.startedAt);
|
||||
const boot = parseSqliteUtcMs(tenure.bootAt);
|
||||
if (!first || start === undefined || !Number.isFinite(boot)) return unverified("The predecessor's native process identity or start time was not established.");
|
||||
const argv = codexProcessArgv(first.process);
|
||||
if (!argv) return unverified("The predecessor's exact native arguments could not be read.");
|
||||
const thread = await input.readThreadId(first.process.pid, first.process.startedAt!);
|
||||
const argvThread = codexCommandResumeToken(first.process.command);
|
||||
const argvThread = codexProcessResumeToken(first.process);
|
||||
if (!thread || (argvThread !== undefined && argvThread !== thread)) return unverified("The predecessor's process and native conversation could not be joined.");
|
||||
const path = input.rollout(thread);
|
||||
const record = path ? readCodexPermissionRecord(path, thread, input.cwd, Math.max(start * 1000 + 999, boot)) : null;
|
||||
@@ -124,7 +126,6 @@ export async function observeCodexRebuildPermissions(input: {
|
||||
|| current?.tmuxPane !== binding.tmuxPane || current?.tmuxSession !== binding.tmuxSession) {
|
||||
return unverified("The predecessor changed while its native permission policy was read.");
|
||||
}
|
||||
const argv = processArgv(first.process.command);
|
||||
const profileAt = argv.findIndex(arg => arg === "-p" || arg === "--profile");
|
||||
return { state: "observed", ...record, processStartedAt: first.process.startedAt!,
|
||||
profile: profileAt >= 0 ? argv[profileAt + 1] ?? null : null };
|
||||
|
||||
@@ -181,10 +181,8 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
|
||||
|
||||
/** Conservative uncertainty before a protected Codex paste, never Enter authority.
|
||||
* Codex 0.164 collapses >1000 code points to [Pasted Content N chars], which
|
||||
* the current ownership reader cannot own. Only a strictly shorter logical
|
||||
* line has an exact one-row charge. Wrapped lines use a conservative pair bound
|
||||
* over all native semantic breaks, plus an insertion row; this can retain prose
|
||||
* that fits natively, without recreating UAX#14 or weakening Enter ownership.
|
||||
* the current ownership reader cannot own. Other wrapped text gets a row
|
||||
* upper bound; a line fitting the native width needs only one row.
|
||||
*/
|
||||
export function composerPasteUncertainty(input: ComposerInput, text: string): "controls" | "long" | "viewport" | null {
|
||||
const viewport = input.pasteViewport;
|
||||
@@ -197,11 +195,9 @@ export function composerPasteUncertainty(input: ComposerInput, text: string): "c
|
||||
if (viewport.columns < 1 || viewport.rows < 1) return "viewport";
|
||||
let rows = 0;
|
||||
for (const line of lines) {
|
||||
// Two cells per non-ASCII code point conservatively covers wide glyphs.
|
||||
const cells = [...line].reduce((sum, ch) => sum + (ch >= " " && ch <= "~" ? 1 : 2), 0);
|
||||
// A native semantic fragment may move whole, even across an apparent space.
|
||||
// Bound the entire line rather than assuming character or ASCII-space breaks.
|
||||
// Equality is not the one-row fast path: a full line needs an insertion row.
|
||||
rows += cells < viewport.columns ? 1 : Math.ceil(2 * cells / viewport.columns) + 1;
|
||||
rows += cells <= viewport.columns ? 1 : Math.ceil(2 * cells / viewport.columns) + 1;
|
||||
if (rows > viewport.rows) return "viewport";
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -205,26 +205,19 @@ export function makeQueuePorts(
|
||||
opts: { loadHumanRegistry?: () => LoadResult; sharedChannels?: () => ReadonlyArray<{ address: string }> } = {},
|
||||
): InboundQueuePort & OutboundQueuePort & { listOpenAsks(filter: AlertFilterOpts): Promise<OpenAskEpisode[]> } {
|
||||
/** Each active row's current owner-notification episode, projected to its person or shared channel. Posted episodes
|
||||
* are skipped unless asked for (the alert sweep never wants them). Null when nobody is registered. */
|
||||
* are skipped unless asked for (the alert sweep never wants them). Null when nobody is registered.
|
||||
*
|
||||
* Eligibility lives in the query now: an active row must carry an owner notification, and — for the alert sweep —
|
||||
* it must still be unposted to Slack. Only eligible rows reach the per-row lookup, so a pathological backlog of
|
||||
* active rows without any notification costs a single read instead of one latest-transition and one receipt lookup
|
||||
* per row. There is no row cap: an eligible item older than any window still comes back in both views. */
|
||||
const currentEpisodes = (includePosted: boolean): Array<{ item: QueueItem; posted: boolean }> | null => {
|
||||
// Nobody registered yet isn't a broken registry: rows to a registered shared channel still post (0.7.3).
|
||||
const people = registeredPeople((opts.loadHumanRegistry ?? (() => loadHumanRegistry()))());
|
||||
if (!people) return null;
|
||||
const channels = (opts.sharedChannels ?? (() => sharedChannelAddresses()))();
|
||||
// Avoid building waiting/recovery views for rows with no unposted owner notification.
|
||||
// Keep list(activeOnly)'s selection, ordering and cap; this remains an O(N) ID scan.
|
||||
const ids = queueRepo.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items
|
||||
WHERE state IN ('pending', 'in-progress', 'blocked')
|
||||
ORDER BY CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC
|
||||
LIMIT ?`,
|
||||
).all(1000000) as Array<{ qitem_id: string }>;
|
||||
return ids.flatMap(({ qitem_id: qitemId }) => {
|
||||
const transition = queueRepo.transitionLog.latestOwnerNotificationForQitem(qitemId);
|
||||
if (!transition) return [];
|
||||
const notificationKey = `${qitemId}:${transition.transitionId}`;
|
||||
const posted = queueRepo.transitionLog.hasOwnerNotificationReceipt(qitemId, notificationKey);
|
||||
if (posted && !includePosted) return [];
|
||||
const episodes = queueRepo.transitionLog.activeOwnerNotificationEpisodes(includePosted);
|
||||
return episodes.flatMap(({ qitemId, transition, posted }) => {
|
||||
const row = queueRepo.getById(qitemId);
|
||||
if (!row) return [];
|
||||
const item = project(row, transition, people, channels);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -922,7 +922,10 @@ export class QueueRepository {
|
||||
* best-effort {@link maybeNudge} when no store is attached (P34 made that
|
||||
* fallback real code rather than a promise in a comment). */
|
||||
private outbox: OutboxHandler | undefined;
|
||||
private readonly promptBlockedSessions = new Set<string>();
|
||||
// A first clear may recover durable refusals observed before startup. Later clears need
|
||||
// a new positive refusal/attention episode, so a returning prompt cannot spin retries.
|
||||
private readonly promptRecoveryState = new Map<string, boolean>();
|
||||
private readonly promptClearRetries = new Map<string, number>();
|
||||
private resolveOccupantGeneration?: (sessionName: string) => string | null;
|
||||
private readonly resolveSessionAddresses: (name: string) => string[];
|
||||
private readonly wakeRepo: QueueWakeRepository;
|
||||
@@ -1638,11 +1641,18 @@ export class QueueRepository {
|
||||
}
|
||||
|
||||
/** One recovery pass on a positive attention-clear transition; the existing sweep remains the backstop. */
|
||||
async retryPromptWakesOnClear(sessionName: string, state: AgentActivity["state"] | undefined): Promise<void> {
|
||||
if (state === "needs_input") { this.promptBlockedSessions.add(sessionName); return; }
|
||||
if ((state !== "running" && state !== "idle") || !this.promptBlockedSessions.delete(sessionName)) return;
|
||||
async retryPromptWakesOnClear(sessionName: string, state: AgentActivity["state"] | undefined, paneNeedsInput = false): Promise<void> {
|
||||
const addresses = this.resolveSessionAddresses(sessionName);
|
||||
if (!addresses.length) return;
|
||||
// A fresh idle hook must not hide the pane's positive attention observation here.
|
||||
// Inventory keeps its own precedence; this only arms recovery, never admits a send.
|
||||
if (paneNeedsInput || state === "needs_input") {
|
||||
for (const address of addresses) this.promptRecoveryState.set(address, true);
|
||||
return;
|
||||
}
|
||||
if (state !== "running" && state !== "idle") return;
|
||||
if (addresses.every(address => this.promptRecoveryState.get(address) === false)) return;
|
||||
for (const address of addresses) this.promptRecoveryState.set(address, false);
|
||||
// Correlation may expand the group or release members for separate sends. Each claim must
|
||||
// independently qualify for this clear-triggered pass; general recovery keeps its broader policy.
|
||||
const eligible = (entry: OutboxEntry): boolean => {
|
||||
@@ -1656,15 +1666,26 @@ export class QueueRepository {
|
||||
AND (last_nudge_result LIKE 'failed:Refused:%interactive prompt%' OR last_nudge_result = 'failed:target_needs_input')`)
|
||||
.all(...addresses) as Array<{ qitem_id: string; destination_session: string; source_session: string }>;
|
||||
const attempted = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const intents = this.outbox ? this.db.prepare(`SELECT outbox_id, delivery_state FROM outbox_entries
|
||||
WHERE audit_pointer = ? AND substr(outbox_id, 1, ?) = ?`).all(row.qitem_id, WAKE_INTENT_PREFIX.length, WAKE_INTENT_PREFIX) as Array<{ outbox_id: string; delivery_state: string }> : [];
|
||||
if (intents.length) {
|
||||
// Claims, known delivery, uncertainty and deliberate holds are not a prompt-refused wake.
|
||||
for (const intent of intents) if (intent.delivery_state === "failed") await this.deliverWakeIntent(intent.outbox_id, attempted, eligible);
|
||||
} else if (this.getById(row.qitem_id)?.state === "pending") {
|
||||
// Older/non-precommitted nudges retain the existing best-effort path.
|
||||
await this.maybeNudge(row.qitem_id, row.destination_session, undefined, row.source_session);
|
||||
// Reserve the whole selection before awaiting: a correlated send can include several rows.
|
||||
// Its refusal stays durable, but must not re-arm this same clear-triggered pass.
|
||||
for (const row of rows) this.promptClearRetries.set(row.qitem_id, (this.promptClearRetries.get(row.qitem_id) ?? 0) + 1);
|
||||
try {
|
||||
for (const row of rows) {
|
||||
const intents = this.outbox ? this.db.prepare(`SELECT outbox_id, delivery_state FROM outbox_entries
|
||||
WHERE audit_pointer = ? AND substr(outbox_id, 1, ?) = ?`).all(row.qitem_id, WAKE_INTENT_PREFIX.length, WAKE_INTENT_PREFIX) as Array<{ outbox_id: string; delivery_state: string }> : [];
|
||||
if (intents.length) {
|
||||
// Claims, known delivery, uncertainty and deliberate holds are not a prompt-refused wake.
|
||||
for (const intent of intents) if (intent.delivery_state === "failed") await this.deliverWakeIntent(intent.outbox_id, attempted, eligible);
|
||||
} else if (this.getById(row.qitem_id)?.state === "pending") {
|
||||
// Older/non-precommitted nudges retain the existing best-effort path.
|
||||
await this.maybeNudge(row.qitem_id, row.destination_session, undefined, row.source_session);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const row of rows) {
|
||||
const remaining = this.promptClearRetries.get(row.qitem_id)! - 1;
|
||||
if (remaining) this.promptClearRetries.set(row.qitem_id, remaining);
|
||||
else this.promptClearRetries.delete(row.qitem_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4601,6 +4622,13 @@ export class QueueRepository {
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(ts, result, qitemId);
|
||||
// The transport's definite no-write refusal establishes the episode even when a
|
||||
// fresh hook hid needs_input. Uncertain delivery never arms clear-triggered replay.
|
||||
if (!this.promptClearRetries.has(qitemId) && (result === "failed:target_needs_input"
|
||||
|| (result.startsWith("failed:Refused:") && result.includes("interactive prompt")))) {
|
||||
const row = this.getById(qitemId);
|
||||
if (row?.state === "pending") this.promptRecoveryState.set(row.destinationSession, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ import { resolvePickupThresholdMinutes } from "./queue-pickup.js";
|
||||
|
||||
import { resolveOperatorSeat, resolveRecoveryAgentSeat, resolveSessionNodeId } from "./queue-owner.js";
|
||||
import { nodeSessionConfirmedMissing } from "./node-inventory.js";
|
||||
import { isBoundTerminalSeat } from "./terminal-seat.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type QueueItem, type QueueRepository } from "./queue-repository.js";
|
||||
import { stalledPickupFinding } from "./queue-pickup.js";
|
||||
@@ -865,21 +866,26 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// OpenRig rings, whose work is still unclaimed (its ring failed, or it didn't pick the work up by the unclaimed
|
||||
// age), goes the same way; one that claimed and stalled keeps its own finding, which rings it. Only a person can
|
||||
// make a hosted member check its work, so the person goes first: the operator seat when it's a person's address,
|
||||
// else the row's creator, else the operator agent, skipping any that is unset, this seat, confirmed missing or
|
||||
// unroutable itself.
|
||||
// else the row's creator, else the operator agent, skipping any that is unset, this seat, confirmed missing,
|
||||
// unroutable or a terminal seat itself. A bound terminal seat goes the same way: the queue wake types nothing into
|
||||
// it, and nothing there can claim work.
|
||||
const classOf = (seat: string) => deps.queueRepo.classifyDestinationOf(seat).class;
|
||||
for (const c of candidates) {
|
||||
if (c.route !== c.row.destinationSession) continue;
|
||||
const destination = classOf(c.row.destinationSession);
|
||||
const noRoute = destination === "unroutable" && !!c.row.lastNudgeResult?.startsWith("unroutable:");
|
||||
if (!noRoute && !(destination === "hosted" && !c.row.claimedAt)) continue;
|
||||
const terminalSeat = destination === "pane-bound" && isBoundTerminalSeat(deps.db, c.row.destinationSession);
|
||||
if (!noRoute && !terminalSeat && !(destination === "hosted" && !c.row.claimedAt)) continue;
|
||||
const operatorSeat = (deps.resolveOperatorSeat ?? resolveOperatorSeat)();
|
||||
const person = operatorSeat && classOf(operatorSeat) === "gateway-routable" ? operatorSeat : null;
|
||||
const choices = [person, c.row.sourceSession, (deps.resolveRecoveryAgentSeat ?? resolveRecoveryAgentSeat)()];
|
||||
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !sessionMissing(seat) && classOf(seat) !== "unroutable");
|
||||
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !sessionMissing(seat) && classOf(seat) !== "unroutable"
|
||||
&& !isBoundTerminalSeat(deps.db, seat));
|
||||
c.why += noRoute
|
||||
? `; ${c.route} has no wake route on this daemon (no terminal, not a person). If it's a hosted member, it sees this work only when it next checks, so ask it to; otherwise fix the address`
|
||||
: `; ${c.route} is a hosted member. OpenRig rang its trigger (${c.row.lastNudgeResult ?? "no ring recorded"}), and it hasn't claimed this work. It sees work only when it checks, so ask it to`;
|
||||
: terminalSeat
|
||||
? `; ${c.route} is a terminal seat; nothing there can claim this work, and no wake is typed into it. Route the work to an agent seat`
|
||||
: `; ${c.route} is a hosted member. OpenRig rang its trigger (${c.row.lastNudgeResult ?? "no ring recorded"}), and it hasn't claimed this work. It sees work only when it checks, so ask it to`;
|
||||
if (chosen < 0) {
|
||||
c.why += `. No person, creator or operator agent that can be reached is available, so this finding stays addressed to it`;
|
||||
continue;
|
||||
|
||||
@@ -458,6 +458,62 @@ export class QueueTransitionLog {
|
||||
return this.getOwnerNotificationReceipt(qitemId, notificationKey) !== null;
|
||||
}
|
||||
|
||||
/** The latest owner-notification episode per ACTIVE queue row, in queue order. One query
|
||||
* joins every eligibility rule into the selection — the row is active, it carries an owner
|
||||
* notification, and (when `includePosted` is false, the alert sweep's path) no posted
|
||||
* receipt exists for that notification's key — so rows that cannot matter never reach a
|
||||
* per-row lookup. There is no row cap: an eligible item older than any window still comes
|
||||
* back, and `posted` reports the receipt state for callers that list both.
|
||||
*
|
||||
* The eligible set is restricted to active queue items before any ranking happens, so a
|
||||
* large notification archive never adds scan or ranking work to a sweep with no active item. */
|
||||
activeOwnerNotificationEpisodes(includePosted: boolean): Array<{ qitemId: string; transition: QueueTransition; posted: boolean }> {
|
||||
if (!this.hasOwnerNotificationColumns) return [];
|
||||
// Exact token match with no wildcard risk: pad both ends so a key at the note's
|
||||
// start or end still matches, and instr() compares literally (qitem ids may
|
||||
// contain characters LIKE would treat as wildcards).
|
||||
const receipt = (qitemIdExpr: string, transitionIdExpr: string) =>
|
||||
`EXISTS (
|
||||
SELECT 1 FROM ${this.historySource} r
|
||||
WHERE r.qitem_id = ${qitemIdExpr}
|
||||
AND r.transition_note LIKE 'slack-owner-notification-posted %'
|
||||
AND instr(' ' || r.transition_note || ' ', ' notification_key=' || ${qitemIdExpr} || ':' || ${transitionIdExpr} || ' ') > 0
|
||||
)`;
|
||||
const postedFilter = includePosted ? "" : `AND NOT ${receipt("latest.qitem_id", "latest.transition_id")}`;
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`WITH active_ids AS (
|
||||
SELECT qitem_id FROM queue_items WHERE state IN ('pending', 'in-progress', 'blocked')
|
||||
),
|
||||
active AS (
|
||||
SELECT qitem_id, ts_created FROM queue_items WHERE state IN ('pending', 'in-progress', 'blocked')
|
||||
),
|
||||
history_active AS (
|
||||
SELECT h.* FROM ${this.historySource} h
|
||||
WHERE h.qitem_id IN (SELECT qitem_id FROM active_ids)
|
||||
AND h.owner_notification_level IS NOT NULL
|
||||
),
|
||||
ranked AS (
|
||||
SELECT h.*, ROW_NUMBER() OVER (PARTITION BY h.qitem_id ORDER BY h.transition_id DESC) AS rn
|
||||
FROM history_active h
|
||||
),
|
||||
latest AS (
|
||||
SELECT * FROM ranked WHERE rn = 1
|
||||
)
|
||||
SELECT latest.*, ${receipt("latest.qitem_id", "latest.transition_id")} AS posted
|
||||
FROM active
|
||||
JOIN latest ON latest.qitem_id = active.qitem_id
|
||||
${postedFilter}
|
||||
ORDER BY active.ts_created DESC`,
|
||||
)
|
||||
.all() as Array<QueueTransitionRow & { posted: 0 | 1 }>;
|
||||
return rows.map((row) => ({
|
||||
qitemId: row.qitem_id,
|
||||
transition: this.rowToTransition(row),
|
||||
posted: row.posted === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
getOwnerNotificationReceipt(qitemId: string, notificationKey: string): { messageTs: string | null; threadTs: string | null; digest: string | null } | null {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { resolveEscalationOwner, ESCALATION_HOP_TAG, hasConsumedEscalationHop } from "./escalation-owner.js";
|
||||
import type { ArbitratedSeatState } from "./activity-taxonomy.js";
|
||||
import { seatRuntimeHomeBySession } from "./seat-runtime-home.js";
|
||||
import { OPENRIG_SENDER } from "../lib/pane-envelope.js";
|
||||
import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
|
||||
import { quietHoldCustody } from "./quiet-hold-custody.js";
|
||||
import { latestHookMovedOn, latestHookPermissionPending, latestHookWaitsOnPerson } from "./agent-activity-store.js";
|
||||
import type { AgentActivity } from "./types.js";
|
||||
@@ -640,7 +642,10 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
deps.attemptWake ??
|
||||
(async (qitemId: string, target: string): Promise<string> => {
|
||||
// A ladder retry is OpenRig's own scheduling on the row, whoever created it.
|
||||
await deps.queueRepo.maybeNudge(qitemId, target, true, undefined, undefined, "openrig");
|
||||
// It is sent as OpenRig (daemon@kernel); the row's original source stays visible in the body.
|
||||
const row = deps.queueRepo.getById(qitemId);
|
||||
const bodyOverride = `${renderQueueHandoffNudge(qitemId, null)}${row?.sourceSession ? ` Row source: ${row.sourceSession}.` : ""}`;
|
||||
await deps.queueRepo.maybeNudge(qitemId, target, true, OPENRIG_SENDER, bodyOverride, "openrig");
|
||||
return deps.queueRepo.getById(qitemId)?.lastNudgeWireResult ?? "indeterminate:no transport available";
|
||||
});
|
||||
|
||||
@@ -842,7 +847,7 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
if (!due || suspended) continue;
|
||||
const consumedReason = `${reason}; escalates_to one-hop delivery already used; existing fallback retained`;
|
||||
// Keep this recovery visible without sharing the ordinary destination aggregate or forwarding another hop.
|
||||
await ensureEscalationRow(deps, row.destinationSession, resolveOperatorSeat() ?? row.sourceSession,
|
||||
await ensureEscalationRow(deps, row.destinationSession, humanAlertCarrier(deps, row.destinationSession, row.sourceSession),
|
||||
[{ row, reason: consumedReason }], consumedReason, `consumed-hop:${row.qitemId}`, [ESCALATION_HOP_TAG]);
|
||||
if (!view.orchRung) appendMarker(deps.queueRepo, row, `${LADDER_RUNG_PREFIX} orchestrator self-skip reason=${consumedReason}`);
|
||||
if (await operatorRung(deps, row, consumedReason, actions, view)) {
|
||||
@@ -878,10 +883,10 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
// F4: rung 1 self-skips when it resolves to the destination itself (or nowhere)
|
||||
// — never escalate INTO the dead seat; fall through to the operator rung now.
|
||||
// The operator floor must be a VISIBLE OBJECT, not markers alone: ensure the
|
||||
// per-destination escalation row exists (addressed to the operator seat, else
|
||||
// the obligation's own creator) so the escalations view and the health count
|
||||
// per-destination escalation row has an admissible carrier so the
|
||||
// escalations view and the health count
|
||||
// expose it — it stays open past the batons' exhaustion.
|
||||
const floorDest = resolveOperatorSeat() ?? needsOrchRung[0]!.row.sourceSession;
|
||||
const floorDest = humanAlertCarrier(deps, dest, needsOrchRung[0]!.row.sourceSession);
|
||||
await ensureEscalationRow(deps, dest, floorDest, needsOrchRung, reason);
|
||||
for (const m of needsOrchRung) {
|
||||
appendMarker(
|
||||
@@ -1073,6 +1078,13 @@ function waitsOnPostedPromptAlert(deps: WakeLadderDeps, row: QueueItem, choiceOn
|
||||
return true;
|
||||
}
|
||||
|
||||
// The operator engine owns actual human delivery. Its non-nudged queue carrier
|
||||
// must still be locally admissible; cross-host source provenance is not a local destination.
|
||||
function humanAlertCarrier(deps: WakeLadderDeps, dest: string, source?: string): string {
|
||||
const candidates = [resolveOperatorSeat(), source];
|
||||
return candidates.find((candidate): candidate is string => Boolean(candidate && deps.queueRepo.acceptsDestination(candidate))) ?? dest;
|
||||
}
|
||||
|
||||
/** Positive refusals enter the existing rungs without ever retrying the blocked
|
||||
* seat. One aggregate carries the episode's obligations and notification key;
|
||||
* original work remains owned by its current recipient. */
|
||||
@@ -1282,7 +1294,7 @@ async function advancePromptRefusals(
|
||||
const choiceOnly = Boolean(group.choice && !group.other);
|
||||
const promptTags = choiceOnly ? [PROMPT_ALERT_TAG, PROMPT_CHOICE_TAG, `consumed-prompt-destination:${dest}`,
|
||||
...(group.rowKeys.get(member.row.qitemId) ?? [])] : [];
|
||||
const continuation = await ensureEscalationRow(deps, dest, resolveOperatorSeat() ?? member.row.sourceSession,
|
||||
const continuation = await ensureEscalationRow(deps, dest, humanAlertCarrier(deps, dest, member.row.sourceSession),
|
||||
[{ row: member.row, reason }], reason, `consumed-hop:${member.row.qitemId}`, [ESCALATION_HOP_TAG, ...promptTags]);
|
||||
if (!view.orchRung) appendMarker(deps.queueRepo, member.row, `${LADDER_RUNG_PREFIX} orchestrator self-skip reason=${reason}`);
|
||||
// A choice alert's tags and dispatch marker must live on the same durable row the shared gate reads.
|
||||
@@ -1322,7 +1334,7 @@ async function advancePromptRefusals(
|
||||
const kindTags = choiceOnly ? [PROMPT_CHOICE_TAG] : [];
|
||||
const keys = group.keys.filter(key => group.jobKeys.has(key) || members.some(m => group.rowKeys.get(m.row.qitemId)?.has(key)));
|
||||
const aggregate = await ensureEscalationRow(deps, dest,
|
||||
toOperator ? resolveOperatorSeat() ?? members[0]?.row.sourceSession ?? [...group.jobs.values()][0]! : orch!, members, reason,
|
||||
toOperator ? humanAlertCarrier(deps, dest, members[0]?.row.sourceSession ?? [...group.jobs.values()][0]) : orch!, members, reason,
|
||||
`prompt-destination:${dest}`, [PROMPT_ALERT_TAG, ...kindTags, ...keys], group.jobs);
|
||||
const row = deps.queueRepo.getById(aggregate.qitemId);
|
||||
if (!row) continue;
|
||||
|
||||
@@ -6,8 +6,8 @@ import { assessSnapshotCurrentOccupants, stoppedSnapshotReason } from "./rehydra
|
||||
import { nonInterruptiveNotice, nonInterruptiveSummary } from "../adapters/non-interruptive.js";
|
||||
import { planModelAliasNotice } from "../adapters/yolo-mode.js";
|
||||
import { effortPrecedence, readSeatEffort } from "./seat-effort.js";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { ensureSeatSkillPlugin, hasSeatSkillRecords, rehydrateSeatSkills, seatSkillPaths, seatSkillPathsForNode, seatCatalogKeptNote, seatSkillReplay, writeSeatSkillCatalog, type SeatSkillRehydration, writeSeatSkillRouter, seatRouterKeptNote } from "./seat-skill-storage.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { NativePermissionStore } from "./native-permission-store.js";
|
||||
@@ -29,7 +29,8 @@ import type { OmpResumeAdapter } from "../adapters/omp-resume.js";
|
||||
import type { TranscriptStore } from "./transcript-store.js";
|
||||
import { assessNativeResumeProbe } from "./native-resume-probe.js";
|
||||
import { resumeProbeEvidence, type NativeResumeProbeEvidence } from "./native-resume-probe-evidence.js";
|
||||
import { observeClaudeDelivery, observeClaudeResumeLaunch, verifyClaudePaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess } from "./native-process-lineage.js";
|
||||
import { observeClaudeDelivery, observeClaudeResumeLaunch, processArgv, verifyClaudePaneProcess, verifyClaudePaneRuntime, verifyPaneIdleShell, type ClaudeLaunchedProcess } from "./native-process-lineage.js";
|
||||
import { readNativeExecutablePaths } from "./native-process-executable.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
|
||||
import { applySeatSkillsLineRule, seatGuidance, restoreMissingGuidance, missingGuidanceBlocks } from "./restore-guidance.js";
|
||||
import { readFootprintAnswer } from "./footprint-choice.js";
|
||||
@@ -1200,6 +1201,8 @@ export class RestoreOrchestrator {
|
||||
if (opts?.keepPane && (!keptPane || !priorState.binding?.tmuxSession)) {
|
||||
return { nodeId, logicalId: node.logicalId, status: "failed", error: `${node.logicalId} has no bound tmux pane to keep, so nothing was stopped.` };
|
||||
}
|
||||
// Read before takeover ends the process. An unavailable witness keeps normal launch resolution unchanged.
|
||||
const claudeExecutable = keptPane && node.runtime === "claude-code" ? await this.claudeContinuationExecutable(keptPane) : undefined;
|
||||
|
||||
// Clear stale state so NodeLauncher doesn't see already_bound. A kept pane keeps its binding through the takeover: the
|
||||
// delivery guard resolves the pane through it.
|
||||
@@ -1346,11 +1349,26 @@ export class RestoreOrchestrator {
|
||||
warnings?.push(...launchResult.warnings);
|
||||
}
|
||||
|
||||
const restored = await this.postLaunchRestore(entry, rigId, data, launchResult.sessionName, launchResult, opts, warnings, priorState, seatRehydration, resumeSession);
|
||||
const restored = await this.postLaunchRestore(entry, rigId, data, launchResult.sessionName, launchResult,
|
||||
{ ...opts, ...(claudeExecutable ? { claudeExecutable } : {}) }, warnings, priorState, seatRehydration, resumeSession);
|
||||
const checked = this.checkRestoredGuidance(restored, entry.node, rigId, data, warnings);
|
||||
return selectedHistory ? { ...checked, sourceSessionId: selectedHistory.historySessionId, resultSessionId: launchResult.session.id } : checked;
|
||||
}
|
||||
|
||||
private async claudeContinuationExecutable(pane: string): Promise<string | undefined> {
|
||||
const native = await verifyClaudePaneRuntime({ target: pane, tmux: this.tmuxAdapter,
|
||||
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}) });
|
||||
if (!native) return undefined;
|
||||
const argv0 = processArgv(native.process.command)[0];
|
||||
const executable = argv0 && isAbsolute(argv0) ? argv0 : native.process.executablePath
|
||||
?? (await readNativeExecutablePaths([native.process.pid])).get(native.process.pid);
|
||||
try {
|
||||
if (!executable || !isAbsolute(executable)) return undefined;
|
||||
accessSync(executable, constants.X_OK);
|
||||
return statSync(executable).isFile() ? realpathSync(executable) : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
/** A seat that came back running should also have its role, culture and SOP guidance. If its
|
||||
* guidance file still lacks a managed block it should hold, the seat keeps its true status (a
|
||||
* live resumed session is still running) and the gap is disclosed on the node and in the
|
||||
@@ -1447,7 +1465,7 @@ export class RestoreOrchestrator {
|
||||
data: SnapshotData,
|
||||
sessionName: string,
|
||||
launchResult?: { ok: true; sessionName: string; session: import("./types.js").Session; binding: import("./types.js").Binding },
|
||||
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; selectedHistory?: SelectedRestoreHistory },
|
||||
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; selectedHistory?: SelectedRestoreHistory; claudeExecutable?: string },
|
||||
warnings?: string[],
|
||||
priorState?: { binding: import("./types.js").Binding | null; sessions: { id: string; status: string }[] },
|
||||
seatRehydration?: SeatSkillRehydration,
|
||||
@@ -1471,6 +1489,17 @@ export class RestoreOrchestrator {
|
||||
}
|
||||
return restoreEffort.effort;
|
||||
};
|
||||
// The model `rig seat set-model` saved after the snapshot is the newer choice, and it says so when it differs.
|
||||
let restoreModel: string | null | undefined;
|
||||
const modelForRestore = (): string | null => {
|
||||
if (restoreModel === undefined) {
|
||||
restoreModel = this.liveModel(rigId, node, selected?.nodeId);
|
||||
if (node.model && restoreModel !== node.model) {
|
||||
warnings?.push(`Restore: ${node.logicalId}: this restore launched the seat's saved model ${restoreModel}, not ${node.model} from the snapshot.`);
|
||||
}
|
||||
}
|
||||
return restoreModel;
|
||||
};
|
||||
// OPR.0.5.7.1 D1 — the active occupant is RESOLVED, never inferred from
|
||||
// row ordering (cite site 2 of 2; "latest = max id" was the incident's
|
||||
// defect: a superseded row with a newer ULID defeated the real occupant).
|
||||
@@ -1597,8 +1626,8 @@ export class RestoreOrchestrator {
|
||||
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
|
||||
}
|
||||
if (selected && !selected.beforeNative(launchedSessionId, launchResult?.binding.id)) return { nodeId: node.id, logicalId: node.logicalId, status: "attention_required", error: "The selected seat, settings or history changed after shell creation; native resume was not dispatched." };
|
||||
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", this.liveCodexProfile(rigId, node, selected?.nodeId), node.model, this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
|
||||
selected ? () => selected.beforeNative(launchedSessionId, launchResult?.binding.id) : undefined);
|
||||
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", this.liveCodexProfile(rigId, node, selected?.nodeId), modelForRestore(), this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
|
||||
selected ? () => selected.beforeNative(launchedSessionId, launchResult?.binding.id) : undefined, opts?.claudeExecutable);
|
||||
if (resumeOutcome.kind === "resumed") {
|
||||
baseStatus = "resumed";
|
||||
if (launchedSessionId && this.claudeResume.canResume(resumeType, resumeToken)) {
|
||||
@@ -1819,6 +1848,7 @@ export class RestoreOrchestrator {
|
||||
|
||||
const binding = {
|
||||
...launchResult.binding,
|
||||
...(opts?.claudeExecutable ? { claudeExecutable: opts.claudeExecutable } : {}),
|
||||
cwd: node.cwd ?? ".",
|
||||
codexConfigProfile: this.liveCodexProfile(rigId, node, selected?.nodeId) ?? undefined,
|
||||
// OPR.0.4.8.3 Seam B: the pod-aware restore path binds the restored posture too
|
||||
@@ -1830,7 +1860,7 @@ export class RestoreOrchestrator {
|
||||
// the declared provider) passed nothing through ("No API key
|
||||
// found" on every resumed Pi seat). Claude/Codex silently lost
|
||||
// their -m/--model on restore the same way.
|
||||
model: node.model ?? undefined,
|
||||
model: modelForRestore() ?? undefined,
|
||||
effort: effortForRestore() ?? undefined,
|
||||
advisor: node.advisor ?? undefined,
|
||||
};
|
||||
@@ -2141,6 +2171,18 @@ export class RestoreOrchestrator {
|
||||
return "floor";
|
||||
}
|
||||
|
||||
/** The live seat's current model: `rig seat set-model` saves it on the live node after the snapshot, and every
|
||||
* managed resume reads it at call time, so an older snapshot can't bring back the model it replaced. Found by logical
|
||||
* id, as effort's restore does; a seat with no live row, or no model on it, keeps the snapshot's. */
|
||||
private liveModel(rigId: string, node: { logicalId: string; model?: string | null }, selectedNodeId?: string): string | null {
|
||||
const row = this.db.prepare(selectedNodeId
|
||||
? "SELECT model FROM nodes WHERE id = ? AND rig_id = ? AND logical_id = ?"
|
||||
: "SELECT model FROM nodes WHERE rig_id = ? AND logical_id = ?")
|
||||
.get(...(selectedNodeId ? [selectedNodeId, rigId, node.logicalId] : [rigId, node.logicalId])) as
|
||||
{ model: string | null } | undefined;
|
||||
return row?.model ?? node.model ?? null;
|
||||
}
|
||||
|
||||
/** The live seat's current Codex profile: a setter change made after the snapshot is the newer choice, so an older
|
||||
* snapshot can't bring back the profile it replaced. The seat is found by logical id, as effort's restore does; a
|
||||
* seat with no live row keeps the snapshot's value. */
|
||||
@@ -2168,6 +2210,7 @@ export class RestoreOrchestrator {
|
||||
warnings?: string[],
|
||||
advisor?: string | null,
|
||||
beforeNative?: () => boolean,
|
||||
claudeExecutable?: string,
|
||||
): Promise<
|
||||
| { kind: "resumed"; launchedProcess?: ClaudeLaunchedProcess }
|
||||
| { kind: "retry_fresh" }
|
||||
@@ -2207,10 +2250,11 @@ export class RestoreOrchestrator {
|
||||
const tail: unknown[] = claudePermissions !== undefined
|
||||
? [...claudeTail, ...Array(5 - claudeTail.length).fill(undefined), launch.additions, claudePermissions]
|
||||
: launch.additions ? [...claudeTail, ...Array(5 - claudeTail.length).fill(undefined), launch.additions] : claudeTail;
|
||||
const guardedTail = beforeNative ? [...tail, ...Array(Math.max(0, 7 - tail.length)).fill(undefined), beforeNative] : tail;
|
||||
const guardedTail = beforeNative || claudeExecutable ? [...tail, ...Array(Math.max(0, 7 - tail.length)).fill(undefined), beforeNative,
|
||||
...(claudeExecutable ? [claudeExecutable] : [])] : tail;
|
||||
if (beforeNative && !beforeNative()) return { kind: "attention_required", message: "The selected seat, settings or history changed during native preparation; resume was not dispatched." };
|
||||
const result = await this.claudeResume.resume(sessionName, resumeType, resumeToken, cwd, resolvedPosture, model, permissionMode, nodeId,
|
||||
...(guardedTail as [...typeof launchTail, advisor?: string, launchAdditions?: SeatLaunchAdditions, claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules, beforeNativeDispatch?: () => boolean]));
|
||||
...(guardedTail as [...typeof launchTail, advisor?: string, launchAdditions?: SeatLaunchAdditions, claudePermissions?: import("./claude-permission-rules.js").ClaudePermissionRules, beforeNativeDispatch?: () => boolean, executable?: string]));
|
||||
if (result.ok) {
|
||||
// OPR.0.7.0.9: the resumed conversation's own start, as on the pod-aware path.
|
||||
const promptNote = launch.additions?.appendSystemPromptFile && nodeId && resumeToken ? systemPromptPendingNoteForNode(this.db, nodeId, resumeToken) : undefined;
|
||||
|
||||
@@ -155,6 +155,7 @@ export class RigExpansionService {
|
||||
: {}),
|
||||
...(member.cwd ? { cwd: member.cwd } : {}),
|
||||
...(member.model ? { model: member.model } : {}),
|
||||
...(member.effort === undefined ? {} : { effort: member.effort }),
|
||||
// OPR.0.4.6.FAC1: role rides the fragment→spec map (a
|
||||
// provided role must never be silently dropped here).
|
||||
...(member.role ? { role: member.role } : {}),
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { ClaudeResumeRotation } from "./native-process-lineage.js";
|
||||
|
||||
export interface NodeBinding extends Binding {
|
||||
cwd: string;
|
||||
/** In-place Claude continuation only: executable observed before the old process exits. */
|
||||
claudeExecutable?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
/** Claude-only, session-scoped Advisor model. */
|
||||
|
||||
@@ -188,7 +188,9 @@ export class SeatActivityService {
|
||||
let updated = false;
|
||||
for (const [id, request] of snapshot) {
|
||||
if (seat.permissions.get(id) !== request) continue;
|
||||
request.seen ||= visible === true;
|
||||
// A screen does not identify which Claude actor owns a dialog. With overlapping actors, only a
|
||||
// scoped batch or turn boundary can settle their unseen notifications.
|
||||
request.seen ||= visible === true && (!claude || seat.permissions.size === 1);
|
||||
request.confirmed = visible !== false || (claude && !request.seen);
|
||||
request.nextCheckMs = this.now().getTime()
|
||||
+ (visible === false && this.now().getTime() - request.sinceMs >= 10_000 ? 5_000 : DEFAULT_POLL_INTERVAL_MS);
|
||||
@@ -196,25 +198,62 @@ export class SeatActivityService {
|
||||
updated = true;
|
||||
}
|
||||
if (!updated) return;
|
||||
seat.permissionObservedAt = this.now().toISOString();
|
||||
const count = [...seat.permissions.values()].filter(request => request.confirmed).length;
|
||||
if (claude && count === 0) {
|
||||
for (const source of seat.sources.values()) {
|
||||
if (source.latest.permissionRequest) source.latest = { ...source.latest, needsInput: undefined };
|
||||
}
|
||||
}
|
||||
const sourceId = `${runtime}:permission-prompt`;
|
||||
this.reportEvidence({
|
||||
seatNodeId: seatNodeId!, sessionName, rung: "needs-input-chrome", sourceId,
|
||||
seq: (seat.sources.get(sourceId)?.latest.seq ?? 0) + 1,
|
||||
observedAt: seat.permissionObservedAt,
|
||||
needsInput: { count, reason: count ? (!claude && visible === null ? "permission_request_unverified" : "permission_prompt") : null },
|
||||
});
|
||||
this.reportPermissionObservation(seatNodeId!, seat, "needs-input-chrome", this.now().toISOString(),
|
||||
!claude && visible === null ? "permission_request_unverified" : "permission_prompt");
|
||||
} finally {
|
||||
seat.permissionPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A native Claude PostToolBatch resolves only this actor's earlier permission notifications. The route checks
|
||||
* the emitting occupant first. Missing actor attribution and unordered timestamps retain the pane/turn fallback. */
|
||||
completeClaudeToolBatch(sessionName: string, hookAgentId: string | null, observedAt: string): boolean {
|
||||
const seatNodeId = this.sessionToSeat.get(sessionName);
|
||||
const seat = seatNodeId ? this.ladder.get(seatNodeId) : undefined;
|
||||
if (!seat || seat.retired || seat.inventory?.runtime !== "claude-code") return false;
|
||||
const completedAt = Date.parse(observedAt);
|
||||
if (!Number.isFinite(completedAt)) return false;
|
||||
// A notification may arrive after its completion, including when no request was observed yet.
|
||||
seat.claudeToolBatches.set(hookAgentId, Math.max(completedAt, seat.claudeToolBatches.get(hookAgentId) ?? -Infinity));
|
||||
let settled = false;
|
||||
for (const [id, request] of seat.permissions) {
|
||||
if (request.hookAgentId === undefined || request.hookAgentId !== hookAgentId
|
||||
|| !(Date.parse(request.observedAt) < completedAt)) continue;
|
||||
seat.permissions.delete(id);
|
||||
settled = true;
|
||||
}
|
||||
if (!settled) return false;
|
||||
seat.permissionRevision++;
|
||||
this.reportPermissionObservation(seatNodeId!, seat, "lifecycle-hooks", observedAt, "permission_prompt");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether this occupant's actor already completed a strictly later batch. Equal timestamps do not establish order. */
|
||||
hasLaterClaudeToolBatch(sessionName: string, hookAgentId: string | null | undefined, observedAt: string): boolean {
|
||||
if (hookAgentId === undefined) return false;
|
||||
const seatNodeId = this.sessionToSeat.get(sessionName);
|
||||
const seat = seatNodeId ? this.ladder.get(seatNodeId) : undefined;
|
||||
if (!seat || seat.retired || seat.inventory?.runtime !== "claude-code") return false;
|
||||
const completedAt = seat.claudeToolBatches.get(hookAgentId);
|
||||
return completedAt !== undefined && Date.parse(observedAt) < completedAt;
|
||||
}
|
||||
|
||||
private reportPermissionObservation(seatNodeId: string, seat: SeatLadderState, rung: EvidenceRungId,
|
||||
observedAt: string, reason: string): void {
|
||||
const runtime = seat.inventory?.runtime ?? "codex";
|
||||
seat.permissionEvidence = { rung, observedAt };
|
||||
const count = [...seat.permissions.values()].filter(request => request.confirmed).length;
|
||||
if (runtime === "claude-code" && count === 0) {
|
||||
for (const source of seat.sources.values()) {
|
||||
if (source.latest.permissionRequest) source.latest = { ...source.latest, needsInput: undefined };
|
||||
}
|
||||
}
|
||||
const sourceId = `${runtime}:permission-prompt`;
|
||||
this.reportEvidence({ seatNodeId, sessionName: seat.sessionName, rung, sourceId,
|
||||
seq: (seat.sources.get(sourceId)?.latest.seq ?? 0) + 1, observedAt,
|
||||
needsInput: { count, reason: count ? reason : null } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the latest stored observation for a seat, or null when no
|
||||
* observation has been recorded yet (e.g. service hasn't polled this
|
||||
@@ -237,7 +276,8 @@ export class SeatActivityService {
|
||||
seat.retired = true;
|
||||
seat.sources.clear();
|
||||
seat.permissions.clear();
|
||||
seat.permissionObservedAt = null;
|
||||
seat.claudeToolBatches.clear();
|
||||
seat.permissionEvidence = null;
|
||||
seat.pendingIdle = null;
|
||||
seat.contradictionSinceMs = null;
|
||||
seat.promotion.clear();
|
||||
@@ -387,16 +427,21 @@ export class SeatActivityService {
|
||||
const claude = runtime === "claude-code";
|
||||
const wasConfirmed = seat.arbitrated.needsInput.count > 0;
|
||||
if (evidence.permissionRequest) {
|
||||
seat.permissionRevision++;
|
||||
const request = evidence.permissionRequest;
|
||||
if (claude && this.hasLaterClaudeToolBatch(evidence.sessionName, request.hookAgentId, evidence.observedAt)) return;
|
||||
const previousRequest = seat.permissions.get(request.id);
|
||||
// Relay posts can arrive out of order. Keep the newer actor request so an older batch cannot settle it.
|
||||
if (claude && previousRequest && Date.parse(evidence.observedAt) < Date.parse(previousRequest.observedAt)) return;
|
||||
seat.permissionRevision++;
|
||||
if (claude || !seat.permissions.has(request.id)) {
|
||||
seat.permissions.set(request.id, { sinceMs: this.now().getTime(), confirmed: claude, seen: false, nextCheckMs: 0 });
|
||||
seat.permissions.set(request.id, { sinceMs: this.now().getTime(), observedAt: evidence.observedAt,
|
||||
hookAgentId: request.hookAgentId, confirmed: claude, seen: false, nextCheckMs: 0 });
|
||||
}
|
||||
} else if ((evidence.rung === "lifecycle-hooks" && (evidence.activity === "idle-at-prompt" || evidence.permissionResolved
|
||||
|| (claude && evidence.needsInput?.count && evidence.needsInput.reason !== "permission_prompt")))
|
||||
|| (claude && seat.permissions.size === 0 && evidence.needsInput?.count && evidence.needsInput.reason !== "permission_prompt")) {
|
||||
seat.permissions.clear();
|
||||
seat.permissionObservedAt = null;
|
||||
seat.permissionEvidence = null;
|
||||
seat.sources.delete(`${runtime}:permission-prompt`);
|
||||
}
|
||||
seat.sources.set(evidence.sourceId, {
|
||||
@@ -423,7 +468,7 @@ export class SeatActivityService {
|
||||
const seat = seatNodeId ? this.ladder.get(seatNodeId) : undefined;
|
||||
if (!seat || seat.retired) return;
|
||||
seat.permissions.clear();
|
||||
seat.permissionObservedAt = null;
|
||||
seat.permissionEvidence = null;
|
||||
seat.sources.delete(`${seat.inventory?.runtime ?? "codex"}:permission-prompt`);
|
||||
for (const source of seat.sources.values()) {
|
||||
if (source.latest.needsInput?.count) source.latest = { ...source.latest, needsInput: undefined };
|
||||
@@ -440,7 +485,8 @@ export class SeatActivityService {
|
||||
if (!seat) return;
|
||||
seat.sources.clear();
|
||||
seat.permissions.clear();
|
||||
seat.permissionObservedAt = null;
|
||||
seat.claudeToolBatches.clear();
|
||||
seat.permissionEvidence = null;
|
||||
seat.pendingIdle = null;
|
||||
seat.contradictionSinceMs = null;
|
||||
seat.promotion.clear();
|
||||
@@ -523,9 +569,10 @@ export class SeatActivityService {
|
||||
inventory: null,
|
||||
sources: new Map(),
|
||||
permissions: new Map(),
|
||||
claudeToolBatches: new Map(),
|
||||
permissionRevision: 0,
|
||||
permissionPolling: false,
|
||||
permissionObservedAt: null,
|
||||
permissionEvidence: null,
|
||||
trust: new Map(),
|
||||
promotion: new Map(),
|
||||
contradictionSinceMs: null,
|
||||
@@ -760,7 +807,7 @@ export class SeatActivityService {
|
||||
}
|
||||
|
||||
// Codex permission hooks need probe confirmation after grace; Claude hooks latch
|
||||
// attention until a seen prompt disappears. Unavailable capture fails safe.
|
||||
// attention until a seen prompt disappears or the same actor's tool batch completes. Unavailable capture fails safe.
|
||||
// Other prompts retain their existing rung arbitration.
|
||||
const chrome = this.latestByRung(seat, "needs-input-chrome");
|
||||
const hooksEv = this.latestByRung(seat, "lifecycle-hooks");
|
||||
@@ -775,14 +822,14 @@ export class SeatActivityService {
|
||||
: null;
|
||||
const confirmedCount = [...seat.permissions.values()].filter(p => p.confirmed).length;
|
||||
const permissionTracked = (seat.inventory?.runtime === "codex" || seat.inventory?.runtime === "claude-code")
|
||||
&& (seat.permissions.size > 0 || seat.permissionObservedAt !== null);
|
||||
&& (seat.permissions.size > 0 || seat.permissionEvidence !== null);
|
||||
const needsInput: NeedsInputShape = permissionTracked
|
||||
? { count: confirmedCount, reason: confirmedCount ? chrome?.needsInput?.reason ?? "permission_prompt" : null }
|
||||
: decider?.needsInput ?? { count: 0, reason: null };
|
||||
if (permissionTracked && Boolean(confirmedCount) !== Boolean(seat.arbitrated.needsInput.count)) {
|
||||
this.permissionPromptChanged?.(seat.sessionName, Boolean(confirmedCount), seat.inventory!.runtime);
|
||||
}
|
||||
const needsInputEvidence = permissionTracked ? (seat.permissionObservedAt ? { rung: "needs-input-chrome" as const, observedAt: seat.permissionObservedAt } : null) : decider ? { rung: decider.rung, observedAt: decider.observedAt } : null;
|
||||
const needsInputEvidence = permissionTracked ? seat.permissionEvidence : decider ? { rung: decider.rung, observedAt: decider.observedAt } : null;
|
||||
|
||||
// Which rung answers is part of the answer: default zero → observed zero changes what
|
||||
// consumers may conclude, so it advances seq and pushes like a count change.
|
||||
@@ -847,10 +894,12 @@ interface PromotionRun {
|
||||
}
|
||||
|
||||
interface SeatLadderState {
|
||||
permissions: Map<string, { sinceMs: number; confirmed: boolean; seen: boolean; nextCheckMs: number }>;
|
||||
permissions: Map<string, { sinceMs: number; observedAt: string; hookAgentId?: string | null;
|
||||
confirmed: boolean; seen: boolean; nextCheckMs: number }>;
|
||||
claudeToolBatches: Map<string | null, number>;
|
||||
permissionRevision: number;
|
||||
permissionPolling: boolean;
|
||||
permissionObservedAt: string | null;
|
||||
permissionEvidence: ArbitratedSeatState["needsInputEvidence"];
|
||||
sessionName: string;
|
||||
retired: boolean;
|
||||
inventory: AdapterRungInventory | null;
|
||||
|
||||
@@ -80,7 +80,7 @@ async function describeLineageFailure(input: {
|
||||
const rows = pid ? await Promise.resolve((input.listProcesses ?? listNativeProcesses)()).catch((): NativeProcessRow[] => []) : [];
|
||||
if (!pid || !rows.some((row) => row.pid === pid)) return "The pane's process could not be found in the process listing (the listing may have failed), so the runtime could not be verified";
|
||||
const observation = { target: input.target, tmux: input.tmux, listProcesses: () => rows };
|
||||
const passes = input.variant === "codex" ? !!await observeCodexPaneProcess({ ...observation, expectedToken: input.expectedToken, requireResume: input.exact })
|
||||
const passes = input.variant === "codex" ? !!await observeCodexPaneProcess({ ...observation, expectedToken: input.expectedToken, requireResume: input.exact, requireArgv: true })
|
||||
: input.variant === "claude_conversation" ? !!await observeClaudePaneProcess({ ...observation, expectedToken: input.expectedToken, rotation: input.rotation })
|
||||
: input.variant === "claude_runtime" ? !!await observeClaudePaneRuntime(observation)
|
||||
: !!(input.expectedToken && findExactNativeResumeProcess(rows, pid, "claude-code", input.expectedToken, input.rotation));
|
||||
@@ -208,7 +208,7 @@ export async function rebindAndVerifyPaneIdentity(input: {
|
||||
runtimeMatch = "match";
|
||||
const native = await verifyCodexPaneProcess({ target: pane.id, tmux: input.tmux,
|
||||
listProcesses: input.listProcesses, expectedToken: expectedResumeToken,
|
||||
requireResume: input.requireExactResumeLineage === true });
|
||||
requireResume: input.requireExactResumeLineage === true, requireArgv: true });
|
||||
const currentPanes = await input.tmux.listPanes(input.sessionName).catch(() => []);
|
||||
if (native?.panePid === pid && currentPanes.length === 1 && currentPanes[0]?.id === pane.id) lineageMatch = native.process;
|
||||
} else if (claudeProcessProof) {
|
||||
|
||||
@@ -438,7 +438,7 @@ export class SeatDeliveryGuard {
|
||||
const message = uncertainty === "controls"
|
||||
? "OpenRig couldn't confirm the layout of tabs or control characters. Send the message without them; nothing was pasted."
|
||||
: uncertainty === "long"
|
||||
? "OpenRig couldn't confirm ownership of Codex's collapsed long paste. Send a shorter message; nothing was pasted. Turning this seat's typing guard off explicitly disables draft protection."
|
||||
? "OpenRig couldn't confirm ownership of Codex's collapsed long paste. Send a shorter message, or set this seat's typing guard to off for delivery without draft protection; nothing was pasted."
|
||||
: "OpenRig couldn't confirm the complete message would stay visible in this Codex viewport. Open a larger terminal or shorten the message, then retry this request; nothing was pasted.";
|
||||
throw new DeliveryGuardError("draft_input_unknown", message);
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ export class SeatIdentityReconciler {
|
||||
return Promise.all(nativeSeats.map((seat) => (seat.runtime === "codex" ? observeCodexPaneProcess
|
||||
: seat.resume_token !== null && seat.resume_token !== undefined ? observeClaudePaneProcess : observeClaudePaneRuntime)({
|
||||
target: seat.tmux_pane!, tmux, expectedToken: seat.resume_token, rotation: claudeResumeRotation(seat),
|
||||
...(seat.runtime === "codex" ? { requireArgv: true } : {}),
|
||||
listProcesses: () => snapshot ??= this.listProcesses(),
|
||||
})));
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
type EffortResolution, type NativeEffortObservation,
|
||||
} from "./seat-effort.js";
|
||||
import {
|
||||
claudeCommandSessionToken, codexCommandResumeToken, observeClaudePaneRuntime, observeCodexPaneProcess, processArgv,
|
||||
claudeCommandSessionToken, codexProcessArgv, codexProcessResumeToken, observeClaudePaneRuntime, observeCodexPaneProcess, processArgv,
|
||||
} from "./native-process-lineage.js";
|
||||
import { defaultInPlaceRestartIo } from "./seat-restart-in-place.js";
|
||||
import nodePath from "node:path";
|
||||
@@ -615,19 +615,26 @@ export class SeatLifecycleService {
|
||||
({ emitted, observed } = await this.piEffort(seat.nodeId, session.session_name, saved));
|
||||
} else {
|
||||
const observe = runtime === "claude-code" ? observeClaudePaneRuntime : observeCodexPaneProcess;
|
||||
const process = (await observe({ target, tmux: this.tmuxAdapter, ...(this.listProcesses ? { listProcesses: this.listProcesses } : {}) }))?.process;
|
||||
const process = (await observe({ target, tmux: this.tmuxAdapter, ...(runtime === "codex" ? { requireArgv: true } : {}),
|
||||
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}) }))?.process;
|
||||
if (!process) {
|
||||
emitted = { state: "unknown", detail: `No single ${runtime === "claude-code" ? "Claude" : "Codex"} process could be read in the seat's pane.` };
|
||||
observed = { state: "not_verified", detail: "The running process could not be read, so no turn can be tied to its launch." };
|
||||
} else {
|
||||
const flag = emittedEffortFromArgv(runtime, processArgv(process.command));
|
||||
const value = flag.flag === "present" ? flag.value : null;
|
||||
emitted = { state: "observed", value, pid: process.pid, startedAt: process.startedAt ?? null, matchesSaved: value === saved.effective };
|
||||
// Native effort is attributed only from the transcript of the conversation this process is on.
|
||||
const joined = await this.joinedTranscript(runtime, session, process);
|
||||
const native = "detail" in joined ? { state: "not_verified" as const, detail: joined.detail }
|
||||
: readNativeEffort(runtime, joined.path, process.startedAt ?? null);
|
||||
observed = native.state === "observed" ? { ...native, matchesEmitted: native.value === value } : native;
|
||||
const argv = runtime === "codex" ? codexProcessArgv(process) : processArgv(process.command);
|
||||
if (!argv) {
|
||||
emitted = { state: "unknown", detail: "The running Codex's exact arguments could not be read, so its launch effort is unknown." };
|
||||
observed = { state: "not_verified", detail: "The running Codex's exact arguments could not be read, so no native turn can be tied to its launch." };
|
||||
} else {
|
||||
const flag = emittedEffortFromArgv(runtime, argv);
|
||||
const value = flag.flag === "present" ? flag.value : null;
|
||||
emitted = { state: "observed", value, pid: process.pid, startedAt: process.startedAt ?? null, matchesSaved: value === saved.effective };
|
||||
// Native effort is attributed only from the transcript of the conversation this process is on.
|
||||
const joined = await this.joinedTranscript(runtime, session, process);
|
||||
const native = "detail" in joined ? { state: "not_verified" as const, detail: joined.detail }
|
||||
: readNativeEffort(runtime, joined.path, process.startedAt ?? null);
|
||||
observed = native.state === "observed" ? { ...native, matchesEmitted: native.value === value } : native;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, seat, runtime, requested, saved, emitted, observed, precedence, summary: effortSummary(saved, emitted, observed, runtime) };
|
||||
@@ -696,7 +703,7 @@ export class SeatLifecycleService {
|
||||
/** The transcript of the conversation the observed process is on, or why that join can't be made.
|
||||
* The recorded conversation (Codex: the session's resume token; Claude: the status sidecar's session)
|
||||
* must be the one the process itself names, by argv or, for a Codex that names none, by its own logs. */
|
||||
private async joinedTranscript(runtime: "claude-code" | "codex", session: LatestSessionRow, process: { pid: number; command: string; startedAt?: string }):
|
||||
private async joinedTranscript(runtime: "claude-code" | "codex", session: LatestSessionRow, process: { pid: number; command: string; argv?: string[] | null; startedAt?: string }):
|
||||
Promise<{ path: string | null } | { detail: string }> {
|
||||
if (!this.transcripts) return { detail: "The native transcript for this session isn't known to OpenRig." };
|
||||
const mismatch = "The process in the pane isn't on the conversation recorded for this seat (or its conversation couldn't be read), so no native turn can be attributed to it.";
|
||||
@@ -704,7 +711,7 @@ export class SeatLifecycleService {
|
||||
if (runtime === "codex") {
|
||||
const recorded = this.sessionResumeToken(session.id);
|
||||
if (!recorded) return { detail: "No Codex conversation is recorded for this session yet." };
|
||||
let own = codexCommandResumeToken(process.command) ?? undefined;
|
||||
let own = codexProcessResumeToken(process) ?? undefined;
|
||||
if (own === undefined && process.startedAt) {
|
||||
const io = { ...defaultInPlaceRestartIo(), ...this.inPlaceRestartIo };
|
||||
const facts = await io.readProcess(process.pid);
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { SessionRegistry } from "./session-registry.js";
|
||||
import type { HarnessLaunchResult, NodeBinding } from "./runtime-adapter.js";
|
||||
import type { PersistedEvent, RigEvent } from "./types.js";
|
||||
import {
|
||||
codexCommandResumeToken, listNativeProcesses, processArgv, verifyCodexPaneProcess,
|
||||
codexProcessArgv, codexProcessResumeToken, listNativeProcesses, processArgv, verifyCodexPaneProcess,
|
||||
type NativeProcessLister, type NativeProcessRow,
|
||||
} from "./native-process-lineage.js";
|
||||
import { CodexThreadIdResolver, resolveCodexDbPaths, type CodexThreadEvidence } from "./codex-thread-id.js";
|
||||
@@ -405,6 +405,8 @@ export class CodexInPlaceRestart {
|
||||
return { ok: false, code: "resume_binding_unverified", message: "No single Codex process in the seat's pane could be tied to the stored conversation (a running Codex that names another conversation is refused too). Nothing was stopped." };
|
||||
}
|
||||
const { process, panePid, rows } = codex;
|
||||
const runningArgv = codexProcessArgv(process);
|
||||
if (!runningArgv) return { ok: false, code: "settings_unverified", message: "The running Codex's exact arguments could not be read, so its launch settings are unverified. Nothing was stopped." };
|
||||
const startedAt = process.startedAt ?? null;
|
||||
const facts = await this.io.readProcess(process.pid);
|
||||
if (!facts) return { ok: false, code: "settings_unverified", message: "The running Codex's working directory and Codex home could not be read (this observer reads /proc on Linux). Nothing was stopped." };
|
||||
@@ -412,7 +414,7 @@ export class CodexInPlaceRestart {
|
||||
if (!codexHome) return { ok: false, code: "settings_unverified", message: "The running Codex's home could not be determined (no CODEX_HOME or HOME). Nothing was stopped." };
|
||||
|
||||
// The conversation the PROCESS is on: argv for a resumed Codex, its own native logs for a fresh one.
|
||||
const argvToken = codexCommandResumeToken(process.command);
|
||||
const argvToken = codexProcessResumeToken(process);
|
||||
let conversationProof: "argv" | "native_logs";
|
||||
if (argvToken === token) conversationProof = "argv";
|
||||
else if (argvToken === undefined && startedAt && (await this.io.readThreadId(process.pid, startedAt, codexHome)) === token) conversationProof = "native_logs";
|
||||
@@ -468,8 +470,7 @@ export class CodexInPlaceRestart {
|
||||
const relaunchBinding: NodeBinding = { ...binding, cwd };
|
||||
// The probed and prepared launch choices come from the running Codex, so the relaunch keeps them and no
|
||||
// Codex process is started to decide them again.
|
||||
// The exact argv when /proc gives it: a --add-dir with a space (a cwd's .git) must not be cut at the space.
|
||||
const runningArgv = facts.argv?.length ? facts.argv : processArgv(process.command);
|
||||
// Keep the launch settings tied to the same OS argv used for the selected process's identity.
|
||||
const running = runningLaunch(runningArgv);
|
||||
const relaunch = await this.deps.codex.buildResumeCommand(relaunchBinding, { name: sessionName, resumeToken: token, running });
|
||||
if (!relaunch.ok) return { ok: false, code: "relaunch_unavailable", message: `The resume command could not be built: ${relaunch.error}. Nothing was stopped.` };
|
||||
@@ -568,7 +569,7 @@ export class CodexInPlaceRestart {
|
||||
const panePid = await this.deps.tmux.getPanePid(pane);
|
||||
if (!panePid) return null;
|
||||
const observation = await verifyCodexPaneProcess({ target: pane, tmux: { getPanePid: async () => panePid },
|
||||
listProcesses: () => rows, ...(token ? { expectedToken: token } : {}) });
|
||||
listProcesses: () => rows, requireArgv: true, ...(token ? { expectedToken: token } : {}) });
|
||||
return observation && observation.panePid === panePid ? { process: observation.process, panePid, rows } : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -457,8 +457,7 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
|
||||
// Claude replaces its mode hint after our paste; that noninteractive hint is not a new composer.
|
||||
const stableFooter = prompt.glyph === "❯" && (CLAUDE_MODE_FOOTER.test(footer) || isClaudePasteHint(footer)) ? "claude-composer" : footer;
|
||||
const frame = JSON.stringify([prompt.glyph, prefix, width, stableFooter]);
|
||||
const nativeCodex = prompt.glyph !== "❯" && codexFooterSeparator(lines) === end;
|
||||
const input: ComposerInput = { state: empty ? "empty" : "text", cursor: { ...snapshot.cursor }, suggestionRows, rows: visible, columns: width - prefix - (nativeCodex ? 1 : 0), cursorAtEnd, collapsedPaste: label, frame,
|
||||
const input: ComposerInput = { state: empty ? "empty" : "text", cursor: { ...snapshot.cursor }, suggestionRows, rows: visible, columns: width - prefix, cursorAtEnd, collapsedPaste: label, frame,
|
||||
...(ghost ? { ghost } : {}) };
|
||||
// A numbered body is allowed only as this operation's exact text in its
|
||||
// previously empty composer. A newly observed menu gains no such authority.
|
||||
@@ -468,11 +467,11 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
|
||||
input.collapsedPaste = visible.join("\n");
|
||||
input.pasteMatchesExpected = true;
|
||||
}
|
||||
if (nativeCodex) {
|
||||
if (prompt.glyph !== "❯" && codexFooterSeparator(lines) === end) {
|
||||
// Codex 0.164 fullscreen: composer <= 2/3 of the pane (at least eight
|
||||
// rows, capped by the pane), less padding/status/hint rows. Leave the
|
||||
// one-column right inset out of the native printable width.
|
||||
input.pasteViewport = { columns: input.columns,
|
||||
input.pasteViewport = { columns: width - prefix - 1,
|
||||
rows: Math.min(height, Math.max(8, Math.floor(height * 2 / 3))) - 4 };
|
||||
}
|
||||
candidates.push(input);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -578,10 +578,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// hook/pane resolution as status. No new captures, timer or delivery admission path.
|
||||
const retryPromptWakes = (sessionName: string) => {
|
||||
const runtime = sessionRegistry.findResumeContextByName(sessionName)?.runtime;
|
||||
const structural = seatStructuralActivityService.getStructuralActivity(sessionName);
|
||||
const state = hookOrPaneActivity(agentActivityStore.getLatestForNode({ sessionName,
|
||||
order: runtime === "claude-code" ? "source" : "arrival" }),
|
||||
seatStructuralActivityService.getStructuralActivity(sessionName), false, runtime)?.activity.state;
|
||||
void queueRepoInstance.retryPromptWakesOnClear(sessionName, state)
|
||||
order: runtime === "claude-code" ? "source" : "arrival" }), structural, false, runtime)?.activity.state;
|
||||
void queueRepoInstance.retryPromptWakesOnClear(sessionName, state, structural?.state === "attention")
|
||||
.catch(error => console.warn(`Prompt-clear wake recovery: ${error instanceof Error ? error.name : typeof error}`));
|
||||
};
|
||||
seatStructuralActivityService.attachActivityChanged(retryPromptWakes);
|
||||
@@ -655,6 +655,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
HOME: daemonHome,
|
||||
USER: process.env.USER,
|
||||
LOGNAME: process.env.LOGNAME,
|
||||
// The SSH agent socket, so git over SSH works in a seat (#1159). The path isn't key material, but the socket lets
|
||||
// the seat's coding agent use the keys loaded in ssh-agent. An empty value is left out like an unset one, so a
|
||||
// socket only the seat's terminal has never takes its place.
|
||||
SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK || undefined,
|
||||
CODEX_HOME: codexHome,
|
||||
};
|
||||
// OPR.0.4.6.02 S1 — ONE shared tmux option-defaults applier, injected into
|
||||
|
||||
@@ -11,18 +11,22 @@
|
||||
// BOTH must pass — that's what proves the two primitives are computed
|
||||
// independently. Either alone is symmetry-breakable.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import {
|
||||
attachTerminalActivityAndWork,
|
||||
attachAgentActivity,
|
||||
getNodeInventory,
|
||||
readAssignedWorkBySession,
|
||||
} from "../src/domain/node-inventory.js";
|
||||
import {
|
||||
countAssignedWorkForSession,
|
||||
countPendingWorkForSession,
|
||||
PsProjectionService,
|
||||
} from "../src/domain/ps-projection.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
|
||||
function seedRig(db: Database.Database, name: string): string {
|
||||
const id = `rig-${name}`;
|
||||
@@ -80,6 +84,72 @@ describe("attachTerminalActivityAndWork — slice 15 per-node enrichment", () =>
|
||||
beforeEach(() => { db = createFullTestDb(); });
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
function emitHook(rigId: string, nodeId: string, sessionName: string, state: "needs_input" | "idle") {
|
||||
const eventAt = new Date().toISOString();
|
||||
new EventBus(db).emit({
|
||||
type: "agent.activity", rigId, nodeId, sessionName, runtime: "codex",
|
||||
activity: { state, reason: state === "needs_input" ? "permission_prompt" : "stop",
|
||||
evidenceSource: "runtime_hook", sampledAt: eventAt, eventAt, fallback: false, stale: false },
|
||||
} as never);
|
||||
}
|
||||
|
||||
it.each(["codex", "claude-code"])("keeps %s hooks on their inventory node when another rig reuses the session name", async runtime => {
|
||||
const rig = seedRig(db, "a-owner");
|
||||
const otherRig = seedRig(db, "z-reused");
|
||||
const node = seedNode(db, rig, "dev");
|
||||
const other = seedNode(db, otherRig, "dev");
|
||||
db.prepare("UPDATE nodes SET runtime = ?").run(runtime);
|
||||
seedSession(db, node, "reused-session");
|
||||
seedSession(db, other, "reused-session"); // Later session ID wins the old global name lookup.
|
||||
emitHook(rig, node, "reused-session", "needs_input");
|
||||
emitHook(otherRig, other, "reused-session", "idle");
|
||||
const store = new AgentActivityStore({ db, eventBus: new EventBus(db) });
|
||||
const base = getNodeInventory(db, rig);
|
||||
const [attached] = await attachAgentActivity(base, { tmuxAdapter: {} as never, activityStore: store });
|
||||
expect(attached!.agentActivity?.state).toBe("needs_input");
|
||||
expect(attachTerminalActivityAndWork(base, { db, agentActivity: store })[0]!.needsAttention).toBe(true);
|
||||
const totals = new PsProjectionService({ db, agentActivity: store }).getEntries();
|
||||
expect(totals.find(e => e.rigId === rig)!.attentionCount).toBe(1);
|
||||
expect(totals.find(e => e.rigId === otherRig)!.attentionCount).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["codex", "claude-code"])("does not revive %s hooks from an old session or a node without a session", async runtime => {
|
||||
const rig = seedRig(db, "absence");
|
||||
const current = seedNode(db, rig, "current");
|
||||
const orphan = seedNode(db, rig, "no-session");
|
||||
db.prepare("UPDATE nodes SET runtime = ?").run(runtime);
|
||||
seedSession(db, current, "current-session");
|
||||
emitHook(rig, current, "old-session", "needs_input");
|
||||
emitHook(rig, orphan, "orphan-session", "needs_input");
|
||||
const store = new AgentActivityStore({ db, eventBus: new EventBus(db) });
|
||||
const base = getNodeInventory(db, rig);
|
||||
const attached = await attachAgentActivity(base, { tmuxAdapter: {} as never, activityStore: store });
|
||||
expect(attached.map(e => e.agentActivity?.state)).toEqual(["unknown", "unknown"]);
|
||||
expect(attachTerminalActivityAndWork(base, { db, agentActivity: store }).map(e => e.needsAttention))
|
||||
.toEqual([false, false]);
|
||||
expect(new PsProjectionService({ db, agentActivity: store }).getEntries()[0]!.attentionCount).toBe(0);
|
||||
});
|
||||
|
||||
it("reuses attached unknown activity without another read, but keeps the standalone hook fallback", async () => {
|
||||
const rig = seedRig(db, "reuse");
|
||||
const node = seedNode(db, rig, "dev");
|
||||
seedSession(db, node, "dev@reuse");
|
||||
const store = new AgentActivityStore({ db, eventBus: new EventBus(db) });
|
||||
const read = vi.spyOn(store, "getLatestForNode");
|
||||
const base = getNodeInventory(db, rig);
|
||||
const attached = await attachAgentActivity(base, { tmuxAdapter: {} as never, activityStore: store });
|
||||
expect(attached[0]!.agentActivity?.state).toBe("unknown");
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
emitHook(rig, node, "dev@reuse", "needs_input");
|
||||
expect(attachTerminalActivityAndWork(attached, { db, agentActivity: store })[0]!.needsAttention).toBe(false);
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
for (const agentActivity of [undefined, null]) {
|
||||
const standalone = [{ ...base[0]!, agentActivity }];
|
||||
expect(attachTerminalActivityAndWork(standalone, { db, agentActivity: store })[0]!.needsAttention).toBe(true);
|
||||
}
|
||||
expect(read).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("HG-3 DIRECTION A — node producing output with NOTHING queued: terminalActive=true, hasAssignedWork=false", () => {
|
||||
const rig = seedRig(db, "active-only");
|
||||
const n = seedNode(db, rig, "dev");
|
||||
|
||||
@@ -135,7 +135,8 @@ async function sendAfterHook(mode: Mode, consumer: "send" | "wake" | "handoff" =
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "sh", command: "/bin/sh /fixture/launch", startedAt },
|
||||
{ pid: 102, ppid: 101, pgid: 101, tpgid: 101, executableName: foreign ? "codex" : "claude",
|
||||
command: foreign ? "codex resume other" : `/opt/claude --session-id ${original}`, startedAt },
|
||||
command: foreign ? "codex resume other" : `/opt/claude --session-id ${original}`,
|
||||
...(foreign ? { argv: ["codex", "resume", "other"] } : {}), startedAt },
|
||||
];
|
||||
if (mode === "shim") rows.push({ ...rows[2]!, pid: 103, ppid: 102,
|
||||
command: `/child/claude --session-id ${original} --settings /fixture/settings.json` });
|
||||
|
||||
@@ -238,7 +238,8 @@ describe("fresh in-place readiness requires current launch evidence", () => {
|
||||
const startedAt = "Mon Oct 19 21:30:00 2026";
|
||||
f.listProcesses.mockReturnValue([
|
||||
{ pid: 100, ppid: 1, pgid: 100, tpgid: 101, executableName: "bash", command: "-bash", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "codex", command: "codex resume other-token", startedAt },
|
||||
{ pid: 101, ppid: 100, pgid: 101, tpgid: 101, executableName: "codex", command: "codex resume other-token",
|
||||
argv: ["codex", "resume", "other-token"], startedAt },
|
||||
]);
|
||||
await launch(f.adapter);
|
||||
expect(await f.adapter.checkReady(binding)).toMatchObject({ ready: false, code: "claude_launch_identity_conflict" });
|
||||
|
||||
@@ -13,11 +13,11 @@ const open: Database.Database[] = [];
|
||||
afterEach(() => { for (const db of open.splice(0)) db.close(); });
|
||||
|
||||
// `file` is the exact bytes of `claude` on the daemon's PATH; it must be a script, which runs on every platform.
|
||||
async function prepared(file: string, link?: boolean) {
|
||||
async function prepared(file: string, link?: boolean, recordedExecutable?: boolean) {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "claude-script-launch-")));
|
||||
const cwd = path.join(root, "seat"); const bin = path.join(root, "bin");
|
||||
mkdirSync(cwd); mkdirSync(bin); mkdirSync(path.join(root, "home"));
|
||||
const target = path.join(link ? root : bin, link ? "real-claude" : "claude");
|
||||
const target = path.join(link || recordedExecutable ? root : bin, link || recordedExecutable ? "real-claude" : "claude");
|
||||
writeFileSync(target, file); chmodSync(target, 0o755);
|
||||
if (link) symlinkSync(target, path.join(bin, "claude"));
|
||||
const db = new Database(":memory:"); open.push(db);
|
||||
@@ -27,7 +27,7 @@ async function prepared(file: string, link?: boolean) {
|
||||
db.prepare("INSERT INTO nodes VALUES ('node','claude-code',?)").run(cwd);
|
||||
db.exec("INSERT INTO bindings VALUES ('binding','node','seat','%1'); INSERT INTO occupant_tenures VALUES ('node','generation-1',1)");
|
||||
const managed = new ClaudeManagedLaunch(db, { PATH: `${bin}:/usr/bin:/bin`, HOME: path.join(root, "home") }, {});
|
||||
return { managed: await managed.prepare({ nodeId: "node" }, "auto"), target };
|
||||
return { managed: await managed.prepare({ nodeId: "node", ...(recordedExecutable ? { executable: target } : {}) }, "auto"), target, resolver: managed };
|
||||
}
|
||||
const printsHelp = `printf '%s\\n' '${HELP}'\n`;
|
||||
|
||||
@@ -43,6 +43,15 @@ it("reports it through a symlink, whose realpath is the frozen executable", asyn
|
||||
expect(managed.executableIsScript).toBe(true);
|
||||
});
|
||||
|
||||
it("continues with the recorded executable outside the daemon PATH, freezing the same file", async () => {
|
||||
const { managed, target, resolver } = await prepared(`#!/bin/sh\n${printsHelp}`, false, true);
|
||||
expect(managed.executable).toBe(target);
|
||||
expect(managed.command(["--resume", "same-conversation"])).toContain(target);
|
||||
await expect(resolver.prepare({ nodeId: "node" }, "auto")).rejects.toThrow("unavailable on the intended PATH");
|
||||
writeFileSync(target, `#!/bin/sh\n${printsHelp}\n# changed`);
|
||||
expect(() => managed.assertCurrent()).toThrow("context changed");
|
||||
});
|
||||
|
||||
// Not through prepare(): it runs `--help` on the file first, and a file with no #! at byte 0 can't be exec'd on every
|
||||
// platform (Linux's execvp hands it to /bin/sh, macOS returns ENOEXEC), so such a fixture would pass only on some hosts.
|
||||
// prepare() reports exactly this function's answer for the file it froze.
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { activityRoutes } from "../src/routes/activity.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { attachAgentActivity, attachTerminalActivityAndWork, getNodeInventory } from "../src/domain/node-inventory.js";
|
||||
import { WAKE_INTENT_PREFIX } from "../src/domain/outbox-handler.js";
|
||||
import { createDaemon } from "../src/startup.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildOpenRigPayload } = require("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs") as {
|
||||
buildOpenRigPayload: (native: Record<string, unknown>, env: Record<string, string>, now: () => Date) => Record<string, unknown>;
|
||||
};
|
||||
const databases: ReturnType<typeof createFullTestDb>[] = [];
|
||||
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
|
||||
|
||||
function fixture(visible: boolean | null = false) {
|
||||
const db = createFullTestDb();
|
||||
databases.push(db);
|
||||
const repo = new RigRepository(db);
|
||||
const rig = repo.createRig("batch-permission");
|
||||
const node = repo.addNode(rig.id, "worker", { runtime: "claude-code" });
|
||||
const registry = new SessionRegistry(db);
|
||||
const sessionName = "worker@batch-permission";
|
||||
const session = registry.registerSession(node.id, sessionName);
|
||||
registry.updateStatus(session.id, "running");
|
||||
registry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: sessionName });
|
||||
const generation = registry.currentOccupantTenure(node.id)!.generationUuid;
|
||||
let nowMs = Date.parse("2026-10-28T00:00:00.000Z");
|
||||
const now = () => new Date(nowMs);
|
||||
const store = new AgentActivityStore({ db, eventBus: new EventBus(db), now,
|
||||
resolveOccupantGeneration: id => registry.currentOccupantTenure(id)?.generationUuid ?? null,
|
||||
isRegisteredOccupantGeneration: (id, value) => registry.isOccupantGenerationRegistered(id, value),
|
||||
});
|
||||
const reader = vi.fn(async (): Promise<boolean | null> => visible);
|
||||
const changed = vi.fn((name: string, confirmed: boolean, runtime: string) => {
|
||||
const latest = store.getLatestForNode({ sessionName: name });
|
||||
store.recordHookEvent({ sessionName: name, runtime, generation: latest?.generation,
|
||||
hookEvent: confirmed ? "PermissionPromptConfirmed" : "PermissionPromptCleared" });
|
||||
});
|
||||
const service = new SeatActivityService({ tmux: { readPaneLastActivity: async () => null },
|
||||
defaultWindowSeconds: 3, now, permissionPromptReader: reader, permissionPromptChanged: changed });
|
||||
const app = new Hono();
|
||||
app.use("*", async (context, next) => {
|
||||
context.set("agentActivityStore" as never, store as never);
|
||||
context.set("activityHookToken" as never, "batch-test-token" as never);
|
||||
context.set("seatActivityService" as never, service as never);
|
||||
context.set("sessionRegistry" as never, registry as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/activity", activityRoutes);
|
||||
const hook = async (event: string, native: Record<string, unknown> = {}, wire: Record<string, unknown> = {}) => {
|
||||
nowMs += 10;
|
||||
const payload = buildOpenRigPayload({ hook_event_name: event, session_id: "native-session", permission_mode: "auto",
|
||||
...(event === "Notification" ? { notification_type: "permission_prompt" } : {}), ...native }, {
|
||||
OPENRIG_SESSION_NAME: sessionName, OPENRIG_NODE_ID: node.id,
|
||||
OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: generation,
|
||||
}, now);
|
||||
const response = await app.request("/activity/hooks", { method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer batch-test-token" },
|
||||
body: JSON.stringify({ ...payload, ...wire }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
return payload;
|
||||
};
|
||||
return { app, db, node, registry, session, sessionName, generation, store, service, reader, changed, hook, now,
|
||||
advance: (delta: number) => { nowMs += delta; },
|
||||
count: () => service.getSeatState(node.id)!.needsInput.count,
|
||||
latest: () => {
|
||||
// sampledAt is the read's clock; the retained source event and its meaning must stay unchanged.
|
||||
const { sampledAt: _, ...activity } = store.getLatestForNode({ nodeId: node.id, sessionName })!;
|
||||
return activity;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Claude tool-batch completion resolves its permission notification", () => {
|
||||
it.each([false, null])("clears an unseen permission after the batch finishes, with capture=%s", async visible => {
|
||||
const subject = fixture(visible);
|
||||
await subject.hook("UserPromptSubmit");
|
||||
await subject.hook("Notification");
|
||||
await subject.service.pollSeat(subject.sessionName);
|
||||
expect(subject.count()).toBe(1);
|
||||
await subject.hook("PostToolBatch", { tool_calls: [{ tool_name: "Bash", tool_use_id: "tool-1", tool_response: "finished" }] });
|
||||
expect(subject.count()).toBe(0);
|
||||
expect(subject.service.getSeatState(subject.node.id)).toMatchObject({ activity: "working",
|
||||
needsInputEvidence: { rung: "lifecycle-hooks" } });
|
||||
expect(subject.latest()).toMatchObject({ rawEvent: "PermissionPromptCleared", reason: "permission_prompt_cleared" });
|
||||
const calls = subject.changed.mock.calls.length;
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.changed).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
it.each([undefined, "child-1"])("carries native actor %s without sending tool inputs or results", async agentId => {
|
||||
const subject = fixture();
|
||||
const payload = await subject.hook("Notification", { agent_id: agentId, tool_input: { command: "private" } });
|
||||
expect(payload).toHaveProperty("hookAgentId", agentId ?? null);
|
||||
const recorded = subject.db.prepare("SELECT payload FROM events WHERE type = 'agent.activity' AND json_extract(payload, '$.activity.rawEvent') = 'Notification' ORDER BY seq DESC LIMIT 1")
|
||||
.get() as { payload: string };
|
||||
expect(JSON.parse(recorded.payload).activity).toHaveProperty("hookAgentId", agentId ?? null);
|
||||
const batch = await subject.hook("PostToolBatch", { agent_id: agentId,
|
||||
tool_calls: [{ tool_name: "Bash", tool_use_id: "tool-1", tool_response: "private result" }] });
|
||||
expect(batch).toHaveProperty("hookAgentId", agentId ?? null);
|
||||
expect(batch).not.toHaveProperty("tool_calls");
|
||||
expect(payload).not.toHaveProperty("tool_input");
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not treat one parallel tool finishing as the whole batch finishing", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("PostToolUse", { tool_use_id: "other-tool" });
|
||||
expect(subject.count()).toBe(1);
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("settles only the completed actor when main and subagent permissions overlap", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("Notification", { agent_id: "child-1" });
|
||||
expect(subject.count()).toBe(2);
|
||||
await subject.hook("PostToolBatch", { agent_id: "child-1" });
|
||||
expect(subject.count()).toBe(1);
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not attribute the same visible dialog to two outstanding actors", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("Notification", { agent_id: "child-1" });
|
||||
subject.reader.mockResolvedValue(true);
|
||||
subject.reader.mockClear();
|
||||
subject.advance(1_000);
|
||||
await subject.service.pollSeat(subject.sessionName);
|
||||
expect(subject.reader).toHaveBeenCalledOnce();
|
||||
subject.reader.mockResolvedValue(false);
|
||||
subject.advance(1_000);
|
||||
await subject.service.pollSeat(subject.sessionName);
|
||||
expect(subject.count()).toBe(2);
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(1);
|
||||
await subject.hook("PostToolBatch", { agent_id: "child-1" });
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the current node identity when a relay only carries nodeId", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("PostToolBatch", {}, { sessionName: null });
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not let the completed batch mask later self-reported attention", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("PostToolBatch");
|
||||
subject.service.reportEvidence({ seatNodeId: subject.node.id, sessionName: subject.sessionName,
|
||||
rung: "self-report", sourceId: "claude:pid-json", seq: 1, observedAt: subject.now().toISOString(),
|
||||
needsInput: { count: 1, reason: "dialog owns input" } });
|
||||
expect(subject.count()).toBe(1);
|
||||
});
|
||||
|
||||
it.each(["child-1", "child-2"])("ignores actor %s finishing while the main request is pending", async agentId => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
const before = subject.latest();
|
||||
await subject.hook("PostToolBatch", { agent_id: agentId });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([-1, 0])("does not resolve a notification with a batch timestamp offset of %s ms", async offset => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
const before = subject.latest();
|
||||
const occurredAt = new Date(subject.now().getTime() + offset).toISOString();
|
||||
await subject.hook("PostToolBatch", {}, { occurredAt });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not let a delayed older notification make a newer request eligible for an old batch", async () => {
|
||||
const subject = fixture();
|
||||
const at = (offset: number) => new Date(Date.parse("2026-10-28T00:00:00.000Z") + offset).toISOString();
|
||||
await subject.hook("Notification", {}, { occurredAt: at(30) });
|
||||
await subject.hook("Notification", {}, { occurredAt: at(10) });
|
||||
await subject.hook("PostToolBatch", {}, { occurredAt: at(20) });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest().state).toBe("needs_input");
|
||||
await subject.hook("PostToolBatch", {}, { occurredAt: at(40) });
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not guess an actor for notifications from an older relay", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification", {}, { hookAgentId: undefined });
|
||||
const before = subject.latest();
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([undefined, "child-1"])("does not revive actor %s after its completed notification arrives again", async agentId => {
|
||||
const subject = fixture();
|
||||
await subject.hook("UserPromptSubmit");
|
||||
const notification = await subject.hook("Notification", { agent_id: agentId });
|
||||
const batch = await subject.hook("PostToolBatch", { agent_id: agentId });
|
||||
const before = subject.latest();
|
||||
for (const occurredAt of [notification.occurredAt,
|
||||
new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString()]) {
|
||||
await subject.hook("Notification", { agent_id: agentId }, { occurredAt });
|
||||
expect(subject.count()).toBe(0);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([undefined, "child-1"])("remembers actor %s completing before its first notification arrives", async agentId => {
|
||||
const subject = fixture();
|
||||
const batch = await subject.hook("PostToolBatch", { agent_id: agentId });
|
||||
await subject.hook("Notification", { agent_id: agentId },
|
||||
{ occurredAt: new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString() });
|
||||
expect(subject.count()).toBe(0);
|
||||
expect(subject.store.getLatestForNode({ sessionName: subject.sessionName })?.state).not.toBe("needs_input");
|
||||
expect(subject.changed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([0, 1])("keeps a new notification whose timestamp is %s ms after the prior batch", async offset => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
const batch = await subject.hook("PostToolBatch");
|
||||
await subject.hook("Notification", {},
|
||||
{ occurredAt: new Date(Date.parse(String(batch.occurredAt)) + offset).toISOString() });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest().state).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("retains the latest completion per actor even when older batches arrive later", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("UserPromptSubmit");
|
||||
const completedAt = subject.now().getTime() + 100;
|
||||
const at = (offset: number) => new Date(completedAt + offset).toISOString();
|
||||
await subject.hook("PostToolBatch", { agent_id: "child-1" }, { occurredAt: at(0) });
|
||||
await subject.hook("PostToolBatch", { agent_id: "child-1" }, { occurredAt: at(-20) });
|
||||
await subject.hook("Notification", { agent_id: "child-1" }, { occurredAt: at(-10) });
|
||||
expect(subject.count()).toBe(0);
|
||||
await subject.hook("Notification", {}, { occurredAt: at(-10) });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest().state).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("keeps an unattributed legacy notification even after the main actor completed", async () => {
|
||||
const subject = fixture();
|
||||
const batch = await subject.hook("PostToolBatch");
|
||||
await subject.hook("Notification", {}, { hookAgentId: undefined,
|
||||
occurredAt: new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString() });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest().state).toBe("needs_input");
|
||||
});
|
||||
|
||||
it.each(["forget", "swap"] as const)("discards the prior occupant's completion on %s", async action => {
|
||||
const subject = fixture();
|
||||
const batch = await subject.hook("PostToolBatch");
|
||||
let generation = subject.generation;
|
||||
if (action === "forget") subject.service.forgetSeat(subject.sessionName);
|
||||
else {
|
||||
generation = subject.registry.mintOccupantTenure(subject.node.id, "initial").generationUuid;
|
||||
subject.service.declareOccupantSwap(subject.node.id, generation);
|
||||
}
|
||||
await subject.hook("Notification", {}, { generation,
|
||||
occurredAt: new Date(Date.parse(String(batch.occurredAt)) - 1).toISOString() });
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest().state).toBe("needs_input");
|
||||
});
|
||||
|
||||
it.each(["PostToolBatch", "Notification"])("returns a named 503 for %s when the occupant ledger cannot be read", async hookEvent => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
const before = subject.latest();
|
||||
const resolver = vi.spyOn(subject.registry, "currentOccupantTenure")
|
||||
.mockImplementation(() => { throw new Error("fixture occupant ledger unavailable"); });
|
||||
try {
|
||||
const response = await subject.app.request("/activity/hooks", { method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer batch-test-token" },
|
||||
body: JSON.stringify({ runtime: "claude-code", hookEvent, subtype: "permission_prompt",
|
||||
sessionName: subject.sessionName, nodeId: subject.node.id, generation: subject.generation,
|
||||
hookAgentId: null, occurredAt: new Date(subject.now().getTime() + 1).toISOString() }),
|
||||
});
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toMatchObject({ ok: false, code: "generation_resolver_error" });
|
||||
} finally {
|
||||
resolver.mockRestore();
|
||||
}
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
await subject.hook(hookEvent);
|
||||
expect(subject.count()).toBe(hookEvent === "PostToolBatch" ? 0 : 1);
|
||||
});
|
||||
|
||||
it.each([{ generation: null }, { generation: "retired-generation" }, { hookAgentId: undefined },
|
||||
{ hookAgentId: 42 }, { occurredAt: "invalid" }, { sessionName: "old@batch-permission" }])(
|
||||
"preserves attention when batch attribution is unusable: %j", async wire => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
const before = subject.latest();
|
||||
await subject.hook("PostToolBatch", {}, wire);
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not clear an elicitation that replaced the permission request", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
await subject.hook("Notification", { notification_type: "elicitation_dialog" });
|
||||
const before = subject.latest();
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it("does not revive a completed request from an in-flight pane capture", async () => {
|
||||
const subject = fixture();
|
||||
let finish!: (visible: boolean) => void;
|
||||
subject.reader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
await subject.hook("Notification");
|
||||
expect(finish).toBeTypeOf("function");
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(0);
|
||||
finish(true);
|
||||
await subject.service.pollSeat(subject.sessionName);
|
||||
expect(subject.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not apply a batch to a retired session", async () => {
|
||||
const subject = fixture();
|
||||
await subject.hook("Notification");
|
||||
subject.registry.updateStatus(subject.session.id, "stopped");
|
||||
const before = subject.latest();
|
||||
await subject.hook("PostToolBatch");
|
||||
expect(subject.count()).toBe(1);
|
||||
expect(subject.latest()).toEqual(before);
|
||||
});
|
||||
|
||||
it("clears displayed attention and retries the existing refused handoff once through daemon wiring", async () => {
|
||||
const originalNoKernel = process.env.OPENRIG_NO_KERNEL;
|
||||
process.env.OPENRIG_NO_KERNEL = "1";
|
||||
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async input => {
|
||||
expect(String(input)).toBe("https://github.com/mvschwarz/openrig-plugins/releases/latest/download/openrig-core.tar.gz");
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
const captures = JSON.parse(readFileSync(new URL("./fixtures/claude-permission-2.1.300.json", import.meta.url), "utf8"));
|
||||
let daemon: Awaited<ReturnType<typeof createDaemon>> | undefined;
|
||||
try {
|
||||
daemon = await createDaemon({ dbPath: ":memory:",
|
||||
tmuxExec: async command => command.includes("capture-pane") ? captures.approvedBash : "", cmuxExec: async () => "" });
|
||||
const { rigRepo, sessionRegistry, queueRepo, outboxHandler, agentActivityStore, seatActivityService,
|
||||
seatStructuralActivityService, tmuxAdapter, activityHookToken } = daemon.deps;
|
||||
const rig = rigRepo.createRig("batch-wake");
|
||||
const node = rigRepo.addNode(rig.id, "worker", { runtime: "claude-code" });
|
||||
const sessionName = "worker@batch-wake";
|
||||
const session = sessionRegistry.registerSession(node.id, sessionName);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: sessionName });
|
||||
const generation = sessionRegistry.currentOccupantTenure(node.id)!.generationUuid;
|
||||
const hook = async (event: string, native: Record<string, unknown> = {}) => {
|
||||
const payload = buildOpenRigPayload({ hook_event_name: event,
|
||||
...(event === "Notification" ? { notification_type: "permission_prompt" } : {}), ...native }, {
|
||||
OPENRIG_SESSION_NAME: sessionName, OPENRIG_NODE_ID: node.id,
|
||||
OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: generation,
|
||||
}, () => new Date(event === "PostToolBatch" ? Date.now() : Date.now() - 1_000));
|
||||
const response = await daemon!.app.request("/api/activity/hooks", { method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${activityHookToken}` },
|
||||
body: JSON.stringify(payload) });
|
||||
expect(response.status).toBe(200);
|
||||
};
|
||||
const inventory = async () => attachTerminalActivityAndWork(await attachAgentActivity(getNodeInventory(daemon!.db, rig.id),
|
||||
{ tmuxAdapter, activityStore: agentActivityStore, structuralActivity: seatStructuralActivityService }),
|
||||
{ db: daemon!.db, seatActivity: seatActivityService, agentActivity: agentActivityStore })[0]!;
|
||||
await seatStructuralActivityService!.pollSeat(sessionName, null, "claude-code");
|
||||
await hook("UserPromptSubmit");
|
||||
await hook("Notification");
|
||||
await seatActivityService!.pollSeat(sessionName);
|
||||
expect(await inventory()).toMatchObject({ needsAttention: true, agentActivity: { state: "needs_input" },
|
||||
activityState: { display: "needs-input", needsInput: { count: 1 } } });
|
||||
|
||||
let refused = true;
|
||||
const send = vi.fn(async () => refused
|
||||
? { ok: false, sent: false, reason: "target_needs_input", error: `Refused: '${sessionName}' is at an interactive prompt (permission_prompt). No text was sent.` }
|
||||
: { ok: true, verified: true });
|
||||
queueRepo!.attachTransport({ send });
|
||||
const source = await queueRepo!.create({ sourceSession: "planner@batch-wake", destinationSession: "builder@batch-wake",
|
||||
body: "build", nudge: false });
|
||||
const { created } = await queueRepo!.handoff({ qitemId: source.qitemId, fromSession: "builder@batch-wake",
|
||||
toSession: sessionName, body: "review the finished work" });
|
||||
const wakeId = WAKE_INTENT_PREFIX + created.qitemId;
|
||||
await vi.waitFor(() => expect(outboxHandler!.getById(wakeId)?.deliveryState).toBe("failed"));
|
||||
const original = outboxHandler!.getById(wakeId)!;
|
||||
refused = false;
|
||||
await hook("PostToolBatch", { agent_id: "other-actor" });
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
expect((await inventory()).needsAttention).toBe(true);
|
||||
await hook("PostToolBatch");
|
||||
await vi.waitFor(() => expect(outboxHandler!.getById(wakeId)?.deliveryState).toBe("delivered"));
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(send.mock.calls[1][1]).toContain(original.body);
|
||||
expect(agentActivityStore!.getLatestForNode({ sessionName })).toMatchObject({ rawEvent: "PermissionPromptCleared" });
|
||||
expect(await inventory()).toMatchObject({ needsAttention: false, agentActivity: { state: "running" },
|
||||
activityState: { display: "working", needsInput: { count: 0 } } });
|
||||
await hook("PostToolBatch");
|
||||
await seatStructuralActivityService!.pollSeat(sessionName, null, "claude-code");
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(queueRepo!.list({ limit: 100 })).toHaveLength(2);
|
||||
} finally {
|
||||
daemon?.eventLoopMonitor.stop();
|
||||
daemon?.db.close();
|
||||
fetch.mockRestore();
|
||||
if (originalNoKernel === undefined) delete process.env.OPENRIG_NO_KERNEL;
|
||||
else process.env.OPENRIG_NO_KERNEL = originalNoKernel;
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -33,6 +33,15 @@ function mockTmux(overrides?: {
|
||||
}
|
||||
|
||||
describe("ClaudeResumeAdapter", () => {
|
||||
it("the legacy continuation uses the retained executable and original conversation", async () => {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new ClaudeResumeAdapter(tmux, { maxWaitMs: 1, sleep: async () => {} });
|
||||
await adapter.resume("seat@rig", "claude_id", "original", "/repo", undefined, undefined, undefined, "node",
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, "/native tools/claude");
|
||||
expect(tmux.sendText).toHaveBeenCalledWith("seat@rig", expect.stringContaining("exec '/native tools/claude'"));
|
||||
expect(tmux.sendText).toHaveBeenCalledWith("seat@rig", expect.stringContaining("--resume 'original'"));
|
||||
});
|
||||
|
||||
describe("canResume", () => {
|
||||
it("claude_name + token -> true", () => {
|
||||
const adapter = new ClaudeResumeAdapter(mockTmux());
|
||||
|
||||
@@ -76,6 +76,13 @@ describe("launchHarness — per-agent --model reaches the claude launch (51-07 A
|
||||
return (calls[calls.length - 1]?.[1] as string) ?? "";
|
||||
};
|
||||
|
||||
it("uses the retained executable for a same-conversation continuation", async () => {
|
||||
const tmux = mockTmux();
|
||||
await adapterWith(tmux).launchHarness({ ...makeBinding(), claudeExecutable: "/native tools/claude" }, { name: "seat", resumeToken: "original" });
|
||||
expect(lastCmd(tmux)).toContain("exec '/native tools/claude'");
|
||||
expect(lastCmd(tmux)).toContain("--resume 'original'");
|
||||
});
|
||||
|
||||
it("FRESH launch emits --model when the binding declares one", async () => {
|
||||
const tmux = mockTmux();
|
||||
await adapterWith(tmux).launchHarness(withModel(MODEL), { name: "seat" });
|
||||
@@ -959,7 +966,8 @@ describe("Claude Code runtime adapter", () => {
|
||||
|
||||
describe("a different occupant keeps main's result (timed out, no token): only unknown observations get attention", () => {
|
||||
const timedOut = { ok: false, probe: probeEvidence, error: "Claude resume failed: timed out waiting for Claude to become active" };
|
||||
const codex = { pid: 13, ppid: 11, pgid: 11, tpgid: 11, executableName: "codex", startedAt, command: "codex resume some-codex-session" };
|
||||
const codex = { pid: 13, ppid: 11, pgid: 11, tpgid: 11, executableName: "codex", startedAt,
|
||||
command: "codex resume some-codex-session", argv: ["codex", "resume", "some-codex-session"] };
|
||||
const otherClaude = { ...claude, command: "claude --permission-mode acceptEdits --resume some-other-session --name other" };
|
||||
const booting = [{ ...pane, tpgid: 11 }, launchScript];
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@ describe("late Codex fork identity", () => {
|
||||
pidOnlyRecord = false;
|
||||
processRows = [
|
||||
{ pid: 900, ppid: 1, pgid: 900, tpgid: 901, executableName: "sh", startedAt: STARTED, command: "sh" },
|
||||
{ pid: 901, ppid: 900, pgid: 901, tpgid: 901, executableName: "codex", startedAt: STARTED, command: `codex fork ${PARENT}` },
|
||||
{ pid: 901, ppid: 900, pgid: 901, tpgid: 901, executableName: "codex", startedAt: STARTED,
|
||||
command: `codex fork ${PARENT}`, argv: ["codex", "fork", PARENT] },
|
||||
];
|
||||
tmux = {
|
||||
hasSession: vi.fn(async () => true),
|
||||
@@ -177,12 +178,12 @@ describe("late Codex fork identity", () => {
|
||||
await orch.startNode(input);
|
||||
threadId = CHILD;
|
||||
const child = processRows[1]!;
|
||||
if (shape === "different parent") child.command = `codex fork ${CHILD}`;
|
||||
if (shape === "resume") child.command = `codex resume ${PARENT}`;
|
||||
if (shape === "fresh") child.command = "codex";
|
||||
if (shape === "different parent") { child.command = `codex fork ${CHILD}`; child.argv = ["codex", "fork", CHILD]; }
|
||||
if (shape === "resume") { child.command = `codex resume ${PARENT}`; child.argv = ["codex", "resume", PARENT]; }
|
||||
if (shape === "fresh") { child.command = "codex"; child.argv = ["codex"]; }
|
||||
if (shape === "background") child.pgid = 800;
|
||||
if (shape === "sibling") processRows.push({ ...child, pid: 902 });
|
||||
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.executableName = "helper"; }
|
||||
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.argv = ["helper", "codex", "fork", PARENT]; child.executableName = "helper"; }
|
||||
if (shape === "missing start") delete child.startedAt;
|
||||
expect(await lifecycle().continueStartup("builder@fork-recovery")).toMatchObject({ ok: false, startupStatus: "attention_required" });
|
||||
expect(tmux.sendText).not.toHaveBeenCalled();
|
||||
@@ -193,9 +194,9 @@ describe("late Codex fork identity", () => {
|
||||
threadId = CHILD;
|
||||
const child = processRows[1]!;
|
||||
if (shape === "reused PID") pidOnlyRecord = true;
|
||||
if (shape === "different parent") child.command = `codex fork ${CHILD}`;
|
||||
if (shape === "different parent") { child.command = `codex fork ${CHILD}`; child.argv = ["codex", "fork", CHILD]; }
|
||||
if (shape === "background") child.pgid = 800;
|
||||
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.executableName = "helper"; }
|
||||
if (shape === "helper") { child.command = `helper codex fork ${PARENT}`; child.argv = ["helper", "codex", "fork", PARENT]; child.executableName = "helper"; }
|
||||
readinessSeconds = 1;
|
||||
const result = await orch.startNode(input);
|
||||
expect(result).toMatchObject({ ok: false, startupStatus: "attention_required" });
|
||||
@@ -209,7 +210,8 @@ describe("late Codex fork identity", () => {
|
||||
await orch.startNode(input);
|
||||
threadId = CHILD;
|
||||
processRows[1]!.command = `codex -c example=fork -s workspace-write fork --add-dir /fixture/queue ${PARENT}`;
|
||||
processRows.push({ ...processRows[1]!, pid: 902, ppid: 901, command: "codex" });
|
||||
processRows[1]!.argv = ["codex", "-c", "example=fork", "-s", "workspace-write", "fork", "--add-dir", "/fixture/queue", PARENT];
|
||||
processRows.push({ ...processRows[1]!, pid: 902, ppid: 901, command: "codex", argv: ["codex"] });
|
||||
expect(await lifecycle().continueStartup("builder@fork-recovery")).toMatchObject({ ok: true, continuityOutcome: "forked" });
|
||||
expect(identityReads.at(-1)).toEqual([902, STARTED]);
|
||||
expect(tmux.sendText).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { codexCommandResumeToken, observeCodexForkProcess, observeCodexPaneProce
|
||||
import { prepareCodexSeatFolder } from "../src/domain/codex-team-workspace.js";
|
||||
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
|
||||
import { resolveCodexGitAddDirs } from "../src/domain/codex-git-add-dirs.js";
|
||||
import { shellWords } from "../src/domain/seat-restart-in-place.js";
|
||||
|
||||
// Issue #121: in a linked git worktree `<cwd>/.git` is a FILE ("gitdir: ..."), so passing it to Codex as
|
||||
// `--add-dir` breaks the Linux sandbox for every command. These tests use real temporary Git repositories
|
||||
@@ -154,7 +155,7 @@ describe("Codex resume and fork git add-dirs (a restored seat keeps .git writabl
|
||||
const asPs = (command: string) => command.slice(command.indexOf("codex ")).replace(/'/g, "");
|
||||
const pane = { getPanePid: async () => 4100 };
|
||||
const row = (command: string) => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex",
|
||||
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command) }];
|
||||
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command), argv: shellWords(command.slice(command.indexOf("codex "))) }];
|
||||
const resume = await launchCommand(repo, { resumeToken: "thread-1" });
|
||||
expect(codexCommandResumeToken(asPs(resume))).toBe("thread-1");
|
||||
expect(await observeCodexPaneProcess({ target: "r01-qa", tmux: pane, listProcesses: () => row(resume),
|
||||
@@ -207,7 +208,8 @@ describe("Codex resume and fork git add-dirs (a restored seat keeps .git writabl
|
||||
// The process line joins argv with spaces and no quoting, so a dir before the id would hide it.
|
||||
const line = built.core.replace(/'/g, "");
|
||||
expect(codexCommandResumeToken(line)).toBe("thread-1");
|
||||
const rows = () => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: line }];
|
||||
const rows = () => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000",
|
||||
command: line, argv: shellWords(built.core) }];
|
||||
const pane = { getPanePid: async () => 4100 };
|
||||
// The restart's own proof, then the send path's check behind a shell.
|
||||
expect(await observeCodexPaneProcess({ target: "r01-qa", tmux: pane, listProcesses: rows, expectedToken: "thread-1", requireResume: true })).not.toBeNull();
|
||||
@@ -251,7 +253,7 @@ describe("Codex seat folder add-dir (a seat keeps its LEARNED.md and recap writa
|
||||
const asPs = (command: string) => command.slice(command.indexOf("codex ")).replace(/'/g, "");
|
||||
const pane = { getPanePid: async () => 4100 };
|
||||
const row = (command: string) => [{ pid: 4100, ppid: 1, pgid: 4100, tpgid: 4100, executableName: "codex",
|
||||
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command) }];
|
||||
startedAt: "Sat Jan 1 12:00:00 2000", command: asPs(command), argv: shellWords(command.slice(command.indexOf("codex "))) }];
|
||||
const resume = await launchCommand(repo, { resumeToken: "thread-1" });
|
||||
expect(codexCommandResumeToken(asPs(resume))).toBe("thread-1");
|
||||
expect(await observeCodexPaneProcess({ target: "r01-qa", tmux: pane, listProcesses: () => row(resume),
|
||||
|
||||
@@ -3,6 +3,13 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { defaultListProcesses } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import { readNativeProcessArguments } from "../src/domain/native-process-arguments.js";
|
||||
import { findExactNativeResumeProcess, verifyCodexPaneProcess } from "../src/domain/native-process-lineage.js";
|
||||
// The fixture's synthetic PIDs must never inspect host processes.
|
||||
vi.mock("../src/domain/native-process-arguments.js", async importOriginal => ({
|
||||
...await importOriginal<typeof import("../src/domain/native-process-arguments.js")>(),
|
||||
readNativeProcessArguments: vi.fn(async () => new Map()),
|
||||
}));
|
||||
import { restoreProc, serveProcRoot, writeFakeProc } from "./helpers/fake-proc.js";
|
||||
|
||||
let scratch: string | undefined;
|
||||
@@ -11,6 +18,8 @@ afterEach(() => {
|
||||
restoreProc();
|
||||
Object.defineProperty(process, "platform", platform);
|
||||
vi.unstubAllEnvs();
|
||||
vi.mocked(readNativeProcessArguments).mockReset();
|
||||
vi.mocked(readNativeProcessArguments).mockResolvedValue(new Map());
|
||||
if (scratch) fs.rmSync(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -41,11 +50,19 @@ it("reads foreground identity through the real async executable and parser path"
|
||||
expect(rows).toEqual([
|
||||
{ pid: 101, ppid: 1, pgid: 101, tpgid: 102, executableName: "zsh", startedAt: "Sat Jan 1 12:00:00 2000", command: "-zsh" },
|
||||
{ pid: 102, ppid: 101, pgid: 102, tpgid: 102, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /opt/bin/codex -C /project" },
|
||||
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/vendor/bin/codex -C /project" },
|
||||
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, executableName: "codex", startedAt: "Sat Jan 1 12:00:00 2000", command: "/opt/vendor/bin/codex -C /project", argv: null },
|
||||
{ pid: 104, ppid: 1, pgid: 104, tpgid: -1, executableName: "node", startedAt: "Sat Jan 1 12:00:00 2000", command: "node /tmp/other.js --label codex" },
|
||||
{ pid: 106, ppid: 1, pgid: 106, tpgid: -1, executableName: "tmux: server", startedAt: "Sat Jan 1 12:00:00 2000", command: "tmux new-session -d" },
|
||||
{ pid: 107, ppid: 1, pgid: 107, tpgid: -1, executableName: "node (vitest 1)", startedAt: "Sat Jan 1 12:00:00 2000", command: "node (vitest 1)" },
|
||||
]);
|
||||
// A rejected helper (including a policy timeout) must leave the table intact.
|
||||
vi.mocked(readNativeProcessArguments).mockRejectedValueOnce(new Error("argv helper rejected"))
|
||||
.mockRejectedValueOnce(new Error("policy timeout"));
|
||||
const observation = await verifyCodexPaneProcess({ target: "%1", tmux: { getPanePid: async () => 101 },
|
||||
listProcesses: defaultListProcesses, expectedToken: "recorded", requireResume: false });
|
||||
expect(observation?.process.pid).toBe(103);
|
||||
expect(observation?.process.argv).toBeNull();
|
||||
expect(findExactNativeResumeProcess(await defaultListProcesses(), 101, "codex", "recorded")).toBeNull();
|
||||
fs.writeFileSync(path.join(scratch, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
expect(await defaultListProcesses()).toEqual([]);
|
||||
});
|
||||
@@ -55,7 +72,8 @@ it("reads foreground identity through the real /proc reader and parser path", as
|
||||
const listed = [
|
||||
{ pid: 101, ppid: 1, pgid: 101, tpgid: 102, name: "zsh", startedAt: started, command: "-zsh" },
|
||||
{ pid: 102, ppid: 101, pgid: 102, tpgid: 102, name: "node", startedAt: started, command: "node /opt/bin/codex -C /project" },
|
||||
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, name: "codex", startedAt: started, command: "/opt/vendor/bin/codex -C /project" },
|
||||
{ pid: 103, ppid: 102, pgid: 102, tpgid: 102, name: "codex", startedAt: started, command: "/opt/vendor/bin/codex -C /project",
|
||||
argv: ["/opt/vendor/bin/codex", "-C", "/project"] },
|
||||
{ pid: 104, ppid: 1, pgid: 104, tpgid: -1, name: "node", startedAt: started, command: "node /tmp/other.js --label codex" },
|
||||
// Linux task names may contain spaces (prctl PR_SET_NAME / process.title).
|
||||
{ pid: 106, ppid: 1, pgid: 106, tpgid: -1, name: "tmux: server", startedAt: started, command: "tmux new-session -d" },
|
||||
@@ -71,12 +89,38 @@ it("reads foreground identity through the real /proc reader and parser path", as
|
||||
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "codex-process-read-"));
|
||||
fs.writeFileSync(path.join(scratch, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
vi.stubEnv("PATH", scratch);
|
||||
vi.mocked(readNativeProcessArguments).mockClear();
|
||||
const rows = await defaultListProcesses();
|
||||
expect(rows.sort((a, b) => a.pid - b.pid)).toEqual(listed.map(({ name, ...row }) => ({ ...row, executableName: name })));
|
||||
expect(readNativeProcessArguments).not.toHaveBeenCalled();
|
||||
serveProcRoot(path.join(root, "gone"));
|
||||
expect(await defaultListProcesses()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the foreground Codex deliverable beyond the argv reader's 128-process budget", async () => {
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "codex-process-budget-"));
|
||||
const started = "Sat Jan 1 12:00:00 2000";
|
||||
const output = [
|
||||
"PID PPID PGID TPGID UCOMM LSTART COMMAND",
|
||||
`101 1 101 102 zsh ${started} -zsh`,
|
||||
...Array.from({ length: 128 }, (_, index) => `${200 + index} 1 ${200 + index} ${200 + index} codex ${started} codex resume recorded`),
|
||||
`400 101 102 102 codex ${started} codex resume recorded`,
|
||||
`401 1 401 -1 codex ${started} codex resume recorded`,
|
||||
];
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
fs.writeFileSync(path.join(scratch, "ps"), `#!/bin/sh\nprintf '%s\\n' ${output.map(quote).join(" ")}\n`, { mode: 0o755 });
|
||||
vi.stubEnv("PATH", scratch);
|
||||
vi.mocked(readNativeProcessArguments).mockImplementation(async requested =>
|
||||
new Map(requested.slice(0, 128).map(pid => [pid, ["codex", "resume", "recorded"]])));
|
||||
const rows = await defaultListProcesses();
|
||||
expect(readNativeProcessArguments).toHaveBeenCalledWith([...Array.from({ length: 128 }, (_, index) => 200 + index), 400]);
|
||||
expect(rows.find(row => row.pid === 400)?.argv).toBeNull();
|
||||
expect((await verifyCodexPaneProcess({ target: "%1", tmux: { getPanePid: async () => 101 },
|
||||
listProcesses: defaultListProcesses, expectedToken: "recorded", requireResume: false }))?.process.pid).toBe(400);
|
||||
expect(findExactNativeResumeProcess(rows, 101, "codex", "recorded")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads this test process with the installed ps columns, without starting a provider", async () => {
|
||||
const rows = await defaultListProcesses();
|
||||
const self = rows.find((r) => r.pid === process.pid);
|
||||
|
||||
@@ -41,7 +41,7 @@ it.each([
|
||||
});
|
||||
|
||||
describe("the live predecessor record", () => {
|
||||
function fixture(options: { wrongThread?: boolean; stale?: boolean; incompleteLatest?: boolean; turnover?: boolean } = {}) {
|
||||
function fixture(options: { wrongThread?: boolean; stale?: boolean; incompleteLatest?: boolean; turnover?: boolean; argv?: string[] | null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "codex-rebuild-")); roots.push(root);
|
||||
const path = join(root, "record.jsonl");
|
||||
writeFileSync(path, [
|
||||
@@ -52,13 +52,14 @@ describe("the live predecessor record", () => {
|
||||
const native = provenCodexResume("fixture-thread");
|
||||
let reads = 0;
|
||||
const listProcesses = vi.fn(() => {
|
||||
const rows = native.listProcesses();
|
||||
const rows = native.listProcesses().map(row => row.executableName === "codex" && options.argv !== undefined
|
||||
? { ...row, command: options.argv?.join(" ") ?? row.command, argv: options.argv } : row);
|
||||
if (options.turnover && reads++ > 0) return rows.map(row => ({ ...row, startedAt: "Tue Oct 20 05:02:01 2026" }));
|
||||
return rows;
|
||||
});
|
||||
return { nodeId: "seat", sessionName: "member@team", cwd: "/project", path,
|
||||
tmux: { getPanePid: native.getPanePid }, listProcesses,
|
||||
readThreadId: vi.fn(async () => "fixture-thread"), rollout: () => path,
|
||||
readThreadId: vi.fn(async () => "fixture-thread"), rollout: vi.fn(() => path),
|
||||
sessions: { currentOccupantTenure: () => ({ generationUuid: "generation", bootAt: "2026-10-20T05:00:00Z" }),
|
||||
getBindingForNode: () => ({ tmuxSession: "member@team", tmuxPane: "%1" }),
|
||||
currentOccupantGenerationForSession: () => "generation" } as never };
|
||||
@@ -68,6 +69,25 @@ describe("the live predecessor record", () => {
|
||||
expect(result).toMatchObject({ state: "observed", observedAt: "2026-10-20T05:01:00.000Z",
|
||||
envelope: { approvalPolicy: "on-request", sandboxPolicy: payload.sandbox_policy } });
|
||||
});
|
||||
it("reports a managed profile containing spaces from the selected process's exact argv", async () => {
|
||||
const argv = ["codex", "-p", "openrig desktop work", "resume", "fixture-thread"];
|
||||
expect(await observeCodexRebuildPermissions(fixture({ argv }))).toMatchObject({ state: "observed", profile: "openrig desktop work" });
|
||||
});
|
||||
it("retains the native-log join for a valid fresh Codex process", async () => {
|
||||
expect(await observeCodexRebuildPermissions(fixture({ argv: ["codex", "-p", "managed work"] })))
|
||||
.toMatchObject({ state: "observed", profile: "managed work" });
|
||||
});
|
||||
it.each([null, [], ["codex", "resume", "fixture-thread", "bad\0argument"], ["node", "resume", "fixture-thread"]])(
|
||||
"does not inspect native records for an opaque or malformed predecessor argv: %j", async argv => {
|
||||
const input = fixture({ argv });
|
||||
expect(await observeCodexRebuildPermissions(input)).toMatchObject({ state: "unverified" });
|
||||
expect(input.readThreadId).not.toHaveBeenCalled();
|
||||
expect(input.rollout).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses a thread named only inside a spaced option value", async () => {
|
||||
const argv = ["codex", "resume", "--add-dir", "/tmp/fixture-thread", "another-thread"];
|
||||
expect(await observeCodexRebuildPermissions(fixture({ argv }))).toMatchObject({ state: "unverified" });
|
||||
});
|
||||
it.each(["wrongThread", "stale", "incompleteLatest", "turnover"] as const)(
|
||||
"discloses unverified observation on %s, rather than using an older/different policy", async reason => {
|
||||
expect(await observeCodexRebuildPermissions(fixture({ [reason]: true }))).toMatchObject({ state: "unverified" });
|
||||
|
||||
@@ -23,7 +23,8 @@ const errorFrame = `No saved session found with ID ${token}. Run \`codex resume\
|
||||
const started = "Tue Oct 20 05:00:01 2026";
|
||||
const rows = (startedAt = started) => [
|
||||
{ pid: 900, ppid: 1, command: "-zsh", pgid: 900, tpgid: 901, executableName: "zsh", startedAt: "Tue Oct 20 04:59:00 2026" },
|
||||
{ pid: 901, ppid: 900, command: `codex resume ${token}`, pgid: 901, tpgid: 901, executableName: "codex", startedAt },
|
||||
{ pid: 901, ppid: 900, command: `codex resume ${token}`, argv: ["codex", "resume", token],
|
||||
pgid: 901, tpgid: 901, executableName: "codex", startedAt },
|
||||
];
|
||||
|
||||
type Evidence = (pid: number, identity: string) => Promise<CodexResumeEvidence> | CodexResumeEvidence;
|
||||
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user