Compare commits

...
7 Commits
Author SHA1 Message Date
dev-driver 12df562ceb fix: qualify OpenShell E1 patch fit on the selected release base 2026-11-02 04:18:18 +00:00
Mike Schwarz 16121548c2 fix(cli): include the SDK in clean installations (#2374)
A clean install of the CLI couldn't start: bare `rig` and `rig --help` exited 1 with `ERR_MODULE_NOT_FOUND` for `@modelcontextprotocol/sdk`. `packages/cli/src/commands/environment.ts` imports the SDK client at module load and `index.ts` loads the environment command statically, but the SDK was declared only in the CLI's `devDependencies`, so an install without development dependencies didn't have it. The SDK (`^1.32.2`) is now a production dependency of `packages/cli`; the lockfile update is metadata only (its dev flags), with no package added, removed or re-versioned. `scripts/cli-runtime-dependencies.test.mjs` walks the import closure from the CLI's entry points and fails if any external package it reaches isn't a production dependency, and the testbed image now runs `rig --help` after `rig --version`.

4 files, +77/-87.

(cherry picked from commit a2d4d261fd)
2026-11-02 01:50:24 +00:00
Mike Schwarz e5baf5989b fix(daemon): a provider switch keeps the running effort instead of refusing, and says which effort it used (#2362)
A provider switch on a Claude seat (`rig provider switch --seat <seat>`) no longer refuses with `effort_would_change` when the seat runs with an `--effort` that differs from its saved, non-explicit effort. The relaunch keeps the running effort, so a person recovering from a limit or an outage doesn't have to set effort and switch again. An explicit `rig seat set-effort` choice still wins: restore's launch-effort seam reads the seat at launch, uses the choice when one exists (latest wins) and the kept effort only when none does, and reports what it selected on every keep-pane relaunch. The kept effort is never written to the seat. The switch's `effort` line is worded from that report; without one it says so, and a relaunch that doesn't report resuming says what was asked and the rule the recovery command follows, not a value read before the switch. An unreadable running effort proceeds as before and is disclosed. #2344's token-reference restore, pre-stop and unknown-effect handling are unchanged.

Follows #2344. Source +102/-25, tests +196/-27.
2026-11-01 21:17:30 +00:00
dev60-review 8ca69bf8a8 docs(reference): describe the answered permission path where runner docs still described refusal
The answered permission path (section 7 of the harness and runner contract)
ships in 0.7.8. Four reference pages still described the first, refusal-only
recipe or an older guest surface, and the contract labelled the path 0.7.7.

- author-contracts: the dsh/ACP recipe is the answered one, default permit.
- community-harness-presets: Approval names the runner-preset exception.
- runner-conformance README: OpenRig's side no longer lists refusing another
  person's click or typed words; any registered person's click counts.
- OpenShell README: the guest listener also serves the three
  permission-request routes, with the same seat grant.
- harness-runner-contract: the four answered-path labels say 0.7.8. No other
  word changes.
2026-11-01 19:52:16 +00:00
dev60-review 0957554c2d docs(release): trim the 0.7.8 notes toward the release-notes length 2026-11-01 19:52:15 +00:00
dev60-review 91929c6521 docs(release): describe 0.7.8 2026-11-01 19:47:24 +00:00
dev60-review 821ddac44e chore(release): prepare 0.7.8 2026-11-01 19:47:24 +00:00
37 changed files with 1093 additions and 363 deletions

No files matched your search

+225
View File
@@ -8,6 +8,231 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.8]
OpenRig 0.7.8 lets a person answer a runner seat's permission requests, with a
conformance check and an example runner to build against. It adds a maintained
way to run a Claude worker in an NVIDIA OpenShell sandbox, and includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI.
### Before you upgrade
- **Restart the daemon after installing:**
```bash
npm install -g @openrig/cli@0.7.8
rig daemon stop && rig daemon start
```
Four migrations (142 to 145) store a Claude seat's MCP opt-out, seat activity
grants, OpenShell environment bindings and the person chosen to answer a
seat's permission requests.
([#2177](https://github.com/mvschwarz/openrig/pull/2177), [#2295](https://github.com/mvschwarz/openrig/pull/2295), [#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2301](https://github.com/mvschwarz/openrig/pull/2301))
- **The bundled openrig-core plugin advances from 0.1.23 to 0.1.24.** One line
of its seat-continuity skill changed. Managed refresh preserves replaced files
under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you
still need from that copy. ([#2287](https://github.com/mvschwarz/openrig/pull/2287))
### Permissions for runner seats
- **A runner seat's permission requests now reach a person, and the default is
permit.** The runner contract's first version refused every request. Section 7
of `docs/reference/harness-runner-contract.md` now covers who is asked:
- **With a default person,** "They get an ordinary ask in the Feed and on
Slack. No setup is needed."
- **To choose someone else,** run
`rig seat set-permissions-to <seat> --to <person> --reason "…"`. `--clear`
returns to the default.
- **When no one can be asked,** the request is "Permitted at once, with the
agent's own allow option."
- **Refusal is opt-in,** with `--deny`.
"A person's no is never a grant." An unanswered request "stays pending and
visible" until the agent's turn ends: "It's never timed out into a grant."
([#2301](https://github.com/mvschwarz/openrig/pull/2301))
- **A runner conformance check:**
`node docs/reference/runner-conformance/check-runner.mjs --runner '<runner command with {agent}>' --name <harness>`.
It covers the answered path, and needs Node and tmux but no daemon, model,
credentials or network.
([#2303](https://github.com/mvschwarz/openrig/pull/2303), [#2306](https://github.com/mvschwarz/openrig/pull/2306))
- **An example runner for DeepSeek Harness (`dsh`),** with its preset, is under
`docs/reference/community-harness-examples/dsh/`. OpenRig doesn't install it.
([#2305](https://github.com/mvschwarz/openrig/pull/2305))
- **A seat activity grant** lets a runner outside the host's hook setup report
activity for one seat and launch, without the install-wide hook token. ([#2295](https://github.com/mvschwarz/openrig/pull/2295))
### Claude workers in OpenShell sandboxes
- **`rig environment setup` installs a maintained reference** that binds a seat
to one NVIDIA OpenShell sandbox (`rig environment create` or `attach`, then
`rig up`).
- **Context and results:** the seat's guidance, skills, plugins and startup
files go into the sandbox, and verified JSON results come back through
queue work.
- **Stop and delete:** stopping the agent, stopping compute and deleting the
sandbox are separate actions.
- **Restore:** a recreated sandbox resumes the same conversation.
The steps are in `daemon/assets/openshell/README.md`.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300))
### Seats, delivery and the TUI
- **Seats:**
- an isolated tmux server per OpenRig home
(`rig config set terminal.tmux_server isolated`);
- `claude_mcp: none` for a Claude member;
- resuming with a new token file;
- first starts on `rig launch --seats`;
- an accurate account record and kept effort after `rig provider switch`;
- skill copies, activity hooks and startup declarations kept across
`rig down`, resumes and exports;
- clearer stops for handover's `fork:<seat>`, a missing conversation,
`claude` not found, Codex restart refusals and failed unpins.
([#1260](https://github.com/mvschwarz/openrig/pull/1260), [#2177](https://github.com/mvschwarz/openrig/pull/2177), [#2269](https://github.com/mvschwarz/openrig/pull/2269), [#2342](https://github.com/mvschwarz/openrig/pull/2342), [#2344](https://github.com/mvschwarz/openrig/pull/2344), [#2337](https://github.com/mvschwarz/openrig/pull/2337), [#2338](https://github.com/mvschwarz/openrig/pull/2338), [#2190](https://github.com/mvschwarz/openrig/pull/2190), [#2297](https://github.com/mvschwarz/openrig/pull/2297), [#2287](https://github.com/mvschwarz/openrig/pull/2287), [#2255](https://github.com/mvschwarz/openrig/pull/2255), [#2282](https://github.com/mvschwarz/openrig/pull/2282), [#2330](https://github.com/mvschwarz/openrig/pull/2330), [#2332](https://github.com/mvschwarz/openrig/pull/2332))
- **tmux:**
- with `OPENRIG_TMUX_CONTROL=1`, no crash on tmux 3.3a to 3.7c when the
control client ends while another leaves;
- a submit key that a read-only client blocks is pasted instead.
([#2317](https://github.com/mvschwarz/openrig/pull/2317), [#2325](https://github.com/mvschwarz/openrig/pull/2325), [#2345](https://github.com/mvschwarz/openrig/pull/2345))
- **Wakes:**
- `OPENRIG_WAKE_SUSPEND` matches destinations with a colon;
- prompt escalations stay deliverable without a reachable person;
- reminders stop at a full quota;
- `human@kernel` raises no false finding;
- OpenRig's own Codex hooks are trusted when no choice is recorded;
- refusals name their fix.
([#2353](https://github.com/mvschwarz/openrig/pull/2353), [#2343](https://github.com/mvschwarz/openrig/pull/2343), [#2281](https://github.com/mvschwarz/openrig/pull/2281), [#2286](https://github.com/mvschwarz/openrig/pull/2286), [#2294](https://github.com/mvschwarz/openrig/pull/2294), [#2290](https://github.com/mvschwarz/openrig/pull/2290), [#2284](https://github.com/mvschwarz/openrig/pull/2284), [#2280](https://github.com/mvschwarz/openrig/pull/2280), [#2273](https://github.com/mvschwarz/openrig/pull/2273), [#2202](https://github.com/mvschwarz/openrig/pull/2202))
- **Slack and usage:**
- typed replies to parked decisions are recorded as answers;
- row bodies render Markdown;
- resolved decisions stop showing as live asks;
- OpenRouter charge retries aren't delayed.
([#2293](https://github.com/mvschwarz/openrig/pull/2293), [#2299](https://github.com/mvschwarz/openrig/pull/2299), [#2316](https://github.com/mvschwarz/openrig/pull/2316), [#2349](https://github.com/mvschwarz/openrig/pull/2349), [#2314](https://github.com/mvschwarz/openrig/pull/2314))
- **Hosts and setup:**
- `rig host edit` and `rig host remove`;
- a warning when another OS user's daemon holds the port;
- `rig daemon status` shows a pending start.
([#2310](https://github.com/mvschwarz/openrig/pull/2310), [#2346](https://github.com/mvschwarz/openrig/pull/2346), [#2302](https://github.com/mvschwarz/openrig/pull/2302), [#2348](https://github.com/mvschwarz/openrig/pull/2348))
- **The TUI and views:**
- agent pages show health and recent work;
- pod, spec and instance-table views read correctly;
- `rig terminal open mission:<id> --follow --provider herdr` follows a
mission.
([#2263](https://github.com/mvschwarz/openrig/pull/2263), [#2264](https://github.com/mvschwarz/openrig/pull/2264), [#2265](https://github.com/mvschwarz/openrig/pull/2265), [#2266](https://github.com/mvschwarz/openrig/pull/2266), [#2327](https://github.com/mvschwarz/openrig/pull/2327), [#2260](https://github.com/mvschwarz/openrig/pull/2260), [#2289](https://github.com/mvschwarz/openrig/pull/2289))
- **Also:**
- MCP text is cut between whole characters;
- an ACP cancel survives a failed note;
- long transcript lines read faster;
- there are Pi and kernel dashboard recovery guides.
([#2268](https://github.com/mvschwarz/openrig/pull/2268), [#2262](https://github.com/mvschwarz/openrig/pull/2262), [#2274](https://github.com/mvschwarz/openrig/pull/2274), [#2186](https://github.com/mvschwarz/openrig/pull/2186), [#2275](https://github.com/mvschwarz/openrig/pull/2275))
### Behaviour that changed
- **A runner following section 7 gets answers or permits, not refusals.**
`--deny` keeps a seat refusing.
- **On tmux older than 3.7c,** `OPENRIG_TMUX_CONTROL=1` uses spawned clients
instead of a control client.
### Known limits
- **No harness has run end to end under the contract yet.** The dsh example and
the conformance check were checked against stand-ins.
- **The answer owner is a registered person.** "A colleague's answer is
followed on this host. If the ask is handed to another host, the request
waits, shown as needs input, until the agent's turn ends."
- **`allow_always` from a permit:** with no one to ask, "If the agent offers
only `allow_always`, the harness then remembers a permission nobody chose."
- **"Permits and answers aren't recorded beyond the runner's pane and the ask
itself."**
- **Coming as follow-ups:** recording a permit made when no one can be asked,
and passing a person's typed words that aren't a plain yes or no to the agent.
- **The dsh example keeps one hold per seat:** "if dsh asks twice in one turn,
settling one ask releases the hold while the other may still wait on a
person."
- **The OpenShell reference requires:**
- NVIDIA OpenShell 0.1.6;
- a Linux image;
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
credentials aren't supported.
"Provider registration and image construction can incur costs." The worker's
activity reads unknown, from pane sampling, never idle.
- **"Never grant the guest access to the daemon's main API port (normally
7433)."** The guest-only listener is what confines a guest's reach. Its grant
only authorizes requests for its own seat and launch.
- **The 0.7.7 and 0.7.6 limits below still apply.**
### Credits
Thanks to @Aschenbath, @dajiaohuang, @Vaishnavi220506, @Nikhila-Dhevi-R,
@Nikhi00718, @oodadoudou, @DeryFerd, @ch-davidriott, @vanducng, @Ethereal49,
@AdityaxDeore and @Mydudeclaps for merged contributions. Thanks to @salbang,
@ziadeh, @davey64, @bpamiri and @oneandonlydean for reports carried into this
release, and to @bpamiri for dsh-seat, whose fake agent the conformance check's
stand-in follows.
## [0.7.7]
**Not yet published.** 0.7.7 is a separate reliability patch for 0.7.6. Its three
fixes are also in 0.7.8.
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
idle prompt under Claude's "Update installed" line is recognized again, managed
compaction no longer types into a message you haven't sent, and a Claude seat
launched through a wrapper script can be rebuilt again.
### Before you upgrade
- **Restart the daemon after installing** so the fixes take effect:
```bash
npm install -g @openrig/cli@0.7.7
rig daemon stop && rig daemon start
```
There are no database migrations. The bundled openrig-core plugin stays at
0.1.23 with unchanged files.
### Fixed
- **A Claude seat under the "Update installed" line reads idle again.** After
Claude installs an update, it prints "✔ Update installed · /restart to apply"
(or "Restart to apply") below its mode bar. OpenRig didn't recognize that line,
so a seat at an empty prompt read unknown instead of idle, and managed
compaction waited. Both spellings are now recognized; lines that only look
similar still read unknown. ([#2311](https://github.com/mvschwarz/openrig/pull/2311))
- **Managed compaction no longer types into a draft.** With the optional typing
guard off (the default), compaction's preparation prompt could be pasted onto
a message someone had typed but not sent. Now the preparation prompt,
`/compact` and the restore prompts check the input box first: typed or
unreadable input holds the step with a reason. A held restore prompt goes ahead
on its own once the box is empty; a manual `rig compact` says why it stopped,
to be retried once the box is empty. An empty box or Claude's own suggestion
goes ahead.
([#2347](https://github.com/mvschwarz/openrig/pull/2347))
- **A Claude seat launched through a wrapper script can be rebuilt again.** On
0.7.6, `rig seat handover --source rebuild` on a seat whose `claude` is a `#!`
script that starts the real Claude without `exec` stopped after 30 seconds with
"Waiting to confirm the new Claude process in this pane", though the new Claude
was running. The readiness check now also accepts the script's own direct child
when it names this launch's conversation; every other check is unchanged.
([#2341](https://github.com/mvschwarz/openrig/issues/2341), [#2354](https://github.com/mvschwarz/openrig/pull/2354))
- **A failed handover no longer moves the seat's resume target** to the new,
unprimed conversation while the seat keeps its previous occupant.
([#2354](https://github.com/mvschwarz/openrig/pull/2354))
### Known limits
- **Other reasons managed compaction can wait are unchanged.**
- **The draft check reads the input box just before pasting.** Typing that starts
in the same moment can still be appended to.
- **A Claude seat whose current occupant generation was never registered,** such as
an adopted Claude carrying another generation, or a managed launch whose tenure
record failed to write, keeps its first conversation token, but later hooks don't
move it.
- **The 0.7.6 known limits below still apply.**
### Thanks
- @oneandonlydean for the report and the evidence in #2341.
## [0.7.6]
OpenRig 0.7.6 lets one terminal workspace follow the current owners of a slice's
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.8/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.8/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.8` 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.8/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.8/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.
+2 -1
View File
@@ -49,7 +49,8 @@ COPY ${OPENRIG_TARBALL} /tmp/openrig.tgz
RUN set -eux; \
npm install -g /tmp/openrig.tgz; \
rm -f /tmp/openrig.tgz; \
rig --version
rig --version; \
rig --help
# --- layer 4: stub runtime assets (zero-token by construction; staged by the build verb into the
# build context as ./stub-assets/, hashed into the manifest via deriveStubAssetsHash) ---
+1 -1
View File
@@ -16,7 +16,7 @@ This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.c
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
| The dsh/ACP input, cancellation and refusal-first permission recipe | Proposed author implementation | No end-to-end dsh integration is established. Native reopen capabilities and the tools-off case remain implementer questions. |
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | Proposed author implementation | No end-to-end dsh integration is established. Native reopen capabilities and the tools-off case remain implementer questions. |
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
| Environment preparation, projection, artifact export and owned-resource cleanup | Measured in a private wrapper; generic seam proposed | The platform chapter makes the responsibilities reusable. Core does not provide a generic platform driver or `DeploySpec` loader at this baseline. |
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
+1 -1
View File
@@ -20,7 +20,7 @@ it never reads an unsupported capability as idle, working, ready or verified.
| Resume | when declared | `resume: true` with one of the two above (see [Reopening a conversation](#reopening-a-conversation-resume-true)). Without it, a resume says `Resume is unsupported: community harness "<name>" is launch-only`, and a restore stops and asks. |
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. |
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. A [runner preset](#runner-presets-runner-true) is the exception: its runner submits the agent's permission requests to OpenRig, which asks a person, and the default is permit (section 7 of the [harness and runner contract](harness-runner-contract.md)). |
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
+2 -2
View File
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.8/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.8/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
+4 -4
View File
@@ -183,11 +183,11 @@ existing built-in runtimes or require every possible runner protocol to use ACP.
- take answers typed into the pane;
- route an approval to another seat.
So a job that needed an edit or a command approved couldn't finish. The answered path below, added in 0.7.7,
So a job that needed an edit or a command approved couldn't finish. The answered path below, added in 0.7.8,
replaces this as the default.
- **Unknown requests from the agent:** answer with JSON-RPC `-32601` and carry on.
### The answered path (0.7.7)
### The answered path (0.7.8)
**Only requests the agent actually makes reach anyone.** The harness's own permission mode decides that, and a harness
that runs without asking gains no approval step. Ordinary conversation and `rig send` are unchanged.
@@ -286,7 +286,7 @@ permission nobody chose. That covers:
times first);
- a refusal the table marks "permits", and any status or code this section doesn't list.
**Limits in 0.7.7:**
**Limits in 0.7.8:**
- **The answer owner is a registered person.** Choosing a seat (another agent) to answer is a later change.
- **A colleague's answer is followed on this host.** If the ask is handed to another host, the request waits, shown as
needs input, until the agent's turn ends.
@@ -371,7 +371,7 @@ permission nobody chose. That covers:
2. **For the dsh maintainer:** can dsh's ACP profile run with tools off? A first journey that needs no permission
answers is the simplest to check.
3. **For OpenRig:** should a later version route permission requests to a chosen seat, as `rig acp run` already does
with queue tasks? 0.7.7 routes them to the default person, or to a person chosen for the seat (section 7). A chosen
with queue tasks? 0.7.8 routes them to the default person, or to a person chosen for the seat (section 7). A chosen
seat is still open.
4. **For OpenRig:** once a second ACP harness exists, should one shared ACP runner live in OpenRig rather than one per
harness?
+1 -1
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.8`. 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.
+2 -2
View File
@@ -71,5 +71,5 @@ In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/doc
- **The model handoff:** `OPENRIG_MODEL` is mapped to your harness's own setting, with no wire this check can see.
- **A resume for a preset without `resume`:** OpenRig doesn't pass `OPENRIG_RESUME_REF` to it.
- **Restarting the agent after a failed handshake:** the contract doesn't rule on that.
- **The chosen person's side of an ask:** who may answer, the Feed and Slack, and refusing another person's click or typed
words are OpenRig's side, and its own tests cover them. Routing an ask to a seat is a later change.
- **The chosen person's side of an ask:** who may answer, the Feed and Slack are OpenRig's side, and its own tests
cover them. Routing an ask to a seat is a later change.
+256 -178
View File
@@ -1,192 +1,270 @@
# What changed in OpenRig 0.7.1
# What changed in OpenRig 0.7.8
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
after an upgrade it can still hold the previous version's note.
This note is for an agent on an install that was just upgraded to 0.7.8. OpenRig
0.7.8 lets a community harness's runner have its agent's permission requests
answered by a person, adds a maintained OpenShell path for Claude workers, and
includes 0.7.7's three reliability fixes. If you upgraded from 0.7.5 or earlier,
also read "What changed in OpenRig 0.7.6" below. For the full capability map,
read `rig context get onboarding-width/public-what-you-can-do.md`. After the
upgraded daemon starts, `rig context get reference/whats-new.md` describes this
version. Without a running daemon, read the packaged
`daemon/docs/reference/whats-new.md`; `$OPENRIG_HOME/reference/whats-new.md` can
still be the previous copy until startup.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
`daemon.db_conflict` (`warn`). None is removed.
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`.
- **Migrations 142 to 145** store a Claude seat's MCP opt-out, seat activity
grants, OpenShell environment bindings and a seat's chosen permission person.
- **The bundled openrig-core plugin goes from 0.1.23 to 0.1.24.** One skill line
changed. Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from the preserved copy.
## New things you can do
## Runner seats: a person answers permission requests
- **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.
Section 7 of `docs/reference/harness-runner-contract.md` is the rule. Read it
before writing or changing a runner.
- **Who is asked:**
- **the default person** (`rig gateway default`, or the only registered
person) gets an ordinary ask in the Feed and on Slack;
- **a chosen person,** after
`rig seat set-permissions-to <seat> --to <person> --reason "…"`. `--clear`
returns to the default;
- **no one:** when no one can be asked, the request is "Permitted at once,
with the agent's own allow option";
- **refusal** is opt-in, with `--deny`. `rig seat status` shows the choice.
- **What a person's answer does:** a click gives that option, a plain yes gives
the allow, and a plain no gives the reject. "A person's no is never a grant."
An unanswered request stays pending until the agent's turn ends.
- **The runner's protocol:**
- `POST /api/activity/permission-requests` submits the request;
- `GET …/<askId>?waitSeconds=25` reads the answer (25 at most);
- `POST …/<askId>/withdraw` withdraws it at the turn's end.
## Behaviour that changed
Deliver an answer once, only to the live request.
- **Check a runner** with
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`
from the installed package. It needs Node and tmux, and no daemon, model or
network.
- **The worked example** is the dsh runner under
`daemon/docs/reference/community-harness-examples/dsh/`.
- **A seat activity grant** lets a runner outside the host's hook setup report
activity for one seat and launch, without the install-wide hook token.
- **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.
## OpenShell workers
## Output that changed shape
`rig environment setup` installs a reference that binds a seat to one NVIDIA
OpenShell sandbox. Then use `rig environment create` (or `attach`), `rig up`, and
`rig environment export` for verified JSON results. Stopping the agent, stopping
compute and deleting an owned sandbox are separate actions. A recreated sandbox
can resume the same conversation from a checkpoint. Follow
`daemon/assets/openshell/README.md` in the installed package.
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
`--since` to resume, instead of an empty list.
**What it needs:**
- NVIDIA OpenShell 0.1.6;
- a Linux image;
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
credentials aren't supported.
## What to stop doing
"Provider registration and image construction can incur costs", so name them to
the person before you start. "Never grant the guest access to the daemon's main
API port (normally 7433)."
- **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.
## Other changes to act on
## Known gaps in 0.7.1
- **Mission following:** `rig terminal open mission:<id> --follow --provider herdr`
follows a mission's current owners, with the same Herdr path as slices.
- **Launch choices:**
- `claude_mcp: none` on a Claude member launches it without MCP servers;
- `rig config set terminal.tmux_server isolated` gives this OpenRig home its
own persistent tmux server.
- **Hosts:** `rig host edit` and `rig host remove` correct or retire a host
entry. Use them instead of editing `hosts.yaml` by hand.
- **Another user's daemon:** when `rig doctor` warns that the port is held by
another OS user's daemon, follow its fix (`rig config set daemon.port <free-port>`),
or set `OPENRIG_URL` to share that daemon on purpose.
- **tmux control mode:** with `OPENRIG_TMUX_CONTROL=1`, OpenRig starts a control
client only on tmux 3.7c or newer, and uses spawned clients otherwise.
- **Read-only tmux clients:** a submit key refused because of one goes through
the paste route.
- **Slack:** a typed reply to a decision parked on its person is recorded as the
answer. Read the person's words; don't treat the close as approval.
- **The TUI:** agent pages now show health findings and recently finished items,
and an agent spec's "not loaded in this view" means it wasn't read, not that
nothing runs it.
- **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`.
## Limits to keep in view
- **No harness has run end to end under the runner contract yet.** The dsh
example and the conformance check were checked against stand-ins.
- **The answer owner is a registered person,** and a colleague's answer is
followed on this host only. A request handed to another host waits as needs
input until the turn ends.
- **Permits and answers aren't recorded** beyond the runner's pane and the ask.
If the agent offers only `allow_always`, a permit makes its harness remember a
permission nobody chose.
- **Coming as follow-ups:** recording permits made when no one can be asked, and
passing typed words that aren't a plain yes or no to the agent.
- **The dsh example keeps one hold per seat:** two asks in one turn can leave one
waiting after the hold is released.
- **An OpenShell worker's activity reads unknown,** sampled from the pane, never
idle.
- **The 0.7.7 and 0.7.6 limits below still apply.**
---
## What changed in OpenRig 0.7.7
The changes from 0.7.6 to 0.7.7, a reliability patch for 0.7.6 that isn't
published yet. All three fixes are in 0.7.8.
### The fixes
- A Claude seat at an empty prompt reads idle again when Claude prints
"✔ Update installed · /restart to apply" (or "Restart to apply") below its mode
bar after installing an update. Before, that line hid the mode bar, so the seat
read unknown and managed compaction waited for an idle prompt it could not see.
Both spellings are recognized; lines that only look similar still read unknown.
([#2311](https://github.com/mvschwarz/openrig/pull/2311))
- Managed compaction checks the input box before it pastes its preparation
prompt, `/compact` or a restore prompt, even with the typing guard off. Typed,
unreadable or changed input holds that step. A manual `rig compact` then says it
stopped to preserve the input box; read `rig compact <seat> --state` and retry
once the box is empty. A held restore prompt waits and goes ahead on its own once
the box is empty. An empty box or a high-confidence suggestion goes ahead.
Ordinary `rig send` and the typing-guard defaults are unchanged.
([#2347](https://github.com/mvschwarz/openrig/pull/2347))
- A Claude seat whose `claude` is a `#!` wrapper that starts the real Claude without
`exec` can be rebuilt again (`rig seat handover --source rebuild`): readiness also
accepts the wrapper's direct child when it names this launch's conversation token,
and refuses every other process as before. A rebuild that fails no longer leaves
the seat's resume target on the new, unprimed conversation.
([#2341](https://github.com/mvschwarz/openrig/issues/2341), [#2354](https://github.com/mvschwarz/openrig/pull/2354))
### Limits to keep in view
- Other reasons managed compaction can wait are unchanged.
- The input-box check is a read just before the paste, not a lock: typing that
starts in the same moment can still be appended to.
- If a seat's current occupant generation was never registered (an adopted Claude
with another generation, or a managed launch whose tenure record failed to write),
hooks don't move its resume token after the first one.
- The 0.7.6 limits below still apply.
---
## What changed in OpenRig 0.7.6
The changes from 0.7.5 to 0.7.6, for an install upgrading from 0.7.5 or earlier.
### After the upgrade
- Restart the daemon (`rig daemon stop`, then `rig daemon start`) to load the new
code. Two database migrations record account-home selection and whether a
community harness launch declared a runner. Both use prefix 141, with distinct
full filenames.
- The bundled openrig-core plugin goes from 0.1.22 to 0.1.23. Its Claude activity
hook clears permission marks after tool batches. Managed refresh preserves
replaced files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply
local edits you still need from the preserved copy.
### Follow a slice's work in one workspace
Use `rig terminal open slice:<name-or-id> --follow --provider herdr`. Membership
comes from pending, in-progress and blocked queue rows tagged with the exact
slice. Associate a row with `rig queue create --slice <name-or-id>`; a handoff
inherits its tags unless replaced. A mission-only tag does not associate every
slice. One owner appears once, with all its obligations listed.
When a slice's folder name repeats across missions, tag its work with the slice's
unique ID (`rig queue create --slice <id>`) or add `--mission <mission>`. A row
tagged only with the shared name cannot be placed, so the view reads incomplete
until it is.
New slice views default to four automatic tiles (`--max-tiles` overrides it);
qitem views keep eight. A known empty slice stays following and can repopulate.
An incomplete read keeps the prior viewers. Missing or ambiguous scope identities
are errors, never an empty team. Pins, drafts, unknown input and focus retain
former viewers. Closing a viewer does not stop its agent.
An owner's join refused before any pane split can join on a later refresh;
hold notices keep the original seat, phase and reason. A created viewer whose
attach has not yet run can continue without replacing its pane. A viewer whose
attach already ran and then exited back to a shell is not restarted
automatically, so the whole slice view can hold until that tile is resolved.
No tested workaround is offered for that case.
Slice following targets the Linux Herdr 0.9.5 / protocol 22 path. Mission
following is not included. See
`reference/terminal-sessions.md` for controls, bounds and refresh behavior.
Configured Herdr session endpoints now carry through viewer/follow operations.
The TUI's terminal action opens the agent, pod or rig currently being viewed.
### Changes agents should act on
- **A readiness deadline does not end a still-starting daemon.** A nonzero
pending result names the child PID, launch endpoint and log. The child stays
running and the startup reservation stays in place; readiness is not
confirmed. Follow the returned pending guidance before attempting recovery.
- **Read exited state honestly.** A verified exited process in a retained pane
reads `pane_exited`, instead of running and ready. Its outstanding work uses
the existing recovery-finding route. Unknown observations still mean unknown.
- **Use the same member settings when expanding.** `rig expand` now carries raw
members to the canonical rig validator, keeping env, effort and startup
settings. Restores keep the model saved with `rig seat set-model`.
- **Account-home moves start a fresh conversation with a handover.**
`rig provider switch --seat <seat> --to-account <name> --account-home <dir>`
selects an existing home; preview with `--dry-run`. `--clear-account-home`
returns to configured defaults, not historical credentials. Check the moved
seat's account and conversation.
- **Claude API-key switching resumes the same conversation but interrupts work.**
Use `rig provider switch --seat <seat> --to-account api --api-key-file <abs>`;
return with `--to-account plan --token-file <abs>`. The API-key path remains
unqualified. Do not combine it with account-home selectors. Credential files
stay private; budgets remain the caller's responsibility.
- **A new Claude member can name its subscription token in the spec:**
`claude_token: {label: name, file: /abs/token}`, beside its own
`env.CLAUDE_CONFIG_DIR`. This stores a file reference, not the token. Validation
does not read the file; a missing or unreadable token uses the existing launch
fallback notice, so verify the account actually used.
- **Git over SSH uses the daemon's SSH agent** in managed seats. Make sure that
agent has the intended key (`ssh-add -l`).
- **Slack button answers resume decisions parked on the person asked.** Set
`rig slack setup --reactions off` if acknowledgement emoji should not wake
agents. The default remains `route`; manifest regeneration preserves it.
Replies posted outside a deleted Slack thread are tracked where Slack put them.
- **Queue delivery keeps its original intent.** A wake refused at a selection
prompt retries after it clears. Cross-host `queue create --verify` reads the
destination's receipt. An unreadable receipt stays unconfirmed; do not replay
a create just because verification could not establish delivery.
- **Preset authors can opt into `runner: true` and `passes_model: true`.** The
runner also requires `launch_result: true` and reports activity through the
existing hook route. Model input arrives as `OPENRIG_MODEL`. Passed means the
launch command was sent, not that the harness applied that model; the observed
model remains unknown. Cursor and Hermes examples leave model passing off
pending native qualification.
Start with the draft [integration author contracts](author-contracts.md) for
harness, runner and platform duties, with implemented interfaces distinguished from proposals.
- **Update viewer API handling.** `/api/terminal/viewer/ensure` returns 400 for
`view_required` and `unknown_provider` where it previously returned 200 with
an error body. Slice not-found is 404; ambiguous/unavailable is 409. Continue
reading the body code. Unsupported follow kinds keep `follow_view_required`
in a 200 `ok:false` response.
### Limits to keep in view
- The reported [Codex hook-refresh refusal (#2200)](https://github.com/mvschwarz/openrig/issues/2200)
remains unresolved. A verified Node-launcher fix is included, but that does
not establish that this report is fixed. If
`rig seat restart <seat> --keep-conversation` refuses, nothing is stopped:
leave the seat running and report the refusal with CLI and daemon versions.
A refused restart has not refreshed hooks; review any Codex hook prompt.
- Retained messages are not delivered. Protected Codex pastes and unreadable
composers can still retain work; inspect the explanation, use a larger pane
or shorten the message. Turning the typing guard off removes protection.
- Inventory now avoids redundant reads, but no general speed or stability
improvement is claimed without comparable measurements.
- Internet-facing hosted members remain experimental, unsupported, off by
default and at your own risk. Slice following does not require them.
+6 -90
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.8",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -1136,7 +1136,6 @@
"version": "1.32.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz",
"integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9 || ^2.0.5",
@@ -2902,7 +2901,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
@@ -3125,7 +3123,6 @@
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
@@ -3150,7 +3147,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -3164,7 +3160,6 @@
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -3226,7 +3221,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -3246,7 +3240,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3260,7 +3253,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -3405,7 +3397,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -3419,7 +3410,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -3436,7 +3426,6 @@
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -3452,7 +3441,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.6.0"
@@ -3462,7 +3450,6 @@
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"object-assign": "^4",
@@ -3480,7 +3467,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -3664,7 +3650,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3699,7 +3684,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -3745,7 +3729,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -3760,7 +3743,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": {
@@ -3773,7 +3755,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -3796,7 +3777,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3806,7 +3786,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3823,7 +3802,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3887,7 +3865,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"dev": true,
"license": "MIT"
},
"node_modules/estree-walker": {
@@ -3904,7 +3881,6 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -3914,7 +3890,6 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
@@ -3927,7 +3902,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -3947,7 +3921,6 @@
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
"integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
@@ -3991,7 +3964,6 @@
"version": "8.7.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
@@ -4011,7 +3983,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -4113,7 +4084,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -4135,7 +4105,6 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -4158,7 +4127,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -4213,7 +4181,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4247,7 +4214,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -4286,7 +4252,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4308,7 +4273,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4355,7 +4319,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
@@ -4427,14 +4390,12 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.7.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
"integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -4444,7 +4405,6 @@
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
@@ -4518,7 +4478,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isbot": {
@@ -4534,7 +4493,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
@@ -4550,7 +4508,6 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -4626,7 +4583,6 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/json5": {
@@ -4723,7 +4679,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4733,7 +4688,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -4747,7 +4701,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -4794,7 +4747,6 @@
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -4804,7 +4756,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
@@ -4852,7 +4803,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mz": {
@@ -4888,7 +4838,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"content-type": "^2.1.0"
@@ -4905,7 +4854,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -4968,7 +4916,6 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4981,7 +4928,6 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
"integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
@@ -4998,7 +4944,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -5021,7 +4966,6 @@
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -5031,7 +4975,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5047,7 +4990,6 @@
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -5111,7 +5053,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.20.0"
@@ -5293,7 +5234,6 @@
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -5321,7 +5261,6 @@
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
@@ -5358,7 +5297,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -5372,7 +5310,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -5388,7 +5325,6 @@
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -5654,7 +5590,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -5701,7 +5636,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
@@ -5737,7 +5671,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
@@ -5785,7 +5718,6 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
@@ -5805,14 +5737,12 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"dev": true,
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -5825,7 +5755,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5835,7 +5764,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5855,7 +5783,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5872,7 +5799,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -5891,7 +5817,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -5946,7 +5871,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -6246,7 +6170,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6"
@@ -6314,7 +6237,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"dev": true,
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
@@ -6333,7 +6255,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -6377,7 +6298,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -6475,7 +6395,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -7201,7 +7120,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -7234,7 +7152,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
@@ -7310,7 +7227,6 @@
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
@@ -7346,13 +7262,14 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.8",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/sdk": "^1.32.2",
"@modelcontextprotocol/server": "^2.4.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
@@ -7373,7 +7290,6 @@
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@modelcontextprotocol/sdk": "^1.32.2",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
@@ -7384,7 +7300,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.8",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7426,7 +7342,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.8",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.8",
"private": true,
"license": "Apache-2.0",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.8",
"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": [
@@ -48,6 +48,7 @@
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/sdk": "^1.32.2",
"@modelcontextprotocol/server": "^2.4.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
@@ -64,7 +65,6 @@
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@modelcontextprotocol/sdk": "^1.32.2",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
+5 -4
View File
@@ -110,7 +110,7 @@ export function environmentCommand(override?: EnvironmentCommandDeps): Command {
.requiredOption("--seat <address>").option("--node <id>", "Existing node ID; otherwise claimed by the exact named seat on its first launch")
.requiredOption("--gateway <ref>").requiredOption("--workspace <ref>").requiredOption("--name <name>")
.requiredOption("--image <digest>").requiredOption("--provider <ref>").requiredOption("--policy <path>")
.requiredOption("--guest-base-url <origin>", "Guest-reachable daemon origin for the single P6 activity grant")
.requiredOption("--guest-base-url <origin>", "Guest-reachable guest-only activity listener origin (not the main daemon API)")
.option("--operation <id>", "Stable effect identity (default verb plus binding ID)").option("--json");
if (verb === "attach") sub.requiredOption("--resource-id <id>", "Immutable live resource ID").option("--fingerprint <value>", "Expected host-key fingerprint");
sub.action(action(async (binding: string, opts: Record<string, string>) => {
@@ -160,10 +160,11 @@ export function environmentCommand(override?: EnvironmentCommandDeps): Command {
.option("--json").description("Select finite completion for one task, generation and required verified export; persistent defaults stay unchanged")
.action(action(async (id: string, opts: { request: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/completion`, JSON.parse(readFileSync(opts.request, "utf8")))))));
cmd.command("effect").argument("<binding>").requiredOption("--effect <name>", "close-viewer, stop-native, stop-compute or delete-owned")
.requiredOption("--generation <id>", "Selected execution generation").requiredOption("--operation <id>", "Stable effect identity; retries reconcile without another provider mutation")
.option("--generation <id>", "Selected execution generation; omit only for delete-owned before any generation exists")
.option("--resource-id <id>", "Exact immutable compute ID; required for delete-owned without a generation").requiredOption("--operation <id>", "Stable effect identity; retries reconcile without another provider mutation")
.option("--engine-socket <path>", "Owned local rootless Podman socket, required to capture dependent identities before delete-owned").option("--json")
.description("Execute one selected effect and return its separate observed outcome; attached native/storage/compute are preserved")
.action(action(async (id: string, opts: { effect: string; generation: string; operation: string; engineSocket?: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/effect`, { effect: opts.effect, generation: opts.generation, operationId: opts.operation, engineSocket: opts.engineSocket }, { timeoutMs: 120_000 })))));
cmd.addHelpText("after", "\nStart with rig environment setup --help. Create/attach are explicit effects; a 202 is unconfirmed. The first native start remains rig up with the installed openshell-claude preset. Checkpoint before stop, select effects explicitly, and recreate compute before exact normal rig restore. No unknown effect permits fresh-native fallback.\n");
.action(action(async (id: string, opts: { effect: string; generation?: string; resourceId?: string; operation: string; engineSocket?: string }) => report(checked(await (await client()).post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/effect`, { effect: opts.effect, generation: opts.generation ?? null, ...(opts.resourceId ? { resourceId: opts.resourceId } : {}), operationId: opts.operation, engineSocket: opts.engineSocket }, { timeoutMs: 120_000 })))));
cmd.addHelpText("after", "\nStart with rig environment setup --help. Run from your managed seat, or set OPENRIG_SESSION_NAME to the acting member@rig in a plain shell for existing transport attribution (not authentication). --guest-base-url names the separate guest-only activity listener, reachable from the guest; never the main API. Create/attach are explicit effects; a 202 is unconfirmed. The first native start remains rig up with the installed openshell-claude preset. Checkpoint before stop, select effects explicitly, and recreate compute before exact normal rig restore. No unknown effect permits fresh-native fallback.\n");
return cmd;
}
+2 -1
View File
@@ -24,7 +24,7 @@ export const USAGE_ACROSS_HOSTS_REFRESH_TIMEOUT_MS = 75_000;
type Peer = { logicalId: string; sessionName: string | null; home?: "same" | "different" | "unknown"; effect: string; step?: string };
type SeatView = { home?: string; activity?: string; peers: Peer[] };
type SwitchReport = { credentialKind?: "api-key"; home?: string; account: string; tokenFile?: string; conversation?: string | null; loginChanged: boolean; undo: string | null; peers: Peer[];
restart?: { recoveryCommand?: string; remainingStep?: string }; next?: string; warnings?: string[]; tokenApplied?: boolean };
restart?: { recoveryCommand?: string; remainingStep?: string }; next?: string; warnings?: string[]; tokenApplied?: boolean; effort?: string };
// A switch waits as long as `rig seat restart` does: the seat's in-place restart runs inside it.
const SWITCH_TIMEOUT_MS = 120_000;
@@ -735,6 +735,7 @@ CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before rel
console.log(`Claude ${seat.credentialKind === "api-key" ? "API key" : "token"}: ${seat.tokenFile ? `"${seat.account}", read from ${seat.tokenFile}` : "the config dir's stored login"}${seat.loginChanged
? seat.tokenApplied === false ? " (reference saved; the token didn't take effect)" : " (set)" : ""}`);
for (const warning of seat.warnings ?? []) console.log(`warning: ${warning}`);
if (seat.effort) console.log(`effort: ${seat.effort}`);
if (seat.conversation) console.log(`conversation: ${seat.conversation}`);
if (seat.undo) console.log(`to switch back: ${seat.undo}`);
}
@@ -0,0 +1,23 @@
import { afterEach, expect, it, vi } from "vitest";
import { environmentCommand } from "../src/commands/environment.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import type { EnvironmentCommandDeps } from "../src/commands/environment.js";
afterEach(() => vi.restoreAllMocks());
it.each([undefined, "generation-one"])("sends the explicit cleanup selection with generation %s", async generation => {
const lifecycleDeps: LifecycleDeps = {
spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 7433, db: "test.sqlite", startedAt: "2026-04-20T00:00:00Z" }) : null),
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn((path: string) => path === STATE_FILE),
mkdirp: vi.fn(), openForAppend: vi.fn(() => 3), isProcessAlive: vi.fn(() => true),
};
const post = vi.fn(async () => ({ status: 200, data: { state: "confirmed" } }));
const deps: EnvironmentCommandDeps = { lifecycleDeps, clientFactory: () => ({ post }) as never };
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await environmentCommand(deps).parseAsync(["node", "rig", "effect", "binding-one", "--effect", "delete-owned", "--operation", "delete-one",
...(generation ? ["--generation", generation] : ["--resource-id", "compute-one"]), "--engine-socket", "/owned/engine.sock", "--json"]);
expect(post).toHaveBeenCalledWith("/api/environments/binding-one/effect", {
effect: "delete-owned", generation: generation ?? null, ...(generation ? {} : { resourceId: "compute-one" }), operationId: "delete-one", engineSocket: "/owned/engine.sock",
}, { timeoutMs: 120_000 });
expect(log).toHaveBeenCalled();
});
+7
View File
@@ -392,4 +392,11 @@ describe("rig provider switch --seat tells the person what follows", () => {
expect(after.logs).toContain("recovery: rig launch rig dev.a --snapshot-id snap-1");
expect(after.logs).toContain("If the pane shows no running Claude, stop the seat first.");
});
it("a Claude seat's switch prints which effort its relaunch used, and why", async () => {
const effort = "kept the running effort high: the seat has no effort choice of its own.";
const kept = switchDeps(async () => ({ status: 200, data: { outcome: "rebind_in_progress", message: "Relaunched dev-a@rig in its own pane.",
seat: { account: "work", tokenFile: "/tokens/work", conversation: "conv-1", loginChanged: true, tokenApplied: true, effort, undo: null, peers: [] } } }));
expect((await run(["switch", "--seat", "dev-a@rig", "--to-account", "work", "--token-file", "/tokens/work"], kept)).logs).toContain(`effort: ${effort}`);
});
});
+12 -1
View File
@@ -6,6 +6,8 @@ The image recipe is an input recipe, not a published prebuilt image or a reprodu
## Install the reference and work connection
Run the environment commands from your managed OpenRig seat. In a plain shell, set `OPENRIG_SESSION_NAME` to the actual acting `member@rig` address, for example `export OPENRIG_SESSION_NAME=operator@kernel` when operating as that member. The CLI uses this existing convention to stamp `X-OpenRig-Session`; it attributes requests within the daemon's trusted transport, and is not authentication. The work and activity credentials below are separate authorities, not replacements for this actor attribution.
Start an MCP HTTP listener using `rig mcp serve --http --help` and the normal member-grant file on this daemon host. Select a guest-reachable address and explicit port. Setup adds one non-owner, one-seat, one-rig work grant in that existing file and stores its bearer credential in a private managed file. It prints only the file reference. Existing files/presets with different bytes are preserved.
```sh
@@ -15,7 +17,7 @@ rig environment setup --directory ./openshell-example --binding worker-one \
--guest-base-url http://host.openshell.internal:7782 --json
```
Enable the daemon's separate guest activity listener with `OPENRIG_GUEST_ACTIVITY_PORT=7782` and a guest-reachable `OPENRIG_GUEST_ACTIVITY_HOST` in the daemon launch environment. It is disabled by default; this is an explicit additional listener, not a change to the main API's binding. The listener serves only `POST /api/activity/hooks`, using the seat/generation grant. It exposes no queue, config, event-stream or parked-seat reads and refuses the install-wide token. Use the actual guest-reachable MCP and guest-activity ports. Setup generates `policy.yaml` for those two selected host/port pairs, without blanket egress. Never grant the guest access to the daemon's main API port (normally 7433). OpenShell's REST policy applies to a host and port: the dedicated listener confines the surface, while its activity grant and the separate MCP work grant authorize their respective requests. The activity rule names `/usr/bin/node`, matching the selected plugin hook interpreter. Qualify that path in your image. The separate read-only `platform.claude.com` rule is for the pinned native startup check and carries no inference credential.
Enable the daemon's separate guest activity listener with `OPENRIG_GUEST_ACTIVITY_PORT=7782` and a guest-reachable `OPENRIG_GUEST_ACTIVITY_HOST` in the daemon launch environment. It is disabled by default; this is an explicit additional listener, not a change to the main API's binding. The bind host must be an address on the daemon host that the guest can actually reach; a listener bound only to `127.0.0.1` is not reachable through a host alias that maps to a different interface. Set `OPENRIG_GUEST_ACTIVITY_HOST` to that selected host address and use its guest-visible name in `--guest-base-url`. With rootless networking, verify the host alias's route rather than assuming it reaches loopback. Bind the MCP listener to its separately selected guest-reachable address as well. The listener serves only `POST /api/activity/hooks` and the three permission-request routes (`POST /api/activity/permission-requests`, `GET /api/activity/permission-requests/<askId>` and `POST /api/activity/permission-requests/<askId>/withdraw`), using the seat/generation grant. It exposes no queue, config, event-stream or parked-seat reads and refuses the install-wide token. Use the actual guest-reachable MCP and guest-activity ports. Setup generates `policy.yaml` for those two selected host/port pairs, without blanket egress. Never grant the guest access to the daemon's main API port (normally 7433). OpenShell's REST policy applies to a host and port: the dedicated listener confines the surface, while its activity grant and the separate MCP work grant authorize their respective requests. The activity rule names `/usr/bin/node`, matching the selected plugin hook interpreter. Qualify that path in your image. The separate read-only `platform.claude.com` rule is for the pinned native startup check and carries no inference credential.
Import `claude-code-provider.yaml` using OpenShell's provider-profile commands, then create a provider with its `ANTHROPIC_API_KEY` environment lookup. See `openshell provider --help`; keep the master credential in provider custody, never an argument or source file. `create` passes that provider reference with `--no-auto-providers`. The guest helper saves only the provider-injected reference in a private file and uses Claude's `apiKeyHelper`. It does not copy an owner authentication home.
@@ -82,6 +84,15 @@ rig environment effect worker-one --effect stop-compute --generation YOUR_OCCUPA
rig environment effect worker-one --effect delete-owned --generation YOUR_OCCUPANT_GENERATION --operation delete-one --engine-socket /run/user/YOUR_UID/podman/podman.sock --json
```
If create succeeded but projection failed before any execution generation was established, inspect the binding and select its exact immutable resource ID instead of inventing a generation:
```sh
rig environment inspect worker-one --json
rig environment effect worker-one --effect delete-owned --resource-id YOUR_IMMUTABLE_RESOURCE_ID --operation delete-unstarted-one --engine-socket /run/user/YOUR_UID/podman/podman.sock --json
```
Omitting `--generation` is supported only for `delete-owned` with an explicit `--resource-id` while the binding still has no generation. A newly established generation or changed resource refuses this selection; inspect again and select the actual generation. Attached resources remain preserved.
Use the actual local rootless Podman socket on the provider host. The observer is read-only; it never removes engine resources itself. Before deletion it records exact workload/supervisor container identities and their attributable workspace/control/channel volumes. After the single vendor request, a complete gateway listing must establish the immutable resource's absence, and independent queries on that same engine/socket must establish those components absent. Other engine resources are preserved. Transport/auth errors, changed sockets, incomplete lists or a remaining volume stay unconfirmed. A vendor Accepted/Completed response alone does not establish engine cleanup. Per-sandbox secret cleanup is not an observed component class here.
Attached consumers may close only their own viewer. Their external native process, storage and compute remain preserved. `rig seat stop` and `rig down` retain their existing pane semantics and label community panes accordingly; neither stands in for these explicit resource receipts. A pending/unconfirmed effect retains its original operation. Repeating that operation observes its result without blindly repeating stop/delete. A reused name with another immutable ID refuses deletion.
+6 -3
View File
@@ -55,9 +55,11 @@ def safe(root, raw):
def regular(root, raw, limit=LIMIT):
path = safe(root, raw)
parts = path.relative_to(root).parts
directory = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
directory = None
try:
for part in (*Path(root).parts[1:], *parts[:-1]):
# Ancestors need traversal, not a directory-read grant. Anchor reads at the selected root.
directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
for part in parts[:-1]:
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
os.close(directory)
directory = child
@@ -79,7 +81,8 @@ def regular(root, raw, limit=LIMIT):
except OSError:
raise Refused("environment_file_invalid")
finally:
os.close(directory)
if directory is not None:
os.close(directory)
def atomic(root, raw, data):
+5 -4
View File
@@ -2,6 +2,7 @@ version: "0.2"
name: openshell-example
pods:
- id: team
label: Team
members:
- id: coordinator
agent_ref: local:agents/coordinator
@@ -13,7 +14,7 @@ pods:
runtime: openshell-claude
profile: default
cwd: .
edges:
- kind: delegates_to
from: team.coordinator
to: team.worker
edges:
- kind: delegates_to
from: coordinator
to: worker
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.8",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -106,11 +106,12 @@ export class OpenShellDriver implements EnvironmentDriver {
if (beforeDispatch && !beforeDispatch()) throw new EnvironmentError("environment_selection_changed", "The selected stop is no longer eligible; no command was dispatched");
await this.command([...this.prefix(binding.placement), "stop", binding.placement.name]);
}
async deleteEnvironment(binding: EnvironmentBinding, _operationId: string): Promise<void> {
async deleteEnvironment(binding: EnvironmentBinding, _operationId: string, beforeDispatch?: () => boolean): Promise<void> {
const actual = await this.inspect(binding);
if (!binding.resource || actual.id !== binding.resource.id || actual.name !== binding.resource.name || actual.workspace !== binding.resource.workspace
|| binding.resource.fingerprint && actual.fingerprint !== binding.resource.fingerprint)
throw new EnvironmentError("environment_identity_changed", "The selected compute changed before deletion");
if (beforeDispatch && !beforeDispatch()) throw new EnvironmentError("environment_selection_changed", "The selected delete is no longer eligible; no command was dispatched");
await this.command([...this.prefix(binding.placement), "delete", binding.placement.name]);
}
connectArgs(binding: EnvironmentBinding): string[] { return [...this.prefix(binding.placement), "connect", binding.placement.name]; }
@@ -12,7 +12,7 @@ export type EnvironmentLifecycleDriver = EnvironmentDriver & {
nativeIdentity?(binding: EnvironmentBinding): Promise<NativeObservation>;
stopNative?(binding: EnvironmentBinding, native: NativeObservation, operationId: string, beforeDispatch?: () => boolean): Promise<void>;
stopCompute?(binding: EnvironmentBinding, operationId: string, beforeDispatch?: () => boolean): Promise<void>;
deleteEnvironment?(binding: EnvironmentBinding, operationId: string): Promise<void>;
deleteEnvironment?(binding: EnvironmentBinding, operationId: string, beforeDispatch?: () => boolean): Promise<void>;
captureCleanup?(binding: EnvironmentBinding, socket: string): Promise<PodmanCleanupSelection>;
cleanupState?(binding: EnvironmentBinding, selection?: PodmanCleanupSelection): Promise<DependentObservation>;
checkpointFiles?(binding: EnvironmentBinding, native: NativeObservation): Promise<Array<{ path: string; sha256: string; bytes: number }>>;
@@ -22,7 +22,7 @@ export type EnvironmentCheckpoint = {
nativeType: "community:openshell-claude"; nativeId: string; predecessorResourceId: string;
destinationCwd: string; nativeState: string; artifacts: Array<{ path: string; source: string; sha256: string; bytes: number; taskId: string }>; files: Array<{ path: string; source: string; sha256: string; bytes: number }>;
};
export type EffectReceipt = { version: 1; operationId: string; bindingId: string; generation: string; resourceId: string;
export type EffectReceipt = { version: 1; operationId: string; bindingId: string; generation: string | null; resourceId: string;
effect: EnvironmentEffect; state: "confirmed" | "pending" | "unconfirmed"; remaining?: DependentObservation; detail?: string };
type Operation = { binding_id: string; kind: string; request_json: string; state: string; receipt_json: string | null; effect_id: string | null };
const stable = (v: unknown): string => JSON.stringify(v, (_key, x) => x && typeof x === "object" && !Array.isArray(x)
@@ -49,6 +49,14 @@ export class EnvironmentLifecycle {
if (binding.generation !== generation) refuse("environment_generation_changed", "The selected execution generation changed");
return binding;
}
private cleanupSelection(id: string, resourceId: unknown): EnvironmentBinding {
identifier(resourceId, "resourceId");
const binding = this.platform.get(id);
if (!binding?.resource) refuse("environment_binding_missing", "A confirmed compute binding is required");
if (binding.generation !== null) refuse("environment_generation_changed", "An execution generation now exists; select it explicitly");
if (binding.resource.id !== resourceId) refuse("environment_identity_changed", "The selected cleanup resource changed");
return binding;
}
private operation(id: string): Operation | undefined { return this.platform.db.prepare("SELECT * FROM environment_operations WHERE operation_id = ?").get(id) as Operation | undefined; }
private intent(binding: EnvironmentBinding, id: string, kind: string, request: unknown): Operation {
identifier(id, "operationId");
@@ -73,9 +81,11 @@ export class EnvironmentLifecycle {
if (!native || native.nativeType !== "community:openshell-claude" || !/^[0-9a-f]{8}-[0-9a-f-]{27}$/.test(native.nativeId)) refuse("environment_native_unconfirmed", "The selected compute has no confirmed exact native identity");
return native;
}
async effect(id: string, input: { operationId: string; generation: string; effect: EnvironmentEffect; engineSocket?: string; requestedBy?: string }, beforeDispatch?: () => boolean): Promise<EffectReceipt> {
async effect(id: string, input: { operationId: string; generation: string | null; resourceId?: string; effect: EnvironmentEffect; engineSocket?: string; requestedBy?: string }, beforeDispatch?: () => boolean): Promise<EffectReceipt> {
if (!["close-viewer", "stop-native", "stop-compute", "delete-owned"].includes(input.effect)) refuse("environment_effect_unsupported", "Select close-viewer, stop-native, stop-compute or delete-owned", 400);
const binding = this.selected(id, input.generation);
const select = () => input.effect === "delete-owned" && input.generation === null
? this.cleanupSelection(id, input.resourceId) : this.selected(id, identifier(input.generation, "generation"));
const binding = select();
if (input.effect !== "close-viewer" && binding.ownership !== "owned") refuse("environment_attached_preserved", "Attached native, storage and compute are preserved; close only this consumer's viewer");
const request = { ...input, resourceId: binding.resource!.id }, op = this.intent(binding, input.operationId, "effect", request);
if (op.receipt_json) return JSON.parse(op.receipt_json);
@@ -119,8 +129,9 @@ export class EnvironmentLifecycle {
const found = listed.resources.find(row => row.id === binding.resource!.id);
if (found) {
this.platform.assertResource(binding, found);
this.selected(id, input.generation);
if (this.claim(input.operationId)) { try { await this.driver.deleteEnvironment(binding, op.effect_id!); } catch { /* observe original effect, never repeat */ } }
select();
if (!await dispatch(allowed => this.driver.deleteEnvironment!(binding, op.effect_id!, allowed)))
return result("pending", { detail: "The selection changed before dispatch; no delete was sent" });
listed = await this.driver.list(binding.placement);
}
if (!listed.complete || listed.resources.some(row => row.id === binding.resource!.id || row.name === binding.resource!.name)) return result("pending", { detail: "Exact provider absence is not confirmed" });
@@ -127,15 +127,12 @@ async function switchHeld(input: ProviderSwitchInput, relaunch: ClaudeSeatRelaun
if (!seat.pane) {
return { outcome: "failed_safely", reasons: ["no_bound_pane"], message: `${input.seat} has no bound tmux pane to relaunch it in. Nothing changed.` };
}
// The relaunch runs the seat's saved effort. A process running with another --effort (launched by hand, or from an
// older setting) would come back changed with no one choosing it, so stop and show the difference. A relaunch effort
// that is the seat's own explicit choice is the change `rig seat set-effort` asked for, and goes ahead.
const effort = await relaunch.effortChange?.(nodeId, seat.pane);
if (effort?.running && effort.running !== effort.relaunch && !effort.relaunchIsSeatChoice) {
return { outcome: "failed_safely", reasons: ["effort_would_change"],
message: `${input.seat} runs with effort ${effort.running}, but relaunching it would use ${effort.relaunch ?? "the runtime default (no --effort)"}. `
+ `Nothing changed. To keep ${effort.running}, run rig seat set-effort ${input.seat} ${effort.running}, then switch again.` };
}
// A switch is how a person recovers a seat from a limit or an outage, so its relaunch keeps a running process's own
// --effort (launched by hand, or from an older setting) in place of the seat's saved effort. The seat's explicit
// choice (`rig seat set-effort`) is the change a managed relaunch applies, so it goes ahead. A kept effort isn't saved
// as the seat's choice.
const effort = relaunch.effortChange ? await relaunch.effortChange(nodeId, seat.pane) : undefined;
const keepEffort = effort?.running && effort.running !== effort.relaunch && !effort.relaunchIsSeatChoice ? effort.running : undefined;
const snapshot = relaunch.snapshot(rigId, nodeId);
if (!snapshot.resumes) {
return { outcome: "failed_safely", reasons: ["conversation_not_resumable"],
@@ -145,7 +142,7 @@ async function switchHeld(input: ProviderSwitchInput, relaunch: ClaudeSeatRelaun
touching();
relaunch.setTokenRef(nodeId, checked.ref);
let relaunched: Awaited<ReturnType<ClaudeSeatRelaunch["relaunchInPane"]>>;
try { relaunched = await relaunch.relaunchInPane(rigId, logicalId, snapshot.snapshotId); }
try { relaunched = await relaunch.relaunchInPane(rigId, logicalId, snapshot.snapshotId, keepEffort ? { effort: keepEffort } : undefined); }
catch (err) { relaunched = { ok: false, effect: "unknown", code: "relaunch_threw", message: err instanceof Error ? err.message : String(err) }; }
if (!relaunched.ok && relaunched.effect === "none") {
relaunch.setTokenRef(nodeId, before);
@@ -153,6 +150,8 @@ async function switchHeld(input: ProviderSwitchInput, relaunch: ClaudeSeatRelaun
message: `${input.seat} wasn't relaunched (${relaunched.code}: ${relaunched.message}) and wasn't touched, so it's as it was before the switch, with its token reference as it was.` };
}
report.loginChanged = true;
const effortLine = effortNote(input.seat, effort, keepEffort, relaunched);
if (effortLine) report.effort = effortLine;
if (!relaunched.ok) {
const recoveryCommand = `rig launch ${rigName} ${logicalId} --snapshot-id ${snapshot.snapshotId}`;
report.restart = { outcome: relaunched.code, message: relaunched.message, recoveryCommand,
@@ -187,3 +186,48 @@ async function switchHeld(input: ProviderSwitchInput, relaunch: ClaudeSeatRelaun
? `Relaunched ${input.seat} in its own pane without its own token, resuming its conversation. It uses ${checked.instead}, which Claude Code ranks above its config dir's stored login.`
: `Relaunched ${input.seat} in its own pane on its config dir's stored login, resuming its conversation. Work in flight when it stopped didn't carry over.` };
}
/** Which effort a switch's relaunch used, and why, from the selection restore's launch reported, when that isn't simply the
* effort already running. Without that report it says so, rather than naming the effort read before the relaunch. When
* the relaunch didn't report resuming, it's worded as asked, not done, with the rule the recovery command (a plain
* restore) follows; the effort read before the relaunch can be stale by then. Nothing when no reader is wired. */
function effortNote(seat: string, effort: Awaited<ReturnType<NonNullable<ClaudeSeatRelaunch["effortChange"]>>> | undefined,
keepEffort: string | undefined, relaunched: Awaited<ReturnType<ClaudeSeatRelaunch["relaunchInPane"]>>): string | undefined {
if (effort === undefined) return undefined;
const running = effort === null ? null : effort.running;
const named = (value: string | null) => value ?? "the runtime default (no --effort)";
if (!relaunched.ok) {
const recovery = "The recovery command (a plain restore) launches the seat's explicit effort choice at that time, else the snapshot's effort";
if (keepEffort) {
return `the relaunch was asked to keep the running effort ${keepEffort}, but it didn't report resuming. ${recovery}; `
+ `to keep ${keepEffort}, run rig seat set-effort ${seat} ${keepEffort} first.`;
}
if (effort === null) {
return `the running effort couldn't be read, and the relaunch didn't report resuming. ${recovery}; rig seat set-effort ${seat} <level> chooses one first.`;
}
if (effort.running === effort.relaunch && !effort.relaunchIsSeatChoice) return undefined;
return `the relaunch didn't report resuming. ${recovery}.`;
}
const selected = relaunched.effort;
if (!selected) {
return `the relaunch didn't report which effort it launched; rig seat effort ${seat} shows what launched.`;
}
if (selected.source === "kept") {
return `kept the running effort ${named(selected.effort)}: the seat has no effort choice of its own, so the relaunch kept what it ran with. `
+ `Its saved effort, ${named(effort === null ? null : effort.relaunch)}, is unchanged; rig seat set-effort ${seat} <level> chooses one.`;
}
if (effort === null) {
return selected.source === "seat_choice"
? `the running effort couldn't be read; the relaunch used the seat's chosen effort ${named(selected.effort)} (rig seat set-effort).`
: `the running effort couldn't be read, so the relaunch used the seat's saved effort, ${named(selected.effort)}.`;
}
if (selected.effort === running) return undefined;
if (selected.source === "seat_choice") {
return keepEffort
? `the seat's chosen effort ${named(selected.effort)}, saved during the switch, in place of the running ${keepEffort} the relaunch was asked to keep.`
: `the seat's chosen effort ${named(selected.effort)} (rig seat set-effort), in place of the running ${running ?? "process's runtime default (no --effort flag)"}.`;
}
return running === null
? `the running process had no --effort flag, so the relaunch used the seat's saved effort ${named(selected.effort)}.`
: `the relaunch used the seat's saved effort ${named(selected.effort)}, in place of the running ${running}.`;
}
@@ -9,6 +9,7 @@ import type { RestartInPlaceResult } from "../seat-lifecycle-service.js";
import type { PeerReport } from "../seat-restart-in-place.js";
import type { ClaudeSeatTokenRef } from "../claude-seat-token.js";
import type { CredentialPrecedence } from "../claude-credential-precedence.js";
import type { LaunchEffortSelection } from "../seat-effort.js";
export interface ProviderPrecheckInput {
seat: string;
@@ -69,9 +70,13 @@ export interface ClaudeSeatRelaunch {
* whether that one is the seat's own explicit choice (`rig seat set-effort`). null when the running process can't be read. */
effortChange?(nodeId: string, pane: string): Promise<{ running: string | null; relaunch: string | null; relaunchIsSeatChoice: boolean } | null>;
/** Relaunch only this seat from that snapshot in its own pane: its process ends in place, the pane and its scrollback stay,
* and restore resumes the conversation there. `effect: "none"` when it was refused before anything stopped. */
relaunchInPane(rigId: string, logicalId: string, snapshotId: string): Promise<{ ok: true; status: "resumed"; warnings: string[] }
| { ok: false; effect: "none" | "unknown"; code: string; message: string }>;
* and restore resumes the conversation there. `effect: "none"` when it was refused before anything stopped. `effort`:
* launch with it in place of the seat's saved effort (the running process's own, kept by a switch), unless the seat
* has an explicit choice when restore's launch reads it; it isn't saved as the seat's choice. The result's `effort` is
* what that launch selected and why, on every relaunch that reached the selection. */
relaunchInPane(rigId: string, logicalId: string, snapshotId: string, opts?: { effort?: string }): Promise<
| { ok: true; status: "resumed"; warnings: string[]; effort?: LaunchEffortSelection }
| { ok: false; effect: "none" | "unknown"; code: string; message: string; effort?: LaunchEffortSelection }>;
}
/** What would outrank a Claude seat's own token at its next launch (`ClaudeManagedLaunch.credentialPrecedence`). */
@@ -98,6 +103,8 @@ export interface SeatSwitchReport {
restart?: { outcome: string; message: string; recoveryCommand?: string; remainingStep?: string };
/** Claude: what the relaunch reported, such as a token it couldn't apply. */
warnings?: string[];
/** Claude: which effort the relaunch used, and why, when that isn't simply the effort already running. */
effort?: string;
/** Claude: false when the relaunch reported starting without the seat's token. True means no such fallback was reported;
* the seat's /status after a tool turn is the evidence. */
tokenApplied?: boolean;
@@ -5,7 +5,7 @@ import { leftAttachedReason } from "./restore-plan-preview.js";
import { assessSnapshotCurrentOccupants, stoppedSnapshotReason } from "./rehydrate-eligibility.js";
import { nonInterruptiveNotice, nonInterruptiveSummary } from "../adapters/non-interruptive.js";
import { planModelAliasNotice } from "../adapters/yolo-mode.js";
import { effortPrecedence, readSeatEffort } from "./seat-effort.js";
import { effortPrecedence, readSeatEffort, type LaunchEffortSelection } from "./seat-effort.js";
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";
@@ -617,12 +617,17 @@ export class RestoreOrchestrator {
}
/** `keepPane`: the seat is running and is relaunched in its own pane (its process ended in place, the pane and scrollback
* kept) instead of a new session; nothing checks whether it is live first, since that is the point. */
* kept) instead of a new session; nothing checks whether it is live first, since that is the point. `keepEffort`, with
* `keepPane`: launch with that effort (the running process's own, kept by a provider switch) in place of the seat's
* saved one, unless the seat has an explicit choice when the launch reads it; it isn't saved as the seat's choice.
* `onLaunchEffort`, with `keepPane`, hears the effort the launch selected and why, whether or not one was kept. */
async launchSingleNode(rigId: string, logicalId: string, opts?: {
adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>;
fsOps?: { exists(path: string): boolean };
snapshotId?: string;
keepPane?: boolean;
keepEffort?: string;
onLaunchEffort?: (selected: LaunchEffortSelection) => void;
selectedHistory?: SelectedRestoreHistory;
}): Promise<NarrowLaunchResult> {
return this.launchNodeTargets(rigId, [logicalId], { ...opts, namedAgent: true, nonTargetMode: "unchanged" });
@@ -634,6 +639,8 @@ export class RestoreOrchestrator {
holdReason?: string;
snapshotId?: string;
keepPane?: boolean;
keepEffort?: string;
onLaunchEffort?: (selected: LaunchEffortSelection) => void;
selectedHistory?: SelectedRestoreHistory;
namedAgent?: boolean;
nonTargetMode: "unchanged" | "detach_and_hold";
@@ -689,7 +696,8 @@ export class RestoreOrchestrator {
}
if (opts.keepPane) {
launched.push(await this.restoreNodeWithCompensation({ node }, rigId, snapshot.id, snapshot.data,
{ adapters: opts.adapters, fsOps: opts.fsOps, keepPane: true, resumePeriodicCarry }, subsetWarnings));
{ adapters: opts.adapters, fsOps: opts.fsOps, keepPane: true, resumePeriodicCarry,
...(opts.keepEffort ? { keepEffort: opts.keepEffort } : {}), ...(opts.onLaunchEffort ? { onLaunchEffort: opts.onLaunchEffort } : {}) }, subsetWarnings));
continue;
}
// An external CLI's claim has no tmux session to probe; restoreNodeWithCompensation leaves it attached.
@@ -1071,7 +1079,7 @@ export class RestoreOrchestrator {
rigId: string,
snapshotId: string,
data: SnapshotData,
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; onSessionLaunched?: (nodeId: string, sessionId: string) => void; automaticReplacements?: AutomaticSeatReplacement[]; keepPane?: boolean; selectedHistory?: SelectedRestoreHistory; resumePeriodicCarry?: boolean },
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; onSessionLaunched?: (nodeId: string, sessionId: string) => void; automaticReplacements?: AutomaticSeatReplacement[]; keepPane?: boolean; keepEffort?: string; onLaunchEffort?: (selected: LaunchEffortSelection) => void; selectedHistory?: SelectedRestoreHistory; resumePeriodicCarry?: boolean },
warnings?: string[],
preserveStoppedCurrent = false,
): Promise<RestoreNodeResult> {
@@ -1590,7 +1598,7 @@ export class RestoreOrchestrator {
data: SnapshotData,
sessionName: string,
launchResult?: { ok: true; sessionName: string; session: import("./types.js").Session; binding: import("./types.js").Binding },
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; selectedHistory?: SelectedRestoreHistory; claudeExecutable?: string; keepPane?: boolean },
opts?: { adapters?: Record<string, import("./runtime-adapter.js").RuntimeAdapter>; fsOps?: { exists(path: string): boolean }; freshLogicalIds?: string[]; selectedHistory?: SelectedRestoreHistory; claudeExecutable?: string; keepPane?: boolean; keepEffort?: string; onLaunchEffort?: (selected: LaunchEffortSelection) => void },
warnings?: string[],
priorState?: { binding: import("./types.js").Binding | null; sessions: { id: string; status: string }[] },
seatRehydration?: SeatSkillRehydration,
@@ -1611,9 +1619,14 @@ export class RestoreOrchestrator {
? "SELECT id FROM nodes WHERE id = ? AND rig_id = ? AND logical_id = ?"
: "SELECT id FROM nodes WHERE rig_id = ? AND logical_id = ?").get(...(selected ? [node.id, rigId, node.logicalId] : [rigId, node.logicalId])) as
{ id: string } | undefined;
const decided = effortPrecedence.restoreLaunchEffort({ snapshot: node, live: readSeatEffort(this.db, liveRow?.id ?? node.id) });
const live = readSeatEffort(this.db, liveRow?.id ?? node.id);
// A provider switch's relaunch keeps the effort its seat was running with, unless the seat has an explicit choice
// by now: read here, at launch, the latest choice wins. A kept effort isn't saved as the seat's choice.
const kept = opts?.keepEffort && !live?.choice ? opts.keepEffort : undefined;
const decided: { effort: string | null; warning?: string } = kept ? { effort: kept } : effortPrecedence.restoreLaunchEffort({ snapshot: node, live });
if (decided.warning) warnings?.push(`Restore: ${node.logicalId}: ${decided.warning}`);
restoreEffort = decided;
opts?.onLaunchEffort?.({ effort: decided.effort, source: kept !== undefined ? "kept" : live?.choice ? "seat_choice" : "saved" });
}
return restoreEffort.effort;
};
@@ -30,6 +30,13 @@ export function resolveSeatEffort(node: { effort?: string | null; effortChoice?:
/** The seat's resolution from its node row. null: no such node. A database older than the
* effort_choice column resolves from nodes.effort alone, as launches did before it existed. */
/** The effort a launch selected, and why: the effort a provider switch asked it to keep, the seat's explicit choice,
* or its saved resolution (the snapshot's authored effort, or the runtime default when `effort` is null). */
export interface LaunchEffortSelection {
effort: string | null;
source: "kept" | "seat_choice" | "saved";
}
export function readSeatEffort(db: Database.Database, nodeId: string): EffortResolution | null {
try {
const row = db.prepare("SELECT effort, effort_choice FROM nodes WHERE id = ?").get(nodeId) as
+10 -5
View File
@@ -16,7 +16,7 @@ import type { SessionRegistry } from "../domain/session-registry.js";
import { resolveActiveSnapshotSession } from "../domain/active-occupant.js";
import { readClaudeSeatTokenRef } from "../domain/claude-seat-token.js";
import { processArgv, verifyClaudePaneRuntime, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { emittedEffortFromArgv, readSeatEffort } from "../domain/seat-effort.js";
import { emittedEffortFromArgv, readSeatEffort, type LaunchEffortSelection } from "../domain/seat-effort.js";
import type { ProviderKind } from "../domain/provider/provider-types.js";
import { checkMoveReport, credentialRefusal, findCredential, MOVE_REPORT_TAG, readMoveReport, renderMoveReport, type MoveReport } from "../domain/provider/subscription-move-report.js";
import { QueueRepositoryError, type QueueRepository } from "../domain/queue-repository.js";
@@ -117,19 +117,24 @@ function claudeSeatOf(c: Context): ClaudeSeatRelaunch {
const guard = (c.get("tmuxAdapter" as never) as TmuxAdapter | undefined)?.deliveryGuard;
return guard ? guard.lifecycle([nodeId], fn) : fn();
},
relaunchInPane: async (rigId, logicalId, snapshotId) => {
relaunchInPane: async (rigId, logicalId, snapshotId, relaunchOpts) => {
const restore = c.get("restoreOrchestrator" as never) as RestoreOrchestrator | undefined;
if (!restore) return { ok: false, effect: "none", code: "restore_unavailable", message: "This daemon has no restore orchestrator." };
const adapters = (c.get("runtimeAdapters" as never) as Record<string, import("../domain/runtime-adapter.js").RuntimeAdapter> | undefined) ?? {};
const result = await restore.launchSingleNode(rigId, logicalId, { snapshotId, adapters, fsOps: { exists: (p: string) => fs.existsSync(p) }, keepPane: true });
// The effort restore's launch selected, and why, heard on every relaunch.
const heard: { effort?: LaunchEffortSelection } = {};
const result = await restore.launchSingleNode(rigId, logicalId, { snapshotId, adapters, fsOps: { exists: (p: string) => fs.existsSync(p) }, keepPane: true,
onLaunchEffort: (selected: LaunchEffortSelection) => { heard.effort = selected; },
...(relaunchOpts?.effort ? { keepEffort: relaunchOpts.effort } : {}) });
const effort = heard.effort ? { effort: heard.effort } : {};
// A refusal of the whole launch (no usable snapshot, no such seat) comes before anything is touched.
if (!result.ok) return { ok: false, effect: "none", code: result.code ?? "launch_failed", message: result.message ?? "The seat wasn't launched from the snapshot." };
const node = result.launched?.find((n) => n.logicalId === logicalId);
if (node?.status === "resumed") return { ok: true, status: "resumed", warnings: result.warnings ?? [] };
if (node?.status === "resumed") return { ok: true, status: "resumed", warnings: result.warnings ?? [], ...effort };
// Restore stops for a decision, or leaves an attached CLI alone, before it touches the seat; a launch that failed
// before its takeover stopped anything left it as this attempt found it too.
const untouched = node?.nothingStopped === true || (!node?.launchStarted && (node?.status === "awaiting-decision" || node?.status === "left_attached"));
return { ok: false, effect: untouched ? "none" : "unknown", code: node?.status ?? "not_launched", message: node?.error ?? "The seat wasn't launched from the snapshot." };
return { ok: false, effect: untouched ? "none" : "unknown", code: node?.status ?? "not_launched", message: node?.error ?? "The seat wasn't launched from the snapshot.", ...effort };
},
};
}
@@ -11,7 +11,7 @@ import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
const cleanup: Array<() => void> = [];
afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); });
async function fixture(attached = false, automatic = true) {
async function fixture(attached = false, automatic = true, beforeGeneration = false) {
const root = realpathSync(mkdtempSync(join(tmpdir(), "environment-lifecycle-"))); cleanup.push(() => rmSync(root, { recursive: true, force: true }));
const db = createFullTestDb(); cleanup.push(() => db.close());
const resource = { id: "compute-one", name: "worker-one", workspace: "workspace", phase: "Ready", operationId: "create-one" };
@@ -40,16 +40,82 @@ async function fixture(attached = false, automatic = true) {
const rig = f.rigRepo.createRig("lifecycle"), node = f.rigRepo.addNode(rig.id, "worker", { runtime: "openshell-claude", cwd: root });
const request = async (path: string, body?: unknown) => { const res = await f.app.request(`/api/environments${path}`, { method: body ? "POST" : "GET", headers: { "content-type": "application/json", "x-openrig-session": "coordinator@lifecycle" }, ...(body ? { body: JSON.stringify(body) } : {}) }); return { status: res.status, body: await res.json() as Record<string, any> }; };
const policy = join(root, "policy.yaml"); writeFileSync(policy, "version: 1\n");
const input = { version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: node.id, seat: "worker@lifecycle", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://guest.test:7433", placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider", policy } };
const input = { version: 1, bindingId: "binding-one", operationId: "create-one", nodeId: beforeGeneration ? undefined : node.id, seat: "worker@lifecycle", baseRuntime: "claude-code", guestRoot: "/sandbox/openrig", guestBaseUrl: "http://guest.test:7433", placement: { gateway: "gateway", workspace: "workspace", name: "worker-one", image: `image@sha256:${"a".repeat(64)}`, provider: "provider", policy } };
const created = await request(attached ? "/attach" : "/create", { ...input, ...(attached ? { resourceId: resource.id } : {}) }); expect(created.status, JSON.stringify(created.body)).toBeLessThan(300);
if (!beforeGeneration) {
db.prepare("UPDATE environment_bindings SET generation='generation-one' WHERE binding_id='binding-one'").run();
const receipt = { version: 1, bindingId: "binding-one", nodeId: node.id, resourceId: resource.id, ...native, activityGrantId: "grant-one" };
db.prepare("INSERT INTO environment_operations(operation_id,binding_id,kind,request_json,state,receipt_json) VALUES ('launch-generation-one','binding-one','launch',?,'confirmed',?)").run(JSON.stringify(receipt), JSON.stringify(receipt));
}
const effect = (name: string, operationId = "effect-one") => request("/binding-one/effect", { operationId, generation: "generation-one", effect: name });
const lifecycle = new EnvironmentLifecycle(new EnvironmentPlatform(db, driver), queue, join(root, "retained"));
return { root, db, driver, queue, lifecycle, request, effect, resource, native, files, history, capture: () => f.snapshotCapture.captureSnapshot(rig.id, "manual"), disappear: () => { present = false; }, counts: () => ({ stopCalls, deleteCalls, creates }), loseStop: () => { loseStop = true; }, unavailable: () => { unavailable = true; }, dirtyCleanup: () => { dependents.volumes = true; } };
}
describe("owned and attached environment effects", () => {
async function failedProjection(attached = false) {
const f = await fixture(attached, true, true), platform = new EnvironmentPlatform(f.db, f.driver);
vi.spyOn(f.driver, "read").mockRejectedValueOnce(new Error("guest root read denied"));
const source = join(f.root, "policy.yaml");
await expect(platform.project("binding-one", { operationId: "projection-failed", generation: "not-established", files: [
{ path: "work/role.md", source, sha256: sha256(readFileSync(source)), required: true },
] })).rejects.toThrow("guest root read denied");
expect(platform.get("binding-one")).toMatchObject({ generation: null, resource: { id: f.resource.id } });
expect(platform.get("binding-one")!.nodeId).toBeUndefined();
return { ...f, cleanup: () => f.request("/binding-one/effect", { operationId: "cleanup-failed-create", effect: "delete-owned", generation: null, resourceId: f.resource.id }) };
}
it("cleans the exact owned create after projection fails before node and generation binding", async () => {
const f = await failedProjection(), result = await f.cleanup();
expect(result.status, JSON.stringify(result.body)).toBe(200);
expect(result.body).toMatchObject({ state: "confirmed", generation: null, resourceId: "compute-one", effect: "delete-owned" });
expect(f.counts()).toMatchObject({ creates: 1, deleteCalls: 1, stopCalls: 0 });
expect((await f.cleanup()).body).toEqual(result.body); expect(f.counts().deleteCalls).toBe(1);
});
it("reconciles uncertain pre-generation cleanup without another provider delete", async () => {
const f = await failedProjection();
let observe = false;
vi.spyOn(f.driver, "deleteEnvironment").mockImplementationOnce(async () => { f.disappear(); throw new Error("lost delete reply"); });
const original = f.driver.cleanupState;
vi.spyOn(f.driver, "cleanupState").mockImplementation(async () => { if (!observe) throw new Error("engine unavailable"); return original(); });
expect((await f.cleanup()).body.state).toBe("unconfirmed");
observe = true;
expect((await f.cleanup()).body.state).toBe("confirmed");
expect(f.driver.deleteEnvironment).toHaveBeenCalledTimes(1);
});
it("preserves an attached resource after projection fails before a generation exists", async () => {
const f = await failedProjection(true), result = await f.cleanup();
expect(result.status).toBe(409); expect(result.body.code).toBe("environment_attached_preserved");
expect(f.counts()).toMatchObject({ creates: 0, deleteCalls: 0, stopCalls: 0 });
});
it("requires the exact resource for pre-generation cleanup and never infers a native generation", async () => {
const f = await failedProjection();
for (const body of [
{ effect: "delete-owned", generation: null },
{ effect: "delete-owned", generation: null, resourceId: "another-resource" },
{ effect: "stop-compute", generation: null, resourceId: f.resource.id },
]) {
const result = await f.request("/binding-one/effect", { operationId: "bad-selection", ...body });
expect(result.status).toBeGreaterThanOrEqual(400);
}
expect(f.counts()).toMatchObject({ deleteCalls: 0, stopCalls: 0 });
});
it("refuses generation-free cleanup once a generation exists", async () => {
const f = await fixture();
const result = await f.request("/binding-one/effect", { operationId: "bad-selection", effect: "delete-owned", generation: null, resourceId: f.resource.id });
expect(result.status).toBe(409); expect(result.body.code).toBe("environment_generation_changed");
expect(f.counts().deleteCalls).toBe(0);
});
it("does not dispatch pre-generation deletion if projection establishes a generation during observation", async () => {
const f = await failedProjection(), list = f.driver.list;
vi.spyOn(f.driver, "list").mockImplementationOnce(async () => {
const result = await list();
f.db.prepare("UPDATE environment_bindings SET generation='new-generation' WHERE binding_id='binding-one'").run();
return result;
});
const result = await f.cleanup();
expect(result.status).toBe(409); expect(result.body.code).toBe("environment_generation_changed");
expect(f.counts().deleteCalls).toBe(0);
});
it("stops only the selected native and reconciles a lost acknowledgement without a second signal", async () => {
const f = await fixture(); f.loseStop();
const first = await f.effect("stop-native"); expect(first.body.state).toBe("confirmed");
@@ -738,6 +738,55 @@ describe("RestoreOrchestrator.launchNodeSubset", () => {
expect(db.prepare("SELECT status FROM sessions WHERE id = ?").get(live.id)).toEqual({ status: "superseded" });
});
it("launches a kept running effort in place of the saved one, without saving it as the seat's choice", async () => {
const { rigId, nodeIds } = seedPodAwareRig();
db.prepare("UPDATE nodes SET effort = 'low' WHERE id = ?").run(nodeIds[0]);
seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext("claude-code") });
liveDriver(rigId, nodeIds[0]!);
const adapter = readyAdapter("claude-code");
const onLaunchEffort = vi.fn();
const result = await orchestrator.launchSingleNode(rigId, "dev.driver", { adapters: { "claude-code": adapter }, keepPane: true, keepEffort: "high", onLaunchEffort });
expect(result.launched).toEqual([expect.objectContaining({ logicalId: "dev.driver", status: "fresh-primed" })]);
expect(adapter.launchHarness).toHaveBeenCalledWith(expect.objectContaining({ effort: "high" }), expect.anything());
expect(onLaunchEffort).toHaveBeenCalledWith({ effort: "high", source: "kept" });
expect(db.prepare("SELECT effort, effort_choice FROM nodes WHERE id = ?").get(nodeIds[0])).toEqual({ effort: "low", effort_choice: null });
});
it("an explicit effort choice saved after the switch read the seat, during the relaunch's own awaits, wins over the kept effort, and says so", async () => {
const { rigId, nodeIds } = seedPodAwareRig();
db.prepare("UPDATE nodes SET effort = 'low' WHERE id = ?").run(nodeIds[0]);
seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext("claude-code") });
liveDriver(rigId, nodeIds[0]!);
// The choice lands while the relaunch is respawning the pane: after the switch read "no choice", before the launch.
(tmux as any).respawnPane = vi.fn(async () => {
db.prepare("UPDATE nodes SET effort_choice = 'medium' WHERE id = ?").run(nodeIds[0]);
return { ok: true as const };
});
const adapter = readyAdapter("claude-code");
const onLaunchEffort = vi.fn();
const result = await orchestrator.launchSingleNode(rigId, "dev.driver", { adapters: { "claude-code": adapter }, keepPane: true, keepEffort: "high", onLaunchEffort });
expect(result.launched).toEqual([expect.objectContaining({ logicalId: "dev.driver", status: "fresh-primed" })]);
expect(adapter.launchHarness).toHaveBeenCalledWith(expect.objectContaining({ effort: "medium" }), expect.anything());
expect(onLaunchEffort).toHaveBeenCalledWith({ effort: "medium", source: "seat_choice" });
expect(db.prepare("SELECT effort, effort_choice FROM nodes WHERE id = ?").get(nodeIds[0])).toEqual({ effort: "low", effort_choice: "medium" });
});
it("reports the effort a relaunch without a kept effort selected: a newer explicit choice saved during its awaits", async () => {
const { rigId, nodeIds } = seedPodAwareRig();
db.prepare("UPDATE nodes SET effort = 'low', effort_choice = 'low' WHERE id = ?").run(nodeIds[0]);
seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext("claude-code") });
liveDriver(rigId, nodeIds[0]!);
(tmux as any).respawnPane = vi.fn(async () => {
db.prepare("UPDATE nodes SET effort_choice = 'medium' WHERE id = ?").run(nodeIds[0]);
return { ok: true as const };
});
const adapter = readyAdapter("claude-code");
const onLaunchEffort = vi.fn();
await orchestrator.launchSingleNode(rigId, "dev.driver", { adapters: { "claude-code": adapter }, keepPane: true, onLaunchEffort });
expect(adapter.launchHarness).toHaveBeenCalledWith(expect.objectContaining({ effort: "medium" }), expect.anything());
expect(onLaunchEffort).toHaveBeenCalledWith({ effort: "medium", source: "seat_choice" });
});
it("a pane that isn't in the seat's session is left alone, and the seat's records are put back", async () => {
const { rigId, nodeIds } = seedPodAwareRig();
seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext("claude-code") });
@@ -77,3 +77,15 @@ it("rechecks dispatch permission after the driver's own identity await", async (
await entered; eligible = false; release(); await refused;
expect(command.mock.calls.some(([args]) => args.includes("stop"))).toBe(false);
});
it("rechecks cleanup selection after delete's identity await", async () => {
let eligible = true;
const command = vi.fn(async (args: string[]) => {
if (args.includes("get")) { eligible = false; return JSON.stringify({ id: "immutable", name: "worker-one", workspace: "workspace", phase: "Ready" }); }
return "";
});
const binding = { placement: { gateway: "gateway", workspace: "workspace", name: "worker-one" }, resource: { id: "immutable", name: "worker-one", workspace: "workspace" } } as EnvironmentBinding;
await expect(new OpenShellDriver(command).deleteEnvironment(binding, "cleanup-one", () => eligible))
.rejects.toMatchObject({ code: "environment_selection_changed" });
expect(command.mock.calls.some(([args]) => args.includes("delete"))).toBe(false);
});
@@ -0,0 +1,32 @@
import { afterEach, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
import { RigSpecSchema } from "../src/domain/rigspec-schema.js";
import { validateRigSpecImport } from "../src/domain/spec-validation-service.js";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
const sourceRef = resolve(import.meta.dirname, "../assets/openshell/rig.yaml");
const yaml = readFileSync(sourceRef, "utf8");
afterEach(() => vi.restoreAllMocks());
it("the shipped OpenShell example parses and validates with its edge inside the pod", () => {
expect(validateRigSpecImport(yaml)).toMatchObject({ valid: true, errors: [] });
const spec = RigSpecSchema.normalize(RigSpecCodec.parse(yaml) as Record<string, unknown>);
expect(spec.pods[0]!.edges).toContainEqual({ kind: "delegates_to", from: "coordinator", to: "worker" });
expect(spec.edges ?? []).toEqual([]);
});
it("the ordinary up plan classifies the shipped example without launching seats", async () => {
const db = createFullTestDb();
try {
const f = createTestApp(db, { upRouterFsOps: {
exists: (path: string) => path === sourceRef,
readFile: () => yaml,
readHead: (_path: string, bytes: number) => Buffer.from(yaml).subarray(0, bytes),
} });
const plan = vi.spyOn(f.bootstrapOrchestrator, "bootstrap").mockResolvedValue({ runId: "plan", status: "planned", stages: [], errors: [], warnings: [] } as never);
const result = await f.app.request("/api/up", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceRef, plan: true }) });
expect(result.status, await result.text()).toBe(200);
expect(plan).toHaveBeenCalledWith(expect.objectContaining({ mode: "plan", sourceRef }));
expect(f.tmuxAdapter.createSession).not.toHaveBeenCalled();
} finally { db.close(); }
});
@@ -58,3 +58,33 @@ it("the shipped resume latch refuses missing exact history before provider prepa
writeFileSync(join(root,"control","launch.pending.json"),JSON.stringify({version:1,bindingId:"binding-one",generation:"generation-two",nativeId:id,resumeToken:id,nativeType:"community:openshell-claude",files:[],checkpoint:{nativeId:id,nativeType:"community:openshell-claude",files:[]}}));
expect(await command(root,"--prepare")).toMatchObject({ok:false,code:"environment_checkpoint_invalid"});
});
// Model the measured filesystem grant: the selected root is readable, its ancestors are not.
it("reads inside the granted workspace when opening every ancestor is denied", async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-granted-root-"))); roots.push(root);
mkdirSync(join(root, "work")); const file = join(root, "work", "role.md"); writeFileSync(file, "selected context");
const script = `import importlib.util,os,sys
from pathlib import Path
spec=importlib.util.spec_from_file_location("guest",sys.argv[1]); g=importlib.util.module_from_spec(spec); spec.loader.exec_module(g)
original=os.open
ancestors={str(p) for p in Path(sys.argv[2]).parents}
def granted(path, flags, *args, **kwargs):
if str(path) in ancestors: raise PermissionError(13,"ancestor not granted",str(path))
return original(path,flags,*args,**kwargs)
os.open=granted
try:
print(g.regular(sys.argv[2],sys.argv[3]).decode())
except Exception as error: print(type(error).__name__+":"+str(error))
`;
const result = await run("python3", ["-c", script, helper, root, file], { timeout: 5000 });
expect(result.stdout.trim()).toBe("selected context");
});
it("preserves rejection of a symlink in the selected root's ancestors", async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "openshell-root-link-"))); roots.push(root);
mkdirSync(join(root, "real")); mkdirSync(join(root, "real", "workspace"));
writeFileSync(join(root, "real", "workspace", "role.md"), "preserved");
symlinkSync(join(root, "real"), join(root, "link"));
expect(await command(join(root, "link", "workspace"), "--read", "role.md"))
.toMatchObject({ ok: false, code: "environment_root_invalid" });
});
@@ -196,6 +196,17 @@ describe("provider routes", () => {
expect(leased).toEqual([[node.id]]);
expect(await lent.relaunchInPane(rig.id, "dev.a", "snap-1")).toEqual({ ok: true, status: "resumed", warnings: ["dev-a@fixture-rig: a launch warning"] });
expect(launchSingleNode).toHaveBeenCalledWith(rig.id, "dev.a", expect.objectContaining({ snapshotId: "snap-1", keepPane: true }));
expect(launchSingleNode).not.toHaveBeenCalledWith(rig.id, "dev.a", expect.objectContaining({ keepEffort: expect.anything() }));
// The launch's selection is heard on every relaunch, with or without a kept effort.
expect(launchSingleNode).toHaveBeenLastCalledWith(rig.id, "dev.a", expect.objectContaining({ onLaunchEffort: expect.any(Function) }));
launchSingleNode.mockImplementationOnce((async (_rigId: string, _logicalId: string, o: { onLaunchEffort?: (e: { effort: string | null; source: string }) => void }) => {
o.onLaunchEffort?.({ effort: "medium", source: "seat_choice" });
return launched;
}) as never);
expect(await lent.relaunchInPane(rig.id, "dev.a", "snap-1", { effort: "high" }))
.toMatchObject({ ok: true, status: "resumed", effort: { effort: "medium", source: "seat_choice" } });
expect(launchSingleNode).toHaveBeenLastCalledWith(rig.id, "dev.a", expect.objectContaining({ snapshotId: "snap-1", keepPane: true, keepEffort: "high",
onLaunchEffort: expect.any(Function) }));
expect(tmux.killSession).not.toHaveBeenCalled();
// Restore's stops before it touches the seat leave it unchanged; anything else after the launch began is unknown.
launched = { ok: true, launched: [{ logicalId: "dev.a", status: "awaiting-decision", error: "decide first" }] };
@@ -39,6 +39,9 @@ function harness(opts: {
relaunch?: Relaunched | (() => Promise<Relaunched>);
exclusive?: (fn: () => Promise<unknown>) => Promise<unknown>;
effort?: Awaited<ReturnType<NonNullable<ClaudeSeatRelaunch["effortChange"]>>>;
// What restore's launch-effort seam selected and why (default: the kept effort when one was asked for, else what the
// seat's effort read before the relaunch implies).
selected?: { effort: string | null; source: "kept" | "seat_choice" | "saved" } | null;
} = {}) {
const calls: string[] = [];
let ref: ClaudeSeatTokenRef | null = opts.ref ?? null;
@@ -59,9 +62,15 @@ function harness(opts: {
return run;
},
...(opts.effort === undefined ? {} : { effortChange: async (nodeId: string, pane: string) => { calls.push(`effort ${nodeId} ${pane}`); return opts.effort!; } }),
relaunchInPane: async (rigId, logicalId, snapshotId) => {
calls.push(`relaunch ${rigId} ${logicalId} ${snapshotId} with ${ref ? ref.label : "no reference"}`);
return typeof opts.relaunch === "function" ? opts.relaunch() : opts.relaunch ?? RESUMED;
relaunchInPane: async (rigId, logicalId, snapshotId, o) => {
calls.push(`relaunch ${rigId} ${logicalId} ${snapshotId} with ${ref ? ref.label : "no reference"}${o?.effort ? ` keeping effort ${o.effort}` : ""}`);
const done = typeof opts.relaunch === "function" ? await opts.relaunch() : opts.relaunch ?? RESUMED;
const read = opts.effort;
const selected = opts.selected !== undefined ? opts.selected
: o?.effort ? { effort: o.effort, source: "kept" as const }
: read ? { effort: read.relaunch, source: read.relaunchIsSeatChoice ? "seat_choice" as const : "saved" as const }
: read === null ? { effort: null, source: "saved" as const } : null;
return done.ok && selected ? { ...done, effort: selected } : done;
},
};
const claudePrecedence = async (nodeId: string, o?: { storedLogin?: boolean }) => {
@@ -75,33 +84,120 @@ function harness(opts: {
const to = (account: string, file: string | null) => ({ seat: SEAT, toAccount: account, tokenFile: file, forceUnsafe: false });
describe("rig provider switch --seat moves a Claude seat to its own subscription token on the same conversation, in its own pane", () => {
// The relaunch runs the seat's saved effort. A process running with another one would come back changed without anyone
// choosing it, so the switch stops before touching the seat and shows the difference, unless the relaunch's effort is the
// seat's own explicit choice (`rig seat set-effort`), which a managed relaunch is meant to apply.
it("stops before touching a seat whose running --effort its relaunch would drop, and shows the difference", async () => {
// A switch is how a person recovers a seat from a limit or an outage, so it keeps what the seat was running with. A
// process running with its own --effort keeps it through the relaunch, unless the seat has an explicit effort choice
// (`rig seat set-effort`), which the relaunch applies. The kept value is never saved as the seat's choice, and the
// result says which effort the relaunch used and why.
it("keeps the running --effort through the relaunch when the seat has no effort choice of its own, and says so", async () => {
const h = harness({ effort: { running: "high", relaunch: null, relaunchIsSeatChoice: false } });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result).toMatchObject({ outcome: "failed_safely", reasons: ["effort_would_change"] });
expect(result.message).toContain("effort high");
expect(result.message).toContain("the runtime default");
expect(result.message).toContain(`rig seat set-effort ${SEAT} high`);
expect(result.outcome).toBe("rebind_in_progress");
expect(h.calls).toContain("effort node-a %7");
expect(h.calls.some((call) => call.startsWith("setTokenRef") || call.startsWith("snapshot") || call.startsWith("relaunch"))).toBe(false);
expect(h.ref()).toBeNull();
expect(h.calls).toContain("relaunch rig-1 dev.a snap-1 with work keeping effort high");
expect(result.seat?.effort).toContain("kept the running effort high");
expect(result.seat?.effort).toContain("the runtime default");
expect(h.ref()).toMatchObject({ label: "work" });
});
it("relaunches when the seat's explicit effort choice is what changes, when the running process has no flag, or when it can't be read", async () => {
for (const effort of [
{ running: "high", relaunch: "low", relaunchIsSeatChoice: true },
{ running: null, relaunch: "high", relaunchIsSeatChoice: false },
{ running: "high", relaunch: "high", relaunchIsSeatChoice: false },
null,
]) {
const h = harness({ effort });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result.outcome).toBe("rebind_in_progress");
expect(h.calls.some((call) => call.startsWith("relaunch"))).toBe(true);
}
it("says a kept effort was asked for, not done, when the relaunch didn't report resuming, and how the recovery keeps it", async () => {
const h = harness({ effort: { running: "high", relaunch: "low", relaunchIsSeatChoice: false },
relaunch: { ok: false, effect: "unknown", code: "failed", message: "the seat didn't come back" } });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result.outcome).toBe("unknown");
expect(h.calls).toContain("relaunch rig-1 dev.a snap-1 with work keeping effort high");
expect(result.seat?.restart?.recoveryCommand).toBe("rig launch rig dev.a --snapshot-id snap-1");
expect(result.seat?.effort).not.toContain("kept the running effort");
expect(result.seat?.effort).toContain("asked to keep the running effort high");
expect(result.seat?.effort).toContain("explicit effort choice at that time, else the snapshot's effort");
expect(result.seat?.effort).not.toContain("saved effort low");
expect(result.seat?.effort).toContain(`rig seat set-effort ${SEAT} high`);
});
it("reports the seat's chosen effort when a choice saved during the switch won at restore's launch-effort seam", async () => {
const h = harness({ effort: { running: "high", relaunch: "low", relaunchIsSeatChoice: false }, selected: { effort: "medium", source: "seat_choice" } });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result.outcome).toBe("rebind_in_progress");
expect(h.calls).toContain("relaunch rig-1 dev.a snap-1 with work keeping effort high");
expect(result.seat?.effort).not.toContain("kept the running effort");
expect(result.seat?.effort).toContain("the seat's chosen effort medium");
expect(result.seat?.effort).toContain("high");
});
it("words the chosen and unreadable efforts as intended, not applied, when the relaunch didn't report resuming", async () => {
const unknown = { ok: false as const, effect: "unknown" as const, code: "failed", message: "the seat didn't come back" };
const chosen = harness({ effort: { running: "high", relaunch: "low", relaunchIsSeatChoice: true }, relaunch: unknown });
const c = await chosen.svc.switchAccount(to("work", tokenFile()), chosen.seatRestart, chosen.claudeSeat);
expect(c.outcome).toBe("unknown");
expect(c.seat?.effort).toContain("didn't report resuming");
expect(c.seat?.effort).toContain("explicit effort choice at that time");
expect(c.seat?.effort).not.toContain("low");
const unread = harness({ effort: null, relaunch: unknown });
const u = await unread.svc.switchAccount(to("work", tokenFile()), unread.seatRestart, unread.claudeSeat);
expect(u.outcome).toBe("unknown");
expect(u.seat?.effort).toContain("couldn't be read");
expect(u.seat?.effort).toContain("didn't report resuming");
expect(u.seat?.effort).not.toContain("kept");
});
it("keeps a running --effort that differs from the seat's saved, authored one", async () => {
const h = harness({ effort: { running: "high", relaunch: "low", relaunchIsSeatChoice: false } });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result.outcome).toBe("rebind_in_progress");
expect(h.calls).toContain("relaunch rig-1 dev.a snap-1 with work keeping effort high");
expect(result.seat?.effort).toContain("kept the running effort high");
expect(result.seat?.effort).toContain("low");
});
it("applies the seat's explicit effort choice, and says so", async () => {
const h = harness({ effort: { running: "high", relaunch: "low", relaunchIsSeatChoice: true } });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result.outcome).toBe("rebind_in_progress");
expect(h.calls).toContain("relaunch rig-1 dev.a snap-1 with work");
expect(result.seat?.effort).toContain("the seat's chosen effort low");
expect(result.seat?.effort).not.toContain("kept");
});
it("reports the explicit choice the launch actually used when another choice was saved during the switch", async () => {
const h = harness({ effort: { running: "high", relaunch: "low", relaunchIsSeatChoice: true }, selected: { effort: "medium", source: "seat_choice" } });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result.outcome).toBe("rebind_in_progress");
expect(h.calls).toContain("relaunch rig-1 dev.a snap-1 with work");
expect(result.seat?.effort).toContain("the seat's chosen effort medium");
expect(result.seat?.effort).not.toContain("low");
});
it("says which effort launched wasn't reported, rather than naming the effort read before the relaunch", async () => {
const h = harness({ effort: { running: "high", relaunch: "low", relaunchIsSeatChoice: true }, selected: null });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result.outcome).toBe("rebind_in_progress");
expect(result.seat?.effort).toContain("didn't report which effort it launched");
expect(result.seat?.effort).not.toContain("low");
});
it("relaunches with the saved effort when the running effort can't be read, and says it wasn't read, never that it was kept", async () => {
const h = harness({ effort: null });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result.outcome).toBe("rebind_in_progress");
expect(h.calls).toContain("relaunch rig-1 dev.a snap-1 with work");
expect(result.seat?.effort).toContain("couldn't be read");
expect(result.seat?.effort).not.toContain("kept");
});
it("relaunches with the saved effort when the running process has no --effort flag, and says so", async () => {
const h = harness({ effort: { running: null, relaunch: "high", relaunchIsSeatChoice: false } });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result.outcome).toBe("rebind_in_progress");
expect(h.calls).toContain("relaunch rig-1 dev.a snap-1 with work");
expect(result.seat?.effort).toContain("no --effort flag");
expect(result.seat?.effort).toContain("high");
});
it("says nothing about effort when the relaunch uses the effort already running", async () => {
const h = harness({ effort: { running: "high", relaunch: "high", relaunchIsSeatChoice: false } });
const result = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(result.outcome).toBe("rebind_in_progress");
expect(h.calls).toContain("relaunch rig-1 dev.a snap-1 with work");
expect(result.seat?.effort).toBeUndefined();
});
it("switches to an API-key reference and back without changing the resumed conversation", async () => {
@@ -178,8 +274,11 @@ describe("rig provider switch --seat moves a Claude seat to its own subscription
});
it("a relaunch that ended the seat's process but didn't resume reads unknown, with the snapshot's recovery and the way back, and isn't retried", async () => {
const h = harness({ relaunch: { ok: false, effect: "unknown", code: "failed", message: "The pane couldn't be respawned." } });
const h = harness({ relaunch: { ok: false, effect: "unknown", code: "failed", message: "The pane couldn't be respawned." },
effort: { running: "high", relaunch: "low", relaunchIsSeatChoice: false } });
const r = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(r.seat?.effort).toContain("asked to keep the running effort high");
expect(r.seat?.effort).not.toContain("kept the running effort");
expect(r).toMatchObject({ outcome: "unknown", reasons: ["relaunch_failed"], message: expect.stringContaining("may have been ended"), seat: {
loginChanged: true, undo: `rig provider switch --seat ${SEAT} --stored-login`,
restart: { recoveryCommand: "rig launch rig dev.a --snapshot-id snap-1", remainingStep: expect.stringContaining(`rig seat stop ${SEAT}`) } } });
@@ -188,9 +287,12 @@ describe("rig provider switch --seat moves a Claude seat to its own subscription
});
it("a relaunch that throws reads unknown, with the snapshot's recovery, instead of rejecting the switch", async () => {
const h = harness({ relaunch: async () => { throw new Error("transport failure after the launch request"); } });
const h = harness({ relaunch: async () => { throw new Error("transport failure after the launch request"); },
effort: { running: "high", relaunch: "low", relaunchIsSeatChoice: false } });
const r = await h.svc.switchAccount(to("work", tokenFile()), h.seatRestart, h.claudeSeat);
expect(r).toMatchObject({ outcome: "unknown", reasons: ["relaunch_threw"], seat: { restart: { recoveryCommand: "rig launch rig dev.a --snapshot-id snap-1" } } });
expect(r.seat?.effort).toContain("asked to keep the running effort high");
expect(r.seat?.effort).toContain(`rig seat set-effort ${SEAT} high`);
});
it("a token the relaunch couldn't apply is reported: the reference is saved, the token didn't take effect, and the warning is carried", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.8",
"private": true,
"license": "Apache-2.0",
"type": "module",
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { isBuiltin } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import ts from "typescript";
const root = fileURLToPath(new URL("../", import.meta.url));
function runtimeImports(text, file) {
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
const imports = [];
const visit = (node) => {
let literal;
if (ts.isImportDeclaration(node)) {
const clause = node.importClause;
if (clause?.isTypeOnly) return;
const bindings = clause?.namedBindings;
if (!clause?.name && bindings && ts.isNamedImports(bindings)
&& bindings.elements.length && bindings.elements.every((e) => e.isTypeOnly)) return;
literal = node.moduleSpecifier;
} else if (ts.isExportDeclaration(node) && !node.isTypeOnly) {
if (node.exportClause && ts.isNamedExports(node.exportClause)
&& node.exportClause.elements.every((e) => e.isTypeOnly)) return;
literal = node.moduleSpecifier;
} else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword
|| (ts.isIdentifier(node.expression) && node.expression.text === "require"))) {
literal = node.arguments[0];
}
if (literal && ts.isStringLiteralLike(literal)) imports.push(literal.text);
ts.forEachChild(node, visit);
};
visit(source);
return imports;
}
test("runtime import audit includes lazy loads and re-exports, excludes types and prose", () => {
assert.deepEqual(runtimeImports(`
import type { A } from "types-only";
import { type B } from "inline-types-only";
export type { C } from "exported-types";
const prose = 'import("not-an-import")';
import "side-effect";
export { value } from "re-export";
const lazy = () => import("lazy");
const cjs = require("required");
`, "control.ts"), ["side-effect", "re-export", "lazy", "required"]);
});
test("the CLI entry points declare every reachable external runtime dependency", () => {
const manifest = JSON.parse(readFileSync(resolve(root, "packages/cli/package.json"), "utf8"));
const pending = ["bin-wrapper.ts", "index.ts"].map((f) => resolve(root, "packages/cli/src", f));
const visited = new Set();
const missing = new Set();
while (pending.length) {
const file = pending.pop();
if (visited.has(file)) continue;
visited.add(file);
for (const specifier of runtimeImports(readFileSync(file, "utf8"), file)) {
if (specifier.startsWith(".")) {
pending.push(resolve(dirname(file), specifier.replace(/\.js$/, ".ts")));
continue;
}
if (isBuiltin(specifier)) continue;
const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
// build-package rewrites these to the daemon files shipped inside the CLI.
if (name === "@openrig/daemon") continue;
if (!Object.hasOwn(manifest.dependencies, name)) missing.add(`${name} (${file.slice(root.length)})`);
}
}
assert.deepEqual([...missing].sort(), [], "Runtime imports must survive a production-only install");
});