Compare commits

..
Author SHA1 Message Date
dev60-review df3bbeb3c7 docs(capability-map): teach what 0.7.12 added
The packaged capability map's header marker moves to
capability-delta-v0.7.12; the verb count stays at ninety-two (no new
top-level verb).

- A person's answer goes to a receiving agent that always exists, with the
  question; pairing and runner permission answers included. A relay
  connector can pull instead of being pushed to.
- rig down stops only sessions carrying the seat's OPENRIG_NODE_ID, and
  exits 2 naming the terminals it left.
- rig send records one transport.delivery_evidence event per send.
- rig seat status shows guidance written after load and the refocus hook's
  record; rig queue show names a successor; queue create advises on scope;
  custom blocker IDs explain the wait.
- rig terminal open --provider herdr-app; pi-rpc, launch wrappers and Pi
  switches for community harnesses; setup registers the person; a stale
  start reservation is archived; closed-work erasure is opt-in.
2026-11-11 13:25:01 +00:00
dev60-review 62e1483cb2 docs(release): describe 0.7.12 in what's new
Add "What changed in OpenRig 0.7.12" for an agent on an upgraded install: the
five migrations and three retention settings (erasure is opt-in), openrig-core
0.1.28 and what changed in it, rig down's narrower teardown, answers reaching
an agent, the other changes to act on, and the limits, including #2970 and
#2948, which aren't in this release. The published 0.7.11, 0.7.10 and 0.7.9
notes follow, one heading level down; 0.7.8's is dropped, as 0.7.11 kept three.
The context pack's reference twin is a link to this file.
2026-11-11 13:23:44 +00:00
dev60-review 37a9638eb8 docs(release): describe 0.7.12 in the changelog
Add the [0.7.12] section: answers to the team go to a receiving agent, setup
registers the person, community harness and relay additions, herdr-app views,
queue erasure and delivery evidence, rig down's narrower teardown, fixes
across delivery, Codex, restore, the queue, Slack and speed, the limits that
remain (including #2970 and #2948, which aren't in this release), and credits.

Carry the published [0.7.11] section from v0.7.11 unchanged beneath it.
2026-11-11 13:21:31 +00:00
dev60-review 598d214582 chore(release): prepare 0.7.12
Set 0.7.12 in the root, cli, daemon, ui and acp packages and the lockfile,
the ACP launcher's @openrig/cli dependency and registry listing, and the
v0.7.12 install tags in the README, getting started and quickstart.

Bind the packaged-plugin baseline to published 0.7.11 (archive sha256
71e3b727) and advance openrig-core to 0.1.28: its activity-relay and refocus
hooks changed, refocus-state.cjs is new, and six skills changed since 0.7.11.
The openrig plugin is unchanged at 0.1.0.
2026-11-11 13:14:40 +00:00
20 changed files with 1339 additions and 1320 deletions

No files matched your search

+546
View File
@@ -8,6 +8,552 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.12]
OpenRig 0.7.12 is a patch for people running their own agent teams, cut from
main `16e8b7fb`. When you answer your team, by typing, clicking or qualifying
an answer, the answer now goes to an agent that reads it with the question and
decides, and there is always a default agent to receive it. Setup offers to
register you as the team's person. `rig down` no longer stops a terminal it
can't show belongs to the seat. There are fixes across delivery, Codex,
restore, the queue, Slack and speed, many contributed by people using
OpenRig. Two repairs aren't in this release: see Known limits.
### Before you upgrade
- **`rig down` is more careful.** It stops only a tmux session that carries the
seat's own `OPENRIG_NODE_ID`, on the server and session it read. A terminal
OpenRig didn't create, a mismarked one or a replaced one is left running, and
`rig down` exits 2 naming it and how to stop it yourself. A seat joined with
`rig attach --self` is in that group. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
- **openrig-core is 0.1.28.** A Codex session kept running across the upgrade
can ask for hook approval until it's relaunched.
### Answering your team
- A default receiving agent always exists for answers nothing else reads, such
as a host pairing request or a mode's scheduled end, and one setting decides
which agent it is. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
- Pairing answers go to the receiving agent, and you can decide directly in the
TUI's pairing settings. A pairing request says who it went to and who reads
the answer. ([#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2935](https://github.com/mvschwarz/openrig/pull/2935))
- A runner's permission answer goes to the receiving agent, which decides while
the runner waits, so a qualified answer such as "only if it's the test file"
is read before anything acts. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
- Setup offers to register you as the team's person, and the empty-registry
hint is right. A team you start from your own assistant fits your own logins,
with a review before anything is committed. ([#2924](https://github.com/mvschwarz/openrig/pull/2924), [#2905](https://github.com/mvschwarz/openrig/pull/2905))
### Harnesses and connections
- A community harness can declare `protocol: pi-rpc`, presets can carry launch
wrappers, and a seat can switch between Pi and another runtime. The
community-maintained nodecode runner is listed in the reference.
([#2870](https://github.com/mvschwarz/openrig/pull/2870), [#2759](https://github.com/mvschwarz/openrig/pull/2759), [#2669](https://github.com/mvschwarz/openrig/pull/2669), [#2859](https://github.com/mvschwarz/openrig/pull/2859))
- A relay connector that can't accept incoming connections can pull pending
messages for a person instead of being pushed to: set
`OPENRIG_RELAY_MODE=pull` in the relay secrets file. Push stays the default.
A person's gateway binding can be unset. ([#2844](https://github.com/mvschwarz/openrig/pull/2844), [#2812](https://github.com/mvschwarz/openrig/pull/2812))
- `rig terminal open <view> --provider herdr-app` opens a Rig view inside the
herdr-app desktop window. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2956](https://github.com/mvschwarz/openrig/pull/2956), [#2981](https://github.com/mvschwarz/openrig/pull/2981))
### Queue, scope and seats
- Queue content can be erased for closed-work retention and account deletion,
and a retried create can't bring erased work back. ([#2837](https://github.com/mvschwarz/openrig/pull/2837), [#2843](https://github.com/mvschwarz/openrig/pull/2843))
- Each send records what's known about its delivery in one
`transport.delivery_evidence` event: what was admitted, what reached the
input, what the screen check showed, and for which occupant. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
- `rig queue create` advises on an unresolved scope, slice lists show observed
queue counts, a proof policy can require every listed judge, the execution
ladder shows each slice's recorded status, `rig queue show` names a handed-off
row's successor, and a custom blocker ID explains the wait.
([#2901](https://github.com/mvschwarz/openrig/pull/2901), [#2902](https://github.com/mvschwarz/openrig/pull/2902), [#2867](https://github.com/mvschwarz/openrig/pull/2867), [#2872](https://github.com/mvschwarz/openrig/pull/2872), [#2874](https://github.com/mvschwarz/openrig/pull/2874), [#2983](https://github.com/mvschwarz/openrig/pull/2983))
- Every seat's managed guidance points to its skills, help and project context.
`rig seat status` shows guidance written after the agent loaded it and what
the seat's refocus hook recorded. A rebuilt seat's briefing lists older recaps
as optional history. ([#2918](https://github.com/mvschwarz/openrig/pull/2918), [#2920](https://github.com/mvschwarz/openrig/pull/2920), [#2916](https://github.com/mvschwarz/openrig/pull/2916), [#2876](https://github.com/mvschwarz/openrig/pull/2876))
### Fixes
- **Delivery and drafts:** Claude seats with a custom status line read idle and
verify sends; a fresh idle hook doesn't type into a current Claude dialog;
Claude's context hint and drawn cursor are read correctly; a deliberate
override is delivered when the input can't be read; update notices beside the
paste hint are recognized. ([#2807](https://github.com/mvschwarz/openrig/pull/2807), [#2813](https://github.com/mvschwarz/openrig/pull/2813), [#2947](https://github.com/mvschwarz/openrig/pull/2947), [#2942](https://github.com/mvschwarz/openrig/pull/2942), [#2933](https://github.com/mvschwarz/openrig/pull/2933), [#2800](https://github.com/mvschwarz/openrig/pull/2800), [#2786](https://github.com/mvschwarz/openrig/pull/2786))
- **Codex:** answering a Codex choice prompt lets a held wake resume without
escalating; a working counter is compared only within one pane process; hook
trust records are written under smol-toml 1.9, so launches no longer stop at
"Hooks need review". ([#2952](https://github.com/mvschwarz/openrig/pull/2952), [#2980](https://github.com/mvschwarz/openrig/pull/2980), [#2804](https://github.com/mvschwarz/openrig/pull/2804))
- **Restore, start and handover:** owed restore input is delivered after a
bounded wait on unknown readiness; compaction uses the draft defaults and
reports repeated unsent preparation; `rig daemon start` archives a start
reservation left from an earlier boot; the kernel restart guidance describes
the existing recovery; an earlier occupant can't replace a Claude seat's
resume target; token switches keep the Claude launch; rebuild continuity and
Codex launch choices are explained.
([#2818](https://github.com/mvschwarz/openrig/pull/2818), [#2799](https://github.com/mvschwarz/openrig/pull/2799), [#2797](https://github.com/mvschwarz/openrig/pull/2797), [#2915](https://github.com/mvschwarz/openrig/pull/2915), [#2965](https://github.com/mvschwarz/openrig/pull/2965), [#2796](https://github.com/mvschwarz/openrig/pull/2796), [#2853](https://github.com/mvschwarz/openrig/pull/2853), [#2906](https://github.com/mvschwarz/openrig/pull/2906))
- **Queue and recovery:** a polling hosted member (an outside assistant on the
team; experimental and off by default) gets one standing notice; a hosted
member's ring that times out reads unknown and isn't rung again;
unroutable items wake as soon as the seat is reachable; the stuck sweep counts
a finding once; escalation custody survives a handoff; findings route to the
seat's own fallback; a park on an outside condition with no wake says so;
`rig workflow validate` is read-only.
([#2864](https://github.com/mvschwarz/openrig/pull/2864), [#2957](https://github.com/mvschwarz/openrig/pull/2957), [#2790](https://github.com/mvschwarz/openrig/pull/2790), [#2824](https://github.com/mvschwarz/openrig/pull/2824), [#2946](https://github.com/mvschwarz/openrig/pull/2946), [#2865](https://github.com/mvschwarz/openrig/pull/2865), [#2851](https://github.com/mvschwarz/openrig/pull/2851), [#2852](https://github.com/mvschwarz/openrig/pull/2852), [#2817](https://github.com/mvschwarz/openrig/pull/2817), [#2763](https://github.com/mvschwarz/openrig/pull/2763))
- **Slack and people:** a Slack reply that can't be read says the message "may
have posted"; threads are recovered when needed; an ask settles after its
original message is deleted; eligible asks post when quiet hours can't be
read, marked when OpenRig can't fully check them; a held ask whose person has
no connector closes as no-connector; update delivery is described without
assuming Slack. ([#2909](https://github.com/mvschwarz/openrig/pull/2909), [#2788](https://github.com/mvschwarz/openrig/pull/2788), [#2792](https://github.com/mvschwarz/openrig/pull/2792), [#2814](https://github.com/mvschwarz/openrig/pull/2814), [#2805](https://github.com/mvschwarz/openrig/pull/2805), [#2815](https://github.com/mvschwarz/openrig/pull/2815), [#2927](https://github.com/mvschwarz/openrig/pull/2927))
- **CLI and config:** `rig context recap-write` writes where every reader looks;
config accepts two keys the daemon already resolves; `rig whoami` says the
team is still being built during `rig up`; rigs with `@` in the name find
attention; a seat waiting on a person names its question; the installer
reports PATH gaps with shell hints; daemon credentials stay out of the tmux
server's environment. ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
- **Speed:** queue listings, show and the parked view stop re-reading each row;
the stuck sweep yields by elapsed time; Markdown section ends are found in one
pass. ([#2825](https://github.com/mvschwarz/openrig/pull/2825), [#2926](https://github.com/mvschwarz/openrig/pull/2926), [#2833](https://github.com/mvschwarz/openrig/pull/2833))
### Behaviour that changed
- **Your answer goes to an agent.** Through 0.7.11 some answers acted on the
request directly, and some had no receiver when no operator was set. Now a
default receiving agent always exists and gets the answer with its question.
([#2923](https://github.com/mvschwarz/openrig/pull/2923), [#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2934](https://github.com/mvschwarz/openrig/pull/2934))
- **`rig down`** leaves terminals it can't show belong to the seat; see Before
you upgrade. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
- **Codex "Hooks need review" with smol-toml 1.9** no longer needs a manual
workaround. ([#2804](https://github.com/mvschwarz/openrig/pull/2804))
- **A daemon start reservation left after a reboot** is archived by
`rig daemon start`; don't clear it by hand. ([#2915](https://github.com/mvschwarz/openrig/pull/2915))
### Known limits
- **Not in this release:** the daemon can pause for up to about a second while
reading proof on large mission trees ([#2970](https://github.com/mvschwarz/openrig/pull/2970)), and delivery to a Claude seat
showing Claude's auto-update failure footer can stall because that footer
isn't recognized ([#2948](https://github.com/mvschwarz/openrig/pull/2948)).
- **`rig down`:** a terminal it leaves running stays running until you stop it.
It doesn't keep a stored host or boot identity, doesn't guard against another
program running as you, and a daemon restart between losing the tmux server
and the stop wasn't exercised. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
- **Codex working counter:** a capture that doesn't name its pane keeps the old
comparison, a restart that keeps both the pane and its process id isn't seen,
and a leftover live frame can count once until it expires. ([#2980](https://github.com/mvschwarz/openrig/pull/2980))
- **Custom blocker IDs** explain the wait, but their progress gives no event
wake; the timer remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
- **Delivery evidence** records what was known, not that the agent read the
message; where the input landed can read unknown. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
- **Runner permission answers** are checked with test runners; a real runner
waiting on a person's answer hasn't been run end to end. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
- **herdr-app views** are one-shot; following isn't claimed. **Pull relay**
needs a connector that implements pull. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2844](https://github.com/mvschwarz/openrig/pull/2844))
- **From 0.7.11:** its known limits below still apply, except Codex hook setup
with smol-toml 1.9.x ([#2720](https://github.com/mvschwarz/openrig/issues/2720)), fixed by [#2804](https://github.com/mvschwarz/openrig/pull/2804). 0.7.12 changes openrig-core
again, so the Codex hook note there applies to this upgrade too. They weren't
re-checked for this release unless named here.
### Credits
Thanks to @billbenetti, @oodadoudou, @DeryFerd, @Aummadour, @seanmcn, @w4rc0n,
@ziadeh, @vanducng, @smota, @sn1thy, @dajiaohuang and @Vaishnavi220506 for
merged contributions.
## [0.7.11]
OpenRig 0.7.11 is a patch for people running their own agent teams, cut from
main `b40bc71c`. Its biggest change: an ordinary send or work notice now goes
into a person's typed draft by default instead of being refused (see Behaviour
that changed). A person can now work with their team from their own coding
agent through the new `openrig` plugin or from an ACP app such as Zed or T3
Code, and, as an experiment that's off by default, answer the team's questions
from an assistant connected through a grant service. A prepared managed compaction waits for an idle moment instead
of asking again, and a restore stopped at a prompt clears itself. On Linux, a
daemon run as a systemd user service no longer takes its seats down when its
unit stops or restarts, and writable terminal tiles reflow text when the screen resizes. There
are fixes across the queue, workflows, launch and speed under load, many
contributed or reported by people using OpenRig. The OpenShell worker's
maintained cleanup now works on OpenShell 0.1.6, but the worker still isn't end
to end: see Known limits.
### Before you upgrade
- **Back up your database first.** Five new migrations run at the daemon's
first start: 150 (node launch wrapper), 151 (Pi account switches), 152
(workflow instance spec), 153 (events retention marks) and 154 (workflow
instance root objective). 0.7.10 had none. With the daemon stopped, copy the
file `$OPENRIG_DB` names. Going back to 0.7.10 after these migrations isn't
established.
- **Restart the daemon after installing:**
```bash
npm install -g @openrig/cli@0.7.11
rig daemon stop && rig daemon start
```
- **Linux, with the daemon run as a systemd user service:** the seats move into
their own scope only when their tmux server next starts fresh. Until then the
server stays in the daemon's unit, so stopping or restarting that unit still
takes the seats down, as before. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
- **The openrig-core plugin moves from 0.1.26 to 0.1.27.**
Its activity relay and refocus hooks changed, as did the refocusing,
openrig-user and openrig-skills skills. Replaced files are kept under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from there. ([#2756](https://github.com/mvschwarz/openrig/pull/2756), [#2743](https://github.com/mvschwarz/openrig/pull/2743), [#2717](https://github.com/mvschwarz/openrig/pull/2717), [#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2647](https://github.com/mvschwarz/openrig/pull/2647), [#2619](https://github.com/mvschwarz/openrig/pull/2619))
- **Sends now join a person's draft** (`transport.draft_delivery` = `deliver`).
To keep the old refusal, run `rig config set transport.draft_delivery refuse`. ([#2645](https://github.com/mvschwarz/openrig/pull/2645))
- **A Pi seat that declares an absolute `PI_CODING_AGENT_DIR`** in its `env:`
now uses it as its account home and needs a readable `auth.json` there, or
the launch stops. Prepare one, or remove the declaration to keep the managed
seat login. ([#2630](https://github.com/mvschwarz/openrig/pull/2630))
### Sends and drafts
- **A send now goes into a person's typed draft.** Under the new
`transport.draft_delivery` setting's default, `deliver`, an ordinary
`rig send` or work notice to a seat whose prompt holds a person's typed draft
pastes the message at the cursor and submits the combined input as one
prompt, and the send's result says whether submission was confirmed.
`refuse` (also `OPENRIG_TRANSPORT_DRAFT_DELIVERY` in the daemon's
environment) restores the old refusal where OpenRig detects the draft, from
the next send with no restart. Menus, permission prompts, a seat's typing
guard, managed compaction and `--wait-for-idle` still refuse or wait under
either value. ([#2645](https://github.com/mvschwarz/openrig/pull/2645))
- **A draft only the input box shows is caught:** an ordinary Claude Code or
Codex send reads the box once before pasting (three tmux calls, within
500 ms). Under `refuse` the send is refused before any input; under `deliver`
the result says it joined the draft. ([#2653](https://github.com/mvschwarz/openrig/pull/2653))
- **Claude seats showing a statusLine or the background task panel** read as a
normal composer under the draft-aware typing guard, so an empty box gets the
delivery instead of `draft_input_unknown`. ([#2703](https://github.com/mvschwarz/openrig/pull/2703))
- **The bare `✔ Update installed · Restart to update` row** of Claude Code
2.1.319 in a narrow pane is recognised, so an idle seat's empty prompt reads
empty and wakes go through under the draft-aware guard. Checked against the
row as reported, not a live pane. ([#2662](https://github.com/mvschwarz/openrig/pull/2662))
- **A full held-message quota no longer drops a sender's later wakes:** the
sender's newest held message retires its earlier ones, listed as `coalesced`
in `rig seat held-messages <seat>` and readable with `--id`. ([#2701](https://github.com/mvschwarz/openrig/pull/2701))
- **Seats stopped while they owe work** don't get a burst of undeliverable
wakes at each daemon start; the start-up log says `waiting=N (not replayed: …)`. ([#2620](https://github.com/mvschwarz/openrig/pull/2620))
### Compaction, restore and handover
- **A prepared managed compaction waits for its window.** When the restore map
is complete but `/compact` couldn't go in (a draft, a tmux mode, no idle
moment), the attempt is held as `prepared` and sends `/compact` at the next
idle moment instead of asking the seat to prepare again about every 11
minutes. `rig compact --state` shows the hold and `--cancel` disarms it. A
busy seat isn't asked again after an ended attempt, across a restart too. ([#2698](https://github.com/mvschwarz/openrig/pull/2698), [#2716](https://github.com/mvschwarz/openrig/pull/2716))
- **The final `/compact` waits out the preparation's own fresh activity:** its
guarded send now allows the 15-second hook freshness window plus the 8-second
capture budget (23 seconds, against 9), so a seat that's already idle, such
as one out of context, gets `/compact` instead of a refusal. A busy seat can
wait up to about 14 seconds longer; the attempt's deadline still limits it. ([#2764](https://github.com/mvschwarz/openrig/pull/2764))
- **A restore stopped at a prompt clears itself** (such as Claude's resume
selection) once the seat runs the conversation it was restoring, using the
check `rig seat clear-attention` runs. The docs cover the manual route. ([#2711](https://github.com/mvschwarz/openrig/pull/2711), [#2674](https://github.com/mvschwarz/openrig/pull/2674))
- **A queue wake refused at a prompt no longer goes in right after "Compacted"**
while the compaction still owes its turn boundary and restore request. It
goes in at the seat's next idle moment after that step clears, as a recovered
notification. ([#2666](https://github.com/mvschwarz/openrig/pull/2666))
- **A Codex helper inside a Claude seat** (such as `codex exec` from its Bash
tool) no longer becomes the seat's resume token, so a restart resumes the
Claude conversation instead of failing with `no_conversation_found`. ([#2740](https://github.com/mvschwarz/openrig/pull/2740))
- **A Claude rebuild handover checks its priming packet:** if it's still
staged, the handover reports the submission unconfirmed and says to submit
the existing packet once, never to resend or rerun. ([#2650](https://github.com/mvschwarz/openrig/pull/2650))
- **Restoring a Claude seat's activity hooks** finds the agent's own source from
its saved launch recipe, not a same-name agent in the rig's folder. ([#2617](https://github.com/mvschwarz/openrig/pull/2617))
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry
refreshes that skill from its source when a Claude or Codex seat is restored,
keeping its conversation; `keep_saved` stays the default. ([#2626](https://github.com/mvschwarz/openrig/pull/2626))
### Your own coding agent
- **The `openrig` plugin,** shipped beside openrig-core, lets a person work with
their local team from their own Claude Code or Codex: hand it work, follow
it, answer the team's questions about it, and read the result. Claude Code:
`claude plugin marketplace add "$(npm root -g)/@openrig/cli/daemon/assets/plugins/openrig"`,
then `claude plugin install openrig@openrig-app`. Codex: the same
`codex plugin marketplace add` path, `codex plugin add openrig@openrig-app`, then
`codex mcp add openrig --env OPENRIG_SESSION_NAME=<address> -- rig mcp serve --front-door`.
The plugin's README has the steps. ([#2647](https://github.com/mvschwarz/openrig/pull/2647))
- **VS Code, Copilot CLI and Cursor** install it too, with no seat; the README
and `rig_whoami` say to add your own `rig mcp serve --front-door` server with
`OPENRIG_SESSION_NAME` set and turn the plugin's off. A seatless app can still
create a task, send and post, as `unknown-mcp-<8 hex>`. ([#2683](https://github.com/mvschwarz/openrig/pull/2683), [#2709](https://github.com/mvschwarz/openrig/pull/2709), [#2718](https://github.com/mvschwarz/openrig/pull/2718), [#2657](https://github.com/mvschwarz/openrig/pull/2657))
- **Answers are recorded exactly as the person typed them.** ([#2733](https://github.com/mvschwarz/openrig/pull/2733))
- **Marketplace files at the repository root** let Claude Code's
`/plugin marketplace add mvschwarz/openrig`, and directories that take a public
Git repo, install it from GitHub (marketplace `openrig-app`). ([#2719](https://github.com/mvschwarz/openrig/pull/2719))
### ACP apps: `rig acp serve`
- **OpenRig as an agent in ACP apps:** `rig acp serve --to <member@rig>` runs
on stdio for an app that launches a custom agent (Zed's `agent_servers`, T3
Code). A prompt becomes a queue row, each row the work reaches shows as a
step, and the closing resolution is the reply. Nothing runs until an app
starts it, and it talks to the daemon `rig` uses. ([#2648](https://github.com/mvschwarz/openrig/pull/2648), [#2714](https://github.com/mvschwarz/openrig/pull/2714))
- **The team's questions reach the app** while a turn is open, as a permission
request or a one-choice form, and the choice is the person's answer. Sessions
can be listed and reopened, and a question left unanswered there is named as
still open in the TUI and Slack. ([#2651](https://github.com/mvschwarz/openrig/pull/2651), [#2708](https://github.com/mvschwarz/openrig/pull/2708), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2712](https://github.com/mvschwarz/openrig/pull/2712))
- **The `@openrig/acp` launcher** (`openrig-acp`) runs the matching
`@openrig/cli`'s `rig acp serve` for the ACP registry's `npx` launch, with a
new `acp.to` setting for the receiver. `npm run pack:release` packs it beside
the CLI at the same version. ([#2655](https://github.com/mvschwarz/openrig/pull/2655), [#2715](https://github.com/mvschwarz/openrig/pull/2715))
### People and connected assistants
- Denying or dropping a host pairing request in Steering now gives the requesting machine no access; in 0.7.9 and 0.7.10, that action could return the daemon access token. ([#2884](https://github.com/mvschwarz/openrig/pull/2884))
- **Pairing answers now reach the target's kernel operator.** On a 0.7.11 target with a receiving kernel operator, typed words and Approve or Deny choices in the TUI, Steering or Slack are replies for that agent to read. Receiving a reply neither grants nor denies access. The operator acts on clear intent with `rig host approve <code>` or `rig host deny <code>` and asks back when the answer is unclear. The commands also work from a plain terminal on the target; they use its operator bearer (`OPENRIG_AUTH_BEARER_TOKEN`, or `--bearer <token>`). ([#2900](https://github.com/mvschwarz/openrig/pull/2900))
- **An Approve click on an operator-owned request waits for the operator to act.** A stopped operator keeps ownership; the answer waits in its queue. A stopped or unknown running state is named with the terminal route, without claiming the answer was read. If there is no operator, it cannot receive answers, or OpenRig cannot determine one, the request names that case and uses the agent-free path: Approve and Deny decide directly, and replies lead to another ask rather than denial. File-only replies and the literal `[file reply]` have no words stored on the agent-free ask; they still do not deny it.
- **TUI answers use the daemon's operator bearer when configured.** Ordinary terminal calls keep their separate terminal credential. A missing or wrong action credential leaves the answer unrecorded and shows what to set; this does not weaken the daemon's authentication.
- **Receipt, clarification and outcome are separate steps.** Receipts, agent-free re-asks and outcomes inferred from clicks or closes need the requesting machine to keep polling. An explicit `rig host approve` or `rig host deny` attempts its outcome update directly. An expiry timer closes the ask even if polling stopped; an unanswered expired ask gets no further update. Requests and decisions live in daemon memory, so a restart drops an open request. Approval still must be collected before expiry. This target-side change does not change older targets.
- **Assistants connected through a grant service** (experimental, unsupported,
off by default: you serve `rig mcp serve --http --grant-service <url>` behind
your own TLS tunnel, and an outside service grants each connection):
`rig_question_list` and `rig_question_answer` let a registered person answer
the team's questions as their own; `--task-destination <member@rig>` names
the receiver for `rig_queue_create`, and a bare seat name resolves. ([#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2638](https://github.com/mvschwarz/openrig/pull/2638), [#2750](https://github.com/mvschwarz/openrig/pull/2750))
- **The `relay` gateway connector** sends questions, updates and notices to an
operator-owned bridge over loopback HTTP and takes replies back. It's off
unless you add a relay binding, and the bridge, with its reach to a messaging
platform, is yours (`docs/reference/gateway-relay.md`). ([#2624](https://github.com/mvschwarz/openrig/pull/2624))
- **The web UI's For You feed** answers a local ask's questions on its card. ([#2612](https://github.com/mvschwarz/openrig/pull/2612))
### Queue, workflows and status
- **One `unclaimed-backlog` notice** replaces a finding per row for a seat that
reads without claiming and has no escalation route or delegation parent. ([#2752](https://github.com/mvschwarz/openrig/pull/2752))
- **Claims read `working`, not stalled,** for up to 60 minutes while a Claude
owner has a live background shell, and 20 while a managed restore is owed. ([#2675](https://github.com/mvschwarz/openrig/pull/2675))
- **`rig queue show`, `list`, `transitions`, `update` and `block` take
`--host <id>`,** as `create` and `handoff` did; `claim` doesn't. ([#2678](https://github.com/mvschwarz/openrig/pull/2678))
- **Cross-host handoffs** that override a claim wake the claimer, and a close
to an unverifiable successor names `rig queue handoff`. ([#2734](https://github.com/mvschwarz/openrig/pull/2734), [#2621](https://github.com/mvschwarz/openrig/pull/2621))
- **Code results:** an `openrig.code-result/v1` manifest as `evidenceRef` gives
`rig_artifact_read` and the new `rig queue artifact` the commit pair to diff. ([#2659](https://github.com/mvschwarz/openrig/pull/2659))
- **Workflows:** a run keeps its own spec when another pod's copy reuses the id
and version; the root objective is kept and printed as `objective:`;
`rig workflow unregister` removes versions; `trace --full` returns the whole
trail. ([#2696](https://github.com/mvschwarz/openrig/pull/2696), [#2738](https://github.com/mvschwarz/openrig/pull/2738), [#2676](https://github.com/mvschwarz/openrig/pull/2676), [#2667](https://github.com/mvschwarz/openrig/pull/2667))
- **Activity:** a helper harness inside a seat no longer reports its turns as
the seat's, and a resumed seat no longer reads a false model divergence. ([#2743](https://github.com/mvschwarz/openrig/pull/2743), [#2664](https://github.com/mvschwarz/openrig/pull/2664))
- **Also:** refocus traces projects outside `workspace.root`; a timed-out
`rig stream emit` names a retry that can't duplicate; `rig context
reconstruction inspect` and `view` read an exact revision from local stores.
([#2717](https://github.com/mvschwarz/openrig/pull/2717), [#2619](https://github.com/mvschwarz/openrig/pull/2619), [#2207](https://github.com/mvschwarz/openrig/pull/2207))
### Launch, install and setup
- **On Linux, a daemon run as a systemd user service starts the seats' tmux
server in its own user scope** (`openrig-tmux-<id>.scope`, with
`OOMPolicy=continue` from systemd 253), so stopping or restarting the
daemon's unit no longer ends the seats, and their memory no longer counts
against it. A daemon started from a login session, or a failed `systemd-run`,
starts the server as before with one log line giving the reason; macOS and
Windows are unchanged. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
- **A Claude seat whose launch passes `--settings` JSON written with spaces**
(such as `{"env": {…}}` from a proxy alias) can be verified again: OpenRig
reads the value whole, so it finds the seat's conversation. ([#2665](https://github.com/mvschwarz/openrig/pull/2665))
- **A Claude launcher script that execs the native installer's symlink**
becomes ready after a launch or rebuild, and `--no-chrome` is recognised. ([#2751](https://github.com/mvschwarz/openrig/pull/2751))
- **`launch_wrapper`,** an argv prefix whose launcher prepares the environment
and execs the agent, works for local Claude Code, Pi and Codex seats; a
wrapped seat never starts unwrapped. ([#2746](https://github.com/mvschwarz/openrig/pull/2746), [#2609](https://github.com/mvschwarz/openrig/pull/2609))
- **`rig provider switch --seat <pi seat> --to-account <name> --account-home <dir> --reason "<why>"`**
moves a Pi seat to another account through a confirmed successor, keeping its
model, effort and wrapper; `--dry-run` shows the plan. A Pi seat whose spec
declares its account home starts, resumes and switches accounts again: on the
first 0.7.11 candidate it didn't start, because the staged command began with
shell setup that `exec` can't run. ([#2630](https://github.com/mvschwarz/openrig/pull/2630), [#2782](https://github.com/mvschwarz/openrig/pull/2782))
- **Claude seats that load openrig-core themselves** stop loading its skills
twice: the next launch removes OpenRig's unchanged copies. ([#2660](https://github.com/mvschwarz/openrig/pull/2660))
- **Also:** a short permission-hook policy name; `rig attach --self` outside
tmux adds the rig; `rig host pair --timeout` holds the request that long;
`rig config set` names a masking variable; daemon start and stop treat a
probe the OS blocked as unknown; a bare piped `rig` skips the compile cache.
([#2640](https://github.com/mvschwarz/openrig/pull/2640), [#2641](https://github.com/mvschwarz/openrig/pull/2641), [#2692](https://github.com/mvschwarz/openrig/pull/2692), [#2547](https://github.com/mvschwarz/openrig/pull/2547), [#2502](https://github.com/mvschwarz/openrig/pull/2502), [#2643](https://github.com/mvschwarz/openrig/pull/2643))
### Terminals and views
- **Writable tiles reflow with the screen.** They attach as in 0.7.10: the screen
in use sizes the seat, and text reflows on resize. They stay attached until
detached or closed. Read-only tiles keep showing the seat without attaching,
typing into it or resizing it. Fixed web geometry is opt-in through
`terminal.fixed_geometry` (off by default).
([#2868](https://github.com/mvschwarz/openrig/pull/2868), [#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2821](https://github.com/mvschwarz/openrig/pull/2821))
- **Herdr follow views** work on macOS too (protocol 22), open a labelled
snapshot where live following isn't supported, and show a cleaner strip. ([#2663](https://github.com/mvschwarz/openrig/pull/2663), [#2706](https://github.com/mvschwarz/openrig/pull/2706))
- **Closing the herdr-core terminal that started OpenRig** no longer ends the
daemon or its seats (per herdr-core's documented behaviour; a real close
wasn't exercised). ([#2737](https://github.com/mvschwarz/openrig/pull/2737))
- **Also:** terminal snapshots keep colours; a reattach during a broker
teardown works; a home under a symlinked parent records its real path;
`rig doctor` says why tmux control mode is off. ([#2654](https://github.com/mvschwarz/openrig/pull/2654), [#2671](https://github.com/mvschwarz/openrig/pull/2671), [#2633](https://github.com/mvschwarz/openrig/pull/2633), [#2707](https://github.com/mvschwarz/openrig/pull/2707))
### Speed under load
- **The stuck sweep** batches its reads and yields every 50 rows: on a copy of
a busy 340 MB database, a pass went from 10.2–11.4 s to 5.1–5.5 s and the
longest stall from about 10 s to 0.57 s. ([#2649](https://github.com/mvschwarz/openrig/pull/2649))
- **`view.changed` events are no longer stored,** so the `events` table grows
about a third as fast on the busy host measured, and opt-in
`retention.events_days` prunes eight history-only event types. ([#2695](https://github.com/mvschwarz/openrig/pull/2695), [#2699](https://github.com/mvschwarz/openrig/pull/2699))
- **The web UI** loads the newest 100 events instead of the whole log. ([#2704](https://github.com/mvschwarz/openrig/pull/2704))
- **`daemon.seat_poll_interval_ms`** (1000 to 2000) trades activity freshness
for daemon CPU on large fleets. ([#2688](https://github.com/mvschwarz/openrig/pull/2688))
- **Also:** with `OPENRIG_TMUX_CONTROL=1`, short sends and `set-option` use the
control client; the idle-shell check reads the plain process table; skill
catalog Git reads time out after five seconds. ([#2636](https://github.com/mvschwarz/openrig/pull/2636), [#2690](https://github.com/mvschwarz/openrig/pull/2690), [#2713](https://github.com/mvschwarz/openrig/pull/2713))
### Docs and tests
Daemon HTTP clients and bearer tokens ([#2652](https://github.com/mvschwarz/openrig/pull/2652)), the 0.7.10 capability map on
main ([#2736](https://github.com/mvschwarz/openrig/pull/2736)), a neutral TUI demo snapshot ([#2724](https://github.com/mvschwarz/openrig/pull/2724)), a web UI editor timer
([#2689](https://github.com/mvschwarz/openrig/pull/2689)), and test and CI fixes ([#2753](https://github.com/mvschwarz/openrig/pull/2753), [#2681](https://github.com/mvschwarz/openrig/pull/2681), [#2685](https://github.com/mvschwarz/openrig/pull/2685), [#2673](https://github.com/mvschwarz/openrig/pull/2673), [#2628](https://github.com/mvschwarz/openrig/pull/2628)).
### Behaviour that changed
- **Sends and work notices that were refused with `prompt_draft` now go
through by default, joined to the person's draft** (above).
`rig config set transport.draft_delivery refuse` restores the old refusal
where OpenRig detects the draft, from the next send with no restart; under
`refuse`, a draft only the input box shows now refuses too. ([#2645](https://github.com/mvschwarz/openrig/pull/2645), [#2653](https://github.com/mvschwarz/openrig/pull/2653))
- **A Pi seat with an absolute `PI_CODING_AGENT_DIR`** uses it as its account
home on launch, restart, resume and restore; no `auth.json`, no launch. ([#2630](https://github.com/mvschwarz/openrig/pull/2630))
- **`/api/events` no longer emits `view.changed`,** live or replayed, so new
ones leave the web UI's log, activity feed and For You and the historical
telemetry export; the view streams still carry them. ([#2695](https://github.com/mvschwarz/openrig/pull/2695))
- **The stuck sweep** files one backlog notice where it filed one per row,
skips `stalled-after-claim` inside the two windows above, re-checks each
rule just before filing, and skips overlapping ticks. ([#2752](https://github.com/mvschwarz/openrig/pull/2752), [#2675](https://github.com/mvschwarz/openrig/pull/2675), [#2649](https://github.com/mvschwarz/openrig/pull/2649))
- **Managed compaction:** a prepared attempt stays `waiting`, and a manual
`rig compact` over it reads `already_in_progress`. ([#2698](https://github.com/mvschwarz/openrig/pull/2698), [#2716](https://github.com/mvschwarz/openrig/pull/2716))
- **The daemon and seats no longer inherit `HC_SCOPE`, `HC_TERMINAL_ID` or
`HC_TERMINAL_SOCKET`;** one already running keeps them until restarted. ([#2737](https://github.com/mvschwarz/openrig/pull/2737))
- **On Linux, the seats' tmux server runs in its own systemd user scope** when
the daemon is a user service (above): the daemon unit's stop, restart and
memory accounting no longer cover the seats. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
- **Read-only terminal tiles** show the seat without attaching and end with
tmux's own error if they cannot read it. Writable tiles use the ordinary
sizing attach; detaching ends that viewer, while the agent keeps running.
([#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2868](https://github.com/mvschwarz/openrig/pull/2868))
- **Also:** each sender keeps one live held message per seat; daemon start
doesn't re-send a failed wake to a seat with no receiver; restores record
`restore.outcome_reconciled` themselves; `rig terminal open --follow` returns
`follow_unsupported` with a snapshot. ([#2701](https://github.com/mvschwarz/openrig/pull/2701), [#2620](https://github.com/mvschwarz/openrig/pull/2620), [#2711](https://github.com/mvschwarz/openrig/pull/2711), [#2663](https://github.com/mvschwarz/openrig/pull/2663))
- **MCP and ACP:** an empty or placeholder seat value means no seat; the
seatless `rig_whoami` text changed; `--front-door` is `front-door/v2`;
grant-service callers' `destination` is optional; `rig acp serve` gives
form-taking apps a form, leads step titles with the latest note, and asks
nothing after a cancel. ([#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2709](https://github.com/mvschwarz/openrig/pull/2709), [#2718](https://github.com/mvschwarz/openrig/pull/2718), [#2733](https://github.com/mvschwarz/openrig/pull/2733), [#2638](https://github.com/mvschwarz/openrig/pull/2638), [#2750](https://github.com/mvschwarz/openrig/pull/2750), [#2708](https://github.com/mvschwarz/openrig/pull/2708), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2655](https://github.com/mvschwarz/openrig/pull/2655))
- **CLI:** `rig attach --self` outside tmux records `<name>@<rig>`;
`rig host pair` keeps a request for its `--timeout`; `rig config set` and
`reset` name environment overrides; `rig daemon start` and `stop` refuse when
a blocked probe leaves identity or liveness unknown; `--host` receipts print
a runnable command. ([#2641](https://github.com/mvschwarz/openrig/pull/2641), [#2692](https://github.com/mvschwarz/openrig/pull/2692), [#2547](https://github.com/mvschwarz/openrig/pull/2547), [#2502](https://github.com/mvschwarz/openrig/pull/2502), [#2678](https://github.com/mvschwarz/openrig/pull/2678))
- **Seat launches:** unchanged OpenRig copies of openrig-core's skills are
removed, with warnings; the permission hook reads the installed policy lists
on each call; model divergence reads pending until the new occupant's record.
([#2660](https://github.com/mvschwarz/openrig/pull/2660), [#2640](https://github.com/mvschwarz/openrig/pull/2640), [#2664](https://github.com/mvschwarz/openrig/pull/2664))
### Known limits
- **OpenShell:** on OpenShell 0.1.6 the maintained cleanup (`delete-owned`)
now works for a custom image, and recreate and an exact resume passed in our
native run. A restored guest still receives its startup text again, so strict
recall after a restore isn't met, and the worker isn't end to end. The CA fix
holds on 0.1.6. Never give the guest the daemon's main API port (normally
7433). After a same-generation reconciled launch, run
`rig environment connect` yourself. ([#2755](https://github.com/mvschwarz/openrig/pull/2755))
- **Drafts:** detection isn't complete. Under `refuse`, an unreadable input box
lets the send go ahead, so an undetected draft goes with the message; under
`deliver`, a result may not mention a draft it joined. ([#2645](https://github.com/mvschwarz/openrig/pull/2645), [#2653](https://github.com/mvschwarz/openrig/pull/2653))
- **A post-compaction step can still hold** when the box reads as typed, under
draft-aware delivery or the typing guard, or after a lost `/compact`. Run
`rig compact <seat> --cancel`, then send a restore request naming the newest
restore map, marker and packet. A queue wake refused at a prompt now waits for
that step, but recovery at daemon start and a new handoff's wake don't check
for it yet. ([#2575](https://github.com/mvschwarz/openrig/issues/2575), [#2666](https://github.com/mvschwarz/openrig/pull/2666))
- **Draft-aware delivery to Claude:** a message held as `draft_input_unknown`
still isn't retried. Whether managed compaction now gets past the banner
(`target_activity_unknown`) wasn't retested. ([#2605](https://github.com/mvschwarz/openrig/issues/2605))
- **Claude provider switch:** the failure reported in [#2341](https://github.com/mvschwarz/openrig/issues/2341) ("The probe pane
returned to a shell instead of staying inside the runtime") isn't shown fixed.
- **`rig exec`:** checked with Claude Code and Codex through their ACP
adapters. A cancel is acknowledged, not settled, and says nothing about
further spend. Cancel with `rig exec cancel`, not `rig acp cancel`.
- **ACP apps:** checked in T3 Code (Beta) 0.1.7 and with a scripted client; the
unanswered-question notice hasn't run in a live app, nor the note-first step
title in Zed. A cancel stops waiting; the work carries on. ([#2648](https://github.com/mvschwarz/openrig/pull/2648), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2712](https://github.com/mvschwarz/openrig/pull/2712))
- **The `openrig` plugin** hasn't run in Claude Desktop, the Codex app or
Copilot CLI. A seatless app can't claim, update, hand off or answer
questions, and everyone using the app on a machine acts as its seat. ([#2647](https://github.com/mvschwarz/openrig/pull/2647), [#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2683](https://github.com/mvschwarz/openrig/pull/2683))
- **Grant-service assistants** still aren't woken: an item stays queued until
its receiver claims it. **Relay:** "Accepted by bridge" means the bridge has
it, not the person, and a failed send replays only at a restart. ([#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2624](https://github.com/mvschwarz/openrig/pull/2624))
- **Event retention** can't be undone, and the database file doesn't shrink. ([#2699](https://github.com/mvschwarz/openrig/pull/2699))
- **`keep_saved` skills:** a skill whose source changes between `rig down` and
the restore comes back missing, named in the restore's warnings; an edited seat
copy is kept. ([#2626](https://github.com/mvschwarz/openrig/pull/2626))
- **Read-only terminal tiles** each run one tmux command a second while
showing. The earlier eight-tile showing measurement used about 6–8% of one
core on an Apple Silicon Mac mini, against about 0.2% for attached 0.7.10
tiles. This polling cost no longer applies to writable tiles. ([#2757](https://github.com/mvschwarz/openrig/pull/2757))
- **Early writable-tile input:** as in 0.7.10, Enter or Ctrl-C typed while a
new tile is still attaching can close that viewer. The agent keeps running;
reopen the view. Once attached, tmux handles input and paste directly.
([#2868](https://github.com/mvschwarz/openrig/pull/2868))
- **Kept writable tiles from 0.7.8, 0.7.9 or pre-reflow 0.7.11** keep their old
client. Manually refresh them: close only their owned viewer and reopen with
`rig terminal open`. For a `--follow` view, use `--no-follow` first, then open
a fresh view. This does not touch the agent or its conversation; it is not an
automatic migration. Existing 0.7.10 plain and read-only tiles stay as they are.
([#2868](https://github.com/mvschwarz/openrig/pull/2868))
- **Claude `launch_wrapper`** hasn't run with zsh, on macOS arm64 or with a real
installed Claude; an alias or shell function named `claude` fails closed. ([#2746](https://github.com/mvschwarz/openrig/pull/2746))
- **Going back to 0.7.10:** besides the migrations, an older permission hook
gives named-policy Claude sessions no decision until relaunched. ([#2640](https://github.com/mvschwarz/openrig/pull/2640))
- **Codex hooks:** a session kept running across an upgrade can show "Hook
failed — plugin contents changed; hook approval required" until relaunched,
and 0.7.11 changes openrig-core again. `rig seat restart --keep-conversation`
refuses a Codex seat launched with developer instructions (`settings_drift`).
- **Codex hook setup:** with smol-toml 1.9.x and an existing `hooks.json`,
OpenRig can leave its activity hooks and trust records unchanged. The range
(`^1.8.0`) still permits 1.9.x; the repository lockfile uses 1.8.0. ([#2720](https://github.com/mvschwarz/openrig/issues/2720))
- **Claude Code 2.1.319 starts in auto mode by default,** so a new Claude seat
may act without asking; manual mode restores asking.
- **Also:** permission requests in seat status show only the latest, in memory
for five minutes, and suggested rules can contain sensitive values; Slack
private channels need the documented, untested route, direct messages aren't
supported and people reads haven't run live; a Codex snapshot restore writes
OpenRig's guidance blocks into a tracked `AGENTS.md` (check `git status`); a
scheduled reminder that fails before input isn't retried; SSH host status
reads `unknown` behind ProxyJump or ProxyCommand; advisories don't show on a
native resume; seats handed over before 0.7.9 refuse stop, restart and
clean; after a project-local install, plain `rig` isn't found in the TUI's
own shell; with Claude Code 2.1.319, `rig up` can wrongly say "Startup prompt
still staged … press Enter"; two npm installs into one folder at once once
left a partial install, cause unknown.
- **Fixed or changed from 0.7.10's known issues:** the bare update banner
([#2662](https://github.com/mvschwarz/openrig/pull/2662)) and the Claude launcher-script readiness timeout with `--no-chrome`
([#2751](https://github.com/mvschwarz/openrig/pull/2751)). 0.7.10's OpenShell limit is superseded by the one above.
- **The earlier limits below that 0.7.11 doesn't fix or change still apply.**
### Credits
Thanks to @billbenetti, @seanmcn, @smota, @Aummadour, @dajiaohuang, @w4rc0n,
@Mohsintl, @twotnguyen, @ziadeh and @Nikhi00718 for merged contributions.
Thanks to @billbenetti for the reports behind [#2752](https://github.com/mvschwarz/openrig/pull/2752), [#2660](https://github.com/mvschwarz/openrig/pull/2660), [#2738](https://github.com/mvschwarz/openrig/pull/2738), [#2749](https://github.com/mvschwarz/openrig/pull/2749), [#2666](https://github.com/mvschwarz/openrig/pull/2666) and [#2665](https://github.com/mvschwarz/openrig/pull/2665), to
@pr0xc3nt4ur1 for the report behind [#2740](https://github.com/mvschwarz/openrig/pull/2740) and [#2743](https://github.com/mvschwarz/openrig/pull/2743), to @oneandonlydean for
the process chain behind [#2751](https://github.com/mvschwarz/openrig/pull/2751), and to @niklasbaldi for the report and cause
behind [#2654](https://github.com/mvschwarz/openrig/pull/2654).
Thanks also to @ziadeh for the reports behind [#2622](https://github.com/mvschwarz/openrig/issues/2622) and [#2486](https://github.com/mvschwarz/openrig/issues/2486), and to @mlc-factory-bot for the reports behind [#2605](https://github.com/mvschwarz/openrig/issues/2605) and [#2607](https://github.com/mvschwarz/openrig/issues/2607).
## [0.7.10]
Published 2026-11-08 as `@openrig/[email protected]`, built from the commit tagged
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/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.11` 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.12` 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.11/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh
```
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
+2 -2
View File
@@ -201,11 +201,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.11/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
+1 -1
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.11`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.12`. 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.
+684 -178
View File
@@ -1,94 +1,288 @@
# What changed in OpenRig 0.7.1
# What changed in OpenRig 0.7.12
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.12.
OpenRig 0.7.12 sends a person's answers, typed, clicked or qualified, to an
agent that reads them with the question and decides, with a default receiving
agent that always exists. Setup offers to register the person. `rig down` stops
only terminals it can show belong to the seat. It adds five database
migrations. If you upgraded from 0.7.10 or earlier, also read "What changed in
OpenRig 0.7.11" below, and so on back. 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`.
## 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.
- **Back up the database, then restart the daemon.** Five migrations run on the
daemon's first start: 155, 156 and 157 (queue erasure and its batches), 158
(queue inbox check-ins) and 159 (an index for handed-off rows). If
`$OPENRIG_DB` isn't backed up yet, copy that file (`rig config get db.path`
shows it) with the daemon stopped, then `rig daemon start`. Going back to
0.7.11 after these migrations isn't established.
- **New settings:** `retention.closed_content_days` (`0`, keeps closed work's
content), `retention.closed_metadata_days` (`0`) and `retention.erasure_ledger`
(empty, no ledger). Erasing closed work is opt-in. `rig config` shows each with
its source.
- **The openrig-core plugin moves from 0.1.27 to 0.1.28.** Its activity relay
and refocus hooks changed (with a new `refocus-state.cjs`), as did the
agent-operated-software, claude-compaction-restore, openrig-user,
retiring-and-inheriting-a-seat, seat-continuity-and-handover and
session-compaction-and-restore skills. A Codex session kept running across the
upgrade can ask for hook approval until it's relaunched.
- **`rig down` now leaves some terminals running.** It stops only a tmux session
carrying the seat's own `OPENRIG_NODE_ID`, on the server and session it read.
For a terminal OpenRig didn't create, a mismarked or replaced one, or a seat
joined with `rig attach --self`, it exits 2 and names the terminal and how to
stop it. Tell the person, and stop those by hand if they're theirs to stop.
([#2962](https://github.com/mvschwarz/openrig/pull/2962))
## New things you can do
## Answers from a person reach an agent
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
readable (`rig seat held-messages`) and is never replayed.
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
interventions are guidance only.
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
one line to shared guidance files.
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
--seat <session>` gives each caller its own seat.
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
`docs/reference/a2a-agents.md` covers A2A agents.
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
`docs/reference/community-harness-examples/`.
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
strands an accepted create or join whose effect hadn't landed.
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
`rig stream show <id>` says what became of an observation.
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
the kernel starts seats only for those.
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
deliberate start.
- A default receiving agent always exists for answers nothing else reads, such
as a host pairing request or a mode's scheduled end. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
- Pairing answers go to the receiving agent, and a person can decide directly in
the TUI's pairing settings. ([#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2935](https://github.com/mvschwarz/openrig/pull/2935))
- A runner's permission answer goes to the receiving agent, which decides while
the runner waits, so a qualified answer ("only if it's the test file") is
read before anything acts. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
- **What to do:** when a person answers by typing, clicking or qualifying,
expect an agent to receive the words with the question. Don't design or
advise a fixed button action, or a path where no agent receives the answer.
## Behaviour that changed
## Other changes to act on
- **Setup** offers to register the person, and a team started from the person's
own assistant fits their logins, with a review before commit. ([#2924](https://github.com/mvschwarz/openrig/pull/2924), [#2905](https://github.com/mvschwarz/openrig/pull/2905))
- **Harnesses:** `protocol: pi-rpc`, launch wrappers in presets, and Pi
cross-runtime switches. ([#2870](https://github.com/mvschwarz/openrig/pull/2870), [#2759](https://github.com/mvschwarz/openrig/pull/2759), [#2669](https://github.com/mvschwarz/openrig/pull/2669))
- **A relay connector** that can't accept incoming connections can pull
(`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push stays the default.
([#2844](https://github.com/mvschwarz/openrig/pull/2844))
- **herdr-app:** `rig terminal open <view> --provider herdr-app` opens a view in
the herdr-app window. Following isn't claimed. ([#2744](https://github.com/mvschwarz/openrig/pull/2744))
- **Queue erasure** for closed-work retention and account deletion, off by
default. ([#2837](https://github.com/mvschwarz/openrig/pull/2837), [#2843](https://github.com/mvschwarz/openrig/pull/2843))
- **Delivery evidence:** each send records one `transport.delivery_evidence`
event. Read it as what was known about a delivery, not proof the agent read
it. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
- **Seat self-knowledge:** managed guidance points to skills, help and project
context; `rig seat status` shows guidance written after load and what the
refocus hook recorded. ([#2918](https://github.com/mvschwarz/openrig/pull/2918), [#2920](https://github.com/mvschwarz/openrig/pull/2920), [#2916](https://github.com/mvschwarz/openrig/pull/2916))
- **Codex "Hooks need review" with smol-toml 1.9** is fixed; stop working around
it by hand. ([#2804](https://github.com/mvschwarz/openrig/pull/2804))
- **A start reservation left after a reboot** is archived by `rig daemon start`;
don't clear it by hand. ([#2915](https://github.com/mvschwarz/openrig/pull/2915))
## Limits to keep in view
- **Not in this release:** daemon pauses of up to about a second while reading
proof on large mission trees ([#2970](https://github.com/mvschwarz/openrig/pull/2970)), and a delivery stall to a Claude seat
showing Claude's auto-update failure footer ([#2948](https://github.com/mvschwarz/openrig/pull/2948)). Don't tell a person
either is fixed.
- **`rig down`** keeps no stored host or boot identity, doesn't guard against
another program running as the person, and a daemon restart between losing the
tmux server and the stop wasn't exercised. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
- **Codex working counter:** a capture that doesn't name its pane keeps the old
comparison, and a restart that keeps the pane and its process id isn't seen.
([#2980](https://github.com/mvschwarz/openrig/pull/2980))
- **Custom blocker IDs** explain the wait but give no event wake; the timer
remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
- **Runner permission answers** haven't been run end to end with a real runner
waiting. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
- **0.7.11's limits below** still apply, except Codex hook setup with smol-toml
1.9.x ([#2720](https://github.com/mvschwarz/openrig/issues/2720)), fixed by [#2804](https://github.com/mvschwarz/openrig/pull/2804).
## What changed in OpenRig 0.7.11
This note is for an agent on an install that was just upgraded to 0.7.11.
OpenRig 0.7.11 sends ordinary messages and work notices into a person's typed
draft by default instead of refusing them, lets a person reach the team from
their own coding agent (the `openrig` plugin) or an ACP app (`rig acp serve`),
holds a prepared managed compaction until the seat is idle, and clears a
restore that stopped at a prompt. On Linux, a daemon run as a systemd user
service keeps its seats when its unit restarts. It adds five database
migrations. On OpenShell 0.1.6 the worker's maintained cleanup now works, but
the worker still isn't end to end. If you upgraded
from 0.7.9 or earlier, also read "What changed in OpenRig 0.7.10" below, and
from 0.7.8 or earlier, "What changed in OpenRig 0.7.9" too. 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
- **Back up the database, then restart the daemon.** Five migrations run on
the daemon's first start: 150 (node launch wrapper), 151 (Pi account
switches), 152 (workflow instance spec), 153 (events retention marks) and 154
(workflow instance root objective). If `$OPENRIG_DB` isn't backed up yet,
copy that file (`rig config get db.path` shows it) with the daemon stopped,
then `rig daemon start`. Going back to 0.7.10 after these migrations isn't
established.
- **New settings:** `transport.draft_delivery` (`deliver`), `acp.to` (empty),
`daemon.seat_poll_interval_ms` (1000) and `retention.events_days` (`0`, keeps
every event). `rig config` shows each with its source.
- **The openrig-core plugin moves from 0.1.26 to 0.1.27.**
Its activity relay and refocus hooks changed, as did the refocusing,
openrig-user and openrig-skills skills. Managed refresh preserves replaced
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
you still need from the preserved copy.
- **Linux, with the daemon as a systemd user service:** the seats move into
their own scope only when their tmux server next starts fresh; until then,
stopping or restarting the daemon's unit still takes them down.
- **Tell the person sends now join their drafts** (below). To keep the refusal,
run `rig config set transport.draft_delivery refuse`; it applies from the
next send, with no restart.
- **Pi seats:** a Pi seat that declares an absolute `PI_CODING_AGENT_DIR` in its
`env:` now uses it as its account home and needs a readable `auth.json`
there, or the launch stops. Prepare one, or remove the declaration to keep
the managed seat login. Ask the person before changing their spec.
- **herdr-core terminals:** new starts no longer inherit `HC_SCOPE`,
`HC_TERMINAL_ID` or `HC_TERMINAL_SOCKET`; a daemon or tmux server already
running with them keeps them until it's restarted.
- **Claude seats that load openrig-core themselves** lose OpenRig's unchanged
copies of its skills at their next launch, so each skill loads once; the
launch result warns with the copies removed or kept.
### Sends and drafts
- **Under `deliver` (the default),** an ordinary `rig send` or work notice to a
seat whose prompt holds a person's typed draft pastes the message at the
cursor and submits the combined input as one prompt. The result says whether
submission was confirmed and, when OpenRig saw the draft, that it joined it.
An unset, unreadable or invalid value follows `deliver`.
- **Still refused or held under either value:** menus, permission prompts, a
seat's typing guard (`draft-aware`, `hold`), managed compaction's sends and
`--wait-for-idle`.
- **Under `refuse`,** a send is refused, and a work notice retried once the
prompt is clear, where OpenRig detects the draft. Claude Code and Codex sends
now also read the input box before pasting, so a draft only the box shows
refuses too (`target_needs_input` / `prompt_draft`).
- **The draft-aware typing guard** reads a Claude composer with a statusLine or
the background task panel, and Claude Code 2.1.319's bare
`✔ Update installed · Restart to update` row in a narrow pane, so an empty box
gets the wake instead of `draft_input_unknown`.
- **Held messages coalesce:** a sender's newest held message at a seat retires
its earlier ones. Read them with `rig seat held-messages <seat>` (each with
`--id <id>`); an older backlog clears at that sender's next held message or
with `rig seat retire-held-message`.
- **Stopped seats aren't woken at each daemon start:** their rows stay owed, and
the start-up log says `waiting=N (not replayed: …)`. That isn't delivery.
### Compaction, restore and handover
- **A prepared managed compaction is held, not re-requested.** When the map is
complete but `/compact` couldn't go in, the attempt stays `waiting`, marked
`prepared`, and sends `/compact` at the next idle moment with an empty box.
`rig compact <seat> --state` shows it; a manual `rig compact` reads
`already_in_progress`; `--cancel` disarms it. It ends on a context dip, a
conversation change, a stale map (`prepared_map_stale`: 15 points of growth or
2 hours) or a map that stops reading ready.
- **The final `/compact` waits out the preparation's own fresh activity** (up
to about 23 s, against 9 s), so an idle seat that ran out of context compacts
instead of being refused; a busy seat can wait up to about 14 s longer.
- **A queue wake refused at a prompt** no longer goes in right after
"Compacted" while the compaction still owes its turn boundary and restore
request; it goes in at the next idle moment after that step clears.
- **A full restore stopped at a prompt clears itself** once the seat runs the
conversation it was restoring. If a seat still reads `attention_required`, run
`rig seat clear-attention <seat>`, with `--reason` after a partial restore or
a community harness's assisted startup. `proof_superseded` means the restore
changed mid-check.
- **A Codex helper inside a Claude seat** no longer takes the seat's resume
token (`transcript_not_this_session`).
- **A Claude rebuild handover may report its priming packet unconfirmed.**
Submit the existing packet once, only if it's still staged. Never resend it
blindly or rerun the handover.
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry
refreshes that skill from its source on restore; `keep_saved` is the default.
### Working with the team from outside
- **The `openrig` plugin** lets a person use their own Claude Code or Codex
with their local team: hand it work, follow it, answer the team's questions
in their own words, and read the result. Install it from the package
(`claude plugin marketplace add "$(npm root -g)/@openrig/cli/daemon/assets/plugins/openrig"`,
then `claude plugin install openrig@openrig-app`) or from this repository on
GitHub. Everyone using the app on a machine acts as the seat it names.
- **VS Code, Copilot CLI and Cursor** start its server with no seat: add your
own `rig mcp serve --front-door` server with `OPENRIG_SESSION_NAME` set and
turn the plugin's off. A seatless app can create tasks, send and post as
`unknown-mcp-<8 hex>`, but can't claim, update, hand off or answer.
- **ACP apps:** `rig acp serve --to <member@rig>` (or the `acp.to` setting)
makes the team an agent in an app that launches a custom ACP agent, such as
Zed or T3 Code. Nothing runs until an app starts it, and it uses the daemon
`rig` uses. A question left unanswered in the app stays open in the TUI and
Slack. The `@openrig/acp` launcher is packed beside the CLI at the same
version. See `docs/reference/acp-agents.md`.
- **Assistants through a grant service** are experimental, unsupported and off
by default: they need your own internet-reachable `rig mcp serve --http
--grant-service <url>` and an outside grant service. A registered person can
answer the team's questions from one, and `--task-destination <member@rig>`
names where its tasks go (`docs/reference/hosted-members.md`).
- **The `relay` gateway connector** is off unless you add a relay binding. It
hands questions and updates to a bridge the operator runs on loopback;
"Accepted by bridge" isn't delivery (`docs/reference/gateway-relay.md`).
### OpenShell workers
On OpenShell 0.1.6 the maintained cleanup (`delete-owned`) now works for a
custom image, and recreate and an exact resume passed in our native run. A
restored guest still receives its startup text again, so strict recall after a
restore isn't met, and the worker isn't end to end. The CA fix holds on 0.1.6.
"Never grant the guest access to the daemon's main API
port (normally 7433)."
### Other changes to act on
- Denying or dropping a host pairing request in Steering now gives the requesting machine no access; in 0.7.9 and 0.7.10, that action could return the daemon access token. ([#2884](https://github.com/mvschwarz/openrig/pull/2884))
- **Pairing answers now reach the target's kernel operator.** On a 0.7.11 target with a receiving kernel operator, typed words and Approve or Deny choices in the TUI, Steering or Slack are replies for that agent to read. Receiving a reply neither grants nor denies access. The operator acts on clear intent with `rig host approve <code>` or `rig host deny <code>` and asks back when the answer is unclear. The commands also work from a plain terminal on the target; they use its operator bearer (`OPENRIG_AUTH_BEARER_TOKEN`, or `--bearer <token>`). ([#2900](https://github.com/mvschwarz/openrig/pull/2900))
- **An Approve click on an operator-owned request waits for the operator to act.** A stopped operator keeps ownership; the answer waits in its queue. A stopped or unknown running state is named with the terminal route, without claiming the answer was read. If there is no operator, it cannot receive answers, or OpenRig cannot determine one, the request names that case and uses the agent-free path: Approve and Deny decide directly, and replies lead to another ask rather than denial. File-only replies and the literal `[file reply]` have no words stored on the agent-free ask; they still do not deny it.
- **TUI answers use the daemon's operator bearer when configured.** Ordinary terminal calls keep their separate terminal credential. A missing or wrong action credential leaves the answer unrecorded and shows what to set; this does not weaken the daemon's authentication.
- **Receipt, clarification and outcome are separate steps.** Receipts, agent-free re-asks and outcomes inferred from clicks or closes need the requesting machine to keep polling. An explicit `rig host approve` or `rig host deny` attempts its outcome update directly. An expiry timer closes the ask even if polling stopped; an unanswered expired ask gets no further update. Requests and decisions live in daemon memory, so a restart drops an open request. Approval still must be collected before expiry. This target-side change does not change older targets.
- **Rows on another host:** `rig queue show`, `list`, `transitions`, `update`
and `block` take `--host <id>`; `claim` doesn't, since only the destination
seat on its own host claims. `--host` never follows `rig host select`.
- **A seat's unclaimed backlog:** a seat with no escalation route or delegation
parent gets one `unclaimed-backlog` notice. To route its findings to a
watching seat:
`rig edge add <rig> <root seat's logical id> <watching seat> --kind escalates_to`.
- **Claims read `working`** for up to 60 minutes while a Claude owner has a
live background shell, and 20 while a managed restore is owed.
- **A cross-host handoff that overrides your claim** wakes you with the
successor's id: stop and coordinate with that seat.
- **Code results:** set an `openrig.code-result/v1` manifest as `evidenceRef`;
`rig queue artifact <qitemId>` and `rig_artifact_read` return the commit pair.
- **Workflows:** runs keep their own spec; `rig workflow unregister
<name>[@<version>]` (or `--prune-missing`) removes registered versions;
`rig workflow trace <id> --full` returns the whole trail; `show`, `trace` and
`status` print `objective:`.
- **`rig stream emit` timeouts:** repeat with the same `--id`, or check with
`rig stream list` before emitting again.
- **Launch and setup:** `launch_wrapper` works for local Claude Code, Pi and
Codex seats; `rig provider switch --seat <pi seat> --to-account <name>
--account-home <dir> --reason "<why>"` switches a Pi seat's account (`--dry-run`
first), and a Pi seat with a declared account home starts again; `rig config set` says when
an environment variable masks the saved value.
- **Linux seats:** when the daemon runs as a systemd user service, the seats'
tmux server starts in its own user scope (`openrig-tmux-<id>.scope`), so the
daemon's unit can stop or restart without ending them. Otherwise the server
starts as before, with one log line giving the reason.
- **Writable terminal tiles** attach as in 0.7.10: the screen in use sizes the
seat and text reflows on resize. Read-only tiles keep showing without attaching
or resizing the seat. Web fixed geometry is opt-in through
`terminal.fixed_geometry` (off by default). As in 0.7.10, Enter or Ctrl-C while
the first attach is in flight can close that viewer; the agent keeps running,
so reopen the view. Kept writable tiles from 0.7.8, 0.7.9 or pre-reflow 0.7.11
need a manual refresh: close only the owned viewer and reopen with
`rig terminal open`; for `--follow`, use `--no-follow` first, then open a fresh
view. The agent and conversation are preserved; no automatic migration runs.
([#2868](https://github.com/mvschwarz/openrig/pull/2868), [#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2821](https://github.com/mvschwarz/openrig/pull/2821))
- **A Claude seat launched with `--settings` JSON written with spaces** can be
verified again.
- **`view.changed` is no longer stored,** and `/api/events` no longer sends it.
- **Old history-only events can be pruned, if you choose to.** `retention.events_days` (default `0`, which keeps every
event) turns on a pass in the retention sweep (at daemon start and daily) that deletes `view.changed`,
`snapshot.created`, `watchdog.evaluation_fired` and the `queue.created`, `queue.updated`, `queue.claimed`,
@@ -107,105 +301,417 @@ after an upgrade it can still hold the previous version's note.
- **With pruning on, `rig telemetry events` reports the pruned ranges.** A page that spans pruned events inside the
retained window has a `retained_sequence_gap` for each missing seq range and `coverage.status` `partial` (it used to be
`retained_window`); events pruned from the start of the log show up as `before_retained_floor`.
- **Large fleets:** `daemon.seat_poll_interval_ms` (up to 2000) lowers CPU at
the cost of activity freshness; keep 1000 if tmux is slow.
- **Terminals:** Herdr follow views work on macOS (protocol 22) and open a
snapshot (`follow_unsupported`) where live following isn't supported.
- **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.
### Limits to keep in view
## Output that changed shape
- **OpenShell:** a restored guest gets its startup text again, strict recall
isn't met, and the worker isn't end to end (above).
- **Drafts:** detection isn't complete. Under `refuse`, an unreadable input box
lets the send go ahead; under `deliver`, a result may not mention a draft.
- **A post-compaction step can still hold** when the box reads as typed, under
draft-aware delivery or the typing guard, or after a lost `/compact`. Run
`rig compact <seat> --cancel`, then send a restore request naming the newest
restore map, marker and packet. A refused queue wake now waits for that step,
but recovery at daemon start and a new handoff's wake don't check yet.
- **`keep_saved` skills:** a skill whose source changes between `rig down` and
the restore comes back missing, named in the restore's warnings; an edited seat
copy is kept.
- **Terminal tiles** each run one tmux command a second while showing (8 tiles
used about 6–8% of one core on an Apple Silicon Mac mini).
- **A message held as `draft_input_unknown`** isn't retried, and whether
managed compaction gets past the update banner wasn't retested.
- **Claude provider switch:** the "probe pane returned to a shell" failure in
[#2341](https://github.com/mvschwarz/openrig/issues/2341) isn't shown fixed;
the launcher-script rebuild timeout and `--no-chrome` are fixed.
- **ACP apps** were checked in T3 Code (Beta) 0.1.7 and with a scripted client;
the unanswered-question notice hasn't run in a live app. A cancel stops
waiting; the team's work carries on.
- **The `openrig` plugin** hasn't run in Claude Desktop, the Codex app or
Copilot CLI. **Grant-service assistants** still aren't woken.
- **Claude `launch_wrapper`** hasn't run with zsh, on macOS arm64 or with a real
installed Claude; an alias or shell function named `claude` fails closed.
- **Going back to 0.7.10** isn't established; an older permission hook also
gives named-policy Claude sessions no decision until they're relaunched.
- **The other 0.7.10 limits below still apply:** `rig exec` cancels,
permission requests in seat status, Codex hooks (including
`rig seat restart --keep-conversation` refusing a Codex seat launched with
developer instructions), smol-toml 1.9.x, Slack, Claude Code 2.1.319's auto
mode, Codex snapshot restores and `AGENTS.md`, unretried reminders, SSH host
status, advisories on a native resume, seats handed over before 0.7.9, `rig`
in the TUI's shell after a project-local install, the "Startup prompt still
staged" message, and a partial install from two npm installs at once.
0.7.10's bare-banner and launcher-script limits are fixed, and its OpenShell
limit is replaced by the one above.
- **`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 to stop doing
## What changed in OpenRig 0.7.10
- **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.
The changes from 0.7.9 to 0.7.10, published on 2026-11-08, for an install
upgrading from 0.7.9 or earlier. 0.7.10 added `rig exec` for finite runs on
Claude Code or Codex, sent a held post-compaction step after two idle polls that
can't read the input box, left every tmux mode before a delivery, and showed
agent spec advisories at launch. Its OpenShell line below is corrected.
## Known gaps in 0.7.1
### After the upgrade
- **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`.
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`. This release adds no migrations.
- **The bundled openrig-core plugin goes from 0.1.25 to 0.1.26.** Its Claude
hooks add a `PermissionRequest` hook that reports permission requests to seat
status, and its refocus hook and the refocusing skill's trace script changed.
Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from the preserved copy.
- **Check agent specs before relaunching seats:** run `rig agent validate <path>`
and read any `agent spec advisory` line. A profile's `uses.<category>` written
as a single value now selects that resource; if the resource pool doesn't hold
it, a member using that profile fails to resolve with "not found in resource
pool". Ask the person before you change their spec.
- **Codex hooks:** a Codex seat kept running across the upgrade can show "Hook
failed — plugin contents changed; hook approval required" until relaunched; in
0.7.9's upgrade test, one seat restarted with OpenRig's managed restart showed
no review afterwards.
- **OpenShell:** rebuild a guest image with this release's `guest.py` before
using it; the CA repair is in that file, which the image carries.
- **Codex tool commands use a non-login shell:** managed Codex seats now pass
Codex `-c allow_login_shell=false`, so plain `rig` works however OpenRig was
installed, with a Codex that supports `allow_login_shell` (checked in Codex's
source at 0.162.1 and 0.166); not yet run with a live Codex seat. Setup that lives only in login or rc files, and shell functions and
aliases, may be missing from tool commands, and a model request for
`login: true` gets Codex's refusal, "login shell is disabled by config". To
defer to Codex's own setting, run `rig config set launch.codex_login_shell
true`; a login file that sets `PATH` outright can then hide `rig`. It applies
at each seat's next launch, and a restart in place keeps the running choice.
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
### Finite runs: `rig exec`
`docs/reference/acp-agents.md` is the rule. Read it before handing a row to
Claude Code or Codex.
- **Install the adapters first:** `@agentclientprotocol/claude-agent-acp`
0.101.0 and `@agentclientprotocol/codex-acp` 2.5.0, as that page shows.
`rig exec harnesses` reports the adapter and native versions it finds.
- **Run:** `rig exec run <row> --harness claude` (or `codex`). `--plan` shows
what would run and starts nothing; `--wait` waits for the outcome. A connector
owns the run, so leaving `--wait` or closing the terminal doesn't stop it.
Running it again returns the row's run instead of sending the task twice;
`--again` starts another turn once that run has ended.
- **Read the outcome** with `rig exec status <runId|row>`. `interrupted` means
the connector died without recording an outcome, so the outcome is unknown. A
`completed` turn is the agent's report, not finished work: check the work
before you close the row. `--wait` exits 0 for `completed`, 2 for any other
outcome and 3 when you stop waiting.
- **Cancel** with `rig exec cancel <runId>`, not `rig acp cancel`. An
acknowledgement isn't a settled shutdown, and it says nothing about further
spend.
- **Usage and cost** the agent reported are on the row's outcome note, for
`rig acp run` too, each with its source; what it didn't report reads unknown.
### Compaction and delivery
- **A held post-compaction step goes in.** After two idle polls in a row that
can't read a seat's input box, the next poll sends the step (the turn
boundary, the restore request or the audit). `rig compact <seat> --state` and
`rig seat status` say it went in over a box OpenRig couldn't read; they never
call that box empty. A draft OpenRig can't recognise in that box may receive
the restore text.
- **Still held:** a box that reads as typed, with or without a suggestion; a
seat whose draft-aware delivery or typing guard holds OpenRig's input; and the
step after a `/compact` whose result was lost. To take over a held restore by
hand, run `rig compact <seat> --cancel`, then send the seat a restore request
naming its newest restore map, its restore marker and its packet. A step
already being pasted can still land.
- **tmux modes:** a send or wake now leaves every tmux mode in a seat's pane,
including a chooser, customize or clock mode a person has open. If a send is
still refused for a tmux mode, the refusal names why leaving failed.
- **A scrolled pane with a stale record:** leaving the mode now goes through the
seat's address, so a stale binding naming the same reused tmux pane, from
another rig for example, no longer makes the send fail with nothing sent.
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
as the same attachment when only OpenRig's tile script changed, and moves to
the current command, so the viewer stays confirmed and the follow view goes on
adding and closing tiles. A kept viewer that's already running keeps its
earlier sizing for as long as it runs, its own re-attaches included; writable
viewers opened after the upgrade size their pane. A different session, tmux,
server or read-only setting still holds the view, as before. Checked against
the scripts those releases used, not yet with a live Herdr. It follows
@natefaerber's approach in
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
### OpenShell workers
0.7.10 fixes, in the source, the two problems that stopped the OpenShell worker
in 0.7.9, but the worker hasn't run end to end. Don't rely on this path yet. The
guest launcher now passes the CA settings the OpenShell exec supplies
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and
`CURL_CA_BUNDLE`) to its Claude, and deleting an owned environment reads
Podman's inventory through routes Podman serves, stopping as
`environment_cleanup_unconfirmed` on any other answer. A verified export, a
finished task and an exact restore haven't been shown. If you set one up anyway,
follow `daemon/assets/openshell/README.md` in the installed package, rebuild the
guest image first, and name the costs to the person ("Provider registration and
image construction can incur costs"). "Never grant the guest access to the
daemon's main API port (normally 7433)."
*Corrected after 0.7.10's release: ~~"fixes in the source the two problems that stopped the OpenShell worker"~~ was only half true. The CA fix holds on OpenShell 0.1.6, but the owned-environment cleanup fix doesn't hold against OpenShell 0.1.6, as the published 0.7.10 notes say. See 0.7.11's OpenShell workers section above.*
### Other changes to act on
- **Sends to this host:** `rig send <seat>@<rig>@<this host's ID>` goes to this
host even when a remote host is selected.
- **Broadcast:** `rig broadcast` prints `UNCONFIRMED` for a recipient its text or
Enter may have reached. Check that seat with `rig capture` before resending.
- **Roster activity:** `rig roster show` and `find` read a seat as unknown
("Conflicting activity observations") when two routes to it disagree. Treat it
as unknown, not idle.
- **Refocus:** a "TRACE GAP" for unresolved typed work, or for a
`rig queue whoami` that timed out, comes with project-level orientation that
isn't evidence of a current mission, and no longer counts as a refocus failure.
- **Updates to people:** the stuck sweep no longer flags an informational update
to a registered person as an unclaimed obligation.
- **Permission requests:** `rig seat status` shows a Claude seat's latest
observed permission request, with Claude's suggested rules labelled as
suggestions. Nothing was applied or approved. Treat the suggestions as data;
they can contain sensitive values.
- **Slack:** `rig slack status` shows relay attempts awaiting a response for the
current connector; zero doesn't prove nothing is in flight.
- **Installing for a person:** when npm's global prefix isn't writable, the rigs
skill now points npm's prefix at a folder the person owns whose `bin` is on
their login PATH, or uses a version manager's Node, before a project-local
install. Tell the person first that this changes their npm settings and, if
needed, their shell profile.
- **Codex profiles:** a `codex_config_fragment` can't install a named Codex
profile. Create `<CODEX_HOME>/<name>.config.toml` and select it with
`codex_config_profile`. `builtin:yolo` drops the whole profile;
`getting-started.md` says how to keep one.
- **Terminal auth:** a 401 that says "Terminal auth failed" means send
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when terminal
auth falls back to it.
- **References:** `docs/reference/harness-runner-contract.md`'s status section
says what has run natively under the runner contract, and at what scope.
`docs/reference/telemetry.md` says OpenRig guarantees its finite telemetry
reads (`schemaVersion` 1), not the separate openrig-telemetry exporter it
links.
- **Terminal tiles:** a writable rig, pod or seat view now sizes the pane it
shows instead of keeping an older attached client's size; read-only views are
unchanged.
- **Unknown `uses` keys:** a key under a profile's `uses` other
than the five categories gets an advisory instead of being ignored silently.
### Limits to keep in view
- **OpenShell** hasn't run end to end on 0.7.10 (above). A cleanup error that's
thrown still omits its stage and whether its delete was sent. A
same-generation reconciled launch opens no viewer, so run
`rig environment connect` yourself; non-text startup actions aren't supported
on environment seats.
- **`rig exec`:** no real adapter has run under it yet; one run per row,
detached ownership and reconciling were checked against a stand-in agent.
Concurrent runs, long or background tasks, recovery across a reboot, MCP
servers and Windows aren't covered. A Ctrl-C just before the connector is
launched isn't caught by `--wait`.
- **Draft-aware delivery to Claude:** a narrow Claude pane showing
`✔ Update installed · Restart to update` on its own line can still hold
deliveries at an empty prompt, queue wakes included; 0.7.10 doesn't recognize
that bare banner. A held message isn't replayed automatically by that path.
- **Seats handed over before 0.7.9** keep their adopted origin until a later
launched handover or managed launch records a new session. Until then
`rig seat stop`, `clean` and in-place restart refuse them, and now say why.
Don't use `rig unclaim` to get around it: that ends OpenRig's management of
the seat.
- **Permission requests in seat status** show only the latest one, held in
memory for five minutes and lost on a daemon restart.
- **Agent spec advisories** don't show for a native resume that continues a
saved conversation.
- **The other 0.7.9 limits below still apply:** open updates for a person, Slack,
SSH host status, shared asks, permit records, existing seats, Codex hooks, and
grant-service assistants not being woken. 0.7.9's limits on a restore request
held after compaction, `rig broadcast`'s `FAILED`, what has run under the
runner contract, and Codex seats' `rig: command not found` are fixed or
changed. The 0.7.8 limits below that 0.7.9
didn't fix or change, and the
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
apply.
---
## What changed in OpenRig 0.7.9
The changes from 0.7.8 to 0.7.9, published on 2026-11-06, for an install
upgrading from 0.7.8 or earlier.
### After the upgrade
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`.
- **Migrations 146 to 149** record a Pi seat's guidance file, what OpenRig last
wrote in each managed guidance block, an index for reading a rig's outbox,
and the stream intake position.
- **Tell the person that stream intake is on.** Each `rig stream emit` now opens
an intake row for the kernel queue worker (`queue.worker`), or adds a note to
the one still unclaimed, and the row wakes the queue worker like any queue row.
Earlier stream history isn't replayed. If they want 0.7.8's behaviour, with no
intake rows, run `rig config set stream.intake off`.
- **The bundled openrig-core plugin goes from 0.1.24 to 0.1.25.** Its
seat-continuity-and-handover skill changed. Managed refresh preserves replaced
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
you still need from the preserved copy.
- **Where seat guidance goes now:**
- a fresh Pi seat gets OpenRig's guidance in its own agent folder's
`AGENTS.md`, unless that file or folder is a link;
- a fresh Codex seat on Codex 0.164.0 or later gets it as launch developer
instructions, not in the repository `AGENTS.md`, when no config layer
OpenRig reads sets `developer_instructions`;
- a Codex resume, fork, restore or handover successor, an older or unknown
Codex, and a config layer that sets `developer_instructions` or can't be read
still use the repository `AGENTS.md`;
- if only the person's shell startup sets `CODEX_HOME`, OpenRig reads
`~/.codex`, so a `developer_instructions` set in their home is replaced for
that session. Declare the same absolute path as `CODEX_HOME` in the member's
or rig's `env:` to have it checked. `docs/reference/agent-startup-guide.md`
("Codex guidance at launch") is the rule;
- a managed block a person edited by hand is kept and named
(`guidance_block_edited`), not replaced. Read the warning before you change
the block.
- **Slack:** on the first start after upgrading, the connector edits the asks
that ended earlier to show they were handled, at Slack's edit rate: about an
hour for 450 asks. `rig slack status` shows pending edits.
- **OpenShell:** rebuild a guest image built for 0.7.8 with this release's
helper; an older helper rejects the new startup step, and the error names the
rebuild.
### Runner seats
Section 7 of `docs/reference/harness-runner-contract.md` is the rule for
permissions, and section 4 for activity. Read them before writing or changing a
runner.
- **Typed words:** a reply of just yes or no counts as the agent's allow or
reject. Any other words choose nothing: OpenRig's default permits the request
when the agent offered an allow option, and the read result carries the words
whole as `said`. A runner may queue them for the agent as an ordinary prompt
after the current turn, never as the answer; one that drops them still keeps
the contract. The ask now tells the person this.
- **Permit records:** each permit OpenRig decides by default (no one to ask, or
an ask closed without a choice, including a row passed to an agent seat) is
recorded once, as a stream item from the seat, naming the tool call, why no
person answered and the option given. Read them with
`rig stream list --tag runner-permission-permit` or `--source <seat>`. A record
has no destination and never changes the permit. Stream intake reads past it, so
it opens no intake row and wakes no one.
- **The working beat:** while a prompt is outstanding and the agent is alive, a
runner may post `active` / `liveness` so a long call keeps reading working.
After delivering an answer or permit, it posts `active` /
`permission_resolved`. A runner that sends no beats behaves as before. The Pi
runner and the dsh example post it.
- **Check a runner** with
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`.
It now covers the beat, and no longer falsely fails a correct runner on a busy
machine.
- **The dsh example** reads `DSH_SEAT_BIN` and `DSH_SEAT_HOME` from the member's
`env:`, and section 8 names ACP's `session/resume`.
### OpenShell workers
0.7.9 includes a repair for the OpenShell worker that wasn't usable in 0.7.8,
but the worker still doesn't work in 0.7.9. Don't rely on this path in 0.7.9.
Startup reaches the guest through a hash-checked first-input file, and nothing
is typed through the host viewer. Readiness checks the guest's own execution,
and a failed owned delete reports the stage that failed. Release testing on
0.7.9 then found two more problems: the guest's Claude couldn't reach its model
(`SELF_SIGNED_CERT_IN_CHAIN`), because the guest launcher left out the CA
settings the OpenShell exec supplies, and deleting an owned environment never
sent the delete, because its inventory step misread a "Not Found" reply. 0.7.10's
fixes for both are described above. "Never grant the guest access to the
daemon's main API port (normally 7433)."
### Assistants connected through a grant service (experimental)
Experimental, unsupported, off by default, and at the person's own risk. With
`rig mcp serve --http --grant-service <url>` and a grant service the person
supplies and runs, a connected assistant can claim, update and park the rows
addressed to its own seat, and see its team with `rig_team`, within the scope its
existing tools have. Nothing wakes it: an item stays queued until its receiver
claims it. Name the outside service, and the exposure of serving MCP over HTTP,
to the person before you set this up. `docs/reference/hosted-members.md` is the
rule.
### Other changes to act on
- **Held messages:** a seat that starts, or takes over a seat, with held
messages is told how many there are. Read them with
`rig seat held-messages <seat>`.
- **Unconfirmed sends:** `rig send --verify` now reports unconfirmed and exits 1
when it can't see what its Enter did, and `rig walk` reports unconfirmed
instead of lost. Inspect the recipient before any resend.
- **Deadlines:** to accept a re-estimate without unclaiming the work, run
`rig queue update <id> --closure-required-at <ISO>`.
- **Shared workspaces:** any registered person can settle another's ask as
themselves, and the first committed answer wins; a later answer gets 409
`qitem_already_terminal` and records nothing. A person registered with no
connector binding is asked on Steering and in the Feed only.
- **Handover:** a fresh or rebuild successor's first turn lists its startup
files, role first. A successor that started without a required startup file is
marked in the seat list.
- **Ambiguous rig names:** when an active rig name matches more than one rig,
`rig env down`, `status` and `logs` list the matching rig IDs and stop, and
`rig doctor --spec` reports conformance as unknown. Use the rig ID.
- **Seat activity:** window samples older than their bound no longer decide a
seat's activity, so a seat whose pane can't be read reads unknown rather than
its last verdict. Treat unknown as unknown, not idle.
- **Launch options:** `specs.builtins_visible: false` hides built-in specs
(`rig specs ls --include-builtins` still lists them); `plugin_channels` gives
one Claude member its own `--channels`, untested with a real channel plugin.
- **Files:** `rig down` removes OpenRig's own additions from a working folder
once no live seat uses them. `rig export` with no `-o` writes
`rig-export.yaml` beside an existing `rig.yaml`. `rig workspace validate` exits
1 for malformed optional frontmatter.
- **Kernels:** a new default kernel starts in `<workspace.root>/kernel`.
Existing kernels stay where they are. `docs/reference/agent-startup-guide.md`
explains the optional move; a fresh launch there starts a new native
conversation, so ask the person before moving.
- **PATH:** if preflight warns that a Codex seat's login shell can't find `rig`,
follow its fix. `getting-started.md` explains the `~/.profile` line and how to
check it with `bash -lc 'command -v node rig'`.
### Limits to keep in view
- **OpenShell:** the worker doesn't work in 0.7.9 (above). A same-generation
reconciled launch opens no viewer, so run `rig environment connect` yourself;
non-text startup actions aren't supported on environment seats.
- **Open updates for a person** read a 1,001-row window, so a page can come back
short, marked `truncated`.
- **Slack:** a private channel needs the documented route, which isn't tested,
and direct messages aren't supported. The channel and people reads haven't
been run against a live Slack workspace. Redaction of the bot token from a
transport error can still leave a malformed token fragment shorter than six
characters, so review an error before sharing it.
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
isn't proof the connection is broken. `rig host ls` reads the ssh config with
`ssh -G`, which runs any `Match exec` command in it.
- **Shared asks:** a click's option written before its answer row can stand
beside or inside the closer's answer without the first clicker's authorship.
- **Permit records** last as long as stream retention keeps them. A permit a
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
an ask opened before 0.7.9, a record may omit the option's kind.
- **Existing seats** pick up Codex launch instructions and the single
openrig-core skill load at their next fresh launch. A `CLAUDE.md` or
`AGENTS.md` that already grew a line per launch isn't trimmed.
- **Codex hooks after an upgrade:** Codex sessions kept running across the
upgrade show "Hook failed — plugin contents changed; hook approval required"
until relaunched.
- **0.7.8's limits** on the dsh `env:` names, conformance false failures, typed
words, the README's operator address, a symlinked rebuild, the rebuild receipt,
a re-parked click and unrecorded default permits are fixed or changed. The
other 0.7.8 limits below, and the
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
apply.
---
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.12",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7298,7 +7298,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.12",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7336,7 +7336,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.12",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7379,7 +7379,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.12",
"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.12",
"private": true,
"license": "Apache-2.0",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/acp",
"version": "0.7.1",
"version": "0.7.12",
"description": "OpenRig as an agent in ACP apps: starts rig acp serve from the @openrig/cli it pins.",
"type": "module",
"bin": {
@@ -15,7 +15,7 @@
"node": ">=22"
},
"dependencies": {
"@openrig/cli": "0.7.1"
"@openrig/cli": "0.7.12"
},
"license": "Apache-2.0",
"author": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "openrig",
"name": "OpenRig",
"version": "0.7.1",
"version": "0.7.12",
"description": "Send work to your OpenRig team of agents and follow it through to the result",
"repository": "https://github.com/mvschwarz/openrig",
"website": "https://openrig.dev",
@@ -12,7 +12,7 @@
"license_url": "https://github.com/mvschwarz/openrig/blob/main/LICENSE",
"distribution": {
"npx": {
"package": "@openrig/[email protected]"
"package": "@openrig/[email protected]2"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.12",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.27",
"version": "0.1.28",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.27",
"version": "0.1.28",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
@@ -11,7 +11,7 @@ not know exists.** So the point of what follows is not skill. It is recognition:
lands, something rhymes, and you go check instead of building.
Ninety-two top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.11`). This marker describes the pack's teaching, not publication or live
`capability-delta-v0.7.12`). This marker describes the pack's teaching, not publication or live
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
What changed in the installed version, and what to do about it, is one read away:
`rig context get reference/whats-new.md`.
@@ -62,6 +62,8 @@ one command away. Your memory of the fleet is a claim about the past.
arguments) and Claude's bounded suggested rules, which can contain paths, patterns or sensitive
values. It's evidence, not an applied permission: it applies and approves nothing, and it's held
in memory for five minutes and lost on a daemon restart (`docs/reference/seat-delivery.md`).
It also shows guidance written after the agent loaded it and what the seat's refocus hook
recorded; every seat's managed guidance points to its skills, help and project context.
- **`rig view list` / `rig view show <lens>`** — named lenses over coordination state: use
`view show escalations` for owner attention, `view show pickup` for claimed-row state, and
`view show execution` for done/now/next and current authored planning guidance with its
@@ -104,6 +106,10 @@ one command away. Your memory of the fleet is a claim about the past.
before querying the database yourself. If `retention.events_days` is set (default `0` keeps
everything), the history-only event types (views, snapshots, watchdog firings, queue events) older
than that are pruned, and an older window reports them as a `retained_sequence_gap`.
Closed work's content and metadata can also be erased on a schedule
(`retention.closed_content_days`, `retention.closed_metadata_days`; `0`, the default, keeps them),
with an optional `retention.erasure_ledger`, for a retention policy or an account deletion. It's
opt-in, and a retried create can't bring erased work back.
- **Check the exit code.** Many commands used to print nothing and exit 0 when a daemon read
failed; now they exit 1 or 2. Empty output from a failed read is not empty state.
@@ -142,6 +148,9 @@ unseen — which makes messages the one delivery channel that never gets skipped
A seat's typing guard and `--wait-for-idle` retain their checks. Managed compaction follows the
ordinary draft default and explicit operator protections; it separately reconciles its own
uncertain `/compact` write before sending restore input. Draft detection isn't complete.
**Each send records one `transport.delivery_evidence` event**: what was admitted, what reached
the input, what the screen check showed, and for which occupant. Read it as what was known about
the delivery, not proof the agent read it; where the input landed can read unknown.
- **`rig send --raw`** — send exact text or keystrokes without the From/To envelope;
the interactive-prompt guard still applies. To deliberately drive an interactive prompt,
**`--dangerously-interact --reason "<why>"`** is the explicit override and implies raw text.
@@ -307,6 +316,8 @@ unseen — which makes messages the one delivery channel that never gets skipped
**Open terminals ▸** opens the inspected plan through the same desktop launcher, herdr or plain
tmux, so the TUI must run on the daemon's desktop; when no window was attempted it shows the
preview and attach commands. Help or a side trip returns to the same preview.
`--provider herdr-app` opens the view inside the herdr-app desktop window as an equal grid with a
title per cell; it's a one-shot view (following isn't claimed), so check the cells yourself.
- **`rig walk <seat> --through <files> --pace <n>`** — deliver context pieces through
file-backed terminal paste. When the current native generation record resolves, the complete
piece and its matching Claude/Codex turn closure must arrive before pacing onward, including
@@ -374,6 +385,9 @@ your context, your compaction and your replacement — and it is the only thing
another host, as `create` and `handoff` do; `claim` doesn't, since only the destination seat
claims, on its own host. `rig queue artifact <qitemId>` (and MCP `rig_artifact_read`) returns the
exact commit pair from an `openrig.code-result/v1` manifest set as the row's `evidenceRef`.
It names a handed-off row's successor. `rig queue create` advises when a row's scope doesn't
resolve, and a custom blocker ID explains the wait in the waiting views (its progress gives no
event wake; the timer remains).
- **`rig queue claim` / `unclaim`** — is this mine and running, or still pending where two agents
might double-work it. Put it down honestly when it is not yours.
- **`rig queue update`** — record what happened in a form the rest of the system can act on. A
@@ -655,6 +669,8 @@ not have to tear one down to change it, and you rarely have to start from nothin
so capture the pane first; for an unattended seat use the Gateway preset, within what it declares.
A Hermes gateway runs per Hermes home, so one per seat with the example launcher; its preset
doesn't declare `resume`.
A harness can declare `protocol: pi-rpc`, a preset can carry a launch wrapper, and a seat can
switch between Pi and another runtime.
- **`rig down`** — stop a rig's seats and take it out of the running set. It ends the agents'
sessions and any work in progress; don't run it on a live team unless the person asked. In
human mode it first prints one line with each agent's last known activity, or “unknown”: a
@@ -666,6 +682,11 @@ not have to tear one down to change it, and you rarely have to start from nothin
edited skills-line block, edited team skill files, and settings or a collector that don't match
what OpenRig wrote or that another seat shares by a link. Any other guidance block goes even if
someone edited it.
**Since 0.7.12 it stops only a tmux session that carries the seat's own `OPENRIG_NODE_ID`**, on the
server and session it read. A terminal OpenRig didn't create, a mismarked or replaced one, or a seat
joined with `rig attach --self` is left running, and `rig down` exits 2 naming it and the manual way
to stop it. Don't read exit 2 there as a failed teardown: check the named terminal and stop it
yourself if it's yours.
- **`rig launch <rig> [seat]`** — one seat is down; start just that one, without disturbing the
rest. It takes the rig's name or ID; a name shared by more than one active rig stops and lists their IDs. Add `--plan` to preview it first, locally or with `--host`; a plan is never sent to a
daemon older than 0.5.9, which would ignore it and launch. A seat whose agent exited to a shell in
@@ -767,6 +788,8 @@ version, from checking the environment to a useful report to the OpenRig team.
database another live daemon already holds (the default, `warn`, only warns), and
`rig daemon start --allow-db-conflict` allows one deliberate start. A start still in progress reads
`Daemon starting: launch reserved by pid N since T; not listening yet` in `rig daemon status`, not stopped.
A start reservation left from an earlier boot is archived by the next start; don't clear it by
hand.
- **A busy daemon isn't a down one.** The CLI refuses only when the daemon is confirmed down
(stopped, stale, a refused probe), its probe is blocked, or it's a sibling home; a busy daemon
gets the real request. A write that then times out says its outcome is **unknown**: check its
@@ -1239,6 +1262,14 @@ reaching them is ordinary work rather than an escalation.
person can settle an ask that was asked of someone else, recorded as themselves beside the person
asked. The first committed answer wins; a later one gets 409 `qitem_already_terminal` and records
nothing. A park addressed to a registered person can be resolved.
- **A person's answer goes to an agent.** A default receiving agent always exists for answers nothing
else reads (a host pairing request, a mode's scheduled end), pairing answers and a runner's
permission answers go to it with the question, and it decides; a qualified answer ("only if it's
the test file") is read before anything acts. Design for that agent, never for a fixed button
action or for no receiver. A pairing request says who it went to and who reads the answer, and a
person can decide directly in the TUI's pairing settings. A relay connector that can't accept
incoming connections can pull instead (`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push
stays the default, and a person's connector binding can be unset.
- **`rig gateway channel add <id> --display-name <name> --binding
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
`<id>@external` (Slack only). Anyone writing there reaches the team, registered or not. Each row
@@ -1435,6 +1466,8 @@ your circumstances is configuration, and the ones that are not, another agent ca
The choice is saved as `provider.selected`, and the kernel starts seats only for those providers
(empty, the default, means every signed-in one). `--router-line yes|no` decides whether OpenRig
adds its one line to shared guidance files (`skills.router_line.enabled`).
Setup offers to register the person when none is registered, and a team started from the
person's own assistant fits their own logins, with a review before anything is committed.
- **`rig usage series`** — what a seat's token curve has looked like over time: climbing steadily,
reset, or stopped reporting entirely. The last one is a signal, not a gap.
- **`rig usage top`** — which of this machine's seats used the most tokens over a window (`--window
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.12",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -811,22 +811,19 @@ export class SliceIndexer {
// --- internals ---
private readSliceLocations(): SliceLocation[] {
return this.walkSliceLocations(false, [], undefined, this.missionId || undefined);
return this.walkSliceLocations().filter((location) => !this.missionId || location.missionId === this.missionId);
}
/** Every slice location under the roots. A requested mission is filtered before
* directory-name deduplication; otherwise the first matching name wins unless
* `physical` asks for every folder. */
/** Every slice location under the roots, whatever mission this indexer is scoped to.
* Directory names are deduplicated (first wins) unless `physical` asks for every folder. */
private walkSliceLocations(
physical = false,
additionalRoots: readonly string[] = [],
onReadError?: () => void,
missionId?: string,
): SliceLocation[] {
const locations: SliceLocation[] = [];
const seen = new Set<string>();
const addLocation = (location: SliceLocation) => {
if (missionId !== undefined && location.missionId !== missionId) return;
if (!physical) {
if (seen.has(location.name)) return;
seen.add(location.name);
@@ -1088,7 +1085,7 @@ export class SliceIndexer {
const status = this.mapStatus(frontmatter["status"] as string | undefined);
const railItem = this.extractRailItem(frontmatter, missionId);
const qitemIds = this.matchQitems(name, served);
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
const proofPacket = this.findProofPacket(name);
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
return {
@@ -1118,7 +1115,7 @@ export class SliceIndexer {
const status = this.mapStatus(frontmatter["status"] as string | undefined);
const railItem = this.extractRailItem(frontmatter, missionId);
const qitemIds = this.matchQitems(name, served);
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
const proofPacket = this.findProofPacket(name);
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
const commitRefs = this.extractCommitRefs(frontmatter);
const files = this.listSliceFiles(slicePath);
@@ -1505,34 +1502,7 @@ export class SliceIndexer {
return this.membershipIndex;
}
private discoverMissionIds(): Set<string> {
const missionIds = new Set<string>();
for (const root of this.sliceRoots()) {
let entries: fs.Dirent[];
try { entries = fs.readdirSync(root, { withFileTypes: true }); }
catch { continue; }
for (const entry of entries) {
if ((!entry.isDirectory() && !entry.isSymbolicLink()) || entry.name.startsWith(".")) continue;
// Project readers treat every non-dot directory or symlink entry as a mission;
// ownership needs only its name and never follows a sibling link.
missionIds.add(entry.name);
}
}
return missionIds;
}
private proofDirectoryMissionOwner(dirName: string, missionIds: ReadonlySet<string>): string | null {
let owner: string | null = null;
for (const candidate of missionIds) {
if (!dirName.startsWith(candidate)) continue;
const separator = dirName[candidate.length];
if (separator !== "-" && separator !== "." && separator !== "_") continue;
if (!owner || candidate.length > owner.length) owner = candidate;
}
return owner;
}
private findProofPacket(sliceName: string, missionId: string | null): SliceProofPacket | null {
private findProofPacket(sliceName: string): SliceProofPacket | null {
if (!this.dogfoodEvidenceRoot) return null;
let entries: fs.Dirent[];
try {
@@ -1541,26 +1511,19 @@ export class SliceIndexer {
return null;
}
// Qualified proof directories use the observed `<mission>-<slice>` form:
// the literal mission ID must lead the name at a separator boundary. If
// known mission IDs overlap, the longest prefix owns the directory. This
// is a fixture-supported rule, not a historically documented contract.
// Unqualified reads retain their legacy slice-only token match. Once a
// qualified directory is attributed, slice tokens match against the
// remainder after that mission prefix and separator (`vN` handling unchanged);
// latest mtime remains the tie-break among same-mission matches.
let knownMissionIds: Set<string> | null = null;
if (missionId) {
knownMissionIds = this.discoverMissionIds();
knownMissionIds.add(missionId);
}
// Match strategy: token-based. Tokenize slice name on `-`, drop
// trailing `vN` version tokens (real proof dirs typically don't
// include the version suffix), then a dir matches if every remaining
// token appears as a hyphen-bounded substring of the dir name. This
// handles real-world dir-naming where phase indicators land at the
// front (e.g. `pl005-phase-a-mission-control-queue-observability-...`)
// even though the slice folder uses suffix form
// (`mission-control-queue-observability-phase-a`). Latest mtime wins.
const sliceTokens = sliceName.split("-").filter((t) => t.length > 0 && !/^v\d+$/.test(t));
const matches: { dirent: fs.Dirent; mtime: number }[] = [];
for (const dirent of entries) {
if (!dirent.isDirectory()) continue;
if (knownMissionIds && this.proofDirectoryMissionOwner(dirent.name, knownMissionIds) !== missionId) continue;
const tokenSource = knownMissionIds && missionId ? dirent.name.slice(missionId.length + 1) : dirent.name;
const dirTokenSet = new Set(tokenSource.split(/[-._]/).filter((t) => t.length > 0));
const dirTokenSet = new Set(dirent.name.split(/[-._]/).filter((t) => t.length > 0));
const allTokensPresent = sliceTokens.every((t) => dirTokenSet.has(t));
if (!allTokensPresent) continue;
try {
+6 -46
View File
@@ -1,5 +1,5 @@
import type { EventBus } from "../domain/event-bus.js";
import { listProjects, selectedProject, projectMission, workSource, insideProject, projectReadResponse } from "../domain/workspace/project-read.js";
import { listProjects, selectedProject, projectMission, workSource, projectReadResponse } from "../domain/workspace/project-read.js";
import { inferCatalogProject, ProjectReadError } from "../domain/workspace/project-catalog.js";
// Slice Story View v0 — HTTP routes.
//
@@ -166,20 +166,15 @@ export function slicesRoutes(): Hono {
const abs = deps.projector.resolveProofAssetPath(slice.proofPacket, relPath);
if (!abs) return c.json({ error: "proof_asset_not_found" }, 404);
let servedPath = abs;
if (selected.qualified) {
try { servedPath = insideProject(selected.projectRoot, abs); }
catch (err) { return projectReadResponse(err); }
}
const contentType = inferContentType(abs);
return fileAssetResponse(servedPath, contentType, c.req.header("Range"));
return fileAssetResponse(abs, contentType, c.req.header("Range"));
});
// 3) Doc serving for the Docs tab — markdown content of a single file
// inside the slice folder. Path-traversal guarded by the projector.
app.get("/:name/doc/*", (c) => {
const name = c.req.param("name");
const selected = selectSliceDeps(c, name, { includeEvidenceRoot: false });
const selected = selectSliceDeps(c, name);
if ("response" in selected) return selected.response;
const { deps } = selected;
const fullPath = c.req.path;
@@ -210,39 +205,10 @@ export function slicesRoutes(): Hono {
return app;
}
const UNAVAILABLE_EVIDENCE_ROOT_CODES = new Set([
"ENOENT",
"EACCES",
"EPERM",
"ENOTDIR",
"ELOOP",
"EIO",
"ENAMETOOLONG",
"ESTALE",
]);
export function qualifiedEvidenceRoot(projectRoot: string, missionsRoot: string): string | null {
const candidate = path.join(path.dirname(missionsRoot), "dogfood-evidence");
const relative = path.relative(path.resolve(projectRoot), path.resolve(candidate));
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
try {
const resolved = insideProject(projectRoot, candidate);
fs.accessSync(resolved, fs.constants.R_OK | fs.constants.X_OK);
fs.readdirSync(resolved);
return resolved;
} catch (err) {
if (err instanceof ProjectReadError && err.code === "project_path_escape") return null;
const code = (err as NodeJS.ErrnoException | null | undefined)?.code;
if (typeof code === "string" && UNAVAILABLE_EVIDENCE_ROOT_CODES.has(code)) return null;
throw err;
}
}
function selectSliceDeps(
c: Context,
name: string,
opts: { includeEvidenceRoot?: boolean } = {},
): { deps: SlicesRoutesDeps; qualified: false } | { deps: SlicesRoutesDeps; qualified: true; projectRoot: string } | { response: Response } {
): { deps: SlicesRoutesDeps; qualified: boolean } | { response: Response } {
let deps = getDeps(c);
if (!deps) return { response: c.json({ error: "slices_indexer_unavailable" }, 503) };
try {
@@ -252,15 +218,9 @@ function selectSliceDeps(
if (!mission || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) return { response: c.json({ error: "exact_mission_and_slice_required" }, 400) };
const dir = projectMission(project, mission);
workSource(project.root, path.join(dir, "slices", name));
const indexer = new SliceIndexer({
db: deps.indexer.db,
slicesRoot: project.missionsRoot,
dogfoodEvidenceRoot: (opts.includeEvidenceRoot ?? true) ? qualifiedEvidenceRoot(project.root, project.missionsRoot) : null,
projectId: project.id,
missionId: mission,
});
const indexer = new SliceIndexer({ db: deps.indexer.db, slicesRoot: project.missionsRoot, dogfoodEvidenceRoot: null, projectId: project.id, missionId: mission });
deps = { indexer, projector: deps.projector.withIndexer(indexer) };
return { deps, qualified: true, projectRoot: project.root };
return { deps, qualified: true };
} catch (err) { return { response: projectReadResponse(err) }; }
}
-172
View File
@@ -161,31 +161,6 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
);
});
it("filters the requested mission before deduplicating a same-named slice", () => {
const missionsRoot = path.join(cleanup, "qualified-missions");
for (const mission of ["mission-one", "mission-two"]) {
writeSlice(path.join(missionsRoot, mission, "slices"), "same-name", {
"SPEC.md": `# ${mission}\n`,
});
}
const traversedMissions = fs.readdirSync(missionsRoot);
expect(traversedMissions).toHaveLength(2);
const requestedMission = traversedMissions[1]!;
const expectedPath = path.join(missionsRoot, requestedMission, "slices", "same-name");
const indexer = new SliceIndexer({
slicesRoot: missionsRoot,
dogfoodEvidenceRoot: null,
db,
missionId: requestedMission,
});
expect(indexer.list().map(({ name, missionId, slicePath }) => [name, missionId, slicePath])).toEqual([
["same-name", requestedMission, expectedPath],
]);
expect(indexer.get("same-name")?.slicePath).toBe(expectedPath);
expect(fs.readFileSync(path.join(expectedPath, "SPEC.md"), "utf8")).toBe(`# ${requestedMission}\n`);
});
it("can index legacy flat slices and mission-aware slices from compatibility roots", () => {
const missionsRoot = path.join(cleanup, "missions");
writeSlice(slicesRoot, "legacy-flat-slice", {
@@ -548,153 +523,6 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
expect(indexer.get("x")!.proofPacket).toBeNull();
});
const packetPrefixSeparators = [
{ label: "hyphen", separator: "-" },
{ label: "dot", separator: "." },
{ label: "underscore", separator: "_" },
] as const;
it.each(packetPrefixSeparators)(
"qualified lookup selects an older slice packet over a newer sibling packet whose only matching token is in the mission prefix ($label separator)",
({ separator }) => {
const missionsRoot = path.join(cleanup, "missions");
writeSlice(path.join(missionsRoot, "a-first", "slices"), "first", { "SPEC.md": "# a-first/first\n" });
writeSlice(path.join(missionsRoot, "a-first", "slices"), "second", { "SPEC.md": "# a-first/second\n" });
const correctDir = path.join(dogfoodRoot, `a-first${separator}first`);
fs.mkdirSync(path.join(correctDir, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(correctDir, "correct-first.md"), "# correct proof for slice first\n");
fs.writeFileSync(path.join(correctDir, "screenshots", "first.png"), "FIRST-PNG\n");
fs.utimesSync(correctDir, 1600000010, 1600000010);
const wrongSiblingDir = path.join(dogfoodRoot, `a-first${separator}second`);
fs.mkdirSync(path.join(wrongSiblingDir, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(wrongSiblingDir, "wrong-second.md"), "# wrong sibling proof for slice second\n");
fs.writeFileSync(path.join(wrongSiblingDir, "screenshots", "second.png"), "SECOND-PNG\n");
fs.utimesSync(wrongSiblingDir, 1600000020, 1600000020);
const indexer = new SliceIndexer({
slicesRoot: missionsRoot,
dogfoodEvidenceRoot: dogfoodRoot,
db,
missionId: "a-first",
});
const byName = new Map(indexer.list().map((entry) => [entry.name, entry.hasProofPacket]));
const firstRecord = indexer.get("first")!;
const secondRecord = indexer.get("second")!;
expect({
listHasProof: {
first: byName.get("first"),
second: byName.get("second"),
},
firstProofDir: firstRecord.proofPacket?.dirName,
firstMarkdownFiles: firstRecord.proofPacket?.markdownFiles,
firstScreenshots: firstRecord.proofPacket?.screenshots,
firstMarkdownBody: firstRecord.proofPacket?.markdownFiles[0]
? fs.readFileSync(path.join(firstRecord.proofPacket.absPath, firstRecord.proofPacket.markdownFiles[0]), "utf8")
: null,
secondProofDir: secondRecord.proofPacket?.dirName,
}).toEqual({
listHasProof: {
first: true,
second: true,
},
firstProofDir: `a-first${separator}first`,
firstMarkdownFiles: ["correct-first.md"],
firstScreenshots: ["screenshots/first.png"],
firstMarkdownBody: "# correct proof for slice first\n",
secondProofDir: `a-first${separator}second`,
});
},
);
it.each(packetPrefixSeparators)(
"qualified lookup returns no proof packet when only a newer sibling packet shares the mission-prefix token ($label separator)",
({ separator }) => {
const missionsRoot = path.join(cleanup, "missions");
writeSlice(path.join(missionsRoot, "a-first", "slices"), "first", { "SPEC.md": "# a-first/first\n" });
writeSlice(path.join(missionsRoot, "a-first", "slices"), "second", { "SPEC.md": "# a-first/second\n" });
const wrongSiblingDir = path.join(dogfoodRoot, `a-first${separator}second`);
fs.mkdirSync(path.join(wrongSiblingDir, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(wrongSiblingDir, "wrong-second.md"), "# wrong sibling proof for slice second\n");
fs.writeFileSync(path.join(wrongSiblingDir, "screenshots", "second.png"), "SECOND-PNG\n");
fs.utimesSync(wrongSiblingDir, 1600000020, 1600000020);
const indexer = new SliceIndexer({
slicesRoot: missionsRoot,
dogfoodEvidenceRoot: dogfoodRoot,
db,
missionId: "a-first",
});
const byName = new Map(indexer.list().map((entry) => [entry.name, entry.hasProofPacket]));
expect({
listHasProof: {
first: byName.get("first"),
second: byName.get("second"),
},
firstProofPacket: indexer.get("first")!.proofPacket,
secondProofDir: indexer.get("second")!.proofPacket?.dirName,
}).toEqual({
listHasProof: {
first: false,
second: true,
},
firstProofPacket: null,
secondProofDir: `a-first${separator}second`,
});
},
);
it("preserves qualified -vN suffix stripping, same-mission overlapping-slice latest-mtime tie-break, and unqualified whole-name matching", () => {
const missionsRoot = path.join(cleanup, "missions");
writeSlice(path.join(missionsRoot, "a-first", "slices"), "first-v1", { "SPEC.md": "# a-first/first-v1\n" });
const olderQualified = path.join(dogfoodRoot, "a-first-first-20260101");
const newerOverlappingQualified = path.join(dogfoodRoot, "a-first-first-extra-20260601");
fs.mkdirSync(olderQualified, { recursive: true });
fs.mkdirSync(newerOverlappingQualified, { recursive: true });
fs.writeFileSync(path.join(olderQualified, "older.md"), "# older\n");
fs.writeFileSync(path.join(newerOverlappingQualified, "newer.md"), "# newer\n");
fs.utimesSync(olderQualified, 1600000010, 1600000010);
fs.utimesSync(newerOverlappingQualified, 1600000020, 1600000020);
const qualifiedIndexer = new SliceIndexer({
slicesRoot: missionsRoot,
dogfoodEvidenceRoot: dogfoodRoot,
db,
missionId: "a-first",
});
expect({
qualifiedDir: qualifiedIndexer.get("first-v1")!.proofPacket?.dirName,
qualifiedDocs: qualifiedIndexer.get("first-v1")!.proofPacket?.markdownFiles,
}).toEqual({
qualifiedDir: "a-first-first-extra-20260601",
qualifiedDocs: ["newer.md"],
});
const legacyDogfoodRoot = path.join(cleanup, "legacy-dogfood");
writeSlice(slicesRoot, "first", { "README.md": "---\n---\n" });
const legacyPacket = path.join(legacyDogfoodRoot, "a-first-legacy-only");
fs.mkdirSync(legacyPacket, { recursive: true });
fs.writeFileSync(path.join(legacyPacket, "legacy.md"), "# legacy\n");
fs.utimesSync(legacyPacket, 1600000030, 1600000030);
const unqualifiedIndexer = new SliceIndexer({
slicesRoot,
dogfoodEvidenceRoot: legacyDogfoodRoot,
db,
});
expect({
unqualifiedDir: unqualifiedIndexer.get("first")!.proofPacket?.dirName,
unqualifiedDocs: unqualifiedIndexer.get("first")!.proofPacket?.markdownFiles,
}).toEqual({
unqualifiedDir: "a-first-legacy-only",
unqualifiedDocs: ["legacy.md"],
});
});
});
describe("cache invalidation", () => {
+23 -844
View File
@@ -22,28 +22,9 @@ const nodeMetadataFault = vi.hoisted(() => ({
selectionCode: "EACCES",
selectionHits: 0,
}));
const evidenceRootFault = vi.hoisted(() => ({
targetPath: null as string | null,
stage: null as "resolve" | "access" | "readdir" | "unexpected" | null,
code: "EACCES",
hits: 0,
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const injectedError = (code: string) => Object.assign(new Error("injected node metadata failure"), { code });
const matchesEvidenceFaultTarget = (candidate: unknown): boolean =>
evidenceRootFault.targetPath !== null && typeof candidate === "string" && candidate === evidenceRootFault.targetPath;
const maybeThrowEvidenceResolveFault = (candidate: unknown): void => {
if (!matchesEvidenceFaultTarget(candidate)) return;
if (evidenceRootFault.stage === "resolve") {
evidenceRootFault.hits++;
throw injectedError(evidenceRootFault.code);
}
if (evidenceRootFault.stage === "unexpected") {
evidenceRootFault.hits++;
throw Object.assign(new TypeError("unexpected caller type error"), { code: "ERR_INVALID_ARG_TYPE" });
}
};
const existsSync = ((...args: Parameters<typeof actual.existsSync>) => {
if (String(args[0]) === nodeMetadataFault.selectionPath && nodeMetadataFault.selectionPath !== null) {
nodeMetadataFault.selectionHits++;
@@ -52,14 +33,6 @@ vi.mock("node:fs", async (importOriginal) => {
}
return actual.existsSync(...args);
}) as typeof actual.existsSync;
const lstatSync = ((...args: Parameters<typeof actual.lstatSync>) => {
maybeThrowEvidenceResolveFault(args[0]);
return actual.lstatSync(...args);
}) as typeof actual.lstatSync;
const realpathSync = Object.assign(((...args: Parameters<typeof actual.realpathSync>) => {
maybeThrowEvidenceResolveFault(args[0]);
return actual.realpathSync(...args);
}) as typeof actual.realpathSync, { native: actual.realpathSync.native });
const statSync = ((...args: Parameters<typeof actual.statSync>) => {
if (String(args[0]) === nodeMetadataFault.selectionPath && nodeMetadataFault.selectionPath !== null) {
nodeMetadataFault.selectionHits++;
@@ -72,20 +45,9 @@ vi.mock("node:fs", async (importOriginal) => {
nodeMetadataFault.selectionHits++;
throw injectedError(nodeMetadataFault.selectionCode);
}
if (matchesEvidenceFaultTarget(args[0]) && evidenceRootFault.stage === "access") {
evidenceRootFault.hits++;
throw injectedError(evidenceRootFault.code);
}
return actual.accessSync(...args);
}) as typeof actual.accessSync;
const readdirSync = vi.fn(((...args: Parameters<typeof actual.readdirSync>) => {
if (matchesEvidenceFaultTarget(args[0]) && evidenceRootFault.stage === "readdir") {
evidenceRootFault.hits++;
throw injectedError(evidenceRootFault.code);
}
return actual.readdirSync(...args);
}) as typeof actual.readdirSync) as unknown as typeof actual.readdirSync;
const readFileSync = vi.fn(((...args: Parameters<typeof actual.readFileSync>) => {
const readFileSync = ((...args: Parameters<typeof actual.readFileSync>) => {
const requestedPath = String(args[0]);
if (catalogReadObservation.activePath !== null && requestedPath === catalogReadObservation.activePath) {
catalogReadObservation.reads++;
@@ -99,18 +61,10 @@ vi.mock("node:fs", async (importOriginal) => {
throw injectedError(nodeMetadataFault.selectionCode);
}
return actual.readFileSync(...args);
}) as typeof actual.readFileSync) as unknown as typeof actual.readFileSync;
}) as typeof actual.readFileSync;
const actualDefault = (actual as unknown as { default?: object }).default ?? actual;
const defaultFs = Object.assign(Object.create(actualDefault), {
existsSync,
lstatSync,
realpathSync,
statSync,
accessSync,
readdirSync,
readFileSync,
});
return { ...actual, existsSync, lstatSync, realpathSync, statSync, accessSync, readdirSync, readFileSync, default: defaultFs };
const defaultFs = Object.assign(Object.create(actualDefault), { existsSync, statSync, accessSync, readFileSync });
return { ...actual, existsSync, statSync, accessSync, readFileSync, default: defaultFs };
});
import { Hono } from "hono";
@@ -132,7 +86,6 @@ import { missionControlActionsSchema } from "../src/db/migrations/037_mission_co
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
import { SliceDetailProjector } from "../src/domain/slices/slice-detail-projector.js";
import { slicesRoutes } from "../src/routes/slices.js";
import * as slicesRoutesModule from "../src/routes/slices.js";
import { browserBoundary } from "../src/middleware/browser-boundary.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
@@ -216,14 +169,8 @@ describe("PL-slice-story-view-v0 slices routes", () => {
});
afterEach(() => {
evidenceRootFault.targetPath = null;
evidenceRootFault.stage = null;
evidenceRootFault.code = "EACCES";
evidenceRootFault.hits = 0;
db.close();
fs.rmSync(cleanupDir, { recursive: true, force: true });
(fs.readdirSync as unknown as { mockClear: () => void }).mockClear();
(fs.readFileSync as unknown as { mockClear: () => void }).mockClear();
});
describe("GET /api/slices (list)", () => {
@@ -491,18 +438,6 @@ describe("PL-slice-story-view-v0 slices routes", () => {
"SPEC.md": `# ${project}/${mission}\n`, "notes/a b.md": `${project}/${mission} nested document\n`,
});
}
const projectEvidenceRoot = path.join(projectRoot, "dogfood-evidence");
for (const mission of ["a-first", "z-later"]) {
const scopedProofDir = path.join(projectEvidenceRoot, `${mission}-same-slice`);
fs.mkdirSync(path.join(scopedProofDir, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(scopedProofDir, "proof.md"), `# ${project}/${mission} proof\n`);
fs.writeFileSync(
path.join(scopedProofDir, "screenshots/scope.png"),
`${project.toUpperCase()}-${mission.toUpperCase()}-PROOF\n`,
);
const mtime = mission === "a-first" ? 1600000003 : 1600000002;
fs.utimesSync(scopedProofDir, mtime, mtime);
}
const proofDir = path.join(dogfoodRoot, `same-slice-${project}`);
fs.mkdirSync(path.join(proofDir, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(proofDir, "screenshots/scope.png"), `${project.toUpperCase()}-PROOF\n`);
@@ -514,36 +449,17 @@ describe("PL-slice-story-view-v0 slices routes", () => {
app = buildApp({ indexer, projector, workspaceRoot });
});
const addAlphaMissionWithProof = (mission: string, mtime: number): void => {
const projectRoot = path.join(workspaceRoot, "alpha");
const missionsRoot = path.join(projectRoot, "missions");
writeSlice(missionsRoot, mission, { "SPEC.md": `# ${mission}\n` });
writeSlice(path.join(missionsRoot, mission, "slices"), "same-slice", {
"SPEC.md": `# alpha/${mission}\n`,
"notes/a b.md": `alpha/${mission} nested document\n`,
});
const proofDir = path.join(projectRoot, "dogfood-evidence", `${mission}-same-slice`);
fs.mkdirSync(path.join(proofDir, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(proofDir, "proof.md"), `# alpha/${mission} proof\n`);
fs.writeFileSync(path.join(proofDir, "screenshots/scope.png"), `ALPHA-${mission.toUpperCase()}-PROOF\n`);
fs.utimesSync(proofDir, mtime, mtime);
};
it.each([
{ project: "alpha", mission: "a-first", literal: "# alpha/a-first\n" },
{ project: "beta", mission: "a-first", literal: "# beta/a-first\n" },
])("detail selects project $project/$mission and discovers project-scoped proof", async ({ project, mission, literal }) => {
const detail = await app.request(`/api/slices/same-slice?project=${project}&mission=${mission}`);
{ project: "alpha", literal: "# alpha/a-first\n" },
{ project: "beta", literal: "# beta/a-first\n" },
])("detail selects $project independently and has no qualified proof discovery", async ({ project, literal }) => {
const detail = await app.request(`/api/slices/same-slice?project=${project}&mission=a-first`);
expect(detail.status).toBe(200);
const body = await detail.json();
expect(body.slicePath).toBe(path.join(workspaceRoot, project, "missions", mission, "slices", "same-slice"));
expect(body.slicePath).toBe(path.join(workspaceRoot, project, "missions/a-first/slices/same-slice"));
expect(fs.readFileSync(path.join(body.slicePath, "SPEC.md"), "utf8")).toBe(literal);
expect(body.missionId).toBe(mission);
expect(body.tests.proofPackets).toMatchObject([{
dirName: `${mission}-same-slice`,
primaryMarkdown: { relPath: "proof.md", content: `# ${project}/${mission} proof\n` },
screenshots: ["screenshots/scope.png"],
}]);
expect(body.missionId).toBe("a-first");
expect(body.tests.proofPackets).toEqual([]);
expect(body.tests.aggregate).toEqual({ passCount: 0, failCount: 0 });
});
@@ -557,762 +473,25 @@ describe("PL-slice-story-view-v0 slices routes", () => {
expect(await response.json()).toEqual({ relPath: decodeURIComponent(rel), content: literal });
});
it.each([
{ project: "alpha", mission: "a-first" },
{ project: "beta", mission: "a-first" },
{ project: "alpha", mission: "z-later" },
{ project: "beta", mission: "z-later" },
])("qualified proof for $project/$mission serves its scoped packet, not the injected legacy packet", async ({ project, mission }) => {
const response = await app.request(`/api/slices/same-slice/proof-asset/screenshots/scope.png?project=${project}&mission=${mission}`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/png");
expect(await response.text()).toBe(`${project.toUpperCase()}-${mission.toUpperCase()}-PROOF\n`);
});
it("qualified detail and proof assets distinguish prefix-overlapping mission names", async () => {
addAlphaMissionWithProof("a-first-extra", 1600000004);
const shortDetail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
const shortBody = await shortDetail.json();
const shortPacket = shortBody.tests.proofPackets[0];
const shortAsset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
const shortAssetBytes = await shortAsset.text();
const longDetail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first-extra");
const longBody = await longDetail.json();
const longPacket = longBody.tests.proofPackets[0];
const longAsset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first-extra");
const longAssetBytes = await longAsset.text();
expect({
short: {
detailStatus: shortDetail.status,
missionId: shortBody.missionId,
proofDir: shortPacket?.dirName,
proofMarkdown: shortPacket?.primaryMarkdown?.content,
assetStatus: shortAsset.status,
assetBytes: shortAssetBytes,
},
long: {
detailStatus: longDetail.status,
missionId: longBody.missionId,
proofDir: longPacket?.dirName,
proofMarkdown: longPacket?.primaryMarkdown?.content,
assetStatus: longAsset.status,
assetBytes: longAssetBytes,
},
}).toEqual({
short: {
detailStatus: 200,
missionId: "a-first",
proofDir: "a-first-same-slice",
proofMarkdown: "# alpha/a-first proof\n",
assetStatus: 200,
assetBytes: "ALPHA-A-FIRST-PROOF\n",
},
long: {
detailStatus: 200,
missionId: "a-first-extra",
proofDir: "a-first-extra-same-slice",
proofMarkdown: "# alpha/a-first-extra proof\n",
assetStatus: 200,
assetBytes: "ALPHA-A-FIRST-EXTRA-PROOF\n",
},
it.each(["alpha", "beta"])("qualified proof for %s does not substitute the injected legacy packet", async (project) => {
const response = await app.request(`/api/slices/same-slice/proof-asset/screenshots/scope.png?project=${project}&mission=a-first`);
expect({ status: response.status, text: await response.text() }).toEqual({
status: 404, text: JSON.stringify({ error: "proof_packet_not_found" }),
});
});
it("does not fall back to an overlapping mission's packet when the requested mission has none", async () => {
addAlphaMissionWithProof("a-first-extra", 1600000004);
fs.rmSync(path.join(workspaceRoot, "alpha", "dogfood-evidence", "a-first-same-slice"), { recursive: true });
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
const detailBody = await detail.json();
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
const assetBody = await asset.text();
expect({
detailStatus: detail.status,
proofPackets: detailBody.tests.proofPackets,
assetStatus: asset.status,
assetNotFound: assetBody.includes("proof_packet_not_found"),
otherMissionBytesLeaked: assetBody.includes("ALPHA-A-FIRST-EXTRA-PROOF\n"),
}).toEqual({
detailStatus: 200,
proofPackets: [],
assetStatus: 404,
assetNotFound: true,
otherMissionBytesLeaked: false,
});
});
it.each([
{ siblingKind: "in-project symlinked mission" },
{ siblingKind: "plain mission without slices" },
])("does not use an overlapping sibling packet when the sibling is a $siblingKind", async ({ siblingKind }) => {
addAlphaMissionWithProof("a-first-extra", 1600000004);
const projectRoot = path.join(workspaceRoot, "alpha");
const siblingMission = path.join(projectRoot, "missions", "a-first-extra");
if (siblingKind === "in-project symlinked mission") {
const archiveMission = path.join(projectRoot, "archive", "a-first-extra");
fs.mkdirSync(path.dirname(archiveMission), { recursive: true });
fs.renameSync(siblingMission, archiveMission);
fs.symlinkSync(path.relative(path.dirname(siblingMission), archiveMission), siblingMission, "dir");
} else {
fs.rmSync(path.join(siblingMission, "slices"), { recursive: true, force: true });
}
fs.rmSync(path.join(projectRoot, "dogfood-evidence", "a-first-same-slice"), { recursive: true, force: true });
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
const detailText = await detail.text();
const detailBody = JSON.parse(detailText) as { missionId?: string; tests?: { proofPackets?: unknown[] } };
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
const assetText = await asset.text();
const returnedText = `${detailText}\n${assetText}`;
expect({
detailStatus: detail.status,
missionId: detailBody.missionId,
proofPackets: detailBody.tests?.proofPackets,
assetStatus: asset.status,
assetNotFound: assetText.includes("proof_packet_not_found"),
siblingBytesLeaked: returnedText.includes("# alpha/a-first-extra proof") || returnedText.includes("ALPHA-A-FIRST-EXTRA-PROOF"),
}).toEqual({
detailStatus: 200,
missionId: "a-first",
proofPackets: [],
assetStatus: 404,
assetNotFound: true,
siblingBytesLeaked: false,
});
});
it("preserves mission token order when proof names contain the mission id", async () => {
addAlphaMissionWithProof("first-a", 1600000004);
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
const body = await detail.json();
const packet = body.tests.proofPackets[0];
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
expect({
proofDir: packet?.dirName,
proofMarkdown: packet?.primaryMarkdown?.content,
assetBytes: await asset.text(),
}).toEqual({
proofDir: "a-first-same-slice",
proofMarkdown: "# alpha/a-first proof\n",
assetBytes: "ALPHA-A-FIRST-PROOF\n",
});
});
const packetPrefixSeparators = [
{ label: "hyphen", separator: "-" },
{ label: "dot", separator: "." },
{ label: "underscore", separator: "_" },
] as const;
it.each(packetPrefixSeparators)(
"qualified detail and proof assets select an older slice packet over a newer same-mission packet whose only matching token is in the mission prefix ($label separator)",
async ({ separator }) => {
addAlphaMissionWithProof("a-first-extra", 1600000040);
const alphaRoot = path.join(workspaceRoot, "alpha");
const betaRoot = path.join(workspaceRoot, "beta");
writeSlice(path.join(alphaRoot, "missions", "a-first", "slices"), "first", {
"SPEC.md": "# alpha/a-first/first\n",
});
writeSlice(path.join(alphaRoot, "missions", "a-first", "slices"), "second", {
"SPEC.md": "# alpha/a-first/second\n",
});
const writeScopedPacket = (
evidenceRoot: string,
dirName: string,
markdown: string,
assetBytes: string,
mtime: number,
): void => {
const packetDir = path.join(evidenceRoot, dirName);
fs.mkdirSync(path.join(packetDir, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(packetDir, "proof.md"), markdown);
fs.writeFileSync(path.join(packetDir, "screenshots", "scope.png"), assetBytes);
fs.utimesSync(packetDir, mtime, mtime);
};
const alphaEvidence = path.join(alphaRoot, "dogfood-evidence");
writeScopedPacket(
alphaEvidence,
`a-first${separator}first`,
"# alpha/a-first/first proof\n",
"ALPHA-A-FIRST-SLICE-FIRST-PROOF\n",
1600000010,
);
writeScopedPacket(
alphaEvidence,
`a-first${separator}second`,
"# alpha/a-first/second proof\n",
"ALPHA-A-FIRST-SLICE-SECOND-PROOF\n",
1600000020,
);
writeScopedPacket(
alphaEvidence,
`a-first-extra${separator}first`,
"# alpha/a-first-extra/first proof\n",
"ALPHA-A-FIRST-EXTRA-SLICE-FIRST-PROOF\n",
1600000040,
);
writeScopedPacket(
path.join(betaRoot, "dogfood-evidence"),
`a-first${separator}first`,
"# beta/a-first/first proof\n",
"BETA-A-FIRST-SLICE-FIRST-PROOF\n",
1600000050,
);
const firstDetail = await app.request("/api/slices/first?project=alpha&mission=a-first");
const firstBody = await firstDetail.json();
const firstPacket = firstBody.tests.proofPackets[0];
const firstAsset = await app.request("/api/slices/first/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
const firstAssetBytes = await firstAsset.text();
const secondDetail = await app.request("/api/slices/second?project=alpha&mission=a-first");
const secondBody = await secondDetail.json();
const secondPacket = secondBody.tests.proofPackets[0];
const secondAsset = await app.request("/api/slices/second/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
const secondAssetBytes = await secondAsset.text();
expect({
first: {
detailStatus: firstDetail.status,
missionId: firstBody.missionId,
proofDir: firstPacket?.dirName,
proofMarkdown: firstPacket?.primaryMarkdown?.content,
assetStatus: firstAsset.status,
assetBytes: firstAssetBytes,
},
second: {
detailStatus: secondDetail.status,
missionId: secondBody.missionId,
proofDir: secondPacket?.dirName,
proofMarkdown: secondPacket?.primaryMarkdown?.content,
assetStatus: secondAsset.status,
assetBytes: secondAssetBytes,
},
}).toEqual({
first: {
detailStatus: 200,
missionId: "a-first",
proofDir: `a-first${separator}first`,
proofMarkdown: "# alpha/a-first/first proof\n",
assetStatus: 200,
assetBytes: "ALPHA-A-FIRST-SLICE-FIRST-PROOF\n",
},
second: {
detailStatus: 200,
missionId: "a-first",
proofDir: `a-first${separator}second`,
proofMarkdown: "# alpha/a-first/second proof\n",
assetStatus: 200,
assetBytes: "ALPHA-A-FIRST-SLICE-SECOND-PROOF\n",
},
});
},
);
it.each(packetPrefixSeparators)(
"qualified detail and proof assets do not fall back to a same-mission packet whose only matching token is in the mission prefix ($label separator)",
async ({ separator }) => {
const alphaRoot = path.join(workspaceRoot, "alpha");
writeSlice(path.join(alphaRoot, "missions", "a-first", "slices"), "first", {
"SPEC.md": "# alpha/a-first/first\n",
});
const wrongSiblingPacket = path.join(alphaRoot, "dogfood-evidence", `a-first${separator}second`);
fs.mkdirSync(path.join(wrongSiblingPacket, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(wrongSiblingPacket, "proof.md"), "# alpha/a-first/second proof\n");
fs.writeFileSync(path.join(wrongSiblingPacket, "screenshots", "scope.png"), "ALPHA-A-FIRST-SLICE-SECOND-PROOF\n");
fs.utimesSync(wrongSiblingPacket, 1600000020, 1600000020);
const detail = await app.request("/api/slices/first?project=alpha&mission=a-first");
const detailText = await detail.text();
const detailBody = JSON.parse(detailText) as { missionId?: string; tests?: { proofPackets?: unknown[] } };
const asset = await app.request("/api/slices/first/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
const assetText = await asset.text();
expect({
detailStatus: detail.status,
missionId: detailBody.missionId,
proofPackets: detailBody.tests?.proofPackets,
assetStatus: asset.status,
assetNotFound: assetText.includes("proof_packet_not_found"),
wrongPacketLeaked:
detailText.includes("# alpha/a-first/second proof")
|| assetText.includes("ALPHA-A-FIRST-SLICE-SECOND-PROOF"),
}).toEqual({
detailStatus: 200,
missionId: "a-first",
proofPackets: [],
assetStatus: 404,
assetNotFound: true,
wrongPacketLeaked: false,
});
},
);
const clearEvidenceReadRecord = (): void => {
(fs.readdirSync as unknown as { mockClear: () => void }).mockClear();
(fs.readFileSync as unknown as { mockClear: () => void }).mockClear();
};
const recordedReadsUnder = (roots: string[]): string[] => {
const prefixes = Array.from(new Set(roots.flatMap((root) => {
const resolved = path.resolve(root);
const parentReal = fs.existsSync(path.dirname(resolved))
? path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved))
: resolved;
const targetReal = fs.existsSync(resolved) ? fs.realpathSync(resolved) : parentReal;
return [resolved, parentReal, targetReal];
})));
const matchesRoot = (target: unknown): boolean => {
if (typeof target === "number" || target == null) return false;
const resolved = path.resolve(String(target));
return prefixes.some((prefix) => resolved === prefix || resolved.startsWith(`${prefix}${path.sep}`));
};
const readdirCalls = (fs.readdirSync as unknown as { mock: { calls: unknown[][] } }).mock.calls
.filter((args) => matchesRoot(args[0]))
.map((args) => `readdir:${String(args[0])}`);
const readFileCalls = (fs.readFileSync as unknown as { mock: { calls: unknown[][] } }).mock.calls
.filter((args) => matchesRoot(args[0]))
.map((args) => `readFile:${String(args[0])}`);
return [...readdirCalls, ...readFileCalls];
};
const withEvidenceRootFault = async <T>(
targetPath: string,
stage: "resolve" | "access" | "readdir" | "unexpected",
code: string,
action: () => Promise<T> | T,
): Promise<{ result: T; hits: number }> => {
evidenceRootFault.targetPath = targetPath;
evidenceRootFault.stage = stage;
evidenceRootFault.code = code;
evidenceRootFault.hits = 0;
try {
const result = await action();
return { result, hits: evidenceRootFault.hits };
} finally {
evidenceRootFault.targetPath = null;
evidenceRootFault.stage = null;
evidenceRootFault.code = "EACCES";
evidenceRootFault.hits = 0;
}
};
// Maintainer-directed PR #2960 contract change: an evidence root symlink
// resolving outside the selected project is treated as an absent evidence
// root (detail 200 with empty proofPackets; proof-asset 404
// proof_packet_not_found) without reading outside the project, while an
// escaping asset symlink inside a valid evidence root still returns 409.
it("treats a qualified evidence root symlink outside the selected project as absent", async () => {
const outsideRoot = path.join(cleanupDir, "outside-proof-root");
const outsidePacket = path.join(outsideRoot, "a-first-same-slice");
const outsideMarkdown = "OUTSIDE-ROOT-MARKDOWN";
const outsideAsset = "OUTSIDE-ROOT-ASSET";
fs.mkdirSync(path.join(outsidePacket, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(outsidePacket, "proof.md"), `${outsideMarkdown}\n`);
fs.writeFileSync(path.join(outsidePacket, "screenshots/scope.png"), `${outsideAsset}\n`);
const selectedEvidenceRoot = path.join(workspaceRoot, "alpha", "dogfood-evidence");
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
fs.symlinkSync(outsideRoot, selectedEvidenceRoot, "dir");
clearEvidenceReadRecord();
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
const detailText = await detail.text();
const detailBody = JSON.parse(detailText) as {
slicePath?: string;
missionId?: string;
tests?: { proofPackets?: unknown[] };
};
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
const assetText = await asset.text();
const assetBody = JSON.parse(assetText) as unknown;
expect({
detailStatus: detail.status,
slicePath: detailBody.slicePath,
missionId: detailBody.missionId,
proofPackets: detailBody.tests?.proofPackets,
detailLeaksOutsideProof: detailText.includes(outsideMarkdown),
assetStatus: asset.status,
assetBody,
assetLeaksOutsideProof: assetText.includes(outsideAsset),
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, outsideRoot]),
}).toEqual({
detailStatus: 200,
slicePath: path.join(workspaceRoot, "alpha", "missions", "a-first", "slices", "same-slice"),
missionId: "a-first",
proofPackets: [],
detailLeaksOutsideProof: false,
assetStatus: 404,
assetBody: { error: "proof_packet_not_found" },
assetLeaksOutsideProof: false,
evidenceReads: [],
});
});
it("keeps a missing qualified evidence root optional", async () => {
fs.rmSync(path.join(workspaceRoot, "alpha", "dogfood-evidence"), { recursive: true });
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
const detailBody = await detail.json();
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
expect({
detailStatus: detail.status,
proofPackets: detailBody.tests.proofPackets,
assetStatus: asset.status,
assetError: await asset.json(),
}).toEqual({
detailStatus: 200,
proofPackets: [],
assetStatus: 404,
assetError: { error: "proof_packet_not_found" },
});
});
it("serves a qualified slice document when the selected project's evidence root symlink escapes the project", async () => {
const outsideRoot = path.join(cleanupDir, "outside-doc-proof-root");
const outsidePacket = path.join(outsideRoot, "z-later-same-slice");
const outsideMarkdown = "OUTSIDE-DOC-ROOT-MARKDOWN";
const outsideNote = "OUTSIDE-DOC-ROOT-NOTE";
fs.mkdirSync(path.join(outsidePacket, "notes"), { recursive: true });
fs.writeFileSync(path.join(outsidePacket, "proof.md"), `${outsideMarkdown}\n`);
fs.writeFileSync(path.join(outsidePacket, "notes", "a b.md"), `${outsideNote}\n`);
const selectedEvidenceRoot = path.join(workspaceRoot, "alpha", "dogfood-evidence");
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
fs.symlinkSync(outsideRoot, selectedEvidenceRoot, "dir");
clearEvidenceReadRecord();
const response = await app.request("/api/slices/same-slice/doc/notes/a%20b.md?project=alpha&mission=z-later");
const responseText = await response.text();
const body = JSON.parse(responseText) as unknown;
expect({
status: response.status,
body,
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, outsideRoot]),
leaksOutsideEvidence: responseText.includes(outsideMarkdown) || responseText.includes(outsideNote),
}).toEqual({
status: 200,
body: { relPath: "notes/a b.md", content: "alpha/z-later nested document\n" },
evidenceReads: [],
leaksOutsideEvidence: false,
});
});
describe("when the selected project's evidence root symlink is dangling", () => {
let missingTarget: string;
let selectedEvidenceRoot: string;
beforeEach(() => {
missingTarget = path.join(cleanupDir, "missing-doc-proof-root");
selectedEvidenceRoot = path.join(workspaceRoot, "alpha", "dogfood-evidence");
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
fs.symlinkSync(missingTarget, selectedEvidenceRoot, "dir");
expect(fs.existsSync(missingTarget)).toBe(false);
expect(fs.lstatSync(selectedEvidenceRoot).isSymbolicLink()).toBe(true);
});
it("serves a qualified slice document when the selected project's evidence root symlink is dangling", async () => {
clearEvidenceReadRecord();
const response = await app.request("/api/slices/same-slice/doc/notes/a%20b.md?project=alpha&mission=z-later");
const responseText = await response.text();
const body = JSON.parse(responseText) as unknown;
expect({
status: response.status,
body,
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, missingTarget]),
}).toEqual({
status: 200,
body: { relPath: "notes/a b.md", content: "alpha/z-later nested document\n" },
evidenceReads: [],
});
});
// Maintainer-directed PR #2960 contract change: a dangling evidence root
// symlink is treated as an absent evidence root rather than 409
// project_source_unavailable, matching missing-root behavior on detail
// (200 with empty proofPackets) and proof-asset (404 proof_packet_not_found).
it("treats a dangling qualified evidence root symlink as absent on detail and proof-asset", async () => {
clearEvidenceReadRecord();
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=z-later");
const detailBody = await detail.json() as {
slicePath?: string;
missionId?: string;
tests?: { proofPackets?: unknown[] };
};
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=z-later");
const assetBody = await asset.json() as unknown;
expect({
detailStatus: detail.status,
slicePath: detailBody.slicePath,
missionId: detailBody.missionId,
proofPackets: detailBody.tests?.proofPackets,
assetStatus: asset.status,
assetBody,
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, missingTarget]),
}).toEqual({
detailStatus: 200,
slicePath: path.join(workspaceRoot, "alpha", "missions", "z-later", "slices", "same-slice"),
missionId: "z-later",
proofPackets: [],
assetStatus: 404,
assetBody: { error: "proof_packet_not_found" },
evidenceReads: [],
});
});
});
it.each(["resolve", "access"] as const)(
"treats an unreadable qualified evidence root (%s EACCES) as absent on detail and proof-asset",
async (stage) => {
const selectedEvidenceRoot = fs.realpathSync(path.join(workspaceRoot, "alpha", "dogfood-evidence"));
const { result, hits } = await withEvidenceRootFault(selectedEvidenceRoot, stage, "EACCES", async () => {
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
const detailText = await detail.text();
const detailBody = JSON.parse(detailText) as {
slicePath?: string;
missionId?: string;
tests?: { proofPackets?: unknown[] };
};
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
const assetText = await asset.text();
const assetBody = JSON.parse(assetText) as unknown;
const doc = await app.request("/api/slices/same-slice/doc/SPEC.md?project=alpha&mission=a-first");
const docBody = await doc.json() as unknown;
return {
detailStatus: detail.status,
slicePath: detailBody.slicePath,
missionId: detailBody.missionId,
proofPackets: detailBody.tests?.proofPackets,
detailLeaksProof: detailText.includes("alpha/a-first proof"),
assetStatus: asset.status,
assetBody,
assetLeaksProof: assetText.includes("ALPHA-PROOF"),
docStatus: doc.status,
docBody,
};
});
expect({ ...result, faultTriggered: hits > 0 }).toEqual({
detailStatus: 200,
slicePath: path.join(workspaceRoot, "alpha", "missions", "a-first", "slices", "same-slice"),
missionId: "a-first",
proofPackets: [],
detailLeaksProof: false,
assetStatus: 404,
assetBody: { error: "proof_packet_not_found" },
assetLeaksProof: false,
docStatus: 200,
docBody: { relPath: "SPEC.md", content: "# alpha/a-first\n" },
faultTriggered: true,
});
},
);
it("qualifiedEvidenceRoot returns null for unavailable or escaping roots without swallowing unrelated caller errors", async () => {
const helper = (slicesRoutesModule as {
qualifiedEvidenceRoot?: (projectRoot: string, missionsRoot: string) => string | null;
}).qualifiedEvidenceRoot;
expect(helper).toBeTypeOf("function");
const alphaRoot = fs.realpathSync(path.join(workspaceRoot, "alpha"));
const alphaMissionsRoot = path.join(alphaRoot, "missions");
const selectedEvidenceRoot = path.join(alphaRoot, "dogfood-evidence");
expect(helper!(alphaRoot, alphaMissionsRoot)).toBe(selectedEvidenceRoot);
for (const stage of ["resolve", "access", "readdir"] as const) {
const { result, hits } = await withEvidenceRootFault(selectedEvidenceRoot, stage, "EACCES", () =>
helper!(alphaRoot, alphaMissionsRoot),
);
expect({ stage, result, faultTriggered: hits > 0 }).toEqual({
stage,
result: null,
faultTriggered: true,
});
}
await expect(
withEvidenceRootFault(selectedEvidenceRoot, "unexpected", "EACCES", () =>
helper!(alphaRoot, alphaMissionsRoot),
),
).rejects.toThrow(TypeError);
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
const missingTarget = path.join(cleanupDir, "missing-helper-evidence-root");
fs.symlinkSync(missingTarget, selectedEvidenceRoot, "dir");
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
const outsideRoot = path.join(cleanupDir, "outside-helper-evidence-root");
fs.mkdirSync(outsideRoot, { recursive: true });
fs.symlinkSync(outsideRoot, selectedEvidenceRoot, "dir");
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
fs.writeFileSync(selectedEvidenceRoot, "not-a-directory\n");
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
});
it("rejects a qualified proof asset symlink outside the selected project", async () => {
const outsideAsset = "OUTSIDE-DIRECT-ASSET-BYTES";
const outsidePath = path.join(cleanupDir, "outside-proof-asset.png");
const selectedAsset = path.join(workspaceRoot, "alpha", "dogfood-evidence", "a-first-same-slice", "screenshots", "scope.png");
fs.writeFileSync(outsidePath, `${outsideAsset}\n`);
fs.rmSync(selectedAsset);
fs.symlinkSync(outsidePath, selectedAsset, "file");
const response = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
const responseText = await response.text();
expect({
status: response.status,
projectPathEscape: responseText.includes("project_path_escape"),
leaksOutsideAsset: responseText.includes(outsideAsset),
}).toEqual({ status: 409, projectPathEscape: true, leaksOutsideAsset: false });
});
it("uses the missions-root-relative evidence directory for custom missions.root", async () => {
const project = "custom";
const mission = "custom-first";
const slice = "same-slice";
const projectRoot = path.join(workspaceRoot, project);
fs.writeFileSync(path.join(workspaceRoot, "workspace.yaml"),
"projects:\n - id: alpha\n root: alpha\n - id: beta\n root: beta\n - id: custom\n root: custom\n");
writeSlice(workspaceRoot, project, {
"project.yaml": "metadata:\n id: custom\nmissions:\n root: work/missions\n",
"SPEC.md": "# custom project\n",
});
const missionsRoot = path.join(projectRoot, "work", "missions");
writeSlice(missionsRoot, mission, { "SPEC.md": `# ${mission}\n` });
writeSlice(path.join(missionsRoot, mission, "slices"), slice, { "SPEC.md": `# ${project}/${mission}\n` });
// The CLI contract is path.dirname(project/work/missions)/dogfood-evidence,
// i.e. project/work/dogfood-evidence; this is a literal fixture path, not a helper call.
const conventionalRoot = path.join(projectRoot, "work", "dogfood-evidence");
const projectRootDecoy = path.join(projectRoot, "dogfood-evidence");
const writePacket = (root: string, marker: string, mtime: number): void => {
const packet = path.join(root, `${mission}-${slice}`);
fs.mkdirSync(path.join(packet, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(packet, "proof.md"), `# ${marker}\n`);
fs.writeFileSync(path.join(packet, "screenshots/scope.png"), `${marker}\n`);
fs.utimesSync(packet, mtime, mtime);
};
const expectedMarker = "CUSTOM-WORK-MISSIONS-PROOF";
writePacket(conventionalRoot, expectedMarker, 1600000004);
writePacket(projectRootDecoy, "CUSTOM-PROJECT-ROOT-DECOY", 1600000005);
const detail = await app.request(`/api/slices/${slice}?project=${project}&mission=${mission}`);
const body = await detail.json();
const packet = body.tests.proofPackets[0];
const asset = await app.request(`/api/slices/${slice}/proof-asset/screenshots/scope.png?project=${project}&mission=${mission}`);
expect({
detailStatus: detail.status,
proofDir: packet?.dirName,
proofMarkdown: packet?.primaryMarkdown?.content,
assetStatus: asset.status,
assetBytes: await asset.text(),
}).toEqual({
detailStatus: 200,
proofDir: `${mission}-${slice}`,
proofMarkdown: `# ${expectedMarker}\n`,
assetStatus: 200,
assetBytes: `${expectedMarker}\n`,
});
});
it("keeps missions.root . healthy and treats its parent evidence store as optional", async () => {
const project = "dot-root";
const mission = "dot-first";
const slice = "same-slice";
const projectRoot = path.join(workspaceRoot, project);
fs.writeFileSync(path.join(workspaceRoot, "workspace.yaml"),
"projects:\n - id: alpha\n root: alpha\n - id: beta\n root: beta\n - id: dot-root\n root: dot-root\n");
writeSlice(workspaceRoot, project, {
"project.yaml": "metadata:\n id: dot-root\nmissions:\n root: .\n",
"SPEC.md": "# dot-root project\n",
});
writeSlice(projectRoot, mission, { "SPEC.md": `# ${mission}\n` });
writeSlice(path.join(projectRoot, mission, "slices"), slice, {
"SPEC.md": "# dot-root/dot-first\n",
"notes/a b.md": "dot-root own document\n",
});
// Literal parent path for missions.root: .; this is the legacy store outside the project.
const parentEvidenceRoot = path.join(workspaceRoot, "dogfood-evidence");
const parentPacket = path.join(parentEvidenceRoot, `${mission}-${slice}`);
const parentMarkdownMarker = "PARENT-LEGACY-PROOF-ROOT";
const parentAssetMarker = "PARENT-LEGACY-PROOF-ASSET";
fs.mkdirSync(path.join(parentPacket, "screenshots"), { recursive: true });
fs.writeFileSync(path.join(parentPacket, "proof.md"), `# ${parentMarkdownMarker}\n`);
fs.writeFileSync(path.join(parentPacket, "screenshots/scope.png"), `${parentAssetMarker}\n`);
const detail = await app.request(`/api/slices/${slice}?project=${project}&mission=${mission}`);
const detailBody = await detail.json();
const doc = await app.request(`/api/slices/${slice}/doc/SPEC.md?project=${project}&mission=${mission}`);
const docBody = await doc.json();
const asset = await app.request(`/api/slices/${slice}/proof-asset/screenshots/scope.png?project=${project}&mission=${mission}`);
const assetText = await asset.text();
const returnedText = `${JSON.stringify(detailBody)}\n${JSON.stringify(docBody)}\n${assetText}`;
expect({
detailStatus: detail.status,
slicePath: detailBody.slicePath,
missionId: detailBody.missionId,
proofPackets: detailBody.tests?.proofPackets,
docStatus: doc.status,
docBody,
assetStatus: asset.status,
assetNotFound: assetText.includes("proof_packet_not_found"),
parentProofBytesLeaked: returnedText.includes(parentMarkdownMarker) || returnedText.includes(parentAssetMarker),
}).toEqual({
detailStatus: 200,
slicePath: path.join(projectRoot, mission, "slices", slice),
missionId: mission,
proofPackets: [],
docStatus: 200,
docBody: { relPath: "SPEC.md", content: "# dot-root/dot-first\n" },
assetStatus: 404,
assetNotFound: true,
parentProofBytesLeaked: false,
});
});
it("qualified detail finds the later duplicate mission and its own proof packet", async () => {
const expectedSlicePath = path.join(workspaceRoot, "alpha", "missions", "z-later", "slices", "same-slice");
expect(fs.readFileSync(path.join(expectedSlicePath, "SPEC.md"), "utf8")).toBe("# alpha/z-later\n");
it("records detail's existing later-duplicate mission absence without inventing a successful lookup", async () => {
expect(fs.readFileSync(path.join(workspaceRoot, "alpha/missions/z-later/slices/same-slice/SPEC.md"), "utf8")).toBe("# alpha/z-later\n");
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=z-later");
expect(detail.status).toBe(200);
expect(await detail.json()).toMatchObject({
slicePath: expectedSlicePath,
missionId: "z-later",
tests: { proofPackets: [{
dirName: "z-later-same-slice",
primaryMarkdown: { relPath: "proof.md", content: "# alpha/z-later proof\n" },
screenshots: ["screenshots/scope.png"],
}] },
});
expect(detail.status).toBe(404);
expect(await detail.json()).toEqual({ error: "slice_not_found", name: "same-slice" });
});
it("doc follows the detail route's later-duplicate mission selection", async () => {
it("doc preserves the detail route's current missing later-duplicate selection", async () => {
const response = await app.request("/api/slices/same-slice/doc/SPEC.md?project=alpha&mission=z-later");
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ relPath: "SPEC.md", content: "# alpha/z-later\n" });
});
it.each([
{ route: "detail", endpoint: "" },
{ route: "doc", endpoint: "/doc/SPEC.md" },
{ route: "proof", endpoint: "/proof-asset/screenshots/scope.png" },
])("qualified $route preserves the source error for a nonexistent mission", async ({ endpoint }) => {
const response = await app.request(`/api/slices/same-slice${endpoint}?project=alpha&mission=unrelated`);
expect(response.status).toBe(409);
expect(response.headers.get("content-type")).toContain("application/json");
const body = await response.json() as { error: string; message: string };
expect(body.error).toBe("project_source_unavailable");
expect(body.message).toMatch(/ENOENT|no such file/i);
expect(body).not.toHaveProperty("content");
expect(body).not.toHaveProperty("proofPackets");
const body = await response.json();
expect({ status: response.status, content: body.content }).toEqual({ status: 404, content: undefined });
expect(body).toEqual({ error: "doc_not_found" });
});
it.each([
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.12",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -1,10 +1,14 @@
{
"release": "0.7.10",
"archiveSha256": "c55aff7e7c7d1429e6d12ee89540961005bcb1efd6e7017753071155b1952732",
"release": "0.7.11",
"archiveSha256": "71e3b727db0d8ab68ff0da99f5d46eebe123b9474446a7ff3e6e5497b2506419",
"plugins": {
"openrig": {
"version": "0.1.0",
"sha256": "ee8a1300119576f36889cc6494988c69faa3dfb2c3172cfd856a7ecc3edc0506"
},
"openrig-core": {
"version": "0.1.26",
"sha256": "23ed778d65d7feeff7e22b29538627973678241d1676a8d41253371d8ff4ec65"
"version": "0.1.27",
"sha256": "006f018d2a80bd22949a651461269010627e2ed68c7be91a51fc57c4ed723f43"
}
}
}