mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 03:31:38 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df3bbeb3c7 | ||
|
|
62e1483cb2 | ||
|
|
37a9638eb8 | ||
|
|
598d214582 |
No files matched your search
+546
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
Generated
+5
-5
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.12",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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,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 {
|
||||
|
||||
@@ -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) }; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user