Compare commits

...
17 Commits
Author SHA1 Message Date
dev-driver f3d8bac42e docs(release): describe 0.7.7's three fixes
0.7.7 is now 0.7.6 plus #2311 (the Update installed footer), #2347 (managed
compaction keeps a typed draft) and #2341 (a Claude seat behind a non-exec
launcher can be rebuilt, and a failed handover keeps the resume target).
The notes name each fix, its limits and the reporter's credit.
2026-11-01 18:54:11 +00:00
dev-driver 72ad78e01a test(daemon): the #1077 stale-generation case uses a registered earlier occupant (#2341)
The resume-stale-generation fixture posted a generation the seat never registered, which #2341 now answers as generation_uncommitted. A real earlier occupant's generation is registered and no longer current; the fixture now mints that earlier tenure, so the case keeps testing what it meant (an earlier occupant's resume hook writes, and the conversation reads unverified) and its assertions are unchanged.
2026-11-01 18:46:40 +00:00
dev-driver ad799ebf70 fix(daemon): rebuild a Claude seat behind a non-exec launcher, and keep the resume target on a failed handover (#2341)
A #! wrapper that starts Claude without exec keeps running the frozen script as the pane's foreground leader, with Claude as its child under any name, so neither existing admission matched and a rebuild waited out readiness. Such a launch now also admits the direct child of the process running the frozen script (argv[1], or argv[2] after one interpreter option) when it names a Claude conversation; the launch's own token, a single candidate, two stable samples and the pane PID are still required. A Claude session_identity hook from an occupant generation the seat hasn't registered (a handover's successor before it commits), while the seat has a current tenure, no longer moves the seat's resume token; it answers tokenPersisted false with generation_uncommitted. An absent generation, no tenure or an unreadable ledger keep today's write; Codex is unchanged.
2026-11-01 17:24:41 +00:00
dev-driver f5cd2a34bf test(daemon): a Claude behind a non-exec launcher becomes ready, and a failed handover keeps the resume target (#2341)
Red first. A managed launch whose frozen executable is a #! wrapper that starts Claude without exec leaves the wrapper running the script as the pane's foreground leader, with Claude as its child under any name. That child, naming the launch's conversation, must be proved; a child naming another or no conversation, another script, a grandchild, a second candidate or a changing process stays refused. A Claude SessionStart from an occupant generation the seat hasn't registered (a handover's successor before it commits) must keep the seat's resume target; the registered generation, the committed successor and a post with no generation keep writing.
2026-11-01 17:24:41 +00:00
dev-guard 710001a384 test: supply composer observations to stable compaction controls 2026-11-01 15:02:53 +00:00
dev-guard 6e4530ac54 fix: preserve managed compaction drafts on the stable line 2026-11-01 14:24:38 +00:00
dev-driver d6ba98cbeb docs(release): describe 0.7.7 2026-11-01 10:58:31 +00:00
dev-driver e0dc58cd21 chore(release): prepare 0.7.7 2026-11-01 10:56:25 +00:00
Mike Schwarz 5f83c1e183 fix(daemon): read Claude's "Update installed" footer as a status line (#2311)
A Claude seat at an empty prompt reads idle after Claude installs an update, instead of unknown, so managed compaction can see it is idle and send. Claude prints "✔ Update installed · /restart to apply" (or "Restart to apply") below its mode bar, and CLAUDE_STATUS_WARNINGS didn't list that line, so the footer finder never reached the mode bar. One anchored entry now matches both spellings exactly. The #808 readiness cases run over both spellings across every mode bar, and three near misses (a suffix, no glyph, another verb) still read unknown. Source +2, tests +18/-1, no new refusal or error code.

(cherry picked from commit ff604d943d)
2026-11-01 10:55:58 +00:00
review50-r2 c00c6321e3 docs: omit redundant kernel dashboard session name from operator guidance 2026-10-29 16:27:31 +00:00
review50-r2 a962341d52 docs: name the kernel dashboard's session from rig ps in its recovery steps
Use the terminal's registered canonicalSessionName in recovery examples,
keeping capture, fresh launch without stop, and verification before retry.
2026-10-29 16:08:49 +00:00
dev-driver 7da9a48c1f docs(release): describe 0.7.6 slice following and upgrade limits
Add the changelog, installed upgrade guidance and capability map. Preserve
the selected collision guidance and document the bounded viewer repair,
exited-attach limit, startup pending result and draft author contracts.
2026-10-29 15:04:28 +00:00
Mike Schwarz 510719037d docs: draft integration author contracts (#2272)
Adds a draft entry point for people integrating a harness or an execution platform with OpenRig: `docs/reference/author-contracts.md`, with `harness-runner-contract.md` and `execution-platform-contract.md`. It separates what the current source implements from what is proposed or still open, covering launch, identity, input and activity, permission and cancellation, resume, context, placement, owned versus attached resources, shutdown, and artifact return. Existing presets, AgentSpec and RigSpec stay distinct from the proposed HarnessSpec and DeploySpec vocabulary, and no new loader or conformance guarantee is claimed. `community-harness-presets.md` links to the entry point.
2026-10-29 14:59:02 +00:00
Mike Schwarz 7ea2177018 fix: recover terminal joins refused before split dispatch (#2271)
A following terminal view no longer leaves an owner permanently unable to join when the view's own checks refuse before any pane split is dispatched. Only that fresh, positively known no-effect intent is retired, and its removal is saved before best-effort directory cleanup, so a cleanup failure cannot strand the join again. A dispatched create with an uncertain reply keeps its provenance, including when enumeration is empty. Hold notices keep the seat, observed phase and original failure across passive confirmation reads, and the seat binding is rechecked before an attach is dispatched.

An interrupted join that stopped before its attach continues through the existing ensure path. An attach that was already dispatched stays fail-closed, including a pane that returned to a shell after its viewer exited; this change does not restart it or any native agent.
2026-10-29 14:57:02 +00:00
Mike Schwarz 6eb2cb97f1 fix: keep a still-starting daemon after its readiness deadline (#2267)
On a large database, `rig daemon start` no longer kills its own daemon when startup outlasts the CLI's readiness deadline. When only the deadline has elapsed and the spawned child is still alive, the CLI leaves it running, keeps the startup reservation that excludes a second start, and exits nonzero with a finite pending message. That message names the child PID, the launch's health endpoint and the log, and it says how to restart by hand. Nothing is published as ready.

A child that exits, a native start error, an identity or listener mismatch and owned-child cleanup are handled as before. There are no new error codes or dependencies.
2026-10-29 13:49:37 +00:00
dev-driver d4d6a01df0 chore(release): prepare 0.7.6 2026-10-29 11:41:20 +00:00
Mike Schwarz 85ee9f4ec8 fix: place colliding slice aliases by indexed mission (#2261)
A slice whose folder name is reused by another mission can now be followed by its unique id. When the selected slice's name or id collides with another slice's, a queue row carrying the colliding alias is placed by its exact `mission:` tag: it counts only when it names the selected slice's own indexed mission, and rows naming the other mission are excluded. A row with the colliding alias and no mission tag, an unknown or contradictory one, or a placement that names several claimants makes the read incomplete with that row named, so existing viewers are kept rather than retired. A bare selector that matches several slices still reports `scope_ambiguous`, flat legacy slices get no invented mission, and unique aliases match exactly as before.

Release missions commonly reuse template slice names such as `01-regression-checks`, which previously made those slices impossible to follow even through their unique ids. One queue projection and the existing read bounds are unchanged.
2026-10-29 11:41:19 +00:00
40 changed files with 1524 additions and 296 deletions

No files matched your search

+220
View File
@@ -8,6 +8,226 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.7]
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
idle prompt under Claude's "Update installed" line is recognized again, managed
compaction no longer types into a message you haven't sent, and a Claude seat
launched through a wrapper script can be rebuilt again.
### Before you upgrade
- **Restart the daemon after installing** so the fixes take effect:
```bash
npm install -g @openrig/cli@0.7.7
rig daemon stop && rig daemon start
```
There are no database migrations. The bundled openrig-core plugin stays at
0.1.23 with unchanged files.
### Fixed
- **A Claude seat under the "Update installed" line reads idle again.** After
Claude installs an update, it prints "✔ Update installed · /restart to apply"
(or "Restart to apply") below its mode bar. OpenRig didn't recognize that line,
so a seat at an empty prompt read unknown instead of idle, and managed
compaction waited. Both spellings are now recognized; lines that only look
similar still read unknown. ([#2311](https://github.com/mvschwarz/openrig/pull/2311))
- **Managed compaction no longer types into a draft.** With the optional typing
guard off (the default), compaction's preparation prompt could be pasted onto
a message someone had typed but not sent. Now the preparation prompt,
`/compact` and the restore prompts check the input box first: typed or
unreadable input holds the step with a reason. A held restore prompt goes ahead
on its own once the box is empty; a manual `rig compact` says why it stopped,
to be retried once the box is empty. An empty box or Claude's own suggestion
goes ahead.
([#2347](https://github.com/mvschwarz/openrig/pull/2347))
- **A Claude seat launched through a wrapper script can be rebuilt again.** On
0.7.6, `rig seat handover --source rebuild` on a seat whose `claude` is a `#!`
script that starts the real Claude without `exec` stopped after 30 seconds with
"Waiting to confirm the new Claude process in this pane", though the new Claude
was running. The readiness check now also accepts the script's own direct child
when it names this launch's conversation; every other check is unchanged.
([#2341](https://github.com/mvschwarz/openrig/issues/2341), [#2354](https://github.com/mvschwarz/openrig/pull/2354))
- **A failed handover no longer moves the seat's resume target** to the new,
unprimed conversation while the seat keeps its previous occupant.
([#2354](https://github.com/mvschwarz/openrig/pull/2354))
### Known limits
- **Other reasons managed compaction can wait are unchanged.**
- **The draft check reads the input box just before pasting.** Typing that starts
in the same moment can still be appended to.
- **A Claude seat whose current occupant generation was never registered,** such as
an adopted Claude carrying another generation, or a managed launch whose tenure
record failed to write, keeps its first conversation token, but later hooks don't
move it.
- **The 0.7.6 known limits below still apply.**
### Thanks
- @oneandonlydean for the report and the evidence in #2341.
## [0.7.6]
OpenRig 0.7.6 lets one terminal workspace follow the current owners of a slice's
open work through handoffs and completions. It also keeps more seat settings
through expansion and restore, reports retained dead panes honestly, and repairs
queue and Slack delivery paths.
### Before you upgrade
- **Restart the daemon after installing** so the new code and database updates
take effect:
```bash
npm install -g @openrig/cli@0.7.6
rig daemon stop && rig daemon start
```
Two migrations add the selected account-home path to nodes and the runner
declaration to sessions. Both have prefix 141; their distinct full filenames
identify them. ([#2143](https://github.com/mvschwarz/openrig/pull/2143), [#2239](https://github.com/mvschwarz/openrig/pull/2239))
- **The bundled openrig-core plugin advances from 0.1.22 to 0.1.23.** Claude's
activity hook clears permission marks after a tool batch. Managed refresh
preserves replaced files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`;
reapply local edits you still need from that copy. ([#2209](https://github.com/mvschwarz/openrig/pull/2209))
### Follow the work in a slice
- **Open `rig terminal open slice:<name-or-id> --follow --provider herdr`.**
Membership comes from pending, in-progress and blocked queue rows tagged with
that exact slice. Owners appear once with all their obligations listed;
unavailable owners and tile overflow stay named.
Handoffs retain the association unless their tags are replaced. New slice
views default to four automatic tiles; `--max-tiles` changes that. Known empty
slices stay following; incomplete reads retain the previous viewers. Pins,
drafts, unknown input and focus retain departed viewers, and closing a viewer
never stops its agent. Slice following targets the Linux Herdr 0.9.5 /
protocol 22 path. Mission following is not included.
([#2259](https://github.com/mvschwarz/openrig/pull/2259))
- **Named Herdr sessions use their configured endpoint throughout viewer and
follow operations.** The TUI's terminal action opens the agent, pod or rig
currently in view. ([#2205](https://github.com/mvschwarz/openrig/pull/2205), [#2232](https://github.com/mvschwarz/openrig/pull/2232))
- **Client-visible HTTP change:** `/api/terminal/viewer/ensure` now returns 400
for `view_required` and `unknown_provider`, where it returned 200 with an
error body. Clients should continue reading the body code. Missing slice
identities return 404; ambiguous or unavailable identities return 409.
([#2259](https://github.com/mvschwarz/openrig/pull/2259))
- **When a slice's folder name repeats across missions, tag its work with the
slice's unique ID** (`rig queue create --slice <id>`) or add
`--mission <mission>`. A row tagged only with the shared name cannot be placed,
so the view reads incomplete until it is.
([#2261](https://github.com/mvschwarz/openrig/pull/2261))
- **A join refused before a pane split can join on a later refresh.** Hold
notices retain the original seat, phase and reason. A created viewer whose
attach has not yet run can continue without replacing its pane.
([#2271](https://github.com/mvschwarz/openrig/pull/2271))
### Seats, accounts and harnesses
- **Expansion keeps the member settings accepted by `rig up`,** including env,
effort and startup settings. Restores retain the model saved with
`rig seat set-model`. ([#2250](https://github.com/mvschwarz/openrig/pull/2250), [#2227](https://github.com/mvschwarz/openrig/pull/2227), [#2228](https://github.com/mvschwarz/openrig/pull/2228))
- **Move a running Codex or Claude Code seat to an existing account home** with
`rig provider switch --seat <seat> --to-account <name> --account-home <dir>`;
preview with `--dry-run`. This starts a fresh conversation with a handover,
retaining the seat's address and work. `--clear-account-home` returns to the
configured defaults. ([#2143](https://github.com/mvschwarz/openrig/pull/2143))
- **Claude subscription/API-key switching has a same-conversation path:**
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`;
return with `--to-account plan --token-file <abs>`. In-flight work is
interrupted. The API-key path remains unqualified; verify the resulting
account and conversation. A new Claude member can instead name a prepared
subscription token in its spec with `claude_token: {label: name, file: /abs/token}`.
([#2191](https://github.com/mvschwarz/openrig/pull/2191), [#2246](https://github.com/mvschwarz/openrig/pull/2246))
- **Relaunch retains the selected seat and its observed executable.** Codex
identity reads preserve OS argument boundaries, and in-place restart can
recognize a verified Node launcher named `MainThread`. This does not establish
that the reported #2200 restart refusal is fixed. ([#2196](https://github.com/mvschwarz/openrig/pull/2196), [#2212](https://github.com/mvschwarz/openrig/pull/2212), [#2247](https://github.com/mvschwarz/openrig/pull/2247))
- **Managed seats inherit the daemon's SSH agent socket** for git over SSH;
check the daemon's agent with `ssh-add -l`. ([#2222](https://github.com/mvschwarz/openrig/pull/2222))
- **Community presets can report turns and accept a declared model.**
`runner: true` with `launch_result: true` records runner activity per launch.
`passes_model: true` passes the requested model as `OPENRIG_MODEL`; it does not
observe the effective model. The Cursor and Hermes examples leave this off
pending native qualification. These are opt-in preset fields, not a completed
integration with every harness. ([#2239](https://github.com/mvschwarz/openrig/pull/2239), [#2243](https://github.com/mvschwarz/openrig/pull/2243))
- **Draft integration author contracts** describe harness, runner and platform
duties, distinguishing implemented interfaces from proposals. Start at
[the author-contract entry point](docs/reference/author-contracts.md).
([#2272](https://github.com/mvschwarz/openrig/pull/2272))
### Delivery and Slack
- **Original queue wakes retry after a selection prompt clears.** Retries identify
OpenRig as the sender, workflow entry keeps its caller, and prompt-blocked
alerts remain routable when the original sender is on another host.
([#2235](https://github.com/mvschwarz/openrig/pull/2235), [#2229](https://github.com/mvschwarz/openrig/pull/2229), [#2237](https://github.com/mvschwarz/openrig/pull/2237))
- **Work sent to a terminal-only seat gets a recovery finding for a person.**
Cross-host `rig queue create --verify` reads the destination's receipt;
unreadable receipts remain unconfirmed without replaying the create.
([#2192](https://github.com/mvschwarz/openrig/pull/2192), [#2193](https://github.com/mvschwarz/openrig/pull/2193))
- **Slack button answers resume decisions parked on the person asked.** Replies
Slack places outside a deleted thread are recorded at their actual location,
so the next reply reaches the right conversation. ([#2245](https://github.com/mvschwarz/openrig/pull/2245), [#2240](https://github.com/mvschwarz/openrig/pull/2240))
- **Use `rig slack setup --reactions off` to stop acknowledgement emoji waking
agents.** The default remains `route`, and regenerating the app manifest
preserves the choice. Alert selection filters eligible notifications without
dropping older eligible asks behind an arbitrary row cap. ([#2241](https://github.com/mvschwarz/openrig/pull/2241), [#2183](https://github.com/mvschwarz/openrig/pull/2183))
### Status, inventory and help
- **A readiness deadline no longer ends a still-starting daemon.** The CLI
returns a nonzero pending result naming the child PID, launch endpoint and
log. It leaves the child running and retains the startup reservation, without
declaring readiness or allowing a duplicate start. Follow that pending
guidance before recovery. ([#2267](https://github.com/mvschwarz/openrig/pull/2267))
- **A verified exited process in a retained pane reads `pane_exited`,** instead
of running and ready. Inventory avoids discarded graph loads, scopes startup
history to the requested rig's nodes, and reuses node identity for activity
reads. These changes make no universal latency claim. ([#2248](https://github.com/mvschwarz/openrig/pull/2248), [#2225](https://github.com/mvschwarz/openrig/pull/2225),
[#2226](https://github.com/mvschwarz/openrig/pull/2226), [#2236](https://github.com/mvschwarz/openrig/pull/2236))
- **Completed mission labels reflect accepted proof**, without making proof
readiness complete a mission. Snapshot ages use UTC correctly; emoji stay
whole when TUI health, config and rig-table cells are shortened. ([#2249](https://github.com/mvschwarz/openrig/pull/2249),
[#2251](https://github.com/mvschwarz/openrig/pull/2251), [#2252](https://github.com/mvschwarz/openrig/pull/2252), [#2253](https://github.com/mvschwarz/openrig/pull/2253))
- **Smaller command fixes:** chatroom tmux rig names stay literal; a trailing
slash in `OPENRIG_URL` still identifies the same daemon; a trailing slash in
the missions root no longer hides a slice's mission. `rig host doctor` names
the entry replies actually reach. ([#2218](https://github.com/mvschwarz/openrig/pull/2218), [#2220](https://github.com/mvschwarz/openrig/pull/2220), [#2221](https://github.com/mvschwarz/openrig/pull/2221), [#2214](https://github.com/mvschwarz/openrig/pull/2214))
- **Help and contributor checks:** edge help explains deferred escalation
targets; section addressing ignores HTML comments; the guide covers shared
kernel-dashboard recovery. Install pins, help links and earlier release text
are corrected. Scenario fixtures honor send verification, execute tmux
directly, and reject unknown step verbs at schema load. ([#2204](https://github.com/mvschwarz/openrig/pull/2204), [#2211](https://github.com/mvschwarz/openrig/pull/2211),
[#2213](https://github.com/mvschwarz/openrig/pull/2213), [#2238](https://github.com/mvschwarz/openrig/pull/2238), [#2244](https://github.com/mvschwarz/openrig/pull/2244), [#2254](https://github.com/mvschwarz/openrig/pull/2254), [#2256](https://github.com/mvschwarz/openrig/pull/2256), [#2257](https://github.com/mvschwarz/openrig/pull/2257), [#2215](https://github.com/mvschwarz/openrig/pull/2215), [#2216](https://github.com/mvschwarz/openrig/pull/2216),
[#2217](https://github.com/mvschwarz/openrig/pull/2217))
### Known limits
- **The Codex hook-refresh refusal in [#2200](https://github.com/mvschwarz/openrig/issues/2200)
remains unresolved.** If `rig seat restart <seat> --keep-conversation` refuses,
nothing is stopped: leave the seat running and report the refusal with CLI
and daemon versions. A refused restart has not refreshed the hooks.
- **A viewer whose attach already ran and then exited back to a shell is not
restarted automatically.** The whole slice view can hold until that tile is
resolved. No tested workaround is offered for that case.
- **Following protects uncertain input by retaining tiles.** A larger workspace
or explicit `--max-tiles` can help with overflow. Protected Codex delivery can
still retain messages it cannot confirm before paste; retained is not
delivered. Disabling its typing guard removes protection and is not recovery.
- **Internet-facing hosted members remain experimental, unsupported, off by
default and at your own risk.** Slice following does not require them.
### Credits
Thanks to @seanmcn, @DeryFerd, @Aschenbath, @Vaishnavi220506, @bradvin,
@jay23606, @rudycelekli and @Nikhi00718 for merged contributions, and to
@akushonkamen, @jcmurrayii, @KM199 and @pdurlej for contributed changes, reports
and reproductions carried into this release.
## [0.7.5]
OpenRig 0.7.5 adds `rig terminal open qitem:<id> --follow`: keep one terminal
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.7/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.7/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.7` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/scripts/install.sh | sh
```
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
+74
View File
@@ -0,0 +1,74 @@
# Integration author contracts — DRAFT for 0.7.6
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
1. **[Harness and runner](harness-runner-contract.md):** launch the agent, report its conversation identity and activity, deliver input, handle permission requests and cancellation, and reopen the requested conversation.
2. **[Execution and platform](execution-platform-contract.md):** choose placement, deliver files and credential references, distinguish owned from attached resources, preserve native state, return artifacts and reconcile shutdown.
These are draft author responsibilities. Some use implemented OpenRig interfaces; others describe a proposed integration whose code remains with its author. Including the guide in release documentation does not make every described capability available.
## Candidate and support status
This draft targets the **unsealed 0.7.6 candidate**. Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. Later release repairs and version text are still being assembled; no final tag or package publication is asserted here. References below use the immutable base instead of a moving branch.
| Area | Status at this baseline | What the status means |
|---|---|---|
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
| `runner: true` with `launch_result: true` | Implemented candidate source | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
| `passes_model: true` | Implemented candidate source | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
| The dsh/ACP input, cancellation and refusal-first permission recipe | Proposed author implementation | No end-to-end dsh integration is established. Native reopen capabilities and the tools-off case remain implementer questions. |
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
| Environment preparation, projection, artifact export and owned-resource cleanup | Measured in a private wrapper; generic seam proposed | The platform chapter makes the responsibilities reusable. Core does not provide a generic platform driver or `DeploySpec` loader at this baseline. |
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
## How the pieces compose
```mermaid
flowchart TD
R["RigSpec: members, relationships, work arrangement"] --> A["AgentSpec: role, context and startup declarations"]
R --> H["Built-in runtime or community preset"]
H --> N["Harness / runner: native conversation and control"]
H --> P["Author platform integration: placement and resources"]
P --> N
P --> F["Workspace, retained state and returned artifacts"]
R --> S["Optional application services"]
```
**RigSpec remains the composer.** AgentSpec describes reusable agent intent; the runtime or preset selects how the native agent starts. An integration can surround that launch with an environment-specific wrapper. A bundled application's database or Compose service is software the team operates; it is distinct from the environment in which an agent executes.
The terminal provider, native harness protocol and network connection also have different jobs. A terminal provider carries input and output; a runner understands native turn boundaries; a granted MCP or other connection exposes coordination operations. An HTTPS connection does not by itself provision compute, awaken a suspended application or establish native resume. Each integration states which of those effects it implements.
| Owner | Responsibility |
|---|---|
| OpenRig Core | Team and work identity, queue records, existing launcher contracts, activity interpretation and declared configuration. |
| Harness/runner author | Native launch and protocol, exact native reference, prompt framing, truthful activity, permission/cancel behavior and native resume. |
| Platform integration author | Placement binding, destination projection, resource ownership, state transfer, artifact delivery and resource reconciliation. |
| Operator / credential owner | Selected environments, grants, provider credentials and spending authority. |
The queue records who owns work. It does not prove that an old provider process has stopped, that a new runtime consumed its history, or that guest bytes reached the consumer. Those observations belong in the launch, resume and export receipts alongside the work record. This division reuses the queue rather than creating another task system.
## Start from an existing example
For a terminal harness, begin with the pinned [community preset reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md) and the [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
For a contained worker, the [platform composition](execution-platform-contract.md#a-minimal-composition-using-existing-surfaces) pairs an ordinary coordinator with a worker preset. It uses existing member and preset fields. The wrapper must prepare or attach to the selected environment explicitly; declaring a runtime does not provision it. Its illustrated member list is a fragment, and its launcher is a skeleton, not a ready-to-run provider package.
A rig bundle can carry declarations, roles and supporting scripts. Do not confuse those files with hydrated image layers, installed binaries, registered presets, credentials or live sessions. State what the recipient must supply.
## Lifetime and recovery
**Persistent** and **Non-persistent** describe execution lifetime. A non-persistent invocation has an explicit completion condition tied to its assigned work. **Durable** describes interruption recovery and is independent of that lifetime. An agent can run briefly and still resume its retained conversation later.
Retaining a workspace and reconstruction material can restore useful competence without restoring the same native conversation. Label that as reconstruction. Native resume additionally requires the correct harness-qualified reference and supported native state. The platform chapter's measured example restored copied history into recreated containers on the same host; it does not establish new-host migration or RAM restoration.
## Questions contributors can help resolve
- At a pinned dsh version, which advertised method reopens a session, and can a useful first case run without permission requests?
- Which additional harness would justify a shared protocol runner rather than a separate wrapper?
- Which provider effects can use the existing projection, native-reference and lifecycle dispatch, and which need a new maintained interface?
- What exact state and ownership must travel when compute is recreated or moved?
These questions remain part of the draft. An unanswered contributor thread does not prevent authors from using the implemented interfaces or reviewing the proposed responsibilities. Compatibility claims should name versions, observed behavior and unsupported operations; they should not imply a support guarantee from a manifest alone.
@@ -4,6 +4,9 @@ A community harness is a coding harness an outside contributor maintains. You in
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
harnesses, and it reads their activity only from what the screen shows.
For what an integration author owns end to end, including a runner's duties and running a harness somewhere else, see
the draft [integration author contracts](author-contracts.md).
## What a community harness gets
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
@@ -182,6 +185,8 @@ one at a time and in order, without making the agent wait. It finds the address
- **The screen still counts,** but only as evidence of work: on its own it never reads the seat idle.
- **The generation attributes a post; it isn't authentication.** A post with an earlier occupant's generation doesn't
count for the live seat. The hook token is one local token, so any process that holds it can post for any seat.
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
[harness and runner contract](harness-runner-contract.md).
## An example: Hermes
@@ -0,0 +1,138 @@
# Execution and platform integration contract — draft
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. Inclusion of this draft in 0.7.6 documentation does not implement a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
## Keep the identities and responsibilities separate
RigSpec composes members, their AgentSpecs/profiles, runtimes, working directories and relationships. AgentSpec supplies reusable role, startup and context declarations. A built-in runtime or registered community preset supplies a launch path. An environment integration supplies placement and provider-specific resource handling around that path. Existing declarations do not automatically provision a remote filesystem, credentials or compute.
| Identity or handle | Meaning and responsibility |
|---|---|
| Stable seat and work identity | The member's canonical address/node and owned work records. OpenRig retains this identity across executions; an environment IP or display name does not replace it. |
| Native session identity | The harness's conversation token/UUID, qualified by harness and resume-reference type. The harness owns its format and resume semantics. |
| Environment binding | The integration's durable record linking placement, immutable provider resource IDs, native-state location, projection and ownership. It is separate from the native session token. |
| Execution generation | A particular launch/process instance, with observable process/start identity when available. A new process can resume an old conversation. |
| Viewer/client | A terminal attachment or connection to an execution. Closing it need not stop either the native process or compute. |
An application service bundled with a rig is a separate responsibility. An artifact browser or database in `RigSpec.services` serves the team's application; its presence does not place an agent inside that service. Bundling service definitions also does not bundle image bytes, native binaries, authenticated homes or live runtime state.
## A minimal composition using existing surfaces
The following is a **fragment of one pod** (`pods[].members`), using existing RigSpec fields, not a complete standalone RigSpec:
```yaml
pods:
- id: dev
label: Development
members:
- id: coordinator
agent_ref: local:agents/coordinator
profile: default
runtime: claude-code
- id: worker
agent_ref: local:agents/worker
profile: default
runtime: example-contained-claude
```
The worker runtime names an author-installed community preset. This preset shape uses existing fields exercised on the 0.7.4 baseline:
```yaml
format: 1
name: example-contained-claude
maintainer: Integration author
command: ./scripts/launch-contained-worker
launch_result: true
resume: true
startup_assisted: true
limits:
- Requires an explicitly prepared environment binding and guest projection.
- Native resume is qualified only for the documented retained-state recipe.
- Viewer closure does not implement turn cancellation or resource deletion.
```
This is an integration skeleton, not an installed or qualified preset. The author must supply the executable, resolve its path against the actual launch working directory, register the preset in the existing OpenRig home, and prepare the member's host working directory. The launcher implements the preset's declared launch-result and typed-resume behavior; the runner chapter defines those mechanics. `startup_assisted` requires a startup-submission check; it does not hold back submission or establish successful context delivery or ingestion. A member's model setting is not automatically honored by a custom launcher: its resolved native model/effort must be explicit in that launcher's supported configuration. With `passes_model: true`, Core passes the declared model through `OPENRIG_MODEL`; the launcher must map and apply it. The effective model remains unknown to Core.
For a contained worker, the launcher may resolve a private binding, create or attach to the selected resource, project explicit inputs, then connect to the native process inside it. In the measured recipe, OpenRig ran on the host, the plain coordinator used the built-in native runtime, and the worker used a private community launcher. The wrapper performed projection, provider-reference delivery, resume validation and artifact export explicitly. Those effects were not supplied by the preset declarations themselves. The integration must not assume host guidance/skills, hook paths or environment variables automatically reach a community runtime's guest.
A downstream author can implement this composition today around a qualified provider API and native harness. It remains an author-maintained integration until maintained platform support is separately implemented and documented. Validate against the target OpenRig schema and effective launch configuration; a parsed field or successful process start alone does not establish its effect.
## Placement and launch receipts
Before mutation, resolve the selected host/provider, workspace, image/native versions, reachability, credential references, resource request and ownership. Record existing resources and the selected resource scope. Host headroom includes supervisors, gateway and other team members; a workload memory request is not proof of their combined cap. Report requested, admitted and observed resources separately. Do not infer enforced CPU/memory or isolation policy from accepted flags.
Persist a launch intent before a create operation. Use an attempt/retry identity where the provider supports it; otherwise reconcile the original operation before another create. A public binding receipt carries only nonsecret values. If the harness's native resume reference works like a bearer credential (anyone holding it can reopen the conversation), keep it in secret-held receipt data, never in the public receipt. A useful public receipt identifies:
- canonical seat, harness version and native resume-reference type;
- provider/host/workspace, immutable environment ID and execution generation;
- image/policy versions, requested resources and observed readiness;
- inner working directory, private native-state location and projection manifest;
- a public-safe native reference (never a bearer-like resume token), creator or attached-consumer disposition, and exact owned resource handles;
- lifecycle operations supported, completion observations, pending effects and errors.
A resource name or IP can be reused. Reconcile immutable ID, workspace and any available generation/fingerprint before acting. A lost response is **unconfirmed**, not success or absence. Record provider failures without exposing credentials. Readiness should identify what is ready: provider reachable, filesystem projected, native process started, team identity authenticated, or task completed.
## Project files and credential references deliberately
Stage only declared inputs: role/startup material, selected skills, current work context, schemas and required native settings. Preserve unmanaged files and report conflicts. Review every path-bearing setting for its destination: host-only hook scripts, interpreters, status collectors and loopback URLs may not exist or be reachable in the guest. Missing activity or permission transport must remain unsupported rather than being inferred from the base harness name.
Distinguish three observations: host staging, destination delivery and native ingestion. A destination manifest can record relative path, byte count and hash; a useful task using the current context revision/nonce can show consumption. Neither observation substitutes for the other. Current working input may change between tasks while an earlier artifact stays immutable.
Retained native history is a different data class from ordinary project context. Preserve its exact bytes through the harness's supported resume path; do not edit records or synthesize a conversation. Preserve a complete checkpoint prefix with byte length/hash if the harness may append after the checkpoint. Record which native-state files are required and which remain unavailable. Never substitute a fresh conversation seeded with a report while calling it native resume.
Keep secret values out of RigSpec, preset YAML, bindings, argv and public receipts. The provider/credential owner supplies scoped references or native-supported credential helpers and controls revocation. A successful login, provider registration or model-list request is not evidence of usable inference credit or an authorized spend envelope. Do not infer subscription portability from a provider name or copy an authenticated owner home into a guest.
Authenticate guest coordination through the actual granted member route. A shared source IP is not a seat identity. Record the grant-bound principal and scope separately from resource placement; distinct principals do not imply per-seat row ACLs or isolation between co-located secrets.
## Own only the effects assigned to the integration
Record **created** versus **attached** for each launch receipt. The creator may stop/delete the exact resource it created under the selected policy. An attached consumer normally closes only its own client and any separately identified process it owns; it must preserve the external environment, native session, workspace and shared services. Attachment must not silently transfer provider custody. An explicit owner-authorized transfer is a separate recorded action.
Describe these effects independently:
| Operation | Required distinction |
|---|---|
| Cancel a turn | Ask the runner to stop current work and observe its result. Unsupported cancellation must not be replaced silently by killing compute. |
| Close a viewer | Release that client/connection; report whether native execution continues or is unknown. |
| Stop native execution | End the identified native process and observe its exit. This is separate from task success. |
| Stop compute | Release the documented provider allocation; identify retained storage and actual provider/process state. |
| Delete an owned environment | Remove the exact creator-owned resource and reconcile final absence, including provider-managed dependents. |
Use graceful shutdown first where supported, then only explicitly owned, identified handles for stronger termination. Never equate pane disappearance with process or resource release. Persistent/non-persistent lifetime describes when execution should stop; durability describes recovery. An explicit non-persistent completion condition must name its invocation/work scope. An unrelated queue closure or an empty queue is not permission to terminate a member, and no new lifecycle YAML syntax is specified here.
## Resume exactly, or return an honest disposition
A resume request identifies the seat, harness-qualified native reference, prior binding, expected environment boundary and retained state. Validate those before launching the native harness. Reject mismatched reference types, missing/changed history, wrong immutable resources, unavailable required files and duplicate active executions. Never use “latest” as a replacement for an exact requested conversation or silently start fresh on failure.
Separate the outcomes: same native conversation resumed; new conversation rebuilt from artifacts; fresh launch; unsupported; refused; or unconfirmed. Environment continuity has its own outcome: same retained environment, recreated compute with copied state, different host, or unknown. A matching UUID alone does not show that native history was consumed.
If a provider stops exposing file transfer after canonical-process exit, capture artifacts/history and publish any retained resume descriptor **while transfer is still available**. Verify destination bytes before shutdown. After completion, use host/provider observations instead of assuming guest exec still works. For recreated compute, explicitly transfer the retained checkpoint and required artifact into the successor environment and validate them before publishing its resume descriptor. A new environment ID is expected and must be recorded as a successor, not disguised as the old environment.
The measured private recipe demonstrated this latter boundary: the original sandbox was deleted; copied native history and task bytes were restored into new containers on the same physical host; the native harness launched with `--resume` and the retained UUID. Its first new reply recalled a private conversation-only marker before tools, with no marker in the new input and no native-history tool read. It then verified artifact 1, used changed task context, produced artifact 2 and returned it for independent coordinator verification. This establishes one bounded native-return/work result. It does **not** establish new-host migration, RAM restoration, same-container stop/start, cgroup enforcement, general portability or shipped platform support.
## Export results before teardown
A guest path/hash note means bytes exist in a guest; it is not delivery to the consumer. Identify the actor holding the provider transfer handle. In the measured recipe that actor was the host wrapper, not the guest worker.
The worker publishes its report once and records the task/path/hash on its existing child. The wrapper validates the binding, task, allowed path, file type, size and expected hash; transfers to a fresh host staging path; checks the returned bytes/schema/context; and publishes without overwriting an existing result. It records an actual consumer-accessible destination, hash, byte count and actor through the authenticated work route. The worker closes its child after the matching export receipt; the coordinator independently reads/verifies those bytes before closing its parent. An ambiguous transfer is reconciled by existing bytes/hash, not a new report or duplicate task.
Complete exports before resource deletion. If output cannot be returned, preserve the exact resource/owner and failure state. Cleanup then reconciles the exact owned workload, supervisor, volumes and provider records to the intended stopped/absent state. Keep reused engines, caches, authenticated environments and attached resources. Revoke scoped credentials/grants through their owners and record remaining state. “Delete requested” and “absence verified” are different receipts.
## Proposed generic platform seam — not implemented
The existing composition need not acquire another YAML format to describe these responsibilities. If a maintained platform seam is selected, the following **design pseudocode** names the smallest useful operations; it is not a loader or API available in this release:
```text
prepare(placement, ownership, resourceRequest, credentialRefs) -> bindingReceipt
project(binding, explicitFiles, nativeCheckpoint?) -> deliveryReceipt
launch(binding, harnessLaunch, exactResumeRequest?) -> executionReceipt
observe(binding, execution) -> observedState | unsupported | unconfirmed
export(binding, artifactRequest) -> publicationReceipt
shutdown(binding, execution, selectedEffect) -> effectReceipt
```
The provider implementation interprets its image/network/compute configuration. The runner interprets native protocol and control. Core would retain typed provenance and route the selected effects; it must not reinterpret arbitrary provider keys, leak secrets or collapse ownership into a stop boolean. Each mutating operation needs an attempt identity and reconciliation route; partial receipts remain useful after failure. Capability reporting must expose unsupported projection, cancellation, resume or cleanup rather than letting method names imply support.
The most useful author evidence follows the real work path: exact resolved placement and native launch; delivered current context; authenticated work ownership; returned artifact bytes; exact resume boundary and consumed retained state; and final resource disposition. A parser pass, launcher success, viewer reconnect or same display name leaves the corresponding effect unconfirmed. These distinctions let an author implement and diagnose an integration; they do not create a new release gate or require another runtime campaign.
+8 -7
View File
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.7/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
@@ -859,9 +859,10 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
kernel's `operator.human` terminal seat. Restoring the operator, advisor and
queue worker does not also restore an unselected terminal. Read
`rig ps --nodes --rig kernel --json` and use the terminal's registered
`canonicalSessionName`; the built-in layout uses `operator-human@kernel` in the
examples below. Check the actual pane with `rig capture operator-human@kernel`:
a persisted running row alone does not prove that its tmux session is alive.
`canonicalSessionName` for the `operator.human` row. Replace `<dashboard-session>`
in the commands below with that name. Check the actual pane with
`rig capture "<dashboard-session>"`: a persisted running row alone does not prove
that its tmux session is alive.
A daemon or transport error is not proof that the terminal is absent.
- If only the viewing window closed and the TUI is still running, reopen it
@@ -871,14 +872,14 @@ A daemon or transport error is not proof that the terminal is absent.
- If the terminal session itself is absent, start only that existing seat:
```sh
rig seat launch operator-human@kernel --fresh --reason "recover missing shared kernel dashboard"
rig seat launch "<dashboard-session>" --fresh --reason "recover missing shared kernel dashboard"
```
The terminal has no agent conversation to resume. Its fresh-start action
runs `rig tui` after the shell is ready; sibling agent sessions stay
unchanged. This command intentionally omits `--stop`, so it refuses to
replace a live occupant. Check the result before repeating it, then run
`rig capture operator-human@kernel` to confirm the TUI and
`rig capture "<dashboard-session>"` to confirm the TUI and
`rig ps --nodes --rig kernel --full` to check the seats. Once the dashboard
is running, open its view with `rig terminal open saved:kernel --window`.
+242
View File
@@ -0,0 +1,242 @@
# Harness and runner contract (DRAFT for 0.7.6)
> **Status.** This is a draft for contributors.
> - **In the 0.7.6 candidate, not yet released:** the `runner` field and OpenRig's side of declaration, identity and activity
> (sections 2-4).
> - **Proposed runner-author behavior:** the input, cancellation and permission recipe in sections 5-7, and the
> runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
> handoff. These interfaces do not establish that a particular runner implements them correctly.
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
>
> No harness has been run end to end under this contract yet. The first candidate is dsh over ACP.
>
> Candidate scope and pinned references are in the [joined author guide](author-contracts.md). The
> [community presets reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md)
> describes the implemented fields at that base; this chapter also labels proposed author behavior.
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
the same harness can run in many places, and one place can host many harnesses.
## 1. Plain preset or runner preset?
| | Plain community preset | Runner preset (`runner: true`) |
|---|---|---|
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) |
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input |
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt |
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably |
**If your harness works well as a terminal app, a plain preset is enough. Use a runner only when you can report state
honestly.** A runner that guesses is worse than a quiet screen read as unknown.
**Vocabulary.** "Preset", "runner preset", `launch_result`, `resume`, `first_input` and `passes_model` are existing
terms at the candidate base. Broader names you may see in design discussion, such as *HarnessSpec*, are **proposals**, not file
formats or loaders that exist.
## 2. Declare it: what the preset says, fixed at launch
```yaml
# $OPENRIG_HOME/community-harnesses/dsh.yaml
format: 1
name: dsh
command: dsh-seat # your runner; it starts the agent itself
maintainer: <you>
runner: true # in the 0.7.6 candidate
launch_result: true # required with runner
resume: true # only after you've checked a real reopen and a refused missing one
limits:
- permission requests are refused (first version)
- a prompt whose whole text is /abort can't be sent
- fork unsupported
```
- **`runner: true` requires `launch_result: true`,** and can't be combined with `names_conversation`.
- **The declaration is fixed at launch.** Each launch, a restore's included, records what the preset declares at that
moment. Editing the preset changes the next launch, not a running seat.
- **A preset without `runner` behaves exactly as today,** and so does a session recorded before the field existed.
- **The other fields keep their existing meanings:**
- `first_input: true` passes the seat's first startup turn in a file named by `OPENRIG_FIRST_INPUT`;
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`.
## 3. Identity: report the conversation once, in the launch record
- **When:** after the agent has opened or reopened its conversation (for ACP: after `session/new`, `session/load` or
`session/resume`).
- **How:** write the attempt's launch record to the path in `OPENRIG_LAUNCH_RESULT`, with the attempt from
`OPENRIG_LAUNCH_ATTEMPT`. The record is either the conversation:
```json
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "nativeRef": "<conversation id>", "producer": "dsh-seat 0.3.0; dsh 0.2.1-rc.2; ACP 1"}
```
or the agent's own word that it doesn't exist:
```json
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "error": "missing", "producer": "dsh-seat 0.3.0"}
```
- **`producer` is free text** for your versions. There are no structured version or capability fields.
- **The launch record is the only identity channel.** Don't report identity on the activity endpoint.
- **No record means attention.** For a runner preset:
- **a new launch** with no usable record within OpenRig's wait leaves the seat needing attention, not ready. So does a
new launch where OpenRig can't create the record's folder: the runner still starts, without `OPENRIG_LAUNCH_RESULT`;
- **on a resume,** a folder OpenRig can't create means nothing starts, because the launcher can't be told what to
reopen. When the folder exists but no usable record arrives, the resume command has already been sent, and the
resume reads unconfirmed (section 8).
- **OpenRig shows this identity as launcher-reported.** It doesn't observe the conversation itself.
## 4. Activity: report turn boundaries
Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer <token>`.
- **Finding the base and token:** take them from `OPENRIG_URL` (or `OPENRIG_PORT`, with `OPENRIG_HOST` defaulting to
`127.0.0.1`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else from `$OPENRIG_HOME/activity-endpoint.json`.
- **Posting:** post one at a time, in order, with a short timeout. Never make the agent wait on a post.
```json
{"sessionName": "dev-builder@my-rig", "nodeId": "<node id>", "runtime": "dsh",
"generation": "<OPENRIG_OCCUPANT_GENERATION>", "sequence": 42, "occurredAt": "2026-10-29T12:00:00Z",
"hookEvent": "UserPromptSubmit", "subtype": "accepted", "turnId": "7"}
```
| When | `hookEvent` / `subtype` | The seat reads |
|---|---|---|
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
| The agent process ended | `Stop` / `<preset name>_exited` | unknown, **never idle** |
| Your runner or the agent hit an error | `Notification` / `runtime_error` | recorded, but not used as activity: the prior activity evidence keeps its usual lifetime. It doesn't itself request or clear attention |
- **Liveness:** state `livenessSeconds` (a whole number of seconds from 1 to 3600; any other value is ignored) on every
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
non-exit `Stop` or a beat while holding permission attention.
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
- **"Accepted" means your runner submitted its Nth prompt.** It isn't the agent's own acknowledgement, and it isn't
tied to a particular `rig send`.
- **The generation attributes a post; it isn't authentication.**
- A post carrying an earlier occupant's generation never counts for the live seat.
- The hook token is one local token, so any process holding it can post for any seat.
## 5. Input: one submitted message is one prompt
Sections 5-7 specify the proposed first dsh/ACP runner recipe. They do not change permission or input handling in
existing built-in runtimes or require every possible runner protocol to use ACP.
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
- **What arrives:** `rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
is **one block**, newlines included. Send it to the agent as **one** prompt (for ACP, one `session/prompt`).
- **Never split a block,** and never act on lines inside it.
- **Control input:** the only control is a block that is exactly `/abort`, or Ctrl-C typed outside a paste.
- Ordinary sends and reminders arrive inside a From/To envelope, so they never equal `/abort`, whatever lines they
contain.
- **A message that arrives mid-turn** waits for the next turn. Report it as queued, never as steering the current turn.
- **Limits to keep and to list under `limits`:**
- a prompt whose whole text is `/abort` can't be sent as a prompt;
- a Ctrl-C byte inside a paste also aborts;
- without a TTY, input is read line by line;
- this isn't a binary-safe channel.
## 6. Cancel: requested isn't the same as done
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
and record when you sent it.
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
Then post `Stop` / `cancelled`.
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
## 7. Permissions: refuse every request (first version)
- **Refuse at once:** answer every permission request immediately with a refusal (for ACP,
`{"outcome": {"outcome": "cancelled"}}`).
- **Make it visible:** show what was asked in the pane, and post `Notification` / `permission_prompt`.
- **Hold that attention** as your last post through the end of the turn, so the seat reads needs input. Don't post the
turn's `Stop` or idle beats while holding it.
- **It clears** when your next prompt is accepted, or when an operator clears it
(`rig seat clear-attention <session> --reason "…"`).
- **Never leave a request pending.**
- **Not in the first version:**
- answering from the seat's permission setting;
- answers typed into the pane;
- routing an approval to another seat.
- So a job that needs an edit or a command approved can't finish under this version. Agents that can run without
asking are the best first case.
- **Unknown requests from the agent:** answer with JSON-RPC `-32601` and carry on.
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
- On a resume, OpenRig passes the conversation to reopen in `OPENRIG_RESUME_REF`. Use the method your agent
advertises: ACP's `session/load` (capability `loadSession`), or your agent's own reopen method, named with its
capability and pinned version in your `limits`.
| What happened | Write in the launch record | What OpenRig does |
|---|---|---|
| The same conversation reopened | the same `nativeRef` | resumed |
| The agent says it doesn't exist | `error: "missing"` | reports it missing; nothing new starts |
| Anything else: a timeout, an error, or the working folder no longer matches the conversation's | no record, or an error other than `missing` | **unconfirmed**: the seat keeps its conversation and needs attention |
| A different conversation opened | the other id | a mismatch; OpenRig doesn't switch to it |
| OpenRig couldn't create the record's folder | (nothing to write) | nothing starts |
- **Never start a new conversation on a resume.**
- A conversation saved under another harness's runtime is never handed to your launcher.
## 9. Context, model and shutdown: whose job is what
| Responsibility | OpenRig does | Your preset or runner does |
|---|---|---|
| **First startup turn** | Writes it to a private file and passes `OPENRIG_FIRST_INPUT` (with `first_input: true`) | Gives those exact bytes to the agent through its own first-prompt route |
| **Guidance, skills, startup files** | Doesn't place them for community harnesses; a launch warns that they weren't delivered | Puts whatever the agent needs where *your* harness reads it, or says in `limits` that it isn't supported |
| **Model** | Passes the seat's declared model as `OPENRIG_MODEL` (with `passes_model: true`); never substitutes one | Maps it to the harness's own setting. OpenRig shows the effective model as unknown |
| **Placing the agent somewhere else** (a sandbox, another machine) | See the [execution platform chapter](execution-platform-contract.md) | Keeps the runner independent of where it runs |
| **Shutdown** | `rig seat stop` and `rig down` end the seat's tmux session at once: no graceful stop handshake reaches the runner, its pane just closes. OpenRig ends nothing outside that session, and does no external provider cleanup | **(Proposed)** When the runner's pane closes (a hangup) or it's asked to stop, it attempts where possible to cancel any running turn and end the agent process it started, and lists in `limits` anything that may keep running. Cleanup of external compute needs the platform owner to reconcile it |
## 10. What counts as evidence
- **Keep three things separate:**
- **`rig send --verify`** checks the pane before and after a send. It's pane evidence, not proof the agent received
or finished anything. Mirror each accepted prompt in your pane (for example `you ▸ …`); OpenRig doesn't yet promise
that `--verify` recognises it.
- **A turn ending isn't the work being done.** Task completion is whatever the work's own record says: a queue task
marked done, or an artifact written.
- **Your own tests** should run against a fake agent that covers each section above.
- **The useful real check** for a maintainer:
- a seat starts and reads ready only after the handshake;
- one multi-line message becomes one prompt;
- the seat reads working, then idle;
- a refused permission reads needs input;
- `/abort` ends a turn as cancelled;
- a resume reopens the same conversation, and a missing one is refused, never replaced.
That's the same promotion test the presets reference describes, applied to these declarations. Passing it is
compatibility evidence for the versions you name, not certification.
## Contributor checklist
- [ ] The preset sets `runner: true` and `launch_result: true`, and not `names_conversation`.
- [ ] After the agent opens or reopens its conversation, the runner writes the launch record, with versions in `producer`.
- [ ] Activity posts carry `sessionName`, `nodeId`, `runtime`, `generation`, `sequence`, `occurredAt`, `hookEvent` and
`subtype`. They're sent in order and never block the agent.
- [ ] One submitted message becomes exactly one prompt. Only a bare `/abort` block, or Ctrl-C outside a paste, is control.
- [ ] Cancel records "requested" when sent, and "cancelled" only on the agent's own stop reason.
- [ ] Every permission request is refused at once, shown, and held as needs input until the next accepted prompt.
- [ ] An agent exit posts `Stop` / `<preset name>_exited`, never a plain idle.
- [ ] A resume reopens the exact conversation. It reports `missing` only on the agent's word, and otherwise leaves it
unconfirmed.
- [ ] Unknown agent requests get `-32601`.
- [ ] `limits` names the input limits, the permission limit, what keeps running after a stop, and anything not supported.
## Open questions
1. **For the dsh maintainer:** which method and capability does dsh advertise for reopening a session, at a pinned
version: `session/load` with `loadSession`, or `session/resume`?
2. **For the dsh maintainer:** can dsh's ACP profile run with tools off? A first journey that needs no permission
answers is the simplest to check.
3. **For OpenRig:** should a later version route permission requests to a chosen seat, as `rig acp run` already does
with queue tasks, instead of refusing them?
4. **For OpenRig:** once a second ACP harness exists, should one shared ACP runner live in OpenRig rather than one per
harness?
+1 -1
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.7`. 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.
+10 -3
View File
@@ -320,8 +320,14 @@ name or unique frontmatter `id`. Tags must name it exactly: `rig queue create
--slice <name-or-id> ...` associates a row. Handoff inherits those tags unless
replaced; merely linking an untagged child does not associate it. A mission-only
tag does not put a row in every slice. No project is inferred from a tag or the
caller's working directory. Duplicate names or aliases require disambiguation
in the configured index; no first-match scope is selected.
caller's working directory. A bare name or ID claimed by multiple slices is
`scope_ambiguous`; no first-match scope is selected. Selecting a unique key can
still follow a slice whose other alias is reused: a row carrying that colliding
alias must have exactly one `mission:` tag naming exactly one indexed claimant.
Rows placed in another mission are excluded. Missing, contradictory or otherwise
unplaceable mission/alias tags make the snapshot incomplete, retaining previous
viewers. A unique alias on its own retains exact matching. Flat legacy slices
without an indexed parent mission cannot disambiguate a colliding alias.
Slice keys are case-sensitive and can include Unicode, plus signs and internal
spaces. They cannot be empty or contain slash, backslash, control characters,
@@ -380,7 +386,8 @@ Error bodies remain authoritative. `scope_unsupported` is HTTP 400,
`scope_not_found` is 404, and `scope_ambiguous`/`scope_unavailable` are 409.
An incomplete queue snapshot is a successful preview with `task.complete:false`;
ensure returns `viewer_unconfirmed` without a provider effect. Unsupported
ensure view kinds retain `view_required` (400). Unsupported follow view kinds
ensure view kinds now return `view_required` (400), where they previously returned
200 with an error body. Unsupported follow view kinds
retain `follow_view_required` in a 200 `ok:false` body, as before. A changed
preview remains `preview_changed` (409). During automatic refresh a missing or
ambiguous slice retains the existing tiles and names the source problem in the
+152 -178
View File
@@ -1,192 +1,166 @@
# What changed in OpenRig 0.7.1
# What changed in OpenRig 0.7.7
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.7. OpenRig
0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats. If you
upgraded from 0.7.5 or earlier, also read "What changed in OpenRig 0.7.6" below.
For the full capability map, read
`rig context get onboarding-width/public-what-you-can-do.md`. After the upgraded
daemon starts, `rig context get reference/whats-new.md` describes this version.
Without a running daemon, read the packaged `daemon/docs/reference/whats-new.md`;
`$OPENRIG_HOME/reference/whats-new.md` can still be the previous copy until startup.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
`daemon.db_conflict` (`warn`). None is removed.
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
- Restart the daemon (`rig daemon stop`, then `rig daemon start`) to load the new
code. There are no database migrations.
- The bundled openrig-core plugin stays at 0.1.23 with unchanged files, so there
is no managed plugin refresh.
## New things you can do
## The fixes
- **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 Claude seat at an empty prompt reads idle again when Claude prints
"✔ Update installed · /restart to apply" (or "Restart to apply") below its mode
bar after installing an update. Before, that line hid the mode bar, so the seat
read unknown and managed compaction waited for an idle prompt it could not see.
Both spellings are recognized; lines that only look similar still read unknown.
([#2311](https://github.com/mvschwarz/openrig/pull/2311))
- Managed compaction checks the input box before it pastes its preparation
prompt, `/compact` or a restore prompt, even with the typing guard off. Typed,
unreadable or changed input holds that step. A manual `rig compact` then says it
stopped to preserve the input box; read `rig compact <seat> --state` and retry
once the box is empty. A held restore prompt waits and goes ahead on its own once
the box is empty. An empty box or a high-confidence suggestion goes ahead.
Ordinary `rig send` and the typing-guard defaults are unchanged.
([#2347](https://github.com/mvschwarz/openrig/pull/2347))
- A Claude seat whose `claude` is a `#!` wrapper that starts the real Claude without
`exec` can be rebuilt again (`rig seat handover --source rebuild`): readiness also
accepts the wrapper's direct child when it names this launch's conversation token,
and refuses every other process as before. A rebuild that fails no longer leaves
the seat's resume target on the new, unprimed conversation.
([#2341](https://github.com/mvschwarz/openrig/issues/2341), [#2354](https://github.com/mvschwarz/openrig/pull/2354))
## Behaviour that changed
## Limits to keep in view
- **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.
- Other reasons managed compaction can wait are unchanged.
- The input-box check is a read just before the paste, not a lock: typing that
starts in the same moment can still be appended to.
- If a seat's current occupant generation was never registered (an adopted Claude
with another generation, or a managed launch whose tenure record failed to write),
hooks don't move its resume token after the first one.
- The 0.7.6 limits below still apply.
## Output that changed shape
---
- **`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 changed in OpenRig 0.7.6
## What to stop doing
The changes from 0.7.5 to 0.7.6, for an install upgrading from 0.7.5 or earlier.
- **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.
### After the upgrade
## Known gaps in 0.7.1
- Restart the daemon (`rig daemon stop`, then `rig daemon start`) to load the new
code. Two database migrations record account-home selection and whether a
community harness launch declared a runner. Both use prefix 141, with distinct
full filenames.
- The bundled openrig-core plugin goes from 0.1.22 to 0.1.23. Its Claude activity
hook clears permission marks after tool batches. Managed refresh preserves
replaced files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply
local edits you still need from the preserved copy.
- **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`.
### Follow a slice's work in one workspace
Use `rig terminal open slice:<name-or-id> --follow --provider herdr`. Membership
comes from pending, in-progress and blocked queue rows tagged with the exact
slice. Associate a row with `rig queue create --slice <name-or-id>`; a handoff
inherits its tags unless replaced. A mission-only tag does not associate every
slice. One owner appears once, with all its obligations listed.
When a slice's folder name repeats across missions, tag its work with the slice's
unique ID (`rig queue create --slice <id>`) or add `--mission <mission>`. A row
tagged only with the shared name cannot be placed, so the view reads incomplete
until it is.
New slice views default to four automatic tiles (`--max-tiles` overrides it);
qitem views keep eight. A known empty slice stays following and can repopulate.
An incomplete read keeps the prior viewers. Missing or ambiguous scope identities
are errors, never an empty team. Pins, drafts, unknown input and focus retain
former viewers. Closing a viewer does not stop its agent.
An owner's join refused before any pane split can join on a later refresh;
hold notices keep the original seat, phase and reason. A created viewer whose
attach has not yet run can continue without replacing its pane. A viewer whose
attach already ran and then exited back to a shell is not restarted
automatically, so the whole slice view can hold until that tile is resolved.
No tested workaround is offered for that case.
Slice following targets the Linux Herdr 0.9.5 / protocol 22 path. Mission
following is not included. See
`reference/terminal-sessions.md` for controls, bounds and refresh behavior.
Configured Herdr session endpoints now carry through viewer/follow operations.
The TUI's terminal action opens the agent, pod or rig currently being viewed.
### Changes agents should act on
- **A readiness deadline does not end a still-starting daemon.** A nonzero
pending result names the child PID, launch endpoint and log. The child stays
running and the startup reservation stays in place; readiness is not
confirmed. Follow the returned pending guidance before attempting recovery.
- **Read exited state honestly.** A verified exited process in a retained pane
reads `pane_exited`, instead of running and ready. Its outstanding work uses
the existing recovery-finding route. Unknown observations still mean unknown.
- **Use the same member settings when expanding.** `rig expand` now carries raw
members to the canonical rig validator, keeping env, effort and startup
settings. Restores keep the model saved with `rig seat set-model`.
- **Account-home moves start a fresh conversation with a handover.**
`rig provider switch --seat <seat> --to-account <name> --account-home <dir>`
selects an existing home; preview with `--dry-run`. `--clear-account-home`
returns to configured defaults, not historical credentials. Check the moved
seat's account and conversation.
- **Claude API-key switching resumes the same conversation but interrupts work.**
Use `rig provider switch --seat <seat> --to-account api --api-key-file <abs>`;
return with `--to-account plan --token-file <abs>`. The API-key path remains
unqualified. Do not combine it with account-home selectors. Credential files
stay private; budgets remain the caller's responsibility.
- **A new Claude member can name its subscription token in the spec:**
`claude_token: {label: name, file: /abs/token}`, beside its own
`env.CLAUDE_CONFIG_DIR`. This stores a file reference, not the token. Validation
does not read the file; a missing or unreadable token uses the existing launch
fallback notice, so verify the account actually used.
- **Git over SSH uses the daemon's SSH agent** in managed seats. Make sure that
agent has the intended key (`ssh-add -l`).
- **Slack button answers resume decisions parked on the person asked.** Set
`rig slack setup --reactions off` if acknowledgement emoji should not wake
agents. The default remains `route`; manifest regeneration preserves it.
Replies posted outside a deleted Slack thread are tracked where Slack put them.
- **Queue delivery keeps its original intent.** A wake refused at a selection
prompt retries after it clears. Cross-host `queue create --verify` reads the
destination's receipt. An unreadable receipt stays unconfirmed; do not replay
a create just because verification could not establish delivery.
- **Preset authors can opt into `runner: true` and `passes_model: true`.** The
runner also requires `launch_result: true` and reports activity through the
existing hook route. Model input arrives as `OPENRIG_MODEL`. Passed means the
launch command was sent, not that the harness applied that model; the observed
model remains unknown. Cursor and Hermes examples leave model passing off
pending native qualification.
Start with the draft [integration author contracts](author-contracts.md) for
harness, runner and platform duties, with implemented interfaces distinguished from proposals.
- **Update viewer API handling.** `/api/terminal/viewer/ensure` returns 400 for
`view_required` and `unknown_provider` where it previously returned 200 with
an error body. Slice not-found is 404; ambiguous/unavailable is 409. Continue
reading the body code. Unsupported follow kinds keep `follow_view_required`
in a 200 `ok:false` response.
### Limits to keep in view
- The reported [Codex hook-refresh refusal (#2200)](https://github.com/mvschwarz/openrig/issues/2200)
remains unresolved. A verified Node-launcher fix is included, but that does
not establish that this report is fixed. If
`rig seat restart <seat> --keep-conversation` refuses, nothing is stopped:
leave the seat running and report the refusal with CLI and daemon versions.
A refused restart has not refreshed hooks; review any Codex hook prompt.
- Retained messages are not delivered. Protected Codex pastes and unreadable
composers can still retain work; inspect the explanation, use a larger pane
or shorten the message. Turning the typing guard off removes protection.
- Inventory now avoids redundant reads, but no general speed or stability
improvement is claimed without comparable measurements.
- Internet-facing hosted members remain experimental, unsupported, off by
default and at your own risk. Slice following does not require them.
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.7",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7346,7 +7346,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.7",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7384,7 +7384,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.7",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7426,7 +7426,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.7",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.7",
"private": true,
"license": "Apache-2.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.7",
"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": [
+7
View File
@@ -842,6 +842,13 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
if (unverified) {
throw new StartupChildPendingError(`Daemon child ${pid} responds at ${healthzUrl}, but listener verification is incomplete: ${lastProbe} Child left running; startup reservation retained. Inspect 'rig daemon status', daemon-start.lock and daemon.log before recovery.`);
}
// Expiring the readiness budget is not evidence that this owned child failed.
// Keep launch exclusion until an operator can inspect a slow pre-bind startup.
assertChild();
if (deps.isProcessAlive(pid!)) {
assertChild();
throw new StartupChildPendingError(`Daemon startup is pending after the readiness deadline (child PID ${pid}). Readiness is not confirmed; child left running and startup reservation retained. Last probe: ${lastProbe}. Launch endpoint: ${healthzUrl}. Inspect that URL and ${LOG_FILE}; this check does not publish readiness, adopt the child or release the reservation. To restart manually, stop child PID ${pid} and wait for exit; inspect ${path.join(OPENRIG_DIR, "daemon-start.lock")}, and only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'. 'rig daemon stop' cannot target this unpublished child, and another start remains blocked by the reservation.`);
}
throw new Error(`${summarizeDaemonStartFailure(healthzUrl, deps.readFile(resolveLifecycleFile(deps, "daemon.log")))}. Last probe: ${lastProbe}`);
}
assertChild();
@@ -1202,6 +1202,7 @@ describe("Daemon Lifecycle", () => {
it("start: surfaces native module runtime mismatch inline when healthz never comes up", async () => {
vi.useFakeTimers();
const deps = mockDeps({
isProcessAlive: vi.fn(() => false), // Crash diagnostics require observed process absence.
fetch: vi.fn(async () => { throw new Error("connection refused"); }),
readFile: vi.fn((p: string) => {
if (p === LOG_FILE) {
@@ -1232,6 +1233,7 @@ describe("Daemon Lifecycle", () => {
it("start: surfaces the recent daemon log line when startup crashes for another reason", async () => {
vi.useFakeTimers();
const deps = mockDeps({
isProcessAlive: vi.fn(() => false), // Crash diagnostics require observed process absence.
fetch: vi.fn(async () => { throw new Error("connection refused"); }),
readFile: vi.fn((p: string) => {
if (p === LOG_FILE) {
@@ -4,7 +4,7 @@ import type { ChildProcess } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { startDaemon, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { startDaemon, getDaemonStatus, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { acquireDaemonStartLock } from "../src/daemon-start-lock.js";
afterEach(() => vi.useRealTimers());
@@ -67,6 +67,8 @@ describe("child-bound startup", () => {
? { ok: true, json: async () => ({ ...f.body, pid: 888 }) } : fetch(url);
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/identity mismatch.*127.0.0.2/);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(f.lock.release).toHaveBeenCalledWith(false);
});
it("publishes the verified child, naming the unverified listener, when only a secondary listener stays uncertain", async () => {
vi.useFakeTimers();
@@ -114,7 +116,7 @@ describe("child-bound startup", () => {
expect((outcome as Error).message).toMatch(/identity mismatch.*observed 888/);
expect(f.files.has(STATE_FILE)).toBe(false);
});
it("gives up on a never-answering endpoint within one readiness deadline", async () => {
it("returns pending within one readiness deadline without stopping its still-starting child", async () => {
vi.useFakeTimers();
const f = fixture(); const fetch = f.deps.fetch;
f.deps.fetch = async (url) => (await fetch(url), new Promise(() => {}));
@@ -122,7 +124,11 @@ describe("child-bound startup", () => {
expect((outcome as Error).message).toMatch(/Last probe: .*timed out|Last probe: .*healthz/);
expect(elapsed).toBeLessThanOrEqual(20_000);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.lock.release).toHaveBeenCalledWith(true);
expect((outcome as Error).message).toMatch(/pending.*12345/);
expect((outcome as Error).message).toContain("Launch endpoint: http://127.0.0.1:19873/healthz");
expect((outcome as Error).message).toContain("daemon.log");
});
// Synchronous work crossing the deadline is modelled by a clock jump inside one fetch, before
// any timer runs. Post-spawn fetches: 0 primary read, 1 primary listener check, 2 secondary check.
@@ -190,6 +196,8 @@ describe("child-bound startup", () => {
};
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/exited/);
expect(f.files.has(STATE_FILE)).toBe(false);
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.lock.release).toHaveBeenCalledWith(false);
});
it("handles an asynchronous native spawn error without a PID", async () => {
const f = fixture();
@@ -280,3 +288,52 @@ describe("local startup reservation", () => {
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
});
it.each(["missing", "stale"] as const)("keeps a slow child discoverable with %s old state and excludes a duplicate launch", async (oldState) => {
vi.useFakeTimers();
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-pending-start-"));
const f = fixture(), fetch = f.deps.fetch;
const previousState = oldState === "stale"
? JSON.stringify({ pid: 999, host: "127.0.0.2", port: 19874, db: "old.sqlite", startedAt: "old" })
: undefined;
if (previousState) f.files.set(STATE_FILE, previousState);
f.deps.isProcessAlive = pid => pid === f.child.pid && f.child.exitCode === null;
let ready = false;
f.deps.acquireStartLock = () => acquireDaemonStartLock(home);
f.deps.fetch = async url => {
const response = await fetch(url);
if (!ready) throw Object.assign(new Error("still initializing"), { code: "ECONNREFUSED" });
return response;
};
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${opts.port}`);
vi.stubEnv("RIGGED_URL", "");
try {
const began = Date.now(), result = startDaemon(opts, f.deps).catch((error: Error) => error);
await vi.runAllTimersAsync();
const outcome = await result;
expect(outcome).toBeInstanceOf(Error);
expect((outcome as Error).message).toMatch(/pending.*12345/);
const message = (outcome as Error).message;
expect(message).toContain("Launch endpoint: http://127.0.0.1:19873/healthz");
expect(message).not.toContain("127.0.0.2:19874");
expect(message).toContain("this check does not publish readiness, adopt the child or release the reservation");
expect(message).toContain("stop child PID 12345 and wait for exit");
expect(message).toContain(path.join(path.dirname(STATE_FILE), "daemon-start.lock"));
expect(message).toContain("only after both recorded launcher and child PIDs are absent, archive it and run 'rig daemon start'");
expect(message).toContain("'rig daemon stop' cannot target this unpublished child");
expect(Date.now() - began).toBeLessThanOrEqual(20_000);
expect(f.deps.kill).not.toHaveBeenCalled();
expect(f.child.exitCode).toBeNull();
expect(f.files.get(STATE_FILE)).toBe(previousState);
expect(fs.readFileSync(path.join(home, "daemon-start.lock"), "utf8")).toContain('"childPid":12345');
ready = true;
await expect(getDaemonStatus(f.deps)).resolves.toMatchObject({ state: "running", healthy: true, port: opts.port });
expect(f.files.get(STATE_FILE)).toBe(previousState); // A status probe never retroactively publishes start readiness.
await expect(startDaemon(opts, f.deps)).rejects.toThrow(/already reserved/);
expect(f.deps.spawn).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllEnvs();
fs.rmSync(home, { recursive: true, force: true });
}
});
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.21",
"version": "0.1.23",
"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.21",
"version": "0.1.23",
"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 top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.5`). This marker describes the pack's teaching, not publication or live
`capability-delta-v0.7.6`). 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`.
@@ -34,6 +34,8 @@ one command away. Your memory of the fleet is a claim about the past.
filters to running and **hides detached, exited and attention states** — which are exactly the
cells you were scanning for. A seat whose tmux session is confirmed gone reads `detached`
(activity reason `session_missing`), and `rig ps --nodes` says to restore or relaunch it.
A verified exited process in a retained pane reads `pane_exited`, instead of running and ready;
outstanding work uses the existing recovery-finding route. An unknown observation is not an exit.
`rig ps --nodes` also shows the model each seat was last seen using (`modelObservation`, with its
source and time), or unknown; it never shows the configured model in its place. A Claude seat at
Claude Code's MCP-server approval menu reads as needing input (`mcp_gate`), a Codex seat at its
@@ -227,6 +229,15 @@ unseen — which makes messages the one delivery channel that never gets skipped
The exercised journey used shell stand-ins, not native agents, and active render cost remains
unmeasured. See `reference/terminal-sessions.md` for controls and limits. Preview and open
responses carry `viewDescriptor` (`kind`, `name`); membership grants no input authority.
**`rig terminal open slice:<name-or-id> --follow --provider herdr`** follows owners of the
slice's exact-tagged pending, in-progress and blocked rows, with every obligation listed.
Associate rows with `rig queue create --slice <name-or-id>`; handoffs inherit tags unless replaced.
New slice views default to four automatic tiles (`--max-tiles` overrides it). Known empty slices
stay following; incomplete reads keep prior viewers, and draft/focus protection still applies.
A join refused before a pane split can join on a later refresh, with the original hold reason retained.
An attach that ran and then exited to a shell is not restarted automatically; the whole slice view
can hold until that tile is resolved. No tested workaround is offered for that case.
Slice following targets the Linux Herdr 0.9.5 / protocol 22 path. Mission following is not included.
An unconfirmed pane is reported as `pane_unconfirmed`, not counted as opened. Inspect before
retrying: an unconfirmed result does not prove that nothing opened.
Add `--session <name>` to select a Herdr session for that request; `--session default`
@@ -262,8 +273,10 @@ unseen — which makes messages the one delivery channel that never gets skipped
beats a rig match; the rest use the default channel). After a change, invite the app to each
channel, run `rig slack verify`, and restart the daemon (`rig daemon stop`, then `start`).
Disabling and enabling Slack instead marks alerts still waiting as already posted, so they're
never posted. An emoji reaction on any message posted for an ask reaches the asking seat
never posted. By default, an emoji reaction on a message posted for an ask reaches the asking seat
as a task tagged `human-reaction`: a signal to interpret, not an answer, and the ask stays open.
`rig slack setup --reactions off` disables that routing; the default is `route`, and regenerating
the app manifest preserves the setting.
It needs the `reactions:read` scope and the `reaction_added` event (reinstall the app);
`rig slack verify` warns when the scope is missing. A long ask is posted in numbered thread parts; one that
still can't be posted comes back to the asking seat as a `slack-undeliverable` task. Replies
@@ -517,6 +530,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
`append_system_prompt_file`, `plugin_dirs` and `advisor` (an Advisor model). They're copied into
that seat's own storage, never the project, so one seat in a shared repository can have its own
persona, prompt or plugins. A bundle's before-install view lists them.
- **A new Claude member can name a prepared subscription token** with
`claude_token: {label: name, file: /abs/token}` beside its `env.CLAUDE_CONFIG_DIR`; the seat stores
the file reference and reads it at launch. A missing/unreadable token uses the existing fallback
notice, so verify the account actually used.
- **`rig community-harness list`**: list installed community-harness presets and declared limits
(not tested by OpenRig). A preset can declare how its app reports the conversation each launch
made (`launch_result`), reopens one on resume (`resume`) or lets OpenRig name it
@@ -524,7 +541,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
that's gone reads `missing`, never quietly replaced. A preset's `first_input: true` hands the
harness its first startup text through a private file named by `OPENRIG_FIRST_INPUT`. Examples for
Cursor, Hermes and OpenClaw are in `docs/reference/community-harness-examples/`; OpenClaw's local
and Gateway-client examples each resume a named conversation.
and Gateway-client examples each resume a named conversation. A preset can declare `runner: true`
(with `launch_result: true`) so activity follows its runner reports, recorded per launch.
`passes_model: true` sends the declared model as `OPENRIG_MODEL`; it does not establish the
effective model. Cursor and Hermes examples leave model passing off pending native qualification.
`docs/reference/community-harness-presets.md` is the contract: what a community harness gets from
OpenRig and what it doesn't (no turn-cancel verb, no approvals reaching OpenRig), how to write
one, and how promotion is judged. Whether processes a harness starts outside its pane survive
@@ -1223,6 +1243,13 @@ your circumstances is configuration, and the ones that are not, another agent ca
never the token, and `--stored-login` moves it back. These seat account and runtime moves were
tested with stubs, not real accounts, before 0.7.2: check a moved seat's account and conversation
yourself after the first move.
- **Move to an existing account home** with `rig provider switch --seat <seat> --to-account <name>
--account-home <dir>` (preview with `--dry-run`). It starts a fresh conversation with a handover
and retains the seat's address and work. `--clear-account-home` returns to configured defaults.
- **Claude subscription/API-key switching resumes the same conversation and interrupts work:**
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`; return with
`--to-account plan --token-file <abs>`. The API-key path remains unqualified; verify the resulting
account and conversation. Do not combine API-key and account-home selectors.
- **`rig provider status --refresh --provider claude --claude-token-file <absolute path>
--credential-label <label>`** — read a labelled Claude token's usage on demand. Each refresh spends
one model turn; status and the TUI show only the cached reading, and the label is yours, not an
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.7",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -31,7 +31,7 @@ channel for decisions. If the TUI has exited but its shell remains, run
`rig tui` there once. If the terminal session itself is absent, inspect
`rig ps --nodes --rig kernel --json` for its registered `canonicalSessionName`
and recover only that seat with `rig seat launch <session> --fresh --reason
"recover missing shared kernel dashboard"` (normally `operator-human@kernel`).
"recover missing shared kernel dashboard"`.
Its fresh-start action runs `rig tui`; confirm that with `rig capture <session>`
before opening `saved:kernel`. Keep healthy agent sessions running and omit
`--stop`. Restoring selected agent seats does not restore an unselected
@@ -844,6 +844,7 @@ export class ClaudeCompactionEnforcer {
&& this.pendingPreCompactPrep.get(input.sessionName) === attempt;
const sendOpts = {
openrigNotice: true as const,
preserveInput: true as const,
beforeWrite: () => {
if (!isCurrent()) throw new DeliveryGuardError("compaction_cancelled", "Managed compaction was cancelled or replaced; no further input is authorized.");
},
@@ -1282,7 +1283,7 @@ export class ClaudeCompactionEnforcer {
overflowCompactInstruction: composed.instructionInCommand ? null : attempt.compactInstruction,
usedPercentage: input.usedPercentage!, thresholdPercent: policy.thresholdPercent,
preCompactInstruction: policy.preCompactInstruction, preparation: attempt, constructedAt: this.now(),
}), { openrigNotice: true, beforeWrite: () => {
}), { openrigNotice: true, preserveInput: true, beforeWrite: () => {
this.reconcilePreparations();
if (this.pendingPreCompactPrep.get(input.sessionName) !== attempt || attempt.controller.signal.aborted || attempt.status === "stopped")
throw new DeliveryGuardError("preparation_stopped", "This compaction attempt ended before preparation input.");
@@ -1348,7 +1349,7 @@ export class ClaudeCompactionEnforcer {
// deadline. (The automatic path used to send into a seat still finishing its preparation turn.)
// Idle comes from a live pane read: the newest hook can predate the rest of the preparation turn.
return this.sessionTransport.send(input.sessionName, composed.command,
{ openrigNotice: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs, attempt.deadlineAt - this.now())), readinessFromPaneOnly: true } : {}) });
{ openrigNotice: true, preserveInput: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs, attempt.deadlineAt - this.now())), readinessFromPaneOnly: true } : {}) });
};
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase">;
let guardRefused = false;
@@ -550,10 +550,26 @@ function rotationProcess(row: NativeProcessRow, rotation: ClaudeResumeRotation |
export type NativeProcessObservation = { panePid: number; process: NativeProcessRow; fingerprint: string; afterScript?: true };
export type CodexProcessObservation = NativeProcessObservation;
/** The process still running the frozen `#!` script: the kernel starts it as `<interpreter> [one shebang option] <script>
* args…`, so the script's path is its argv[1], or its argv[2] after a single option. */
function runsFrozenScript(row: NativeProcessRow, selectedExecutable: string): boolean {
const [, first, second] = tokens(row.command);
return first === selectedExecutable || (first?.startsWith("-") === true && second === selectedExecutable);
}
function nativeProcessCandidates(rows: NativeProcessRow[], panePid: number, runtime: NativeRuntime, selectedExecutable?: string, scriptLaunch?: boolean): NativeProcessObservation[] {
const exact = candidatesMatching(rows, panePid, runtime, row => claudeProcess(row, selectedExecutable));
if (exact.length > 0 || runtime !== "claude-code" || !selectedExecutable || scriptLaunch !== true) return exact;
return candidatesMatching(rows, panePid, runtime, claudeProcessAfterScript).map(candidate => ({ ...candidate, afterScript: true as const }));
const named = candidatesMatching(rows, panePid, runtime, claudeProcessAfterScript);
if (named.length > 0) return named.map(candidate => ({ ...candidate, afterScript: true as const }));
// #2341: a wrapper that starts Claude without `exec` keeps running the frozen script as the foreground parent, and
// Claude can run under any name. That script's direct child naming a Claude conversation is the launched runtime;
// the caller still requires the launch's own token, one candidate and a stable fingerprint.
const byPid = new Map(rows.map(row => [row.pid, row]));
return candidatesMatching(rows, panePid, runtime, row => {
const parent = byPid.get(row.ppid);
return !!parent && runsFrozenScript(parent, selectedExecutable) && claudeSessionToken(tokens(row.command).slice(1)) !== null;
}).map(candidate => ({ ...candidate, afterScript: true as const }));
}
function candidatesMatching(rows: NativeProcessRow[], panePid: number, runtime: NativeRuntime, claudeRow: (row: NativeProcessRow) => boolean): NativeProcessObservation[] {
@@ -238,6 +238,8 @@ const CLAUDE_STATUS_WARNINGS = [
/^Update available! Run: \S.*$/,
// The context hint, as Claude prints it; reported as the last line below the mode bar on one seat.
/^new task\? \/clear to save \d+(?:\.\d+)?k tokens$/,
// After Claude installs an update: "/restart to apply" (a Claude seat, 2026-10-30) or "Restart to apply".
/^✔ Update installed · (?:\/restart|Restart) to apply$/,
];
// Current Claude status rows need not end in "thinking)" or show "esc to interrupt".
// Completed summaries such as "✻ Crunched for 2s" lack the live ellipsis/timer shape.
@@ -1738,6 +1740,8 @@ export interface SendOpts {
onInputEffect?: (phase: "pasted" | "submitted") => void;
/** Internal managed lifecycle prerequisite; never accepted from HTTP send options. */
beforeWrite?: () => void;
/** Internal: managed lifecycle input must preserve a person's draft even when the optional typing guard is off. */
preserveInput?: true;
/** The caller's request. Aborted while the send still waits, before any input starts, the send ends with
* `caller_cancelled` and never writes, then or later. Once input may have started it is ignored: the result stays
* attempted or written. Queue wakes and committed outbox deliveries have no caller and ignore it. */
@@ -3201,6 +3205,15 @@ export class SessionTransport {
const cancel = callerSignal(opts);
if (cancel?.aborted) return observe({ ...callerCancelled(sessionName), ...(waitMode ? waitEvidence : {}) });
const beforePaste = cancel ? () => { opts?.beforeWrite?.(); if (cancel.aborted) throw new DeliveryGuardError(CALLER_CANCELLED, "caller cancelled before the paste"); } : opts?.beforeWrite;
const preserveInput = opts?.preserveInput ? async () => {
const box = runtime === "claude-code" || runtime === "codex"
? await this.captureInputBox(readTarget, runtime, { remainingMs: INPUT_BOX_READ_DEADLINE_MS }) : null;
if (bindingChanged()) throw new DeliveryGuardError("draft_input_changed", "The managed input target changed; no input was written.");
if (box?.status !== "empty" && !(box?.status === "likely-suggestion" && box.confidence === "high")) {
throw new DeliveryGuardError(box?.status === "typed" ? "draft_input_busy" : "draft_input_unknown",
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`);
}
} : undefined;
// Set once the text write has been handed to the adapter: from then on a throw is not "nothing sent".
let writeIssued = false;
const tagIssued = (error: unknown): never => {
@@ -3212,7 +3225,9 @@ export class SessionTransport {
() => {
opts?.beforeWrite?.();
writeIssued = true;
if (promptOverride) return this.tmuxAdapter.sendText(sessionName, text, beforePaste, { bracketed: false, beforePaste: overrideBeforePaste });
if (promptOverride) return this.tmuxAdapter.sendText(sessionName, text, beforePaste, { bracketed: false,
beforePaste: preserveInput ? async () => { await overrideBeforePaste?.(); await preserveInput(); } : overrideBeforePaste });
if (preserveInput) return this.tmuxAdapter.sendText(sessionName, text, beforePaste, { beforePaste: preserveInput });
return beforePaste ? this.tmuxAdapter.sendText(sessionName, text, beforePaste) : this.tmuxAdapter.sendText(sessionName, text);
},
(result) => result.ok ? "ok" : "failed",
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import path from "node:path";
import { getDefaultOpenRigPath } from "../../openrig-compat.js";
import { HerdrViewer, findAttributedViewerPanes, readHerdrViewerBody as body, parseHerdrViewerPane as pane,
import { HerdrViewer, ViewerCreateNotDispatchedError, findAttributedViewerPanes, readHerdrViewerBody as body, parseHerdrViewerPane as pane,
type HerdrViewerRun, type InspectViewerSeat, type ViewerKey, type ViewerMapping, type ViewerPane } from "./herdr-viewer.js";
import { DEFAULT_FOLLOW_POLICY, planFollowingTiles, type FollowControls, type FollowPolicy, type FollowTile, type FollowingStatus } from "./terminal-follow-policy.js";
import type { ComposedPane, ComposedView, OpenViewResult } from "./terminal-provider.js";
@@ -161,9 +161,12 @@ export class HerdrFollowView {
throw new Error(`${tile.seat}: Core's current attachment differs from the retained viewer; keeping the previous view unconfirmed.`);
}
}
const current = await currentPane(tile.viewer);
let current: ViewerPane;
try { current = await currentPane(tile.viewer); }
catch (error) { throw new Error(`${tile.seat}: unknown: ${error instanceof Error ? error.message : String(error)}`); }
const verified = await viewers.lookup(tile.member, tile.target);
if (!verified.ok || JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error("A retained viewer is unconfirmed; keeping the whole view.");
if (!verified.ok) throw new Error(`${tile.seat}: ${verified.phase ?? "unknown"}: ${verified.error} Keeping the whole view.`);
if (JSON.stringify(verified.viewer) !== JSON.stringify(tile.viewer)) throw new Error(`${tile.seat}: Viewer mapping changed; keeping the whole view.`);
observations.set(tile.key, { focused: current.focused!, input: await this.deps.input?.(tile.target) ?? "unknown" });
}
const plan = planFollowingTiles(state.tiles, members, true, observations, policy, now());
@@ -174,19 +177,24 @@ export class HerdrFollowView {
if (!target || !member.key.hostId || !member.key.nodeId) { notice(`${join.seat}: ${member.reason ?? "Terminal is unavailable; the member remains in this task."}`); continue; }
const memberKey = { hostId: member.key.hostId, nodeId: member.key.nodeId };
let verified = await viewers.ensure(memberKey, target, stillCurrent, async (cwd) => {
await currentPane(state!.pane!);
if (!await stillCurrent()) throw new Error("Membership changed before the split.");
const anchor = state!.tiles.at(-1)?.viewer;
if (anchor) await currentPane(anchor);
try {
await currentPane(state!.pane!);
if (!await stillCurrent()) throw new Error("Membership changed before the split.");
if (anchor) await currentPane(anchor);
} catch (error) {
throw new ViewerCreateNotDispatchedError(error instanceof Error ? error.message : String(error));
}
const split = body(await this.deps.run(["pane", "split", "--pane", anchor?.paneId ?? state!.pane!.pane_id,
"--direction", anchor && state!.tiles.length % 2 ? "right" : "down", "--no-focus", "--cwd", cwd]));
return JSON.stringify({ root_pane: split.pane });
});
const originalFailure = !verified.ok ? `${verified.phase ?? "unknown"}: ${verified.error}` : undefined;
for (let attempt = 0; !verified.ok && attempt < 5; attempt++) {
await new Promise(resolve => setTimeout(resolve, 50));
verified = await viewers.lookup(memberKey, target); // read-only: never repeats a create/run.
}
if (!verified.ok) { notice(`${join.seat}: ${verified.error}`); continue; }
if (!verified.ok) { notice(`${join.seat}: ${originalFailure}; confirmation: ${verified.phase ?? "unknown"}: ${verified.error}`); continue; }
state.tiles.push({ key: join.key, seat: join.seat, target, member: { hostId: member.key.hostId, nodeId: member.key.nodeId }, viewer: verified.viewer });
notice(`${join.seat}: ${join.reason}`); save();
}
@@ -10,9 +10,11 @@ import type { ComposedPane } from "./terminal-provider.js";
export interface ViewerKey { hostId: string; nodeId: string }
export interface ViewerMapping { workspaceId: string; paneId: string; terminalId: string; readOnly: boolean }
export type ViewerResult = { ok: true; viewer: ViewerMapping; created: boolean }
| { ok: false; code: string; error: string };
| { ok: false; code: string; error: string; phase?: Entry["phase"] };
export type InspectViewerSeat = (argv: string[]) => Promise<{ sessionId: string; nodeId: string }>;
export type HerdrViewerRun = (argv: string[]) => Promise<string>;
/** Only the caller which has not dispatched a create may assert this. */
export class ViewerCreateNotDispatchedError extends Error {}
export type CreateViewerPane = (cwd: string, label: string) => Promise<string>;
export interface ViewerPane { workspace_id: string; pane_id: string; terminal_id: string; cwd: string; focused?: boolean }
type Pane = ViewerPane;
@@ -21,7 +23,6 @@ interface Entry {
phase: "creating" | "created" | "starting"; pane?: Pane;
}
interface Journal { version: 1; entries: Record<string, Entry> }
const unavailable = (error: string): ViewerResult => ({ ok: false, code: "viewer_unconfirmed", error });
const runFile = promisify(execFile);
/** The supported CLI, never guessed socket RPC method parity. Its reported
@@ -103,16 +104,18 @@ export class HerdrViewer {
return findAttributedViewerPanes(this.run, entry.cwd);
}
private async ensureOnce(key: ViewerKey, target: ComposedPane, provision = true, stillCurrent: () => Promise<boolean> = async () => true, createPane?: CreateViewerPane): Promise<ViewerResult> {
let entry: Entry | undefined;
const unconfirmed = (error: string): ViewerResult => ({ ok: false, code: "viewer_unconfirmed", error, ...(entry ? { phase: entry.phase } : {}) });
try {
if (this.platform !== "linux") return { ok: false, code: "viewer_unsupported", error: "Ensure-viewer is qualified on Linux Herdr 0.9.5/protocol 22 only." };
const status = body(await this.run(["status", "server", "--json"]));
if (status.version !== "0.9.5" || status.protocol !== 22 || status.running !== true || status.socket !== this.socketPath) {
return unavailable("The qualified Herdr version/protocol or selected socket was not confirmed.");
return unconfirmed("The qualified Herdr version/protocol or selected socket was not confirmed.");
}
if (provision && !await stillCurrent()) return unavailable("Task membership changed while waiting to provision; refresh its preview.");
if (!target.attachArgv) return unavailable("Only verified local tmux bindings support ensure-viewer.");
if (provision && !await stillCurrent()) return unconfirmed("Task membership changed while waiting to provision; refresh its preview.");
if (!target.attachArgv) return unconfirmed("Only verified local tmux bindings support ensure-viewer.");
const binding = await this.inspect(target.attachArgv);
if (binding.nodeId !== key.nodeId) return unavailable("The current tmux binding does not match this member’s node identity.");
if (binding.nodeId !== key.nodeId) return unconfirmed("The current tmux binding does not match this member’s node identity.");
if (provision) mkdirSync(this.root, { recursive: true, mode: 0o700 });
const endpoint = createHash("sha256").update(this.socketPath).digest("hex");
const file = path.join(this.root, `${endpoint}.json`);
@@ -122,36 +125,49 @@ export class HerdrViewer {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
journal = { version: 1, entries: {} };
}
if (journal.version !== 1 || !journal.entries || typeof journal.entries !== "object" || Array.isArray(journal.entries)) return unavailable("Viewer provenance is unreadable; no provider mutation was attempted.");
if (journal.version !== 1 || !journal.entries || typeof journal.entries !== "object" || Array.isArray(journal.entries)) return unconfirmed("Viewer provenance is unreadable; no provider mutation was attempted.");
const save = () => {
const tmp = `${file}.${randomUUID()}.tmp`;
writeFileSync(tmp, `${JSON.stringify(journal)}\n`, { mode: 0o600 }); renameSync(tmp, file);
};
const id = JSON.stringify([key.hostId, key.nodeId]);
let entry = journal.entries[id];
entry = journal.entries[id];
let created = false;
if (entry && (entry.command !== target.paneCommand || entry.readOnly !== target.readOnly)) {
return unavailable("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
return unconfirmed("The seat attachment changed; the existing viewer must be inspected before reprovisioning.");
}
if (!entry) {
if (!provision) return { ok: false, code: "viewer_not_provisioned", error: "No owned viewer has been provisioned for this member." };
if (Object.keys(journal.entries).length >= 256) return unavailable("This endpoint has 256 retained viewer records; inspect its viewer provenance before adding more.");
if (Object.keys(journal.entries).length >= 256) return unconfirmed("This endpoint has 256 retained viewer records; inspect its viewer provenance before adding more.");
const token = randomUUID();
const cwd = path.join(this.root, endpoint, token); mkdirSync(cwd, { recursive: true, mode: 0o700 });
entry = { cwd, label: `openrig-viewer-${token}`, command: target.paneCommand, argv: target.attachArgv, readOnly: target.readOnly, phase: "creating" };
journal.entries[id] = entry; save();
// No retry here. A timeout is reconciled by enumeration on the next request.
const reply = body(await (createPane ? createPane(cwd, entry.label)
: this.run(["workspace", "create", "--cwd", cwd, "--label", entry.label, "--no-focus"])));
let raw: string;
try {
raw = await (createPane ? createPane(cwd, entry.label)
: this.run(["workspace", "create", "--cwd", cwd, "--label", entry.label, "--no-focus"]));
} catch (error) {
if (createPane && error instanceof ViewerCreateNotDispatchedError) {
// This invocation positively refused BEFORE dispatch, not an empty
// enumeration or a timeout after a possible provider effect.
delete journal.entries[id]; save();
try { rmdirSync(cwd); } catch { /* An unused directory must not prevent a later join. */ }
return unconfirmed(error.message);
}
throw error;
}
const reply = body(raw);
const made = pane(reply.root_pane);
if (!made || made.cwd !== cwd) return unavailable("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
if (!made || made.cwd !== cwd) return unconfirmed("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
entry.pane = made; entry.phase = "created"; save(); created = true;
}
if (!entry.pane) {
// A unique private cwd attributes a previously initiated create, not a
// caller ID or mutable display label. Empty/unreadable is still unknown.
const found = await this.ownedPanes(entry);
if (found.length !== 1) return unavailable("Uncertain viewer creation has no unique private-cwd mapping.");
if (found.length !== 1) return unconfirmed("Uncertain viewer creation has no unique private-cwd mapping.");
entry.pane = found[0]!; entry.phase = "created"; if (provision) save();
}
let current: Pane | null;
@@ -160,13 +176,13 @@ export class HerdrViewer {
if (!current || current.pane_id !== entry.pane.pane_id || current.terminal_id !== entry.pane.terminal_id
|| current.workspace_id !== entry.pane.workspace_id || current.cwd !== entry.cwd) {
if (!provision || (await this.ownedPanes(entry)).length !== 0) {
return unavailable("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
return unconfirmed("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
}
// Only a complete empty readback retires provenance. Remove our empty
// attribution directory, never provider panes or arbitrary journal paths.
if (!/^openrig-viewer-[0-9a-f-]{36}$/.test(entry.label)
|| entry.cwd !== path.join(this.root, endpoint, entry.label.slice("openrig-viewer-".length))) {
return unavailable("Absent viewer provenance has no owned attribution directory.");
return unconfirmed("Absent viewer provenance has no owned attribution directory.");
}
try { rmdirSync(entry.cwd); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
@@ -174,8 +190,10 @@ export class HerdrViewer {
return { ok: false, code: "viewer_not_provisioned", error: "The old viewer is confirmed absent; ensure again to create a verified replacement." };
}
if (entry.phase === "created") {
if (!provision) return unavailable("The created viewer process is not yet confirmed.");
if (!await stillCurrent()) return unavailable("Task membership changed before the viewer process started.");
if (!provision) return unconfirmed("The created viewer process is not yet confirmed.");
if (!await stillCurrent()) return unconfirmed("Task membership changed before the viewer process started.");
const beforeRun = await this.inspect(target.attachArgv);
if (beforeRun.nodeId !== key.nodeId || beforeRun.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed before the viewer process started.");
entry.phase = "starting"; save();
// pane run rc0/empty stdout is not proof. It is sent ONCE then checked.
await this.run(["pane", "run", current.pane_id, entry.command]);
@@ -195,11 +213,11 @@ export class HerdrViewer {
&& (actual[expected.length] === `=${targetName}` || actual[expected.length] === binding.sessionId);
});
const afterBinding = await this.inspect(target.attachArgv);
if (afterBinding.nodeId !== key.nodeId || afterBinding.sessionId !== binding.sessionId) return unavailable("The seat binding changed while provisioning its viewer.");
if (!verified) return unavailable("The viewer process is not confirmed; no pane run was repeated.");
if (afterBinding.nodeId !== key.nodeId || afterBinding.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed while provisioning its viewer.");
if (!verified) return unconfirmed("The viewer process is not confirmed; no pane run was repeated.");
return { ok: true, viewer: { workspaceId: current.workspace_id, paneId: current.pane_id, terminalId: current.terminal_id, readOnly: entry.readOnly }, created };
} catch (error) {
return unavailable(`Viewer effect is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
return unconfirmed(`Viewer effect is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
}
}
@@ -9,7 +9,7 @@ import { projectTaskOwners, type TaskSeat, type TaskTerminalView } from "./task-
export interface ScopeViewError { code: "scope_unsupported" | "scope_not_found" | "scope_ambiguous" | "scope_unavailable"; error: string }
type IndexContext = Pick<SliceIndexer, "slicesRoot" | "additionalSliceRoots" | "projectId">;
interface Scope { name: string; id?: string; title: string; source: string }
interface Scope { name: string; id?: string; title: string; source: string; mission?: string; missionSource?: string }
const MAX_ENTRIES = 4096, MAX_HEADER_BYTES = 16384, MAX_SOURCE_BYTES = 8 * 1024 * 1024;
const MAX_ROWS = 50000, MAX_TAG_BYTES = 16384, MAX_TOTAL_TAG_BYTES = 8 * 1024 * 1024;
class ScopeLimit extends Error {}
@@ -20,7 +20,7 @@ const unavailable = (error: string): ScopeViewError => ({ code: "scope_unavailab
* for retiring viewers. No inferred project, substring match or remote I/O. */
function readScopeIndex(index: IndexContext): Scope[] | ScopeViewError {
let entries = 0, bytes = 0;
const scopes: Scope[] = [], seenRoots = new Set<string>(), seenScopes = new Set<string>();
const scopes: Scope[] = [], seenRoots = new Set<string>(), seenScopes = new Map<string, Scope>();
const list = (dir: string, visit: (name: string) => void) => {
const handle = fs.opendirSync(dir);
try {
@@ -30,10 +30,12 @@ function readScopeIndex(index: IndexContext): Scope[] | ScopeViewError {
}
} finally { handle.closeSync(); }
};
const add = (dir: string, name: string) => {
const source = fs.realpathSync(dir);
if (seenScopes.has(source)) return;
seenScopes.add(source);
const add = (dir: string, name: string, mission?: string, missionSource?: string) => {
const source = fs.realpathSync(dir), existing = seenScopes.get(source);
if (existing) {
if (missionSource && !existing.missionSource) { existing.mission = mission; existing.missionSource = missionSource; }
return;
}
let raw = "";
for (const file of NODE_FILE_PRECEDENCE) {
let fd: number;
@@ -56,7 +58,8 @@ function readScopeIndex(index: IndexContext): Scope[] | ScopeViewError {
const id = typeof fields.id === "string" && fields.id ? fields.id : undefined;
const title = typeof fields.title === "string" && fields.title ? fields.title
: typeof fields.slice === "string" && fields.slice ? fields.slice : raw.match(/^# (.+)$/m)?.[1]?.trim() || name;
scopes.push({ name, id, title, source });
const scope = { name, id, title, source, mission, missionSource };
seenScopes.set(source, scope); scopes.push(scope);
};
try {
for (const [position, root] of [index.slicesRoot, ...index.additionalSliceRoots].entries()) {
@@ -75,7 +78,10 @@ function readScopeIndex(index: IndexContext): Scope[] | ScopeViewError {
let isMission = false;
try { isMission = fs.statSync(nested).isDirectory(); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
if (isMission) list(nested, child => add(path.join(nested, child), child));
if (isMission) {
const missionSource = fs.realpathSync(dir);
list(nested, child => add(path.join(nested, child), child, name, missionSource));
}
else add(dir, name);
});
}
@@ -91,8 +97,8 @@ function selectSlice(scopes: Scope[], key: string): Scope | ScopeViewError {
if (!matches.length) return { code: "scope_not_found", error: `No indexed slice exactly matches '${key}'.` };
if (matches.length !== 1) return { code: "scope_ambiguous", error: `Multiple indexed slices match '${key}'; choose an unambiguous scope.` };
const selected = matches[0]!;
// A unique name cannot certify which rows an ambiguous alias names.
if (scopes.some(other => other !== selected && (other.name === selected.name || other.id === selected.name
// Flat legacy slices have no mission context to place a colliding alias.
if (!selected.missionSource && scopes.some(other => other !== selected && (other.name === selected.name || other.id === selected.name
|| (selected.id && (other.id === selected.id || other.name === selected.id))))) {
return { code: "scope_ambiguous", error: `Slice '${key}' has a colliding name or id association.` };
}
@@ -108,6 +114,17 @@ export function readScopedTaskTerminalView(db: Database.Database, view: string,
if ("code" in identities) return identities;
const scope = selectSlice(identities, view.slice(6));
if ("code" in scope) return scope;
// At most two linear index passes per resolve, never a scan per queue row.
const claims = new Map([...new Set([scope.name, scope.id].filter((key): key is string => !!key))].map(key => {
const candidates = identities.filter(candidate => candidate.name === key || candidate.id === key);
const byMission = new Map<string, Scope[]>();
for (const candidate of candidates) {
if (!candidate.mission) continue;
const sameMission = byMission.get(candidate.mission) ?? [];
sameMission.push(candidate); byMission.set(candidate.mission, sameMission);
}
return [key, { count: candidates.length, byMission }] as const;
}));
return db.transaction(() => {
const issues = new Set<string>();
const selected: Array<{ qitem_id: string; destination_session: string; state: string }> = [];
@@ -129,7 +146,20 @@ export function readScopedTaskTerminalView(db: Database.Database, view: string,
}
if (index.projectId && !belongsToProject(row.tags, index.projectId)) continue;
const parsed = parseScopeTags(row.tags);
if (parsed.slices.has(scope.name) || (scope.id && parsed.slices.has(scope.id))) selected.push(row);
let here = false, elsewhere = false, unplaced = false;
const mission = parsed.missions.size === 1 ? parsed.missions.values().next().value : undefined;
for (const [key, claim] of claims) {
if (!parsed.slices.has(key)) continue;
if (claim.count === 1) { here = true; continue; }
// A reused name/id requires exactly one mission tag and one indexed
// claimant in that mission. Never assign it by first-match or absence.
const placed = mission === undefined ? [] : claim.byMission.get(mission) ?? [];
if (placed.length !== 1) { unplaced = true; continue; }
if (placed[0]!.source === scope.source) here = true; else elsewhere = true;
}
if (unplaced || (here && elsewhere)) {
issues.add(`Queue row '${row.qitem_id}' has unplaceable or contradictory colliding slice tags; membership is incomplete.`);
} else if (here) selected.push(row);
}
selected.sort((a, b) => a.qitem_id.localeCompare(b.qitem_id));
return projectTaskOwners({ kind: "slice", id: scope.id ?? scope.name, name: scope.title,
+16
View File
@@ -407,6 +407,22 @@ activityRoutes.post("/hooks", async (c) => {
});
}
}
// #2341: a reserved occupant generation is registered only when its launch or handover commits. Until then, and
// after a failed handover, its Claude hooks don't move the seat's resume target, which stays the registered
// occupant's conversation. An absent generation, no current tenure or an unreadable ledger keep today's write.
if (runtime === "claude-code" && generation && !currentGeneration) {
let uncommitted = false;
try {
uncommitted = !!sessionRegistry.currentOccupantTenure(resolved.nodeId)
&& !sessionRegistry.isOccupantGenerationRegistered(resolved.nodeId, generation);
} catch { /* an unreadable ledger is no evidence */ }
if (uncommitted) {
return c.json({
ok: true, sessionId, provenance: "hook", tokenPersisted: false, code: "generation_uncommitted",
detail: "This Claude's occupant generation isn't registered for the seat yet (a handover that hasn't committed), so the seat keeps its resume target.",
});
}
}
const persistHook = (type: string, token: string): boolean => {
if (runtime !== "claude-code") return sessionRegistry.updateResumeToken(resolved.sessionId, type, token, "hook");
return sessionRegistry.recordHookSessionIdentity(resolved.sessionId, type, token,
+4
View File
@@ -190,6 +190,10 @@ export function compactResultText(compact: "not_sent" | "submitted_unconfirmed"
function manualReasonMessage(sessionName: string, reason: string): string {
switch (reason) {
case "draft_input_busy":
case "draft_input_unknown":
case "draft_input_changed":
return `Managed compaction stopped before its next input to preserve the composer in '${sessionName}'. Read rig compact ${shellQuote(sessionName)} --state; explicitly retry once its input box is empty. This attempt will not replay the held input.`;
case "terminal_policy_refused":
return `Managed compaction for '${sessionName}' was refused by its terminal policy. Read rig seat status ${shellQuote(sessionName)}; an authorised policy change uses rig seat set-terminal-policy. The existing rig send --dangerously-interact --reason path is unchanged; prompt and interruption guards still apply.`;
case "terminal_policy_unavailable":
@@ -128,7 +128,7 @@ describe("ClaudeCompactionEnforcer", () => {
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining("OpenRig automatic compaction preparation is now required"),
{ openrigNotice: true, beforeWrite: expect.any(Function) },
{ openrigNotice: true, preserveInput: true, beforeWrite: expect.any(Function) },
);
expect(send.mock.calls[0]![1]).toContain("Current context usage is 80%; configured compaction threshold is 80%");
expect(send.mock.calls[0]![1]).toContain("Operator pre-compaction instruction");
@@ -238,7 +238,7 @@ describe("ClaudeCompactionEnforcer", () => {
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining("OpenRig post-compaction turn boundary"),
{ openrigNotice: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) },
{ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) },
);
const belowRestore = await enforcer.maybeAutoCompact({
@@ -251,7 +251,7 @@ describe("ClaudeCompactionEnforcer", () => {
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining(`${TEST_HOME}/compaction/restore-pending/[email protected]`),
{ openrigNotice: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) },
{ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) },
);
expect(send.mock.calls[3]![1]).toContain("/tmp/claude.jsonl");
expect(send.mock.calls[3]![1]).toContain("Restoration is the current task");
@@ -267,7 +267,7 @@ describe("ClaudeCompactionEnforcer", () => {
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining("Now audit your compaction restore"),
{ openrigNotice: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) },
{ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) },
);
now += 61_000;
@@ -445,7 +445,7 @@ describe("ClaudeCompactionEnforcer", () => {
send.mockResolvedValueOnce(held);
expect(await highTick()).toEqual({ triggered: false, reason: "send_failed" });
expect(send.mock.calls.at(-1)?.[1]).toContain("restoring this Claude session after compaction");
expect(send.mock.calls.at(-1)?.[2]).toEqual({ openrigNotice: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true, beforeWrite: expect.any(Function) });
expect(send.mock.calls.at(-1)?.[2]).toEqual({ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true, beforeWrite: expect.any(Function) });
expect(await highTick()).toEqual({ triggered: true });
expect(send.mock.calls.at(-1)?.[1]).toContain("restoring this Claude session after compaction");
expect(await highTick()).toEqual({ triggered: true });
@@ -498,7 +498,7 @@ describe("ClaudeCompactionEnforcer", () => {
// Without a sample, every stage's send must read the pane: no hook may stand in for it. Only the turn boundary, the first
// step after /compact, reads past the compaction output above the input box.
for (let i = 0; i < 3; i++) {
expect(send.mock.calls[queuedSends + i]?.[2]).toEqual({ openrigNotice: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true,
expect(send.mock.calls[queuedSends + i]?.[2]).toEqual({ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true,
beforeWrite: expect.any(Function), ...(i === 0 ? { postCompactTurnBoundary: true } : {}) });
}
@@ -511,7 +511,7 @@ describe("ClaudeCompactionEnforcer", () => {
const { enforcer, send, queuedSends } = await queueStageWithGen("gen-uuid-1");
expect(await belowTick(enforcer)).toEqual({ triggered: true });
// Not pane-only; the first stage is the turn boundary, which also reads past the compaction output.
expect(send.mock.calls[queuedSends]?.[2]).toEqual({ openrigNotice: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
expect(send.mock.calls[queuedSends]?.[2]).toEqual({ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
});
it("the restore prompt tells a seat that did not compact to say so and skip the restore reading", () => {
@@ -1032,7 +1032,7 @@ describe("ClaudeCompactionEnforcer", () => {
expect(send).toHaveBeenCalledWith(
"claude-seat@rig",
expect.stringContaining("OpenRig automatic compaction preparation"),
{ openrigNotice: true, beforeWrite: expect.any(Function) },
{ openrigNotice: true, preserveInput: true, beforeWrite: expect.any(Function) },
);
const compact = await enforcer.maybeAutoCompact({
@@ -1128,28 +1128,28 @@ describe("ClaudeCompactionEnforcer", () => {
// Phase 1 — prep (a normal send, no wait option).
expect(send.mock.calls[0]![0]).toBe(SEAT);
expect(send.mock.calls[0]![1]).toContain("OpenRig manual compaction was requested");
expect(send.mock.calls[0]![2]).toEqual({ openrigNotice: true, beforeWrite: expect.any(Function) });
expect(send.mock.calls[0]![2]).toEqual({ openrigNotice: true, preserveInput: true, beforeWrite: expect.any(Function) });
// Phase 2 — /compact WITH the trust-bridge AND wait-for-idle (two-phase).
expect(send.mock.calls[1]![1]).toContain("Keep in the summary: OpenRig's later restore request is operator-authorized");
expect(send.mock.calls[0]![1]).toContain("Trust-channel note: after compaction");
expect(send.mock.calls[1]![2]).toEqual({ openrigNotice: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true, beforeWrite: expect.any(Function) });
expect(send.mock.calls[1]![2]).toEqual({ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true, beforeWrite: expect.any(Function) });
expect(send).toHaveBeenCalledTimes(2);
// The SAME maybeAutoCompact back-half (single restore path) drains it.
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("OpenRig post-compaction turn boundary"), { openrigNotice: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("OpenRig post-compaction turn boundary"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Please respond to this normal user message now"), { openrigNotice: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Please respond to this normal user message now"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20,
})).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Now audit your compaction restore"), { openrigNotice: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Now audit your compaction restore"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
expect(send).toHaveBeenCalledTimes(5);
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("audit-sent");
@@ -1272,14 +1272,14 @@ describe("ClaudeCompactionEnforcer", () => {
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("OpenRig post-compaction turn boundary"), { openrigNotice: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("OpenRig post-compaction turn boundary"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20,
})).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Now audit your compaction restore"), { openrigNotice: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Now audit your compaction restore"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
});
it("GHOST-STAGE (a) PM-pin: an AUTOMATION-initiated manual trigger is NOT drain-exempt — a disabled tick drains nothing (no laundering bypass)", async () => {
@@ -1332,7 +1332,7 @@ describe("ClaudeCompactionEnforcer", () => {
return { ok: true, activity: { state: "idle" } as any, attempts: 1, waitedMs: 0 };
};
await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(callOrder).toEqual(["prep", "idle", `compact:${JSON.stringify({ openrigNotice: true, waitForIdleMs: 1000, readinessFromPaneOnly: true })}`]);
expect(callOrder).toEqual(["prep", "idle", `compact:${JSON.stringify({ openrigNotice: true, preserveInput: true, waitForIdleMs: 1000, readinessFromPaneOnly: true })}`]);
});
it.each([0, 75])("wait-for-idle failure means /compact never landed: the back-half is NOT seeded (preparation delay=%ims)", async (delayMs) => {
@@ -1423,14 +1423,14 @@ describe("ClaudeCompactionEnforcer", () => {
sessionName: SEAT, runtime: "claude-code", usedPercentage: 95,
})).toEqual({ triggered: true }); // owed turn boundary, not another prep
expect(send).toHaveBeenCalledTimes(3);
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("OpenRig post-compaction turn boundary"), { openrigNotice: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true, postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("OpenRig post-compaction turn boundary"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true, postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true }); // restore
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20,
})).toEqual({ triggered: true }); // audit
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Now audit your compaction restore"), { openrigNotice: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Now audit your compaction restore"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("audit-sent");
expect(send).toHaveBeenCalledTimes(5);
});
@@ -1499,7 +1499,7 @@ describe("ClaudeCompactionEnforcer", () => {
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("compact-sent"); // busy timeout does NOT advance to restore-sent
const restoreCall = send.mock.calls.find(isRestore);
expect(restoreCall, "restore send must be attempted with an idle gate").toBeDefined();
expect(restoreCall![2]).toEqual({ openrigNotice: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) }); // idle-gated, not a busy-pane fire-and-forget
expect(restoreCall![2]).toEqual({ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) }); // idle-gated, not a busy-pane fire-and-forget
expect(send.mock.calls.some(isAudit)).toBe(false); // audit cannot overtake restore
// Claude goes idle → the SAME restore stage delivers and advances ONCE.
@@ -45,6 +45,9 @@ async function sendAfterHook(mode: Mode, consumer: "send" | "wake" | "handoff" =
const rigRepo = new RigRepository(db), registry = new SessionRegistry(db), eventBus = new EventBus(db);
const rig = rigRepo.createRig("rotation");
const node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code" });
// resume-stale-generation: an earlier occupant of this seat, registered and no longer current (#2341 keeps an
// unregistered generation from moving the token; a real earlier occupant's hook keeps today's write).
const earlier = mode === "resume-stale-generation" ? registry.mintOccupantTenure(node.id, "initial").generationUuid : undefined;
const session = registry.registerSession(node.id, name);
registry.updateStatus(session.id, "running");
registry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%1" });
@@ -112,7 +115,7 @@ async function sendAfterHook(mode: Mode, consumer: "send" | "wake" | "handoff" =
// case, a parent whose relay never ran, so the child claims first and only process binding
// refuses it.
const resumeLaunchFirst = !(mode === "resume-after-launch-hook" || mode === "resume-in-process-missed-first");
await hook(rotated, undefined, { source, generation: mode === "resume-stale-generation" ? "an-earlier-generation" : generation, resumeLaunch, hookPid, resumeLaunchFirst });
await hook(rotated, undefined, { source, generation: mode === "resume-stale-generation" ? earlier : generation, resumeLaunch, hookPid, resumeLaunchFirst });
if (mode === "resume-then-clear" || mode === "resume-clear-resume") await hook(third, undefined, { source: "clear", generation });
if (mode === "resume-clear-resume") await hook(rotated, undefined, { source: "resume", generation, resumeLaunch });
} else if (mode !== "unchanged" && !tokenOnly) {
@@ -0,0 +1,90 @@
// #2341: a handover starts its successor Claude on a reserved occupant generation, which the seat registers only when
// the handover commits. Until then (and for good, if the handover fails) that Claude's SessionStart must not move the
// seat's resume target off the occupant it still has. The shipped relay builds each post from a synthetic Claude
// SessionStart stdin; the real activity route and SQLite registry receive it.
import { createRequire } from "node:module";
import path from "node:path";
import { Hono } from "hono";
import { expect, it } from "vitest";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { activityRoutes } from "../src/routes/activity.js";
const relay = createRequire(import.meta.url)(path.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs")) as {
buildSessionIdentityPayload(p: unknown, env: Record<string, string>): Record<string, unknown> | null;
};
const saved = "11111111-1111-4111-8111-111111111111";
const incoming = "22222222-2222-4222-8222-222222222222";
const name = "dev-impl@rig-a";
function seat() {
const db = createDb();
migrate(db, ALL_MIGRATIONS);
const rigRepo = new RigRepository(db), registry = new SessionRegistry(db), eventBus = new EventBus(db);
const rig = rigRepo.createRig("rig-a");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: "/work/repo" });
const session = registry.registerSession(node.id, name);
registry.updateStatus(session.id, "running");
const generation = registry.currentOccupantTenure(node.id)?.generationUuid ?? "";
registry.updateResumeToken(session.id, "claude_id", saved, "hook");
const app = new Hono();
app.use("*", async (c, go) => {
c.set("agentActivityStore" as never, new AgentActivityStore({ db, eventBus }) as never);
c.set("activityHookToken" as never, "fixture" as never);
c.set("sessionRegistry" as never, registry as never);
c.set("eventBus" as never, eventBus as never);
await go();
});
app.route("/api/activity", activityRoutes);
const env: Record<string, string> = { OPENRIG_SESSION_NAME: name, OPENRIG_NODE_ID: node.id, OPENRIG_RUNTIME: "claude-code", OPENRIG_OCCUPANT_GENERATION: generation };
/** One Claude SessionStart, as a fresh launch reports it, through the shipped relay and the real route. */
const sessionStart = async (generationOverride?: string | null) => {
const envFor = { ...env };
if (generationOverride === null) delete envFor.OPENRIG_OCCUPANT_GENERATION;
else if (generationOverride !== undefined) envFor.OPENRIG_OCCUPANT_GENERATION = generationOverride;
const payload = relay.buildSessionIdentityPayload({ hook_event_name: "SessionStart", session_id: incoming, source: "startup" }, envFor);
const res = await app.request("/api/activity/hooks", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer fixture" }, body: JSON.stringify(payload) });
return { status: res.status, body: await res.json() as Record<string, unknown> };
};
const tokenOf = (id: string) => (db.prepare("SELECT resume_token FROM sessions WHERE id = ?").get(id) as { resume_token: string | null }).resume_token;
const identityEvents = () => (db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'agent.session_identity'").get() as { n: number }).n;
return { registry, node, session, sessionStart, tokenOf, identityEvents };
}
it("an uncommitted successor's SessionStart keeps the seat's resume target and says why", async () => {
const s = seat();
const reserved = s.registry.reserveOccupantGeneration()!;
const events = s.identityEvents();
const r = await s.sessionStart(reserved);
expect(r.status).toBe(200);
expect(r.body).toMatchObject({ ok: true, tokenPersisted: false, code: "generation_uncommitted" });
expect(s.tokenOf(s.session.id)).toBe(saved);
expect(s.identityEvents()).toBe(events);
});
it("the seat's registered current generation still moves its resume target", async () => {
const s = seat();
expect((await s.sessionStart()).body).toMatchObject({ ok: true, tokenPersisted: true });
expect(s.tokenOf(s.session.id)).toBe(incoming);
});
it("once the handover commits that generation, the same SessionStart moves the new session's resume target", async () => {
const s = seat();
const reserved = s.registry.reserveOccupantGeneration()!;
await s.sessionStart(reserved);
const successor = s.registry.registerClaimedSession(s.node.id, name, "handover", reserved);
expect((await s.sessionStart(reserved)).body).toMatchObject({ ok: true, tokenPersisted: true });
expect(s.tokenOf(successor.id)).toBe(incoming);
expect(s.tokenOf(s.session.id)).toBe(saved);
});
it("a post that carries no generation keeps today's write", async () => {
const s = seat();
expect((await s.sessionStart(null)).body).toMatchObject({ ok: true, tokenPersisted: true });
expect(s.tokenOf(s.session.id)).toBe(incoming);
});
@@ -210,7 +210,12 @@ const modeBars = [
// Claude seats; reported as the last line on another seat) below the mode bar.
const updateBanner = "Update available! Run: mise upgrade claude";
const clearHint = "new task? /clear to save 324.1k tokens";
const warningSets = [[update], [focus], [weekly], [update, focus], [updateBanner], [clearHint], [update, updateBanner]];
// Claude's footer once it has installed an update, in both spellings seen: "/restart to apply" on a Claude seat
// (2026-10-30) and "Restart to apply" in startup-submit-confirmation's screen.
const installedSlash = "✔ Update installed · /restart to apply";
const installedWord = "✔ Update installed · Restart to apply";
const warningSets = [[update], [focus], [weekly], [update, focus], [updateBanner], [clearHint], [update, updateBanner],
[installedSlash], [installedWord], [update, installedSlash]];
// Native, from a Claude seat running a hook mid-turn (2026-10-05): the timer follows a hook label.
const hookRow = "✽ Sketching… (running PostToolUse hook · 3m 12s · ↓ 7.9k tokens)";
function modePane(hint: string, trailers: string[], composer = "❯\u00a0", before = "● Ready.\n\n✻ Crunched for 2s") {
@@ -278,6 +283,18 @@ describe("#808: Claude permission-mode footers below status warnings", () => {
expect(classifyPaneActivity(reported.replace("Update available! Run: mise upgrade claude", clearHint)))
.toMatchObject({ state: "agent_idle", reason: "idle_prompt", evidence: "❯" });
});
// The installed-update footer last, as a Claude seat showed it (2026-10-30): an empty prompt under it read unknown,
// so managed compaction never saw the idle seat. Only the exact line is a status line; a near miss stays unknown.
it.each([installedSlash, installedWord])("reads an empty prompt under the installed-update footer as idle: %s", footer => {
const content = modePane("⏵⏵ auto mode on (shift+tab to cycle)", [footer]);
for (const options of [{ runtime: "claude-code" }, {}]) {
expect(classifyPaneActivity(content, options)).toMatchObject({ state: "agent_idle", reason: "idle_prompt", evidence: "❯" });
for (const nearMiss of [`${footer} now`, footer.replace("✔ ", ""), footer.replace("installed", "downloaded")]) {
expect(classifyPaneActivity(content.replace(footer, nearMiss), options).state).toBe("unknown");
}
}
});
});
describe("first guarded Claude send with retained warning-shaped composer", () => {
@@ -105,3 +105,47 @@ it("leaves a Nix wrapProgram launch (also a #! script) as it is today", async ()
// And a different launch's wrapper still does not prove it.
expect(await verifyClaudePaneProcess(nixInput({ selectedExecutable: "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-claude-code-2.1.281/bin/claude" }))).toBeNull();
});
// #2341: a wrapper that starts Claude WITHOUT exec stays the pane's foreground group leader, running the frozen script
// (`/bin/sh <wrapper> …`), and Claude is its child under any name (here a versioned binary outside the native layout).
const elf = "/opt/claude-runtime/2.1.316";
const launched = `--permission-mode auto --session-id ${token} --name seat@rig`;
function nonExec(patch: Partial<NativeProcessRow> = {}, wrapperCommand = `/bin/sh ${wrapper} ${launched}`): NativeProcessRow[] {
return [
{ pid: 20, ppid: 1, pgid: 20, tpgid: 21, executableName: "bash", command: "/bin/bash", startedAt },
{ pid: 21, ppid: 20, pgid: 21, tpgid: 21, executableName: "claude", command: wrapperCommand, startedAt },
{ pid: 22, ppid: 21, pgid: 21, tpgid: 21, executableName: "2.1.316", command: `${elf} ${launched}`, startedAt, ...patch },
];
}
const nonExecInput = (list: () => NativeProcessRow[], extra: Record<string, unknown> = {}) =>
input(list, { tmux: { getPanePid: async () => 20 }, ...extra });
it("proves the Claude that a non-exec #! wrapper started, as the direct child of the process running the frozen script", async () => {
expect((await verifyClaudePaneProcess(nonExecInput(() => nonExec())))?.process.pid).toBe(22);
expect((await verifyClaudePaneRuntime(nonExecInput(() => nonExec())))?.process.pid).toBe(22);
});
it("accepts the script after one shebang option (`#!/bin/bash -e`)", async () => {
const list = () => nonExec({}, `/bin/bash -e ${wrapper} ${launched}`);
expect((await verifyClaudePaneProcess(nonExecInput(list)))?.process.pid).toBe(22);
});
const nonExecRefused: [string, () => NativeProcessRow[], Record<string, unknown>?][] = [
["a child naming another conversation", () => nonExec({ command: `${elf} --session-id 00000000-0000-4000-8000-000000000002` })],
["a child naming no conversation", () => nonExec({ command: `${elf} --print hello` })],
["a parent running another script", () => nonExec({}, `/bin/sh /home/user/.local/bin/other ${launched}`)],
["a script path that is only an argument", () => nonExec({}, `/bin/sh -c 'exec x' ${wrapper} ${launched}`)],
["a child outside the foreground group", () => nonExec({ pgid: 88 })],
["a grandchild of the script", () => [...nonExec({ executableName: "sh", command: "/bin/sh -c run-claude" }), { ...nonExec()[2]!, pid: 23, ppid: 22 }]],
["two children naming the conversation", () => [...nonExec(), { ...nonExec()[2]!, pid: 23 }]],
["a launch that wasn't a script", () => nonExec(), { scriptLaunch: false }],
["another launch's wrapper", () => nonExec(), { selectedExecutable: "/home/user/.local/bin/claude-other" }],
];
it.each(nonExecRefused)("still refuses %s", async (_name, list, extra = {}) => {
expect(await verifyClaudePaneProcess(nonExecInput(list, extra))).toBeNull();
});
it("refuses a non-exec child whose process changes between the two samples", async () => {
let reads = 0;
expect(await verifyClaudePaneProcess(nonExecInput(() => nonExec(reads++ === 0 ? {} : { pid: 24 })))).toBeNull();
});
@@ -27,7 +27,7 @@ function frame(body: string): ComposerSnapshot {
cursor: { x: 2 + stringWidth(body), y: 2, width: 100000, height: 60 }, inMode: false };
}
async function fixture(opts: { pane: string | null } = { pane: "%1" }) {
async function fixture(opts: { pane: string | null; mode?: "off" | "draft-aware"; footer?: boolean; suggestion?: "faint" | "plain" } = { pane: "%1" }) {
const db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
@@ -38,25 +38,33 @@ async function fixture(opts: { pane: string | null } = { pane: "%1" }) {
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name));
guard.recoverActivation();
let body = "", staged = "";
let onStage: (() => void) | undefined;
const snapshot = () => {
const s = frame(body);
if (!body && opts.suggestion) s.screen = frame(opts.suggestion === "faint" ? '\x1b[2mTry "fix typecheck errors"\x1b[22m' : 'Try "fix typecheck errors"').screen;
if (opts.footer) s.screen += "\n✔ Update installed · /restart to apply";
return s;
};
const writes: string[] = [];
const tmux = new TmuxAdapter(async command => {
if (command.includes("paste-buffer")) { writes.push(`paste:${staged}`); body = staged; }
if (command.includes("paste-buffer")) { writes.push(`paste:${staged}`); body += staged; }
if (command.includes("send-keys") && command.includes("Enter")) { writes.push("enter"); body = ""; }
return "";
}, { writeFile: async (_path, text) => { staged = text; }, unlink: async () => {}, tmpName: () => "/fixture/compact-input", bufferName: () => "compact-input" });
}, { writeFile: async (_path, text) => { staged = text; onStage?.(); }, unlink: async () => {}, tmpName: () => "/fixture/compact-input", bufferName: () => "compact-input" });
tmux.deliveryGuard = guard;
vi.spyOn(tmux, "probeSession").mockResolvedValue({ state: "present" });
vi.spyOn(tmux, "listPanes").mockImplementation(async () => [{ id: "%1" } as never]);
vi.spyOn(tmux, "getPaneCommand").mockResolvedValue("node");
vi.spyOn(tmux, "captureComposerSnapshot").mockImplementation(async () => frame(body));
vi.spyOn(tmux, "capturePaneContent").mockImplementation(async () => frame(body).screen);
vi.spyOn(tmux, "captureComposerSnapshot").mockImplementation(async () => snapshot());
vi.spyOn(tmux, "capturePaneContent").mockImplementation(async () => snapshot().screen);
vi.spyOn(tmux, "getPaneCursorPosition").mockImplementation(async () => snapshot().cursor);
const transport = new SessionTransport({ db, tmuxAdapter: tmux, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db),
sleep: (ms) => new Promise((r) => setTimeout(r, Math.min(ms, 5))), waitForIdlePollMs: 5 });
// Readiness is the variable under test: the pane says idle or working.
let activity = "idle";
(transport as any).classifySendReadiness = async () => ({ state: activity, reason: "fixture", evidenceSource: "pane_heuristic" });
(transport as any).claudeDeliveryObservation = async () => ({ state: "unknown", detail: "fixture" });
await guard.set("a", { mode: "draft-aware" }, "human@test", "draft-aware seat");
await guard.set("a", { mode: opts.mode ?? "draft-aware" }, "human@test", "fixture input policy");
const home = mkdtempSync(join(tmpdir(), "compaction-draft-aware-"));
let clock = 10_000;
const policy = { enabled: true, thresholdPercent: 80, preCompactInstruction: "Write a restore map.", compactInstruction: "", messageInline: "", messageFilePath: "", postRestoreAuditInstruction: "" };
@@ -74,9 +82,82 @@ async function fixture(opts: { pane: string | null } = { pane: "%1" }) {
const effects = () => ({ compactPastes: writes.filter((w) => w.startsWith("paste:/compact")).length, prepPastes: writes.filter((w) => w.startsWith("paste:") && !w.startsWith("paste:/compact")).length, enters: writes.filter((w) => w === "enter").length });
const compactRows = (state?: string) => (db.prepare("SELECT outbox_id AS id, delivery_state AS state FROM outbox_entries WHERE body LIKE '/compact%'").all() as Array<{ id: string; state: string }>)
.filter((r) => !state || r.state === state);
return { db, e, transport, writes, compactRows, rows, effects, stageMap, activity: (s: string) => { activity = s; }, draft: (text: string) => { body = text; } };
return { db, e, transport, tmux, writes, compactRows, rows, effects, stageMap, body: () => body,
onStage: (fn: () => void) => { onStage = fn; }, activity: (s: string) => { activity = s; }, draft: (text: string) => { body = text; } };
}
it.each([
{ footer: false, skipMap: false }, { footer: false, skipMap: true },
{ footer: true, skipMap: false }, { footer: true, skipMap: true },
])("managed preparation preserves a draft with typing guard off (footer=$footer, skipMap=$skipMap)", async ({ footer, skipMap }) => {
const f = await fixture({ pane: "%1", mode: "off", footer });
f.draft("an unsent message");
const outcome = await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap });
f.e.cancelPreparation(seat);
expect(outcome).toMatchObject({ triggered: false, reason: "draft_input_busy", preparation: { delivery: "not_sent", compact: "not_sent" } });
expect(f.body()).toBe("an unsent message");
expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: 0, enters: 0 });
});
it("checks the composer after buffer setup, immediately before the preparation paste", async () => {
const f = await fixture({ pane: "%1", mode: "off", footer: true });
f.onStage(() => f.draft("typed while the buffer was prepared"));
const outcome = await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
expect(outcome).toMatchObject({ reason: "draft_input_busy", preparation: { delivery: "not_sent" } });
expect(f.body()).toBe("typed while the buffer was prepared");
expect(f.writes).toEqual([]);
});
it("an unreadable composer holds managed preparation without input", async () => {
const f = await fixture({ pane: "%1", mode: "off" });
vi.spyOn(f.tmux, "getPaneCursorPosition").mockResolvedValue(null);
const outcome = await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
expect(outcome).toMatchObject({ reason: "draft_input_unknown", preparation: { delivery: "not_sent" } });
expect(f.writes).toEqual([]);
});
it("a draft arriving after preparation prevents the managed command paste", async () => {
const f = await fixture({ pane: "%1", mode: "off" });
const send = f.transport.send.bind(f.transport);
vi.spyOn(f.transport, "send").mockImplementation(async (s, text, opts) => {
if (text.startsWith("/compact")) f.draft("draft before command");
return send(s, text, opts);
});
const outcome = await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
expect(outcome).toMatchObject({ reason: "draft_input_busy", preparation: { compact: "not_sent" } });
expect(f.body()).toBe("draft before command");
expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: 1, enters: 1 });
});
it("an owed restore stage holds its place while the composer has a draft", async () => {
const f = await fixture({ pane: "%1", mode: "off" });
expect(await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true })).toMatchObject({ triggered: true });
f.draft("draft before restore");
const before = f.writes.length;
await f.e.maybeAutoCompact(input);
expect(f.body()).toBe("draft before restore");
expect(f.writes.length).toBe(before);
expect(f.e.getOwedRestore(seat)?.stage).toBe("turn_boundary");
});
it.each([false, true])("empty composer still permits managed preparation and command (footer=%s)", async footer => {
const f = await fixture({ pane: "%1", mode: "off", footer });
expect(await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true })).toMatchObject({ triggered: true });
expect(f.effects()).toEqual({ compactPastes: 1, prepPastes: 1, enters: 2 });
});
it("a high-confidence faint suggestion still permits managed input", async () => {
const f = await fixture({ pane: "%1", mode: "off", suggestion: "faint" });
expect(await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true })).toMatchObject({ triggered: true });
expect(f.effects()).toEqual({ compactPastes: 1, prepPastes: 1, enters: 2 });
});
it("possible suggestion words without faint styling do not authorize managed input", async () => {
const f = await fixture({ pane: "%1", mode: "off", suggestion: "plain" });
expect(await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true })).toMatchObject({ reason: "draft_input_unknown" });
expect(f.writes).toEqual([]);
});
it("automatic, recorded pane (existing-behaviour control, green before this PR's fixes): busy then idle, the lifecycle lease refuses before any send, so no /compact and no held row", async () => {
const f = await fixture();
expect(f.transport.guardedDelivery).toBeDefined();
@@ -175,6 +175,8 @@ for(const boundary of ['writeFile','load-buffer','key-list'])it(`actual tmux ada
if(argv[1]==='paste-buffer')pasted=true;
return '';
});
vi.spyOn(adapter,'getPaneCursorPosition').mockResolvedValue({x:2,y:2,width:80,height:24});
vi.spyOn(adapter,'capturePaneContent').mockResolvedValue('Ready\n────────────────────\n❯ \n────────────────────\n? for shortcuts\n');
adapter.deliveryGuard=f.guard;(f.transport as any).tmuxAdapter=adapter;
await f.e.maybeAutoCompact(input);await f.e.maybeAutoCompact(input);
expect(commands.filter(c=>c[1]==='paste-buffer')).toHaveLength(boundary==='key-list'?1:0);
+80 -5
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, readdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { TerminalService } from "../src/domain/terminal/terminal-service.js";
@@ -30,7 +30,7 @@ function fixture() {
const frames = new Map<string, ComposerSnapshot | null>();
let transform = (view: ComposedView) => view;
let lateFocus: { seat: string; reads: number } | undefined;
let clock = 100, lostCreate = false, lostClose = false, readsFailed = false;
let clock = 100, lostCreate = false, lostClose = false, readsFailed = false, hideViewers = false;
const run = async (args: string[]) => {
calls.push(args);
const command = args.slice(0, 2).join(" ");
@@ -48,7 +48,7 @@ function fixture() {
if (args.length !== 2) throw new Error("usage: herdr workspace list");
return JSON.stringify({ result: { workspaces: [{ workspace_id: "w1" }] } });
}
if (command === "pane list") return JSON.stringify({ result: { panes: [...panes.values()] } });
if (command === "pane list") return JSON.stringify({ result: { panes: [...panes.values()].filter(p => !hideViewers || !p.cwd.includes("/viewers/")) } });
if (command === "pane get") {
const selected = panes.get(args[2]!); if (!selected) throw new Error("missing pane");
return JSON.stringify({ result: { pane: selected } });
@@ -89,8 +89,14 @@ function fixture() {
return { type: "pane_focused" };
} } });
const provider = create();
const follow = (seats = ["a", "b", "c"], controls = {}, complete = true) => provider.follow("qitem:task", transform(task(seats, complete)), { enabled: true, ...controls }, async () => true);
return { follow, create, panes, calls, rpc, inputs, frames, advance: (ms: number) => { clock += ms; },
const follow = (seats = ["a", "b", "c"], controls = {}, complete = true, current = async () => true) => provider.follow("qitem:task", transform(task(seats, complete)), { enabled: true, ...controls }, current);
const journal = () => {
const directory = path.join(root, readdirSync(root)[0]!, "viewers");
return path.join(directory, readdirSync(directory).find(name => name.endsWith(".json"))!);
};
const exitViewer = (seat: string) => { [...panes.values()].find(p => p.argv.at(-1) === `$${seat}`)!.argv = ["/bin/bash"]; };
return { follow, create, panes, calls, rpc, inputs, frames, exitViewer, journal,
hideViewers: () => { hideViewers = true; }, advance: (ms: number) => { clock += ms; },
loseCreate: () => { lostCreate = true; }, loseClose: () => { lostClose = true; }, failReads: () => { readsFailed = true; },
changeTarget: () => { transform = view => ({ ...view, opened: view.opened.map(target => target.seat === "a"
? { ...target, paneCommand: `${target.paneCommand} --changed`, attachArgv: [...target.attachArgv!.slice(0, 4), "replacement-a", ...target.attachArgv!.slice(5)] } : target) }); },
@@ -99,6 +105,75 @@ function fixture() {
}
describe("stable task-following workspace", () => {
it("resumes a join paused before attach dispatch through its existing journal without changing drafts or focus", async () => {
const f = fixture(); await f.follow(); f.inputs.set("b", "text"); f.focusSeat("b");
const before = [...f.panes.values()].map(p => [p.pane_id, p.terminal_id]); const count = f.calls.length; let reads = 0;
const paused = await f.follow(["a", "b", "c", "d"], {}, true, async () => ++reads !== 4);
expect(paused.opened).toEqual(["a", "b", "c"]);
const entry = JSON.parse(readFileSync(f.journal(), "utf8")).entries[JSON.stringify(["self", "d"])];
expect(entry.phase).toBe("created"); expect(entry.pane).toBeTruthy();
expect(f.calls.slice(count).filter(c => c[1] === "run")).toHaveLength(0);
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true });
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
expect(f.calls.slice(count).filter(c => c[1] === "run")).toHaveLength(1);
expect([...f.panes.values()].slice(0, 4).map(p => [p.pane_id, p.terminal_id])).toEqual(before);
expect([...f.panes.values()].find(p => p.focused)?.argv.at(-1)).toBe("$b");
expect(f.inputs.get("b")).toBe("text"); expect(f.rpc).toHaveLength(0);
});
it("an unconfirmed older viewer holds the whole view before a new join", async () => {
const f = fixture(); await f.follow(); f.exitViewer("c"); const count = f.calls.length;
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: false, opened: ["a", "b", "c"], following: { state: "retained" } });
expect(f.calls.slice(count).some(c => ["run", "close", "split", "create"].includes(c[1]!))).toBe(false);
});
it("keeps an already dispatched attach unconfirmed with its original seat, phase and reason", async () => {
const f = fixture(); await f.follow(); f.exitViewer("a"); const count = f.calls.length;
const held = await f.follow();
expect(held).toMatchObject({ ok: false, opened: ["a", "b", "c"] });
expect(held.error).toContain("a:"); expect(held.error).toContain("starting");
expect(held.error).toContain("The viewer process is not confirmed");
await f.follow(); expect(f.calls.slice(count).some(c => ["run", "close", "split"].includes(c[1]!))).toBe(false);
});
it("a known refusal before split preserves its reason and allows a later join", async () => {
const f = fixture(); await f.follow(); const count = f.calls.length; let reads = 0;
const held = await f.follow(["a", "b", "c", "d"], {}, true, async () => ++reads !== 3);
expect(held.opened).toEqual(["a", "b", "c"]);
expect(held.following!.events.join("\n")).toContain("d: creating: Membership changed before the split.");
expect(f.calls.slice(count).some(c => ["split", "run", "close"].includes(c[1]!))).toBe(false);
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
});
it("a known pre-split refusal retires its unused intent even when directory cleanup fails", async () => {
const f = fixture(); await f.follow(); const count = f.calls.length; let reads = 0, retainedFile = "";
const held = await f.follow(["a", "b", "c", "d"], {}, true, async () => {
if (++reads !== 3) return true;
const entry = JSON.parse(readFileSync(f.journal(), "utf8")).entries[JSON.stringify(["self", "d"])];
retainedFile = path.join(entry.cwd, "retained-note"); writeFileSync(retainedFile, "keep this file");
return false;
});
expect(held.following!.events.join("\n")).toContain("d: creating: Membership changed before the split.");
expect(JSON.parse(readFileSync(f.journal(), "utf8")).entries[JSON.stringify(["self", "d"])]).toBeUndefined();
expect(readFileSync(retainedFile, "utf8")).toBe("keep this file");
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: true, opened: ["a", "b", "c", "d"] });
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
});
it("an uncertain dispatched split keeps its intent even when enumeration is empty", async () => {
const f = fixture(); await f.follow(); f.loseCreate(); f.hideViewers(); const count = f.calls.length;
const held = await f.follow(["a", "b", "c", "d"]);
expect(held.following!.events.join("\n")).toContain("d: creating: Viewer effect is unconfirmed: lost create response");
const before = readFileSync(f.journal(), "utf8");
await f.follow(["a", "b", "c", "d"]);
expect(readFileSync(f.journal(), "utf8")).toBe(before);
expect(f.calls.slice(count).filter(c => c[1] === "split")).toHaveLength(1);
expect(f.calls.slice(count).filter(c => c[1] === "run")).toHaveLength(0);
});
it("a changed terminal identity holds the whole view before joining another member", async () => {
const f = fixture(); await f.follow();
[...f.panes.values()].find(p => p.cwd.includes("/viewers/"))!.terminal_id = "other-generation";
const count = f.calls.length;
expect(await f.follow(["a", "b", "c", "d"])).toMatchObject({ ok: false, following: { state: "retained" } });
expect(f.calls.slice(count).some(c => ["run", "split", "close"].includes(c[1]!))).toBe(false);
});
it.each(["", "\x1b[2mAsk Codex to do anything\x1b[22m"])("closes only a departed viewer when the real reader confirms its two-footer Codex composer is empty (%s)", async body => {
const f = fixture(); f.frames.set("c", codexTwoRowComposer(body));
await f.follow();
@@ -70,10 +70,10 @@ describe("authoritative scoped terminal membership", () => {
f.add("unqualified", "other");
expect(f.read()).toMatchObject({ task: { complete: true, members: [{ seat: "a", obligations: [{ qitemId: "ours" }] }] } });
});
it.each(["same-name", "same-id", "name-id"])("reports %s collisions instead of choosing the first scope", mode => {
it.each(["same-name", "same-id", "name-id"])("reports bare %s collisions instead of choosing the first scope", mode => {
const f = fixture();
f.addSlice("another", mode === "same-name" ? "03-build" : mode === "name-id" ? "OPR.3" : "other", mode === "same-id" ? "OPR.3" : "different");
expect(f.read()).toMatchObject({ code: "scope_ambiguous" });
expect(f.read(mode === "same-name" ? "slice:03-build" : "slice:OPR.3")).toMatchObject({ code: "scope_ambiguous" });
});
it("honors SPEC precedence and exact Unicode, plus, internal-space and percent-literal ids", () => {
const f = fixture(), dir = f.addSlice("another", "04-other", "révision + 50%", "Other scope");
@@ -113,10 +113,10 @@ describe("authoritative scoped terminal membership", () => {
});
it("rejects a slice name claimed as another alias even when selected through its unique id", () => {
it("keeps an unplaced inverse-alias row incomplete when selected through a unique id", () => {
const f = fixture(); f.addSlice("other-mission", "other", "03-build");
f.add("wrong-owner", "b", "pending", ["slice:03-build"]);
expect(f.read("slice:OPR.3")).toMatchObject({ code: "scope_ambiguous" });
expect(f.read("slice:OPR.3")).toMatchObject({ task: { complete: false, members: [] } });
});
function cachedFixture() {
@@ -151,3 +151,57 @@ it("rereads identities for an existing unwatched legacy root and after a scope e
f.addSlice("another", "new");
expect(f.resolver.resolve("slice:new")).toMatchObject({ task: { complete: true } });
});
describe("mission placement of colliding slice aliases", () => {
it("follows repeated release template names by their unique IDs without mixing owners", () => {
const f = fixture();
f.addSlice("release-0.7.6", "01-regression-checks", "OPR.0.7.6.1");
f.addSlice("release-0.6.13", "01-regression-checks", "OPR.0.6.13.1");
f.add("current", "a", "pending", ["mission:release-0.7.6", "slice:01-regression-checks"]);
f.add("older", "b", "blocked", ["mission:release-0.6.13", "slice:01-regression-checks"]);
f.add("by-id", "a", "in-progress", ["slice:OPR.0.7.6.1"]);
expect(f.read("slice:OPR.0.7.6.1")).toMatchObject({ task: { complete: true, members: [
{ seat: "a", obligations: [{ qitemId: "by-id" }, { qitemId: "current" }] }] } });
expect(f.read("slice:OPR.0.6.13.1")).toMatchObject({ task: { complete: true, members: [{ seat: "b" }] } });
expect(f.read("slice:01-regression-checks")).toMatchObject({ code: "scope_ambiguous" });
f.db.prepare("UPDATE queue_items SET state='done'").run();
expect(f.read("slice:OPR.0.7.6.1")).toMatchObject({ task: { complete: true, members: [] } });
});
it.each(["id-name", "name-id", "id-id"])("places a %s alias only through its one indexed mission", mode => {
const f = fixture();
f.addSlice("other-mission", mode === "id-name" ? "OPR.3" : "other", mode === "name-id" ? "03-build" : mode === "id-id" ? "OPR.3" : "OTHER");
const alias = mode === "name-id" ? "03-build" : "OPR.3";
const selector = mode === "name-id" ? "slice:OPR.3" : "slice:03-build";
f.add("ours", "a", "pending", ["mission:release", `slice:${alias}`]);
f.add("theirs", "b", "pending", ["mission:other-mission", `slice:${alias}`]);
expect(f.read(selector)).toMatchObject({ task: { complete: true, members: [{ seat: "a" }] } });
expect(f.read(`slice:${alias}`)).toMatchObject({ code: "scope_ambiguous" });
});
it.each([
{ label: "no mission", tags: [] },
{ label: "contradictory missions", tags: ["mission:release", "mission:other-mission"] },
{ label: "non-claimant mission", tags: ["mission:not-a-claimant"] },
])("retains known owners and reports an unplaceable colliding row with $label", ({ tags }) => {
const f = fixture(); f.addSlice("other-mission", "03-build", "OTHER");
f.add("known", "a", "pending", ["slice:OPR.3"]);
f.add("unplaced", "b", "blocked", ["slice:03-build", ...tags]);
const result = f.read("slice:OPR.3"); if ("code" in result) throw new Error(result.error);
expect(result.task).toMatchObject({ complete: false, members: [{ seat: "a" }] });
expect(result.task.issues.join(" ")).toContain("unplaced");
});
it("does not choose between two same-mission claimants or contradictory alias placements", () => {
const f = fixture(); f.addSlice("release", "other", "03-build");
f.add("same-mission", "b", "pending", ["slice:03-build", "mission:release"]);
expect(f.read("slice:OPR.3")).toMatchObject({ task: { complete: false, members: [] } });
f.db.prepare("DELETE FROM queue_items").run();
f.addSlice("other-mission", "03-build", "OTHER");
f.add("contradictory", "b", "pending", ["slice:OPR.3", "slice:03-build", "mission:other-mission"]);
expect(f.read("slice:OPR.3")).toMatchObject({ task: { complete: false, members: [] } });
});
it("does not invent a mission context for colliding flat legacy slices", () => {
const f = fixture();
const dir = path.join(f.root, "legacy"); mkdirSync(dir); writeFileSync(path.join(dir, "SPEC.md"), "---\nid: 03-build\n---\n");
expect(f.read("slice:legacy")).toMatchObject({ code: "scope_ambiguous" });
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.7",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -1,10 +1,10 @@
{
"release": "0.7.2",
"archiveSha256": "42b4d4980c062f49f99db7e843a2ba96c0066a420cbaace381f435e22699d4c5",
"release": "0.7.6",
"archiveSha256": "e7d2a515b8d946620ca567713b42e75f1b85b8e11eb85326c4d0c44621d05fe6",
"plugins": {
"openrig-core": {
"version": "0.1.19",
"sha256": "21343eb7a36b0726a50372edc781b8e5b344c819a456499552f2560fb9712cb1"
"version": "0.1.23",
"sha256": "d17d96a596396f0b935baa84b2d39ff647163b8410a9031e95ce1e398886a8d0"
}
}
}