Compare commits

...
Author SHA1 Message Date
James B. Shields 8ac23f19e6 fix(daemon): skip synthetic alias entries in spec library source-link loop (#2999)
The spec library's secondary source-link loop in refreshWorkflowEntries skips synthetic workflow-source alias entries. The built-in owns that cache row and the loop always discarded the read, so an unmodified user copy of a built-in workflow is now read twice per list instead of three times. Ordinary user-file source links and fallback byte verification are unchanged, and a new test covers the alias count, alias deletion, an edited copy's ownership and the fallback.
2026-11-11 13:20:05 -08:00
Mike Schwarz 99c6ff5a08 fix(daemon): an adopted session's guidance routes through rig unclaim, not rig down (#2996)
An adopted, bound or self-attached session's guidance now routes through rig unclaim, which releases it and leaves its process running, then ending that process in its own terminal. It says rig down leaves a session OpenRig didn't start running, since rig down stops only sessions carrying the seat's OpenRig marker. The seat count no longer changes the text. getting-started.md, tmux-servers.md and hosted-members.md match, and one stale test pin is updated. The external-CLI text is unchanged.
2026-11-11 13:19:29 -08:00
Mike Schwarz d7d29b9d39 docs(release): bring 0.7.12's notes and capability map onto main (#3000)
Brings 0.7.12's release teaching onto main: the CHANGELOG's [0.7.12] section and the published [0.7.11] section main didn't carry yet, and the capability map moved to the 0.7.12 delta. The opening now says answers go to the configured receiving agent or kernel operator when available, and otherwise wait. The runner-answer limit says what a native run showed. docs/reference/whats-new.md stays at main's version until the package version moves. Docs only.
2026-11-11 13:18:50 -08:00
James B. Shields 98260ad3aa test(cli): make preflight tests deterministic (#2998)
Makes the CLI preflight tests deterministic. The six failure cases use port 0 instead of the ambient default daemon port. The same-endpoint case asserts no probe socket, and the different-host case uses an inert socket double that records its endpoint and teardown. The occupied-port case keeps a real, test-owned ephemeral listener. Test only; preflight's runtime is unchanged.
2026-11-11 12:08:57 -08:00
12 changed files with 866 additions and 56 deletions

No files matched your search

+569
View File
@@ -8,6 +8,575 @@ 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. Answers go to the configured receiving agent or kernel operator when
available; otherwise they wait. 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
- Answers nothing else reads, such as a host pairing request or a mode's
scheduled end, use the configured receiving agent or the kernel operator.
Answers wait when no receiver is available. ([#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 when guidance was written relative to the session's
recorded moments, 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:** when you answer a Codex choice prompt, a message waiting on it is
delivered instead of escalated; 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 durable recovery doesn't
repeat that recorded wake (other recovery paths can still ring it again);
unroutable items wake as soon as the seat is reachable; the stuck sweep counts
a finding once; an escalation keeps its owner through 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))
- **Pi and OMP:** typed edits submit in order on the affected Node releases
(22.21.x to 22.22.0, 24.9.0 to 24.11.1, 25.0.0 to 25.1.0) instead of appending an
insertion at the end. ([#2847](https://github.com/mvschwarz/openrig/pull/2847))
- **Terminals:** a Herdr grid whose blank filler cells have no label is confirmed,
instead of reading every seat unconfirmed. ([#2849](https://github.com/mvschwarz/openrig/pull/2849))
- **Speed:** queue listings, `rig queue show` and `rig parked` stop re-reading
each item;
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 these
answers use the configured receiving agent or the kernel operator, with the
question included. Answers wait when no receiver is available.
([#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. For an adopted session, such as a seat joined
with `rig attach --self`, `rig seat stop` still suggests `rig down`, which
leaves that terminal running too. Release it with `rig unclaim <session>`
(that leaves its process running), then close it in its own terminal. ([#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 ended up can read unknown. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
- **Runner permission answers:** when a person answers a runner's permission
request, the answer is kept, and an explicit decision works. In 0.7.12 the
runner's confirmation updates fail to record, so no receipt or Feed
confirmation appears, and the decision can reach the runner on its next read
rather than at once (up to about 21 seconds in our test). To settle a request
directly, run `rig seat decide-permission <request> --option <id>`. Pairing
answers are separate, and their receipts are kept. Tested on one native daemon
with the TUI and a runner driven by a stand-in agent. ([#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, and to @KM199, co-author of [#2947](https://github.com/mvschwarz/openrig/pull/2947) and [#2952](https://github.com/mvschwarz/openrig/pull/2952), which finish
their [#2513](https://github.com/mvschwarz/openrig/pull/2513) and [#2515](https://github.com/mvschwarz/openrig/pull/2515).
Thanks also for the reports behind these fixes: @nassersala ([#2955](https://github.com/mvschwarz/openrig/pull/2955)), @djuntgen
([#2804](https://github.com/mvschwarz/openrig/pull/2804)), @todd-dhango ([#2851](https://github.com/mvschwarz/openrig/pull/2851)), @Kayhusk ([#2796](https://github.com/mvschwarz/openrig/pull/2796)), @pr0xc3nt4ur1 ([#2849](https://github.com/mvschwarz/openrig/pull/2849) and [#2850](https://github.com/mvschwarz/openrig/pull/2850)), @amsayfi
([#2776](https://github.com/mvschwarz/openrig/pull/2776)), @oodadoudou ([#2847](https://github.com/mvschwarz/openrig/pull/2847)) and @CaptObvious ([#2933](https://github.com/mvschwarz/openrig/pull/2933) and [#2942](https://github.com/mvschwarz/openrig/pull/2942)).
## [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
+3
View File
@@ -866,6 +866,9 @@ What it costs:
## Stop your teams
`rig down <rig-name>` ends that rig's agent sessions and work in progress.
A terminal OpenRig didn't start, such as a seat joined with `rig attach --self`,
is left running: `rig down` names it with the commands to stop it by hand, and
exits 2.
Read `rig ps --nodes --rig <rig-name>` first so you can see who will stop;
`rig down` also prints the rig's recorded agent sessions and their activity
before stopping them.
+5 -2
View File
@@ -78,14 +78,17 @@ entry. The entry holds secret names, never values:
- It's a pointer, not the row's body: the member reads the row under its grant.
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
is rung again on recovery, the same at-least-once window a terminal wake has.
- It waits up to 10 seconds and doesn't follow redirects.
- It waits up to 10 seconds and doesn't follow redirects. A ring with no answer by then may still have reached the
trigger, so it reads unknown, not failed, and durable recovery doesn't repeat that recorded wake (other recovery paths
can still ring it again). The member sees the work when it next checks its rows.
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
## What the record says, and doesn't
These facts stay separate on the row:
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
ring, `indeterminate:webhook …` when it didn't answer in time, or `failed:webhook …`. Accepted means the tool's trigger
answered, not that the member read the row.
- With no trigger, the result is `unroutable`. The member finds work by reading its own inbox and picks a row up by claiming
it, so a row gets no finding of its own. Work it leaves unclaimed past the stuck sweep's unclaimed age
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default) is listed in one standing notice per member. The notice goes to
+4 -1
View File
@@ -109,7 +109,10 @@ To move a team that runs on an older server:
shares your default server, which runs the new version only after every session
on it, yours included, has ended.
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
turn in progress stops. Queue rows, rigs and seats stay as they are.
turn in progress stops. Queue rows, rigs and seats stay as they are. A session
OpenRig didn't start, such as a seat joined with `rig attach --self`, is left
running and `rig down` exits 2; stop it with the commands it prints, or it stays on
the old server.
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
`rig terminal status --json` should show `migrationPending: false`. If it's still
`true`, `blockingSessions` names the sessions still on the old server.
+41 -14
View File
@@ -76,7 +76,7 @@ describe("SystemPreflight", () => {
it("Node version below 22 → ready: false with guidance", async () => {
Object.defineProperty(process, "version", { value: "v18.19.0", writable: true });
const pf = createPreflight();
const result = await pf.run();
const result = await pf.run({ port: 0 });
expect(result.ready).toBe(false);
const nodeCheck = result.checks.find((c) => c.name === "node_version");
expect(nodeCheck!.ok).toBe(false);
@@ -104,7 +104,7 @@ describe("SystemPreflight", () => {
// Test 3
it("tmux missing → ready: false with guidance", async () => {
const pf = createPreflight({ tmuxMode: "missing" });
const result = await pf.run();
const result = await pf.run({ port: 0 });
expect(result.ready).toBe(false);
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
expect(tmuxCheck!.ok).toBe(false);
@@ -114,7 +114,7 @@ describe("SystemPreflight", () => {
it("tmux installed but default control socket unhealthy → ready: false with guidance", async () => {
const pf = createPreflight({ tmuxMode: "unhealthy" });
const result = await pf.run();
const result = await pf.run({ port: 0 });
expect(result.ready).toBe(false);
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
@@ -135,7 +135,7 @@ describe("SystemPreflight", () => {
getDaemonStatus: makeStatus(),
riggedHome: join(readonlyDir, "nested"),
});
const result = await pf.run();
const result = await pf.run({ port: 0 });
expect(result.ready).toBe(false);
const homeCheck = result.checks.find((c) => c.name === "writable_home");
expect(homeCheck!.ok).toBe(false);
@@ -187,9 +187,18 @@ describe("SystemPreflight", () => {
const pf = createPreflight({
daemonStatus: { state: "running", port: 7433, host: "127.0.0.1", healthy: true },
});
const result = await pf.run({ port: 7433, host: "127.0.0.1" });
const portCheck = result.checks.find((c) => c.name === "port_available");
expect(portCheck!.ok).toBe(true);
// A regressed skip would fail at construction before it could reach any socket.
const constructor = vi.spyOn(net, "Socket").mockImplementation(() => {
throw new Error("same-endpoint preflight must not construct a probe socket");
});
try {
const result = await pf.run({ port: 7433, host: "127.0.0.1" });
const portCheck = result.checks.find((c) => c.name === "port_available");
expect(portCheck).toMatchObject({ ok: true });
expect(constructor).not.toHaveBeenCalled();
} finally {
constructor.mockRestore();
}
});
// Test 7
@@ -198,18 +207,33 @@ describe("SystemPreflight", () => {
const pf = createPreflight({
daemonStatus: { state: "running", port: 7433, host: "127.0.0.1", healthy: true },
});
// Use a port that's actually available, but the skip rule should not apply
const result = await pf.run({ port: 7433, host: "10.0.0.5" });
const portCheck = result.checks.find((c) => c.name === "port_available");
// Should have attempted the check (not skipped)
expect(portCheck).toBeDefined();
// A deterministic refusal proves the requested endpoint without dialing it.
const socket = Object.assign(new EventEmitter(), {
setTimeout: vi.fn(),
destroy: vi.fn(),
connect: vi.fn((_port: number, _host: string) => {
queueMicrotask(() => socket.emit("error", Object.assign(new Error("connection refused"), { code: "ECONNREFUSED" })));
}),
});
const constructor = vi.spyOn(net, "Socket").mockImplementation(() => socket as unknown as net.Socket);
try {
const result = await pf.run({ port: 7433, host: "10.0.0.5" });
const portCheck = result.checks.find((c) => c.name === "port_available");
expect(portCheck).toMatchObject({ ok: true });
expect(constructor).toHaveBeenCalledTimes(1);
expect(socket.connect).toHaveBeenCalledTimes(1);
expect(socket.connect).toHaveBeenCalledWith(7433, "10.0.0.5");
expect(socket.destroy).toHaveBeenCalledTimes(1);
} finally {
constructor.mockRestore();
}
});
// Test 8
it("multiple failures reported together (additive)", async () => {
Object.defineProperty(process, "version", { value: "v18.19.0", writable: true });
const pf = createPreflight({ tmuxMode: "missing" });
const result = await pf.run();
const result = await pf.run({ port: 0 });
expect(result.ready).toBe(false);
const failedChecks = result.checks.filter((c) => !c.ok);
expect(failedChecks.length).toBeGreaterThanOrEqual(2);
@@ -343,9 +367,12 @@ describe("Preflight CLI", () => {
it("rig preflight with failure prints three-part error and exits 1", async () => {
const homeDir = join(tmpDir, ".openrig");
mkdirSync(homeDir, { recursive: true });
const configPath = join(tmpDir, "config.json");
const { writeFileSync } = await import("node:fs");
writeFileSync(configPath, JSON.stringify({ daemon: { port: 0 } }));
const cmd = preflightCommand({
exec: makeExec("missing"),
configPath: join(tmpDir, "config.json"),
configPath,
riggedHome: homeDir,
getDaemonStatus: makeStatus(),
});
@@ -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 when guidance was written relative to the session's recorded moments, 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. Teardown is incomplete for those terminals: if one is yours, look at it and stop it
yourself, then run `rig down` again.
- **`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.** Answers nothing else reads (a host pairing request, a
mode's scheduled end) use the configured receiving agent or the kernel operator. Pairing and
runner permission answers go to the receiving agent with the question for it to decide;
qualified words ("only if it's the test file") need that judgment. Answers wait when no receiver
is available: don't describe them as read or decided. A pairing request names its receiver, 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
@@ -72,32 +72,25 @@ import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
/**
* The supported routes for a session registered as adopted (origin=claimed). `rig unclaim` releases
* The supported route for a session registered as adopted (origin=claimed): `rig unclaim` releases
* it and leaves its process running, so ending that process in its own terminal ends only this seat.
* `rig down` ends it with the rest of its rig, so it is the route only when the rig has one seat;
* otherwise it is named for what it ends. Only the guidance names them; the refusal stands.
* `seats` is the rig's node count when known. A seat handed over before 0.7.9 gets
* earlierHandoverRefusal instead. A seat joined with `rig attach` as an external CLI has no tmux
* session that `rig down` stops.
* `rig down` stops only a session carrying the seat's OpenRig marker, which OpenRig sets on the sessions
* it starts and an adopted, bound or self-attached session lacks, so it isn't a route here and the
* guidance says why. Only the guidance names the route; the refusal stands. A seat handed over before
* 0.7.9 gets earlierHandoverRefusal instead. A seat joined with `rig attach` as an external CLI has no
* tmux session that `rig down` stops.
*/
export function claimedSessionRoutes(rigName: string, sessionName: string, seats: number | null, externalCli = false): string {
export function claimedSessionRoutes(rigName: string, sessionName: string, externalCli = false): string {
const session = shellQuote(sessionName);
const release = `To release it from OpenRig and leave its process running, run rig unclaim ${session}.`;
if (externalCli) return `This seat was joined with rig attach as an external CLI, so rig down leaves it running. ${release} End the process in its own terminal.`;
if (seats === 1) return `To end the session, run ${rigDownRoute(rigName)}. ${release}`;
return `To end only this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
+ `then end that process in its own terminal. ${rigDownScope(rigName, seats)}`;
return `To end this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
+ `then end that process in its own terminal. ${rigDownLeavesIt(rigName)}`;
}
/** `rig down <rig>` as the route for a one-seat rig, and how the rig comes back. */
function rigDownRoute(rigName: string): string {
const rig = shellQuote(rigName);
return `rig down ${rig} (this rig has one seat, so it stops only this session; rig up ${rig} --existing brings the rig back)`;
}
/** What `rig down <rig>` ends in a rig with more than one seat, or an unknown count. */
function rigDownScope(rigName: string, seats: number | null): string {
return `rig down ${shellQuote(rigName)} ends ${seats !== null && seats > 1 ? `all ${seats} seats` : "every seat"} in this rig.`;
/** Why `rig down <rig>` isn't the route for an adopted session. */
function rigDownLeavesIt(rigName: string): string {
return `rig down ${shellQuote(rigName)} leaves a session OpenRig didn't start running.`;
}
/** Terminal session statuses — rows the clean verb must NOT touch (they already
@@ -1427,13 +1420,12 @@ export class SeatLifecycleService {
const rows = this.nonTerminalSessions(nodeId);
const claimed = rows.find(row => row.origin === "claimed");
if (claimed) {
const seats = this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null;
const externalCli = seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(nodeId)?.attachmentType)) === "external";
return {
ok: false, code: "claimed_session",
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), end that process yourself, then launch fresh.`
+ (externalCli ? "" : seats === 1 ? ` Or end it with ${rigDownRoute(seat.rigName)}.` : ` ${rigDownScope(seat.rigName, seats)}`),
+ (externalCli ? "" : ` ${rigDownLeavesIt(seat.rigName)}`),
};
}
const binding = this.sessionRegistry.getBindingForNode(nodeId);
@@ -2292,7 +2284,7 @@ export class SeatLifecycleService {
/** The claimed-session routes for this seat's rig (see claimedSessionRoutes). */
private claimedRoutes(seat: SeatDescriptor, sessionName: string): string {
return claimedSessionRoutes(seat.rigName, sessionName, this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null,
return claimedSessionRoutes(seat.rigName, sessionName,
seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(seat.nodeId)?.attachmentType)) === "external");
}
+1 -1
View File
@@ -76,7 +76,7 @@ export function specLibraryRoutes(): Hono {
// their separate two-file byte check above because they have no persisted row of their own.
const sourceHashes = verifiedSourceHashes;
for (const entry of [...workflowEntries]) {
if (entry.sourceType !== "user_file" || entry.status === "error" || dirname(entry.sourcePath) !== folder) continue;
if (entry.id.startsWith("workflow-source:") || entry.sourceType !== "user_file" || entry.status === "error" || dirname(entry.sourcePath) !== folder) continue;
try {
let sourceHash = sourceHashes.get(entry.sourcePath);
if (sourceHash === undefined) {
@@ -493,14 +493,14 @@ describe("SeatLifecycleService.launchFresh", () => {
expect(tmux.startServer).not.toHaveBeenCalled();
});
it("preserves the adopted seat name and names the release and rig down routes in plans and execution", async () => {
it("preserves the adopted seat name and names the release route in plans and execution", async () => {
const seat = seedSeat({ adopted: true });
const expected = {
code: "claimed_session",
message: `Seat "${seat.sessionName}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
// B5: rig unclaim releases it (the process keeps running); rig down ends it with its rig.
// B5: rig unclaim releases it (the process keeps running); rig down leaves a session OpenRig didn't start.
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process yourself, then launch fresh. "
+ "Or end it with rig down 'fresh-rig' (this rig has one seat, so it stops only this session; rig up 'fresh-rig' --existing brings the rig back).",
+ "rig down 'fresh-rig' leaves a session OpenRig didn't start running.",
};
const before = db.serialize();
expect(await service.planFresh({ seatRef: seat.sessionName, fresh: true, stop: true }))
@@ -444,9 +444,10 @@ describe("SeatLifecycleService", () => {
expect(claimedResult.ok).toBe(false);
if (claimedResult.ok) throw new Error("expected refusal");
expect(claimedResult.code).toBe("claimed_session");
// the refusal names the supported routes — rig down for the (here one-seat) rig, and rig unclaim.
expect(claimedResult.guidance).toBe("To end the session, run rig down 's5-rig' (this rig has one seat, so it stops only this session; "
+ "rig up 's5-rig' --existing brings the rig back). To release it from OpenRig and leave its process running, run rig unclaim 'dev-adopted@s5-rig'.");
// the refusal names the supported route, rig unclaim, and says rig down doesn't stop a session OpenRig didn't start,
// even in a one-seat rig.
expect(claimedResult.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@s5-rig' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process in its own terminal. rig down 's5-rig' leaves a session OpenRig didn't start running.");
const probed = seatFixture("s5-rig", "dev.flaky");
tmux.failProbeFor(probed.sessionName);
@@ -464,14 +465,13 @@ describe("SeatLifecycleService", () => {
expect(bare.code).toBe("no_session");
});
it("the claimed-session guidance counts the rig's seats and says rig down leaves an external CLI running", async () => {
it("the claimed-session guidance is the same in a rig of several seats, and says rig down leaves an external CLI running", async () => {
const claimed = seatFixture("adopted-rig", "dev.adopted", { origin: "claimed" });
seatFixture("adopted-rig", "dev.peer");
const shared = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
if (shared.ok) throw new Error("expected refusal");
// more than one seat: the route that ends only this seat comes first, and rig down is named for what it ends.
expect(shared.guidance).toBe("To end only this session, run rig unclaim 'dev-adopted@adopted-rig' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process in its own terminal. rig down 'adopted-rig' ends all 2 seats in this rig.");
expect(shared.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@adopted-rig' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process in its own terminal. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
sessionRegistry.updateBinding(claimed.node.id, { attachmentType: "external_cli", externalSessionName: claimed.sessionName });
const external = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
@@ -483,9 +483,9 @@ describe("SeatLifecycleService", () => {
expect(tmux.killed).toEqual([]);
});
it("claimedSessionRoutes without a seat count stays general", () => {
expect(claimedSessionRoutes("r", "a@r", null)).toBe("To end only this session, run rig unclaim 'a@r' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process in its own terminal. rig down 'r' ends every seat in this rig.");
it("claimedSessionRoutes routes a tmux session through rig unclaim, never rig down", () => {
expect(claimedSessionRoutes("r", "a@r")).toBe("To end this session, run rig unclaim 'a@r' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process in its own terminal. rig down 'r' leaves a session OpenRig didn't start running.");
});
// ---- P5 / P6 — clean ----
@@ -636,8 +636,12 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id);
const claimed = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
expect(claimed).toMatchObject({ ok: false, code: "claimed_session" });
// B5: the refusal names rig down and rig unclaim, not only rig unclaim.
expect((claimed as { guidance?: string }).guidance).toMatch(/run rig down '[^']+' \(.*\)\. To release it from OpenRig and leave its process running, run rig unclaim '/);
// B5: the refusal's route is rig unclaim, then ending the process in its own terminal; rig down leaves a session
// OpenRig didn't start running, so it isn't offered as the way to end this one.
const guidance = (claimed as { guidance?: string }).guidance ?? "";
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process in its own terminal\. /);
expect(guidance).toMatch(/rig down '[^']+' leaves a session OpenRig didn't start running\.$/);
expect(guidance).not.toMatch(/run rig down/);
});
it("names a seat handed over before 0.7.9 instead of pointing at rig unclaim", async () => {
@@ -535,4 +535,180 @@ describe("workflow spec persisted capture and Library reads", () => {
afterScanCachedAt: P3_REFRESH_TIME.toISOString(),
});
});
it("lists an identical built-in alias with two user reads while keeping edited user source links", async () => {
const builtin = join(root, "builtins", "same.yaml");
const user = join(folder, "same.yaml");
const identicalYaml = VALID_YAML("same");
const expectedSourceId = `workflow-source:${captureHash(user)}`;
let observedAt = CAPTURE_TIME;
const cache = new WorkflowSpecCache(db, () => observedAt);
const app = makeLibraryApp({ db, cache, folder, root });
fs.writeFileSync(builtin, identicalYaml);
cache.readThrough(builtin);
fs.writeFileSync(user, identicalYaml);
statAt(builtin);
statAt(user);
const readThroughSpy = vi.spyOn(cache, "readThrough");
fileProbe.readPaths.length = 0;
const firstList = await app.request("/api/specs/library?kind=workflow");
const firstEntries = await firstList.json() as Array<{
id: string; kind: string; name: string; version: string; sourcePath: string; sourceType: string; isBuiltIn: boolean;
}>;
const firstPassCounts = {
status: firstList.status,
userReads: readsOf(user),
builtinReads: readsOf(builtin),
readThroughPaths: readThroughSpy.mock.calls.map(([specPath]) => specPath),
};
fileProbe.readPaths.length = 0;
readThroughSpy.mockClear();
const secondList = await app.request("/api/specs/library?kind=workflow");
const secondPassCounts = {
status: secondList.status,
userReads: readsOf(user),
builtinReads: readsOf(builtin),
readThroughPaths: readThroughSpy.mock.calls.map(([specPath]) => specPath),
};
expect(firstPassCounts).toEqual({
status: 200,
userReads: 2,
builtinReads: 1,
readThroughPaths: [user],
});
expect(secondPassCounts).toEqual({
status: 200,
userReads: 2,
builtinReads: 1,
readThroughPaths: [user],
});
expect(firstEntries).toEqual([
expect.objectContaining({
id: expectedSourceId,
kind: "workflow",
name: "same",
version: "1",
sourcePath: user,
sourceType: "user_file",
isBuiltIn: false,
}),
]);
expect(db.prepare("SELECT source_path, source_hash, cached_at FROM workflow_specs WHERE name = 'same' AND version = '1'").get())
.toEqual({
source_path: builtin,
source_hash: captureHash(identicalYaml),
cached_at: CAPTURE_TIME.toISOString(),
});
const withBuiltins = await (await app.request("/api/specs/library?kind=workflow&include_builtins=true")).json() as Array<{
id: string; sourcePath: string; sourceType: string; isBuiltIn: boolean;
}>;
expect(withBuiltins.map(({ id, sourcePath, sourceType, isBuiltIn }) => ({ id, sourcePath, sourceType, isBuiltIn }))).toEqual([
{ id: "workflow:same:1", sourcePath: builtin, sourceType: "builtin", isBuiltIn: true },
{ id: expectedSourceId, sourcePath: user, sourceType: "user_file", isBuiltIn: false },
]);
const aliasReview = await app.request(`/api/specs/library/${encodeURIComponent(expectedSourceId)}/review`);
expect(aliasReview.status).toBe(200);
expect(await aliasReview.json()).toMatchObject({
name: "same",
version: "1",
sourcePath: user,
isBuiltIn: false,
libraryEntryId: expectedSourceId,
purpose: "capture fixture",
});
// Deleting the identical user copy removes the synthetic alias entry without removing the canonical built-in row.
fs.rmSync(user);
fileProbe.statOverrides.delete(user);
const syncAfterDelete = await app.request("/api/specs/library/sync", { method: "POST" });
expect(syncAfterDelete.status).toBe(200);
expect(await syncAfterDelete.json()).toEqual([]);
expect(await (await app.request("/api/specs/library?kind=workflow")).json()).toEqual([]);
expect((await app.request(`/api/specs/library/${encodeURIComponent(expectedSourceId)}/review`)).status).toBe(404);
expect(db.prepare("SELECT source_path, cached_at FROM workflow_specs WHERE name = 'same' AND version = '1'").get())
.toEqual({ source_path: builtin, cached_at: CAPTURE_TIME.toISOString() });
// Control: an ordinary edited user copy owns the cache row and still receives its source-specific review link.
const editedYaml = identicalYaml.replace("objective: capture fixture", "objective: edited capture fixture");
fs.writeFileSync(user, editedYaml);
statAt(user, TOUCHED_STAT_TIME);
observedAt = P3_REFRESH_TIME;
const editedList = await app.request("/api/specs/library?kind=workflow");
const editedEntries = await editedList.json() as Array<{
id: string; sourcePath: string; sourceType: string; isBuiltIn: boolean;
}>;
expect(editedList.status).toBe(200);
expect(editedEntries.map(({ id, sourcePath, sourceType, isBuiltIn }) => ({ id, sourcePath, sourceType, isBuiltIn }))).toEqual([
{ id: "workflow:same:1", sourcePath: user, sourceType: "user_file", isBuiltIn: false },
]);
expect(db.prepare("SELECT source_path, source_hash, cached_at FROM workflow_specs WHERE name = 'same' AND version = '1'").get())
.toEqual({
source_path: user,
source_hash: captureHash(editedYaml),
cached_at: P3_REFRESH_TIME.toISOString(),
});
statAt(user);
fileProbe.readPaths.length = 0;
const warmEditedList = await app.request("/api/specs/library?kind=workflow");
expect(warmEditedList.status).toBe(200);
expect(readsOf(user)).toBe(0);
fileProbe.readPaths.length = 0;
const editedSourceReview = await app.request(`/api/specs/library/${encodeURIComponent(expectedSourceId)}/review`);
expect(editedSourceReview.status).toBe(200);
expect(await editedSourceReview.json()).toMatchObject({
name: "same",
version: "1",
sourcePath: user,
isBuiltIn: false,
libraryEntryId: expectedSourceId,
purpose: "edited capture fixture",
});
// SpecLibraryService.get reads the workflow YAML once on /:id/review; refreshWorkflowEntries itself adds 0 reads.
expect(readsOf(user)).toBe(1);
// Exclude ordinary user-file fallback verification from the synthetic-entry skip: when the scanner skips without
// pre-populating verifiedSourceHashes (e.g. a newer #503 diagnostic row shares the path), the route still reads
// the ordinary user file once to verify its cached bytes (plus one SpecLibraryService.get read on 200 review)
// and rejects changed disk bytes before SpecLibraryService.get runs.
const fallbackSpec = join(folder, "fallback.yaml");
const fallbackSourceId = `workflow-source:${captureHash(fallbackSpec)}`;
const fallbackYaml = VALID_YAML("fallback-flow");
observedAt = CAPTURE_TIME;
fs.writeFileSync(fallbackSpec, fallbackYaml);
cache.readThrough(fallbackSpec);
cache.writeDiagnostic({
sourcePath: fallbackSpec,
sourceHash: captureHash(BROKEN_YAML),
errorMessage: "diagnostic alongside cached v1",
cachedAt: P3_REFRESH_TIME.toISOString(),
});
statAt(fallbackSpec);
fileProbe.readPaths.length = 0;
const fallbackReview = await app.request(`/api/specs/library/${encodeURIComponent(fallbackSourceId)}/review`);
expect(fallbackReview.status).toBe(200);
expect(await fallbackReview.json()).toMatchObject({
name: "fallback-flow",
version: "1",
sourcePath: fallbackSpec,
isBuiltIn: false,
libraryEntryId: fallbackSourceId,
});
expect(readsOf(fallbackSpec)).toBe(2);
fs.writeFileSync(fallbackSpec, BROKEN_YAML);
fileProbe.readPaths.length = 0;
expect((await app.request(`/api/specs/library/${encodeURIComponent(fallbackSourceId)}/review`)).status).toBe(404);
expect(readsOf(fallbackSpec)).toBe(1);
});
});