Compare commits

...
3 Commits
Author SHA1 Message Date
dev-driver 235a46c143 docs(changelog): add the 0.7.2 entry 2026-10-25 18:00:16 +00:00
dev-driver a48d959ba0 chore(release): prepare 0.7.2: version, installer pins, openrig-core 0.1.19 and what's new
- Version fields 0.7.1 to 0.7.2 in the root, cli, daemon and ui package.json
  and the five matching package-lock.json lines (not class-variance-authority's
  own 0.7.1).
- Installer pins v0.7.0 to v0.7.2 in README, getting-started and quickstart.
  Main still carried 0.7.0's pins, since 0.7.1's pin change was candidate-only.
- openrig-core 0.1.17 to 0.1.19 in both plugin manifests. 0.7.1 published
  0.1.18 from its candidate; since then the bundled delegating-work and
  openrig-user skills changed, and startup's managed refresh only replaces
  installed files when the bundled version is newer.
- docs/reference/whats-new.md (and its context-pack symlink) describes 0.7.2,
  in the same commit as the version, as the package-content test requires.
2026-10-25 18:00:08 +00:00
dev-driver 4191955389 Revert "fix(daemon): read a native install's version label as claude with an OS executable witness (#1345)"
This reverts commit 6c31f02706 for the 0.7.2 candidate, retaining 0.7.1's exclusion (Root's option B). Derived against 4222e608, not replayed from 0.7.1's ef07562d: since 0.7.1's cut, claude-code-adapter.ts, claude-resume.ts, claim-service.ts and startup.ts changed. Two conflicts resolved: the adapter's managed-blocks import keeps main's later names, and startup's Claude adapter keeps main's restoreReadPolicy while dropping #1345's resolvePermissionMode and its import. Eight files' changed lines equal #1345's reversed; startup's differ only by main's added argument on the same line.
2026-10-25 17:42:50 +00:00
21 changed files with 888 additions and 589 deletions

No files matched your search

+574
View File
@@ -8,6 +8,580 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.2]
OpenRig 0.7.2 gives each seat more of its own setup. A seat can run on its own
Claude or Codex account, move to another login or to the other runtime without
leaving its team, and keep its own Claude tool rules in a shared project
folder. Sends are refused less often at seats that are plainly working, and
more reliably at real dialogs, including Pi's. Codex seats can still commit
after a restore, an assistant over MCP can hand work on, and Rex joins Herdr
and cmux as a terminal. It is built from the commit tagged `v0.7.2`. The changes
since 0.7.1:
### Before you upgrade
- **Restart the daemon after installing.** Six database updates apply on its
first start.
```bash
npm install -g @openrig/cli@0.7.2
rig daemon stop && rig daemon start
```
- **Startup replaces OpenRig's own plugin and skill files** (openrig-core goes
from 0.1.18 to 0.1.19; the `delegating-work` and `openrig-user` skills
changed). As in 0.7.1, it first copies each file it replaces under
`$OPENRIG_HOME/backups/managed-refresh/` and logs where, edited or not. If
you did edit one, reapply your change from the copy.
- **On Linux, if a seat's tools use the session bus, set the bus address in
the rig's or member's `env:`.** Seats now start with an address that can't
connect, even when a shell startup file sets `XDG_RUNTIME_DIR` (see Behaviour
that changed and Known issues).
([#2036](https://github.com/mvschwarz/openrig/pull/2036))
- **Declare a seat's own `CLAUDE_CONFIG_DIR` or `CODEX_HOME` in the rig spec's
`env:`.** A one-off `rig up --env` no longer applies those two, and says so.
([#1945](https://github.com/mvschwarz/openrig/pull/1945))
- **If you might go back to an earlier version, set equivalent native Claude
rules first.** Code from before #1914 opens the upgraded database but leaves
per-member Claude tool rules out of launch, so a seat's denies stop applying.
`docs/reference/rig-spec.md` has the details.
([#1914](https://github.com/mvschwarz/openrig/pull/1914))
### What's new and fixed
**A seat's own account and runtime**
- A seat can run on its own Claude or Codex login. Sign in to a separate home
once, natively, then name it as an absolute `CLAUDE_CONFIG_DIR` or
`CODEX_HOME` in the member's or rig's `env:`. It applies at the seat's next
launch, and `rig seat status` and `rig provider status` show which home a
seat uses and where it comes from.
([#1937](https://github.com/mvschwarz/openrig/pull/1937),
[#1945](https://github.com/mvschwarz/openrig/pull/1945))
- `rig provider switch --seat <seat>` now moves one seat to another
subscription login on the same conversation. A Codex seat moves to a login
saved in its own Codex home (`--to-account <name>`, Linux only), after its
current login is saved as `before-switch-<time>`. A Claude seat moves to its
own subscription token file (`--token-file <path>`, made with
`claude setup-token`), and `--stored-login` moves it back; OpenRig keeps only
the file's path and your label, never the token. `rig provider precheck
--seat <seat>` shows what a switch would do, changing nothing.
([#1972](https://github.com/mvschwarz/openrig/pull/1972),
[#1980](https://github.com/mvschwarz/openrig/pull/1980),
[#1991](https://github.com/mvschwarz/openrig/pull/1991))
- `rig seat set-runtime <seat> --runtime claude-code|codex --reason "<why>"`
moves a live seat between Codex and Claude Code. The successor starts first,
gets a handover and confirms it before the old runtime stops, and the seat
keeps its address, queue rows and claims. `--dry-run` shows the settings, what
is cleared and the costs; `--recover` finishes an interrupted switch. Built
on @Aschenbath's design in
[#1214](https://github.com/mvschwarz/openrig/pull/1214).
([#1895](https://github.com/mvschwarz/openrig/pull/1895))
- `rig provider status --refresh --provider claude --claude-token-file <path>
--credential-label <label>` reads a Claude token's usage on request, one
model turn per refresh.
([#1901](https://github.com/mvschwarz/openrig/pull/1901))
- A Claude seat set to the alias `haiku` with the `plan` permission is told at
launch, at restore and in `rig seat status` that Claude Code answers its
plan-mode turns with Sonnet, and how to set a full Haiku id.
([#1942](https://github.com/mvschwarz/openrig/pull/1942))
**Claude tool rules per member.** Claude members that share a project folder
can keep their own tool rules: `claude_permissions` with `allow` and `deny`
lists in the spec, or `rig seat set-permissions <seat> --rules <file.json>
--reason "<why>"` for an existing seat. A change is audited and applies at the
next managed launch or resume; OpenRig never writes the rules into the
project's Claude settings. Checked natively on Claude Code 2.1.309: two seats
in one folder enforce their own rules, a managed policy wins, and fresh, resume
and fork launches enforce them. See Known issues for what isn't claimed.
([#1914](https://github.com/mvschwarz/openrig/pull/1914))
**Sending to a seat**
- A working Claude seat that quotes approval-prompt text in its own output, such
as `› 1. Yes, proceed (y)`, no longer has sends and queue wakes refused as if
it were at a prompt, including when Claude draws its input box high in a tall
pane. Real permission, question and MCP dialogs still refuse.
([#1955](https://github.com/mvschwarz/openrig/pull/1955),
[#2005](https://github.com/mvschwarz/openrig/pull/2005))
- A draft in a Claude seat's input box that starts with a blank line no longer
reads as an empty box, so a send isn't typed into it.
([#1977](https://github.com/mvschwarz/openrig/pull/1977))
- A draft-aware send to a Claude seat keeps ownership of its paste when Claude's
footer changes to a hint such as "paste again to expand" (see Known issues
for #14). ([#1921](https://github.com/mvschwarz/openrig/pull/1921))
- An ordinary `rig send` to an adopted Pi window waiting at a confirmation
dialog is refused with `target_needs_input` and types nothing, instead of
choosing the highlighted answer
([#2015](https://github.com/mvschwarz/openrig/pull/2015)). A later change
extends that recognition to Pi's input and editor dialogs, including help
rows that wrap in an 80-column pane
([#2026](https://github.com/mvschwarz/openrig/pull/2026)). A Pi seat that
OpenRig runs shows an extension dialog in its pane, cancels it and marks the
seat as needing attention
([#1996](https://github.com/mvschwarz/openrig/pull/1996)).
- A queue wake refused at an approval prompt is retried once when the seat's
attention clears, instead of waiting for the stuck sweep.
([#1961](https://github.com/mvschwarz/openrig/pull/1961))
- After a person declines a Claude permission prompt, `rig seat clear-attention
<seat> --reason "<what you saw>"` clears the prompt the latest hook still
names, and the send refusal says to.
([#1912](https://github.com/mvschwarz/openrig/pull/1912))
- `rig send` to a mid-turn Codex seat says the message joins the running turn
and may redirect it, and names `--wait-for-idle` for separate work.
([#2007](https://github.com/mvschwarz/openrig/pull/2007))
- `rig capture` says when a pane smaller than 80x24 may cut off the prompt a
seat is at, with the command to resize it.
([#1969](https://github.com/mvschwarz/openrig/pull/1969))
- A seat with an unrelated pane beside its own still gets sends and wakes, in
its recorded pane.
([#1616](https://github.com/mvschwarz/openrig/pull/1616), @Vaishnavi220506)
**Codex seats**
- A Codex seat on the default workspace-write sandbox can still commit after a
resume, fork or restore, and can write its own topology folder, so it keeps
its `LEARNED.md`. When the sandbox refuses its recap, the daemon saves it.
([#2000](https://github.com/mvschwarz/openrig/pull/2000),
[#2023](https://github.com/mvschwarz/openrig/pull/2023),
[#2018](https://github.com/mvschwarz/openrig/pull/2018),
[#2008](https://github.com/mvschwarz/openrig/pull/2008))
- `rig seat restart --keep-conversation` on a Codex seat keeps a writable root
whose path holds a space, looks for the resumed conversation in the home the
running Codex uses, and, when it can't prove the resume, says the pane
restarted instead of "nothing was restarted".
([#2003](https://github.com/mvschwarz/openrig/pull/2003),
[#2037](https://github.com/mvschwarz/openrig/pull/2037),
[#1950](https://github.com/mvschwarz/openrig/pull/1950),
[#1958](https://github.com/mvschwarz/openrig/pull/1958))
- When a Codex home has a `hooks.json`, OpenRig puts its activity hooks there
and moves its own old inline block out of `config.toml`. Your hooks, their
trust and your disables stay; @fsafey found the mixed-format problem in their
fork. ([#1957](https://github.com/mvschwarz/openrig/pull/1957)) Inline hook
trust is keyed by the managed group's actual position.
([#2028](https://github.com/mvschwarz/openrig/pull/2028), @dajiaohuang)
- A signed-out Codex seat at the sign-in chooser or API-key entry reports
`login_required` instead of waiting out the readiness timeout (Codex 0.164.0
and 0.162.1).
([#1967](https://github.com/mvschwarz/openrig/pull/1967), @Aschenbath)
([#1983](https://github.com/mvschwarz/openrig/pull/1983))
- A resumed Codex seat is recognized when its footer shows a model with a
reasoning label, such as `GPT-6.1 Sol medium`, or a versioned non-`gpt`
model, so `rig up --existing` no longer stops there.
([#1944](https://github.com/mvschwarz/openrig/pull/1944))
([#1778](https://github.com/mvschwarz/openrig/pull/1778), @Nikhila-Dhevi-R)
**Claude compaction and restore**
- A managed Claude restore reads its own restore files without stopping at a
Read approval, including on macOS, through a symlinked home or project, and
for the generic or configured `post-compact-extra.md` and preparation root.
The grants apply from the seat's next launch or resume.
([#1940](https://github.com/mvschwarz/openrig/pull/1940),
[#1952](https://github.com/mvschwarz/openrig/pull/1952),
[#1962](https://github.com/mvschwarz/openrig/pull/1962))
- A seat restored with `claude --resume` that stops at Claude's resume chooser
in a tall pane is reported as waiting for a person's choice instead of
resumed, and a Codex model chooser there reads as `model_selection_gate`. An
old chooser above a resumed session no longer reads as live.
([#2010](https://github.com/mvschwarz/openrig/pull/2010))
- After a managed compaction, a Claude seat's restore starts on the next poll
instead of waiting for something to change its screen.
([#1971](https://github.com/mvschwarz/openrig/pull/1971))
- `rig compact <seat> --state` and seat status show an owed restore stage and
its last delivery result, and repeated failures open one queue item for
whoever can act. ([#1953](https://github.com/mvschwarz/openrig/pull/1953))
- When Claude refuses a startup turn late, for example on an expired login,
OpenRig checks again before the next startup input's Enter and holds the rest
as owed. ([#1976](https://github.com/mvschwarz/openrig/pull/1976))
- The model-divergence monitor no longer flags a Claude seat pinned to an
undated id that answers with its dated snapshot, or to a Bedrock
inference-profile id.
([#1927](https://github.com/mvschwarz/openrig/pull/1927),
[#1931](https://github.com/mvschwarz/openrig/pull/1931))
**Assistants and other agents**
- OpenRig's MCP server adds `rig_queue_handoff` and `rig_queue_unclaim` (36
tools), so an assistant can pass held work on, or put it back, in one step.
([#1970](https://github.com/mvschwarz/openrig/pull/1970))
- A community-harness preset can set `first_input: true` to hand the harness
its first startup turn at launch instead of pasting it into the pane. The
Cursor example uses it and is no longer startup-assisted, and OpenClaw seats
keep named conversations as a local agent or a Gateway client.
([#1987](https://github.com/mvschwarz/openrig/pull/1987),
[#2017](https://github.com/mvschwarz/openrig/pull/2017),
[#2009](https://github.com/mvschwarz/openrig/pull/2009))
- `rig stream act <id>` records what a reader did about a stream item:
`--work <row>`, `--decline "<reason>"` or `--duplicate <id>`.
`rig stream show` lists each reader's current action.
([#1899](https://github.com/mvschwarz/openrig/pull/1899))
- The guide that `rig acp run --help` names now ships:
`rig context get reference/acp-agents.md`.
([#1930](https://github.com/mvschwarz/openrig/pull/1930))
**Running a team**
- Rex is a terminal provider. From a Rex pane, `rig terminal open <view>
--window` and the TUI open the team's view as a Rex session;
`--provider rex` selects it.
([#1935](https://github.com/mvschwarz/openrig/pull/1935), @vanducng)
- An open rig view keeps a seat's tile when its tmux session is replaced, and a
fresh replacement prints how to reopen the view.
([#1964](https://github.com/mvschwarz/openrig/pull/1964),
[#1941](https://github.com/mvschwarz/openrig/pull/1941))
- `rig launch <rig> <seat>` first-starts one seat of a team imported with
`--materialize-only`, without restoring the others.
([#1965](https://github.com/mvschwarz/openrig/pull/1965))
- A pod seat that never launched starts fresh from its saved launch recipe after
a daemon restart, instead of waiting at a shell.
([#1503](https://github.com/mvschwarz/openrig/pull/1503), @Vaishnavi220506)
- After a team's tmux server dies, `rig down` settles its seats, so `rig
archive` and `rig up` can proceed.
([#1999](https://github.com/mvschwarz/openrig/pull/1999))
- A team of terminal seats reads fully restored after `rig start`, and
`rig down` names the terminals it stops.
([#1984](https://github.com/mvschwarz/openrig/pull/1984))
- A launch whose tmux made no session fails as `session_not_created` or
`session_unconfirmed` instead of reading launched.
([#1910](https://github.com/mvschwarz/openrig/pull/1910))
- A service bundle install on a machine without Docker Compose v2 stops with
`compose_missing` before it writes the target or creates the team, and
`--plan` warns that it would.
([#2025](https://github.com/mvschwarz/openrig/pull/2025))
- `rig export` copies the team's culture file and names each seat whose agent
ref points outside the exported folder.
([#1975](https://github.com/mvschwarz/openrig/pull/1975))
- `rig queue resolve <row> --decision "<evidence>"` also releases a row parked
on a typed blocker such as `external:`, `fold:` or `auth:`.
([#1809](https://github.com/mvschwarz/openrig/pull/1809), @dajiaohuang, from
@BillyOutlast's report)
- `rig queue whoami` resolves a project-tagged row against its catalogued
project, `rig proof add` finds a slice in another catalogued project, and
`rig scope mission create` writes the mission's name.
([#1526](https://github.com/mvschwarz/openrig/pull/1526), @Vaishnavi220506)
([#1938](https://github.com/mvschwarz/openrig/pull/1938),
[#1936](https://github.com/mvschwarz/openrig/pull/1936))
- A `rig queue create` whose outcome is unknown reads the row back once by its
ID before reporting, and a cross-host write that times out reads
INDETERMINATE instead of failed.
([#1978](https://github.com/mvschwarz/openrig/pull/1978),
[#1911](https://github.com/mvschwarz/openrig/pull/1911))
- Slack: every post names its sender first, an escalation names the seat the
work waits on, a reply from another registered person reaches the agent as
their own message instead of answering someone else's question, and Slack
rows name who wrote.
([#1960](https://github.com/mvschwarz/openrig/pull/1960),
[#1893](https://github.com/mvschwarz/openrig/pull/1893))
**Reliability**
- A dead seat's stuck-sweep finding goes to someone who can act, not to the
dead seat, and says which rule chose them.
([#2011](https://github.com/mvschwarz/openrig/pull/2011),
[#2021](https://github.com/mvschwarz/openrig/pull/2021),
[#2033](https://github.com/mvschwarz/openrig/pull/2033))
- A seat held at a choice prompt that no hook announces, such as Claude's
usage-limit dialog, now escalates while it holds work.
([#1919](https://github.com/mvschwarz/openrig/pull/1919))
- The parked-owner reminder no longer reaches a seat in the first 5 minutes
after it starts waiting, such as just after a handoff or a question to a
person. A seat at an approval or question prompt gets no grace.
([#1963](https://github.com/mvschwarz/openrig/pull/1963))
- A Pi or OMP seat's second startup block reaches the running turn instead of
raising `runtime_error`.
([#1943](https://github.com/mvschwarz/openrig/pull/1943), reported by
@astral-fleet-sessions)
- The daemon starts listening before it works through a backlog of queue
notifications, so startup doesn't time out behind them.
([#1993](https://github.com/mvschwarz/openrig/pull/1993))
- A startup database-conflict check that can't get an answer now says so,
instead of reading like a clean one.
([#1974](https://github.com/mvschwarz/openrig/pull/1974), from @calv-io-n's
finding)
- Proof-source monitoring watches the missions root, not the whole workspace.
([#1951](https://github.com/mvschwarz/openrig/pull/1951))
- Queue views, health reads with long histories and send checks do less
repeated work. ([#2031](https://github.com/mvschwarz/openrig/pull/2031),
[#2013](https://github.com/mvschwarz/openrig/pull/2013),
[#1994](https://github.com/mvschwarz/openrig/pull/1994))
- A stalled-after-claim finding's age counts from the stall, not the claim.
([#1959](https://github.com/mvschwarz/openrig/pull/1959))
- From contributors: queued terminal input is dropped after its client
disconnects ([#1636](https://github.com/mvschwarz/openrig/pull/1636),
@Vaishnavi220506); TUI focused summaries load their health findings
([#1986](https://github.com/mvschwarz/openrig/pull/1986), @Kero-66 and
@Aschenbath); the TUI keeps the stream's connection state across pages
([#2014](https://github.com/mvschwarz/openrig/pull/2014), @Nikhi00718); the
web dashboard reads a skipped kernel boot as "Boot not tracked", not
"Unknown" ([#1917](https://github.com/mvschwarz/openrig/pull/1917),
@Aschenbath, reported by @Kero-66); the scenario runner binds compaction and
restore, and the test-system docs state the CI job's real counts
([#1924](https://github.com/mvschwarz/openrig/pull/1924),
[#1925](https://github.com/mvschwarz/openrig/pull/1925), @DeryFerd).
- Guidance: the installer guide says what setup and starting Codex seats write
to your home configuration, and the operator puts project work on a team's
queue; a Codex user who declines the remembered command rule is offered the
operator conversation; `rig queue update --help` and its refusal say how to
cancel a row (`--state done --closure-reason canceled`); `rig skill audit`
says what its stale and hash findings mean; 14 subcommands that showed a
blank help summary now have one; the `openrig-user` skill says a queue
create's request ID isn't the row; `delegating-work` also applies when judging
a delegated result; and `docs/reference/seat-delivery.md` lists all six
typing-guard modes.
([#2001](https://github.com/mvschwarz/openrig/pull/2001),
[#1973](https://github.com/mvschwarz/openrig/pull/1973),
[#1956](https://github.com/mvschwarz/openrig/pull/1956),
[#1966](https://github.com/mvschwarz/openrig/pull/1966),
[#1923](https://github.com/mvschwarz/openrig/pull/1923),
[#1909](https://github.com/mvschwarz/openrig/pull/1909),
[#2012](https://github.com/mvschwarz/openrig/pull/2012),
[#1989](https://github.com/mvschwarz/openrig/pull/1989))
### Behaviour that changed
- On Linux, a seat launched by OpenRig now gets the daemon's `XDG_RUNTIME_DIR`,
with `DBUS_SESSION_BUS_ADDRESS=disabled:`, a session bus address that can't
connect. Tools whose sockets live in the runtime directory work in seats
without per-tool setup, and a seat's D-Bus clients don't find or start the
session bus on their own. A seat's own `XDG_RUNTIME_DIR`, from `env:` for
example, leaves both as before, and its own bus address is kept. Unchanged:
macOS, a daemon started without `XDG_RUNTIME_DIR`, and
`terminal.inherit_tmux_environment` on. See Known issues.
([#2036](https://github.com/mvschwarz/openrig/pull/2036), reported by
@borfast in
[#2030](https://github.com/mvschwarz/openrig/issues/2030))
- `rig up --env CLAUDE_CONFIG_DIR=…` or `CODEX_HOME=…` isn't applied, and the
CLI says so; other `--env` values work as before. Name a seat's home in the
spec's `env:`. ([#1945](https://github.com/mvschwarz/openrig/pull/1945))
- `rig provider switch` acts: it used to answer `failed_safely` every time.
([#1972](https://github.com/mvschwarz/openrig/pull/1972),
[#1980](https://github.com/mvschwarz/openrig/pull/1980))
- A `rig send` to a seat that is switching runtime is held, not typed
(`outcome: retained`, `reason: runtime_switch_in_progress`); send it again
after the switch. ([#1895](https://github.com/mvschwarz/openrig/pull/1895))
- `rig seat status --json` always carries `permissions.account.advisories`
(empty unless there's an advisory).
([#1942](https://github.com/mvschwarz/openrig/pull/1942))
- Every Slack post starts with `From <seat> on <machine>`, and an escalation's
headline reads `Waiting on <seat>: <summary>`; update anything that read the
sender from the last line.
([#1960](https://github.com/mvschwarz/openrig/pull/1960))
- A row OpenRig creates itself, such as a stuck-sweep finding or a workflow
escalation, wakes its seat from OpenRig's own sender with no reply line, instead
of from the row's creator. A stuck-sweep closure is recorded as
`stuck-sweep@system`.
([#1905](https://github.com/mvschwarz/openrig/pull/1905))
([#1811](https://github.com/mvschwarz/openrig/pull/1811), @Aschenbath)
- With `agents.operator_session` set, a dead seat's finding goes to that agent
first, then the operator seat (`workspace.operator_seat_name`), then the
row's creator. Approvals still reach the person. With it unset, routing is as
before. ([#2021](https://github.com/mvschwarz/openrig/pull/2021))
- An alert for a seat at a choice prompt follows `queue.wake_human_rung`: under
`explicit-only` it stays with the operator seat as a visible row, unless that
seat is a person. Only one choice-prompt alert reaches a person at a time on a
machine; the others wait as visible rows and post when it retires.
Permission-prompt alerts post as before.
([#1919](https://github.com/mvschwarz/openrig/pull/1919))
- On a Codex home with a `hooks.json`, OpenRig's activity hooks go there, and
its own old inline block moves out of `config.toml`.
([#1957](https://github.com/mvschwarz/openrig/pull/1957))
- The recap store refuses to write through a link at `RECAP.md` or
`recap-superseded/` (`recap_write_refused`), a layout that used to work.
([#2018](https://github.com/mvschwarz/openrig/pull/2018))
- Cross-host queue writes wait 15 seconds for the daemon's answer, and a
timeout reads "queue request got no answer in time" with the ID to reconcile
by. ([#1911](https://github.com/mvschwarz/openrig/pull/1911))
- The Cursor reference preset is `startup_assisted: false` and passes its first
prompt at launch. ([#2017](https://github.com/mvschwarz/openrig/pull/2017))
- In a Pi seat that OpenRig runs, an extension `/command` typed mid-turn runs
at once instead of being refused as a steer.
([#1943](https://github.com/mvschwarz/openrig/pull/1943))
### Known issues
- **On Linux, seats now get the runtime directory with a session bus address
that can't connect.** This is an accepted trade-off, and default hygiene, not
isolation. A shell startup file that sets only `XDG_RUNTIME_DIR` used to let a
seat's clients find the session bus; now the seat keeps
`DBUS_SESSION_BUS_ADDRESS=disabled:`. To use a session bus, set its address in
the rig's or member's `env:`. `systemctl --user` reaches the person's user
service manager from a seat, whatever the bus address. A Codex stdio MCP
server still needs `env_vars = ["XDG_RUNTIME_DIR"]` in Codex's `config.toml`
to see the variable. ([#2036](https://github.com/mvschwarz/openrig/pull/2036))
- **Per-member Claude tool rules have limits.** Member denies aren't claimed to
hold in bypass mode (`full_bypass`). Handover and restore carry the rules, but
aren't proven to keep the same conversation. An allow list doesn't withdraw
broader inherited grants, and these are Claude's own tool rules, not OS or
credential isolation. A change applies at the next launch or resume, not in a
running process. Going back to code from before #1914 drops the stored rules
from launch (see Before you upgrade).
([#1914](https://github.com/mvschwarz/openrig/pull/1914))
- **Pi dialog recognition is a bounded screen check.**
[#2015](https://github.com/mvschwarz/openrig/pull/2015) refuses ordinary
sends at an adopted Pi confirmation dialog, and
[#2026](https://github.com/mvschwarz/openrig/pull/2026) later extended the
same classifier to Pi's input and editor dialogs and wrapped help rows. Each
was checked with a real Pi for its own case only. Layouts outside them keep
the earlier behaviour, so an ordinary send can still type into them: more than
six rows under a confirmation dialog's border, a widget that draws its own
border, clipped or incomplete help, more than three wrapped help rows, and
custom extension UIs. Neither check was repeated on the final 0.7.2 build.
- **Moving a seat's account or runtime has limits.** A conversation saved under
a seat's earlier home doesn't resume in its new one, and live usage refresh
still reads only the daemon's own logins. A Codex login switch is Linux only;
other seats on that Codex home keep their old login until their next token
refresh, then stop with "signed in to another account" until you run the
printed `rig seat restart <peer> --keep-conversation` for each. A runtime
switch starts a new native conversation (only the handover carries over),
clears the source runtime's model, effort and Codex profile, and doesn't apply
Claude launch additions such as an agent file or run startup slash commands.
None of these was run with real accounts before this release.
- **A new Claude agent's startup text can stay unsent in its input box.** Press
Enter in its terminal.
- **Claude seats on a macOS native install can read as needing attention after a
restore** ([#86](https://github.com/mvschwarz/openrig/issues/86)). Check the
seat's terminal before acting on it.
- **A Slack channel-mapping change needs a daemon restart.** Don't use
`rig slack disable` then `enable` for it, which marks waiting alerts as seen.
- **Ubuntu 22.04's tmux 3.2a:** the known crashing query is gone, but a narrower
race remains. Prefer another tmux.
- **`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, run
`rig daemon start` yourself first.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) still
gets no automatic usage reading.** The reader accepts only claude.ai
subscription logins and reports `not_subscription_login` for any other. Read a
token's usage on request with `rig provider status --refresh --provider claude
--claude-token-file <path> --credential-label <label>`; the label is yours,
not an account the provider confirmed.
- **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. 0.7.2
reads Claude's recognized footer hints as part of the same composer
([#1921](https://github.com/mvschwarz/openrig/pull/1921)), but the original
failure didn't reproduce in a native check, so
[#14](https://github.com/mvschwarz/openrig/issues/14) remains open.
- **Prompt-looking text in a seat's recent output can still be mistaken for an
active approval prompt in some layouts, so OpenRig can refuse sends and queue
wakes.** 0.7.2 lets sends through to a Claude seat whose running timer row sits
directly above its empty, framed input box (checked natively on Claude Code
2.1.309, for the layouts exercised). An idle Claude seat with quoted prompt
text above its input box, a draft in the box, a mode bar OpenRig doesn't
recognize (such as `⏸ manual mode on`), an unframed box, and Codex seats are
unchanged. Inspect the current native prompt and the refusal before retrying;
do not assume that waiting 15 seconds clears it.
- **The shared-database check needs `lsof`.** Without a working `lsof`, the
check can't tell whether another live daemon holds the database. The daemon
now says so at startup and on `/healthz`, but the optional
`daemon.db_conflict: stop` stops only on a confirmed conflict, so it doesn't
stop then. The warning for a database outside `OPENRIG_HOME` doesn't depend
on `lsof`. ([#1974](https://github.com/mvschwarz/openrig/pull/1974))
- **Managed Claude restoration can still ask for approvals.** 0.7.2 lets a seat
read its own restore files without asking, from its next launch or resume. A
relative `policies.claude_compaction.message_file_path` still asks once, and a
packet read can ask after the native conversation changes. In 0.7.1's
representative test, the restore also asked to run four read-only commands,
which these grants don't cover. Check the retained restore map and reported
gaps before treating restoration as complete.
([#1940](https://github.com/mvschwarz/openrig/pull/1940),
[#1952](https://github.com/mvschwarz/openrig/pull/1952),
[#1962](https://github.com/mvschwarz/openrig/pull/1962)) A native check on Claude
2.1.309 passed the Read grants with no approval prompt; a complete restore
with no help isn't claimed.
- **A managed Claude restore can remain owed.** If a seat stays idle after
compaction, `rig compact <seat> --state` and seat status now show the owed
stage and its last delivery result; also read its saved restore map. Before
sending a manual restore request, reconcile the native conversation and
pending state to avoid a duplicate; do not repeat `/compact` merely because
the restore request has not arrived.
([#1953](https://github.com/mvschwarz/openrig/pull/1953))
- **On Linux, very large trees can still cause severe daemon stalls and
file-watch-limit failures.** Proof-source monitoring now recurses only at the
configured missions root, so unrelated checkouts and build trees in the
workspace aren't watched, but a large missions tree still is, and the stalls'
cause hasn't been confirmed.
([#1813](https://github.com/mvschwarz/openrig/issues/1813),
[#1951](https://github.com/mvschwarz/openrig/pull/1951))
- **A Claude startup refusal that arrives after OpenRig's last check can still
race the next startup input's Enter.** When OpenRig withholds Enter, the
pasted input may stay in the input box: don't resend it, and follow the
recovery step the seat reports. Recovery after signing in wasn't verified
natively.
([#1976](https://github.com/mvschwarz/openrig/pull/1976))
- **Claude Code answers plan-mode turns with Sonnet when a seat's model is the
alias `haiku`.** That's Claude Code's own behaviour, and OpenRig now says so.
Configuring the full id `claude-haiku-5-5` gave a Haiku answer in a plan-mode
follow-up on Claude Code 2.1.309.
([#1942](https://github.com/mvschwarz/openrig/pull/1942))
- **A cross-host queue create can still end with an unknown outcome.** A
timed-out `rig queue create --host` now reads INDETERMINATE, and the CLI reads
the row back once by its ID. If it's still unknown, check that host for the
original row ID before retrying. If the row exists, do not create a
replacement. If a retry is needed, reuse the same `--id` and payload; an
unsuccessful read is not proof that the row is absent.
([#1911](https://github.com/mvschwarz/openrig/pull/1911),
[#1978](https://github.com/mvschwarz/openrig/pull/1978))
- **A Codex seat whose queue-state folder or team-workspace path contains a
space can still be misread on an ordinary resume or restore,** because those
arguments come before the conversation id. An in-place restart is fixed.
([#2037](https://github.com/mvschwarz/openrig/pull/2037))
- **Automatic Codex hook approval needs OpenRig's plugin folder to match what
shipped.** If `$OPENRIG_HOME/plugins/openrig-core` holds files that didn't
ship, the Codex plugin still installs or upgrades, but OpenRig approves
nothing itself: an existing approval stays as it was, and Codex's own hook
review applies. On a restart without a version change, if a hook file or the
plugin manifest there differs from what this version shipped, OpenRig doesn't
install or upgrade its Codex plugin. On a version upgrade, OpenRig's managed
refresh replaces an edited plugin file with the shipped one and keeps your
edited copy under `$OPENRIG_HOME/backups/managed-refresh/`; after upgrading,
inspect those backups and reapply any deliberate customization.
- **Terminal views:** a view opened before 0.7.2 needs closing and reopening to
keep its tiles through a session replacement, and a replacement that takes
too long can still need a reopen. This wasn't run in the Herdr, cmux or Rex
apps or over SSH, so
[#1939](https://github.com/mvschwarz/openrig/issues/1939) stays open. Rex
wasn't run on Linux, and a Rex tile that fails 2 to 10 seconds after opening
is still listed as opened.
([#1964](https://github.com/mvschwarz/openrig/pull/1964),
[#1935](https://github.com/mvschwarz/openrig/pull/1935))
- **Herdr's own status for a seat can read idle or unknown while OpenRig shows
it waiting** (`needs-input`), because OpenRig doesn't feed Herdr a state. Use
OpenRig's view or the Feed to find waiting seats. Reopening a view with
`--provider herdr` adds a workspace each time; from inside Herdr,
`rig terminal open <view> --window` reuses a matching one.
([#2032](https://github.com/mvschwarz/openrig/pull/2032))
### Credits
Thank you to everyone whose work is in this release: @Vaishnavi220506,
@Aschenbath, @dajiaohuang, @DeryFerd, @vanducng, @Nikhila-Dhevi-R, @Nikhi00718
and @Kero-66, and to @borfast, @BillyOutlast, @tianqizhao0704, @calv-io-n,
@george661, @fsafey, @astral-fleet-sessions and the Heretek-AI team, whose
reports, ideas and outside audit led to fixes here.
Thanks also to @alexveli1 and @george661, who reported
[#1764](https://github.com/mvschwarz/openrig/issues/1764) (addressed by
[#1778](https://github.com/mvschwarz/openrig/pull/1778) and
[#1944](https://github.com/mvschwarz/openrig/pull/1944)); @kainne44 and
@bobbiejaxn, who reported [#81](https://github.com/mvschwarz/openrig/issues/81)
(partly addressed by [#1919](https://github.com/mvschwarz/openrig/pull/1919));
@oneandonlydean, who reported
[#1813](https://github.com/mvschwarz/openrig/issues/1813) (partly addressed by
[#1951](https://github.com/mvschwarz/openrig/pull/1951)); @todie, who reported
[#1419](https://github.com/mvschwarz/openrig/issues/1419) (its closure
attribution addressed by
[#1811](https://github.com/mvschwarz/openrig/pull/1811)); @reisalbuquerque, who
reported [#49](https://github.com/mvschwarz/openrig/issues/49) (partly addressed
by [#1503](https://github.com/mvschwarz/openrig/pull/1503)); @jimallen, who
reported [#17](https://github.com/mvschwarz/openrig/issues/17) (partly addressed
by [#2028](https://github.com/mvschwarz/openrig/pull/2028)); @parkingmeter, who
reported [#1933](https://github.com/mvschwarz/openrig/issues/1933) (addressed by
[#1963](https://github.com/mvschwarz/openrig/pull/1963)); @vanducng, who
reported [#1939](https://github.com/mvschwarz/openrig/issues/1939) (addressed by
[#1964](https://github.com/mvschwarz/openrig/pull/1964) and
[#1941](https://github.com/mvschwarz/openrig/pull/1941), open for confirmation);
and @haichongfu and @vadym-abstract-factory, who reported
[#14](https://github.com/mvschwarz/openrig/issues/14) (worked on again in
[#1921](https://github.com/mvschwarz/openrig/pull/1921); still open).
## [0.7.1] - 2026-10-25
Published 2026-10-25 as `@openrig/[email protected]`, built from the commit tagged
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.0/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.2/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.0/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.2/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.0` 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.2` 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.0/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh
```
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
+2 -2
View File
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
+1 -1
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.0`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.2`. 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.
+264 -151
View File
@@ -1,165 +1,288 @@
# What changed in OpenRig 0.7.1
# What changed in OpenRig 0.7.2
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:
This note is for an agent on an install that was just upgraded to 0.7.2. It lists only what behaves differently from
0.7.1 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.
after an upgrade it can still hold the previous version's note. The capability map hasn't been
updated for 0.7.2 yet, so this note is where 0.7.2's new commands are listed.
## 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
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Six migrations run on the first start (132, 133,
134, 136, 138 and 139). They add records for runtime switches, stream reader actions, a seat's launch recipe, a Claude
seat's token reference and its tool rules, and rebuild the provider usage readings table to hold labelled Claude
token readings, keeping existing readings. No setting is added or removed.
- **The bundled openrig-core plugin goes from 0.1.18 to 0.1.19.** Only two skills changed: `delegating-work` (its
description now also covers deciding how far to trust a delegated result) and `openrig-user` (reading a queue write,
a seat's own Claude or Codex account, moving one seat to another login, and the MCP handoff and unclaim tools). As in
0.7.1, startup 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.
- **Startup also refreshes the person-facing `rigs` skill** in `~/.claude/skills` and `~/.agents/skills`, with the same
copy first, when OpenRig installed it (a copy installed another way is left as it is). It now says what setup and
Codex seats write to a Codex home, offers the operator conversation to a Codex user who declined Codex's remembered
command rule, and covers Rex.
- **Codex seats:** OpenRig's Codex plugin is openrig-core, so Codex sees it as changed. As in 0.7.1, OpenRig renews
its own hook approval only where the installed plugin folder matches what shipped. If a Codex seat stops at Codex's
"Hooks need review" after the upgrade, that folder held other files: review the hooks in Codex.
- **On Linux, check seats that use your session bus** (the keyring, notifications). From their next launch, restore or
handover they get `DBUS_SESSION_BUS_ADDRESS=disabled:` unless their `env:` sets an address (see Known gaps).
- **Launch fixes reach a seat at its next launch, resume, restore or handover,** not a running process: the Read
grants a managed Claude restore needs, a Codex seat's writable git and topology folders, a seat's own account home,
the Linux runtime directory and member tool rules.
- **Close and reopen terminal views opened before the upgrade** (Herdr, cmux or tmux). Only views opened on 0.7.2 keep
their tiles when a seat's session is replaced.
- **If a seat runs on a home given by `rig up --env CLAUDE_CONFIG_DIR=…` or `--env CODEX_HOME=…`,** declare it in the
member's or rig's `env:` instead. 0.7.2 leaves both names out of `--env`, with a note.
## New things you can do
- **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.
- **Move a live seat between Codex and Claude Code:** `rig seat set-runtime <seat> --runtime <claude-code|codex>
--reason "…"`, with `--dry-run` first to see the target's settings, what is cleared and the costs. The successor
starts beside the current occupant, reads a handover (the seat's recap, its open rows and the predecessor's screen)
and confirms with `rig seat set-runtime <seat> --confirm <switch-id> --note "…"`. Only then does the seat move, keeping
its address, connections, rows and claims. Without a confirmation in 180 seconds (`--confirm-timeout`) nothing
changes; `--recover` finishes an interrupted switch. The switch starts a new conversation and isn't written back to
the team file (`docs/reference/getting-started.md`, "Move a seat to the other runtime").
- **Run a seat on its own Claude or Codex account:** sign a home in once natively (`CLAUDE_CONFIG_DIR=<home> claude`
then `/login`, or `CODEX_HOME=<home> codex login`), then declare that absolute path as `CLAUDE_CONFIG_DIR` or
`CODEX_HOME` in the member's or rig's `env:`. Launch, resume, restore, handover, OpenRig's managed Codex config and
hooks, usage limits and status follow it. `rig seat status` and `rig provider status` show the home and where it came
from; a relative value is ignored and shown.
- **Move one seat to another login on the same conversation:** for a Codex seat on Linux, `rig provider precheck --seat
<seat> --to-account <name>`, then `rig provider switch --seat <seat> --to-account <name>`, using a login saved in that
seat's Codex home (`rig auth save`). It saves the outgoing login, activates the chosen one, restarts the seat in place
and prints a restart command for every peer on that home. For a Claude seat, `--to-account <label> --token-file
<absolute path>` moves it to its own subscription token (from `claude setup-token`, in a file only the user can read);
OpenRig keeps the path and label, never the token. `--stored-login` moves it back.
- **Read a labelled Claude token's usage:** `rig provider status --refresh --provider claude --claude-token-file
<absolute path> --credential-label <label>`. Each refresh spends one model turn; status and the TUI show only the
cached reading. The label is yours, not an account Claude reported.
- **Give each Claude member its own tool rules,** even in a shared folder: `claude_permissions: { allow: [...], deny:
[...] }` on the member, or `rig seat set-permissions <seat> --rules <json-file> --reason "…"` for an existing seat.
The update is audited, applies at the next managed launch or resume, and leaves the permission mode alone. Rules join
OpenRig's one `--settings` value and never go into the project's Claude settings. They're native tool rules, not OS
isolation, and an allow list doesn't withdraw broader inherited grants. Limits are under Known gaps.
- **Say what you did about a stream item:** `rig stream act <id>` with exactly one of `--work <qitem>` (work you opened
or joined through the queue), `--decline "<reason>"` or `--duplicate <qitem|stream id>`. Actions are append-only, the
poster's `rig stream show` lists each reader's current action, and retention keeps an item a reader acted on. A
reader who links different work from someone else is told about theirs.
- **Pass held work on over MCP:** `rig_queue_handoff` closes an item as handed off and opens its successor in one step,
and `rig_queue_unclaim` puts a claim back. The MCP server has 36 tools.
- **Release a row parked on a typed blocker:** `rig queue resolve <id> --decision "<evidence>"` now also unparks a row
blocked on `external:`, `fold:` or `auth:`, and records the evidence as a wait resolution, not a person's decision.
- **Open views in Rex:** from a Rex pane, `rig terminal open <view> --window` or the TUI opens the view as a new Rex
session and switches the Rex app to it. `--provider rex` picks it explicitly. Outside Rex the default stays Herdr,
then cmux (`docs/reference/terminal-sessions.md`). Not run on Linux.
- **Hand a community harness its first turn at launch:** a preset's `first_input: true` writes the first startup text
to a private file and passes `OPENRIG_FIRST_INPUT=<path>`; the launcher hands it to the harness. The Cursor example
uses it and now sets `startup_assisted: false`. OpenClaw gains a Gateway-client example (`openclaw-gateway.yaml`)
beside the local one, and both resume a named conversation.
- **Start one seat of a materialized team:** after `rig import <spec> --materialize-only`, `rig launch <rig> <logical
id>` (for example `dev.lead`) starts only that never-started seat. Teams materialized by older daemons aren't covered.
- **Clear a prompt a hook still names:** once `rig capture <seat>` shows it's gone, `rig seat clear-attention <seat>
--reason "<what you saw>"` clears a retained permission or waiting hook, so sends and wakes go through. A refusal that
rests on such a hook now names this step.
## Behaviour that changed
- **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.
- **A working Claude seat that quotes approval text** in its output no longer refuses sends and wakes as if it were at
a prompt, including when its input box is drawn high in a tall pane. The permission dialog, AskUserQuestion and the
MCP menu still refuse.
- **A Claude draft whose first line is blank** reads as a draft, so a send doesn't type into it or submit it.
- **A draft-aware paste to a Claude seat** is no longer read as edited when Claude swaps its footer for "paste again to
expand", so OpenRig no longer withholds Enter for that reason. It isn't confirmed to fix #14 (see Known gaps).
- **A send no longer answers a Pi dialog.** At an adopted Pi TUI's selector (a confirmation), an ordinary `rig send`
used to type and press Enter, choosing the highlighted answer; it now refuses with `target_needs_input` and types
nothing (#2015). #2026 extends this to Pi v1.4.0 extension input and editor dialogs, including help wrapped over up to
three rows in an 80-column pane. The audited deliberate-interaction route is unchanged.
- **Runner-managed Pi seats show and cancel an extension dialog** in the pane, so nothing is approved, and need
attention until their next agent run. On Pi and OMP seats, a second startup block joins the running turn instead of
failing with "Agent is already processing".
- **A send to a busy Codex seat** warns that the message joins the running turn and may redirect it, and names
`--wait-for-idle <seconds>` for separate work.
- **A seat with another pane in its tmux session** gets sends and wakes in its recorded pane instead of a refusal.
- **A wake refused at an approval prompt** retries once when the seat's attention clears, instead of waiting for the
stuck sweep.
- **A seat held at a choice prompt that no hook announces** (a usage-limit dialog, say) now escalates once the refusal
has held for one retry interval (`queue.wake_retry_interval_seconds`, 300 seconds by default). Choice-prompt alerts
follow `queue.wake_human_rung`: under `explicit-only` they stay with the operator seat unless that seat is a person.
Permission-prompt alerts post as before.
- **The parked-owner reminder waits 5 minutes** after a seat starts waiting, so a seat that just handed work off or
asked a person isn't reminded at once; a seat at an approval or question prompt gets no grace. The reminder tells a
seat waiting on a person to hold the row (`rig queue block <id> --on <who> --wake-after <duration>`), not ask again.
- **A dead seat's stuck-sweep finding goes to someone who can act:** the operator agent (`agents.operator_session`)
when set, then the operator seat (`workspace.operator_seat_name`), then the row's creator, skipping any whose session
is confirmed missing. `workspace.operator_seat_name` still decides where approvals go.
- **Rows OpenRig creates itself** (stuck-sweep findings, workflow escalations, continuity batons, diagnoses run with
`--apply`) wake from OpenRig's own sender, with no reply line, instead of from the row's creator. When the sweep
closes its own finding, the actor is `stuck-sweep@system`.
- **A cross-host queue write that times out reads INDETERMINATE,** not failed, and the CLI waits 15 seconds for the
daemon's answer. When `rig queue create` gets no answer, it reads the row back once by its ID and reports an exact
match as created. It never sends the create twice.
- **Codex seats keep write access to their git metadata** after resume, fork and a restore without a pod, so they can
still commit, and to their own topology folder, so they keep `LEARNED.md` and their recap. `rig context recap-write`
falls back to the daemon when the seat's sandbox refuses the write.
- **Recap writes refuse a link** at `RECAP.md` or `recap-superseded/` with `recap_write_refused`, a layout that used to
work. Replace the link with a real file or folder.
- **`rig seat restart --keep-conversation` on a Codex seat** keeps a writable root whose path holds a space, proves the
resume in the `CODEX_HOME` the running Codex uses, and reads complete when a directory it was given has a space. When
the resume isn't proven, it says the pane restarted, not "nothing was restarted".
- **A Codex seat at Codex 0.164.0's sign-in chooser or API-key entry, or 0.162.1's chooser,** reports
`attention_required` with `login_required` instead of a readiness timeout. Nothing is typed into the login screen.
- **Resume recognition:** a resumed Codex whose footer shows a display name with a reasoning label (`GPT-6.1 Sol
medium`) or a non-`gpt` model reads as resumed, so `rig up --existing` doesn't stop. A Claude or Codex resume chooser
drawn high in a tall pane reads as a chooser, not as resumed.
- **Codex hooks:** when a Codex home has a `hooks.json`, OpenRig puts its activity hooks there at the next Codex seat
launch and moves its own old block out of `config.toml`. Your hooks, their order, trust and disables stay; your own
inline hooks stay in `config.toml`. Inline hook trust is keyed by the group's actual position.
- **A managed Claude restore reads its own files without a Read approval:** the seat's marker and restore map, the
generic or configured extra-instruction file, the restore skill files, restore maps under a configured
`preparation_root` and the packet files are granted at launch and resume, at both the named and the real path (so
macOS `/tmp` works).
- **After managed compaction,** a Claude seat's restore starts on the next poll instead of waiting for the screen to
change. `rig compact --state` and seat status show an owed restore stage and its last delivery result; repeated
failures open one queue item for the delegation owner.
- **A Claude startup refused late** (an expired login) no longer gets Enter on its next startup input: OpenRig checks
again before pressing it and reports attention. The text may stay in the input box; don't resend it.
- **A Claude seat with the model alias `haiku` and `plan` mode** gets a line in its launch, restore and `rig seat
status` results: Claude Code answers plan turns with Sonnet; set a full Haiku id with `rig seat set-model` to keep
Haiku. Nothing about the launch changes.
- **The model-divergence monitor** no longer flags a Claude seat pinned to an undated id that answers with its dated
snapshot, or one pinned to a Bedrock cross-Region profile id such as `global.anthropic.<model>`.
- **A pod seat that never launched** starts fresh from its saved launch recipe after a daemon restart, instead of
sitting at a shell. That starts the harness and can use provider usage. Seats materialized before 0.7.2 have no recipe
and report `attention_required`, keeping the shell.
- **`rig down` after a team's tmux server died** treats tmux's "no server running" as absence and records the seats
stopped, so `rig archive` and `rig up` go ahead. A team of plain terminal seats restores as `fully_restored`, and
`rig down` names the terminal sessions it stops.
- **A launch whose tmux made no session** fails with `session_not_created` or `session_unconfirmed` instead of reporting
the seat launched. The message says which directory must be writable.
- **Terminal tiles survive a seat's session replacement:** a tile waits up to 30 seconds for the same session and seat
identity, then reattaches. A fresh replacement's result says how to reopen the view. Input still queued when a
terminal client disconnects is dropped, not typed.
- **Reopening a Herdr view** with `--provider herdr` adds a workspace each time. From inside Herdr, `rig terminal open
<view> --window` focuses a matching one. The `openrig-herdr` skill now says so.
- **A service bundle install without Docker Compose v2** stops with `compose_missing` before writing the target or
creating the team. `rig bundle install --plan` warns instead.
- **`rig export`** copies the declared culture file beside the YAML for a local daemon, and names agent refs that leave
the folder and a rig still named `starter`.
- **`rig daemon start`:** a backlog of queue wakes no longer holds startup past the CLI's readiness wait. When the
database-conflict check can't tell (no `lsof`, a timeout, a listener that doesn't answer), startup warns that
ownership is unknown, under either `daemon.db_conflict` setting.
- **Proof and scope:** proof-source monitoring watches the missions root, not the whole workspace parent. `rig proof
add <absolute path>` finds a slice in another catalogued project. `rig scope mission create` writes
`metadata.name`; an older mission without it needs the line added before it compiles. `rig queue whoami` resolves a
project-tagged row against its catalogued project.
- **Slack:** a registered person's reply to a question parked on someone else reaches the agent as their own message
and leaves the question parked.
- **`rig context get reference/acp-agents.md`** now returns the guide `rig acp run --help` names.
- **TUI and dashboard:** the focused Table and Overview pages load health findings instead of staying `Not assessed`,
the activity stream keeps its connection state across pages, and the dashboard shows "boot not tracked" for a skipped
kernel boot.
- **The kernel operator's guidance** puts a project task on the chosen team's queue rather than doing it itself.
- **`rig auth switch` and `rig provider switch`'s printed undo** say a restored Codex login isn't verified and may need
a fresh sign-in.
## 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.
- **`rig capture` and the MCP capture tool** add `promptPane: { width, height, hint }` when a seat at a prompt has a
pane smaller than 80x24, and the CLI prints the resize hint on stderr.
- **`rig seat status`** shows the seat's runtime home and where it came from, any ignored home, a Claude token
reference and member tool rules. `--json` always has `permissions.account.advisories`, empty unless a note applies.
- **`rig stream show`** lists each reader's current action, flags `parallelWork`, and reads `readers: "unavailable"`
from an older daemon. The expired-cursor 410 says how `--since` resumes.
- **Slack posts start with their sender** (`From <seat>`, with ` on <machine>` when known), and a wake-rung escalation
reads `Waiting on <seat>: <summary>`. Rows from Slack read `<address> via Slack: …`.
- **A wake from a row OpenRig created** ends with "↩ Sent by OpenRig itself, not by a seat. Act on the row it names."
- **Stuck-sweep findings** name the rule that chose their recovery seat, and a stalled-after-claim age counts from the
stall, not from the claim.
- **The parked diagnosis** reports `activity.since`.
- **Cross-host queue writes:** a timed-out forward's 502 carries `outcome: "indeterminate"`, the CLI's headline reads
"queue request got no answer in time (routing to host '…')", and a read-back reports "The create committed after all:
row … (read back by its ID)".
- **`rig queue update --help` and the `closure_fields_not_admitted` refusal** say how to cancel a row.
- **`rig ps --nodes --json`** carries `login_required` for a Codex seat at sign-in; a restore result marks a plain
terminal seat `noConversation`.
- **A send to a seat mid-switch** is held at once: `outcome: retained`, `reason: runtime_switch_in_progress`.
- **New codes:** `permission_rules_unavailable` (a fork whose source rules can't be read), `occupant_retired_by_switch`
and `switch_not_committed` (the two processes of a runtime switch), `runtime_unchanged` and the `runtime_switch_*`
refusals, `session_not_created`, `session_unconfirmed`, `compose_missing`, `rex_outcome_unknown`,
`invalid_claude_token_selection`, and input checks on `rig stream act`.
- **Help:** 14 subcommands that had no summary have one, and `rig skill audit` ends by saying it doesn't judge whether
a refresh is safe, naming the read-only `rig skill loadout` check.
## What to stop doing
- **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.
- **Don't pass `CLAUDE_CONFIG_DIR` or `CODEX_HOME` with `rig up --env`.** Declare the home in `env:`.
- **Don't rebuild a team to move one seat** to the other runtime or another login. Use `rig seat set-runtime` or
`rig provider switch --seat`.
- **Don't close and re-create to pass work on over MCP.** Use `rig_queue_handoff`.
- **Don't cancel with `--state canceled --closure-reason canceled`.** Use `--state done --closure-reason canceled`.
- **Don't take the first `qitem-…` from `rig queue create`'s merged output.** The request ID on stderr isn't a row:
read `qitemId` from stdout and check the exit code.
- **Don't retry a cross-host queue write that timed out.** Reconcile by its ID first.
- **Don't reopen a Herdr view with `--provider herdr` to get back to it.** Focus it, or use `--window` from inside Herdr.
- **Don't put one member's tool rules in a shared project's Claude settings.** Use `claude_permissions`.
## Known gaps in 0.7.1
## Known gaps in 0.7.2
- **On Linux, a seat now gets the daemon's runtime directory with a session bus address that can't connect** (#2036).
When the daemon has an absolute `XDG_RUNTIME_DIR`, a seat launched, restored or handed over gets it with
`DBUS_SESSION_BUS_ADDRESS=disabled:`, so its D-Bus clients don't find your session bus on their own. This is a
default, not isolation: `systemctl --user` reaches your user service manager from a seat. A shell startup file that
sets only `XDG_RUNTIME_DIR` now leaves the seat with `disabled:`; to use a session bus, set the address in the rig's
or member's `env:`. A Codex seat's stdio MCP server still needs `env_vars = ["XDG_RUNTIME_DIR"]` in Codex's
`config.toml` to see the variable. macOS, a daemon started without `XDG_RUNTIME_DIR` and
`terminal.inherit_tmux_environment` on are unchanged.
- **Per-member Claude tool rules** (#1914): member denies aren't claimed to hold in bypass mode (`full_bypass`).
Handover and restore carry the rules, but aren't proven to keep the same conversation. Checked natively: two seats in
one folder each enforce their own rules, a managed policy wins, and fresh, resume and fork launches enforce them.
- **Going back to 0.7.1:** like any version before #1914, it drops the stored member rules from launch, so set
equivalent native rules first (`docs/reference/rig-spec.md`, "Downgrade"). It also refuses a community preset that
sets `first_input`. No other way back to 0.7.1 has been tested.
- **Pi dialog recognition is a bounded screen check.** #2015 recognizes Pi's selector by its own chrome; with more than
six non-blank rows after its border, or under a widget that draws its own border, a send goes through as before.
#2026 adds Pi v1.4.0's extension input and editor dialogs; clipped or incomplete help, help wrapped over more than
three rows and custom extension screens keep their earlier reading, and a draft that copies a dialog's whole frame
reads as that dialog. Each was checked natively on its own cases (#2015 its confirmation dialog, #2026 the input and
editor dialogs on its own build), not on every layout. Check the pane before sending to an adopted Pi seat that may be
at a dialog. Neither
check was repeated on the final 0.7.2 build.
- **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. 0.7.2 treats the "paste again to expand" hint as part of
the composer, but the original failure didn't reproduce in its native check, so this isn't confirmed fixed; #14
remains open.
- **An idle Claude seat whose recent output quotes prompt text** can still read as at a prompt and refuse sends, as can
a seat with a draft, a mode bar OpenRig doesn't recognise or an unframed input box. Check the pane.
- **A Claude startup refusal that arrives after OpenRig's last check can still race Enter.** If a seat with an expired
login shows staged text in its input box, sign in, then clear or send that text yourself.
- **A managed Claude restore can still ask for a Read approval** when `policies.claude_compaction.message_file_path` is
relative, or for a packet file after a resume changed the conversation id. The session transcript isn't granted. A native
check on Claude 2.1.309 passed the Read grants with no approval prompt; a complete restore with no help isn't claimed.
- **`rig seat set-runtime`:** Claude launch additions such as an agent file aren't applied to a switched seat, startup
slash commands aren't run, and a person typing into the old pane of a draft-aware seat stops the switch (the current
occupant keeps the seat). A message sent during a switch is held and must be sent again.
- **A seat's own account:** a conversation saved under a seat's earlier home doesn't resume in its new one, and live
usage refresh (`rig provider status --refresh`) still reads only the daemon's own logins. `rig provider switch --seat`
moves a Codex seat on Linux only. Other seats on that Codex home keep the old login until their next token refresh,
then stop: run the restart command the switch printed for each.
- **Seat account and runtime moves weren't run with real accounts before this release** (#1895, #1937, #1972, #1980).
The tests use stubs; check a moved seat's account and conversation yourself after the first move.
- **A Codex seat whose queue-state folder or team workspace path holds a space** can still read as not resumed after an
ordinary resume or restore. An in-place restart is fixed.
- **Choice-prompt alerts go to a person one at a time per machine.** While the first one's seat stays blocked, later
choice alerts wait unposted; they're visible as rows.
- **Herdr's own status for a seat can read idle or unknown while OpenRig shows it waiting** (`needs-input`). Use
OpenRig's view or the Feed to find waiting seats.
- **Proof-source watching still recurses through a large missions tree,** and the reported Linux daemon stalls (#1813)
weren't reproduced to confirm that the narrower watch helps.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no automatic usage reading;** the reader
reports `not_subscription_login`. Read one on demand with `rig provider status --refresh --provider claude
--claude-token-file <path> --credential-label <label>`, which spends a model turn.
- **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
@@ -172,21 +295,11 @@ after an upgrade it can still hold the previous version's note.
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`.
--provider herdr`, which adds a new workspace; close the old one.
- **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`.
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.2",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7069,7 +7069,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7104,7 +7104,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.2",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7146,7 +7146,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.2",
"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.2",
"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.2",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
@@ -1,6 +1,6 @@
{
"name": "openrig-core",
"version": "0.1.17",
"version": "0.1.19",
"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.17",
"version": "0.1.19",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -17,7 +17,7 @@ import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-plann
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
import { resumeProbeEvidence } from "../domain/native-resume-probe-evidence.js";
import { resolveReadinessTimeoutMs } from "../domain/readiness-timeout.js";
import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProcesses, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyCodexPaneProcess, verifyPaneIdleShell, witnessedClaudeVersionLabel, type ClaudeLaunchedProcess, type NativeProcessLister, type VersionLabelWitnessWait } from "../domain/native-process-lineage.js";
import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProcesses, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyCodexPaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { shellQuote } from "./shell-quote.js";
@@ -66,7 +66,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
private nodeExecutable: string;
private autoDriveProviderPrompts: boolean;
private listProcesses?: NativeProcessLister;
private resolvePermissionMode?: (nodeId: string) => string | undefined;
private readinessTimeoutMs?: number;
private autoLaunches = new Map<string, { binding: NodeBinding; token: string; freshInPlace?: boolean; executable?: string; scriptLaunch?: boolean; fingerprint?: string; process?: ClaudeLaunchedProcess }>();
readonly claudeManagedLaunch?: ClaudeManagedLaunch;
@@ -90,9 +89,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
nodeExecutable?: string;
autoDriveProviderPrompts?: boolean;
listProcesses?: NativeProcessLister;
/** #86 — the seat's persisted managed permission mode (NativePermissionStore.resolve), undefined when
* unmanaged; throws when it can't be read. Absent → every seat counts as managed for the version-label witness. */
resolvePermissionMode?: (nodeId: string) => string | undefined;
/** Bound on a slow resume's wait, from the launch (default: runtime.readiness_timeout_seconds). */
readinessTimeoutMs?: number;
claudeManagedLaunch?: ClaudeManagedLaunch;
@@ -116,7 +112,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
this.nodeExecutable = deps.nodeExecutable ?? process.execPath;
this.autoDriveProviderPrompts = deps.autoDriveProviderPrompts ?? false;
this.listProcesses = deps.listProcesses;
this.resolvePermissionMode = deps.resolvePermissionMode;
this.readinessTimeoutMs = deps.readinessTimeoutMs;
this.claudeManagedLaunch = deps.claudeManagedLaunch;
this.activityRelayPath = deps.activityRelayPath ?? null;
@@ -449,13 +444,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
// -- Private helpers --
/** #86: only a launch binding carries permissionMode, so the seat's persisted selection decides for every other
* caller. Unmanaged needs both to say so; a missing or unreadable selection counts as managed. */
private isUnmanaged(binding: NodeBinding): boolean {
if (binding.permissionMode !== undefined || !this.resolvePermissionMode) return false;
try { return this.resolvePermissionMode(binding.nodeId) === undefined; } catch { return false; }
}
/** A fresh in-place launch keeps the retiree's scrollback. Qualify a history-based ready result
* with the current frame, only for the exact launch that requested it. Unavailable observations
* retain the old admission (including its ambiguity); visible gates and conflicting identity do not. */
@@ -494,16 +482,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
return withIdentity.status === "resumed" && occupant !== "absent" ? historyProbe : current;
}
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string, options?: ReadinessOptions, witnessWait?: VersionLabelWitnessWait) {
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string, options?: ReadinessOptions) {
const input = { runtime: "claude-code", paneCommand, paneContent };
const probe = assessNativeResumeProbe(input);
// #86: an unmanaged native install's version label reads as `claude` once its OS executable is witnessed. The
// witness runs only when the label would change the result; a managed seat keeps its exact proof below.
const asClaude = assessNativeResumeProbe({ ...input, paneCommand: "claude" });
if (probe.status !== "resumed" && asClaude.status === "resumed" && this.isUnmanaged(binding) && binding.tmuxSession
&& await witnessedClaudeVersionLabel(paneCommand, { target: binding.tmuxSession, tmux: this.tmux, listProcesses: this.listProcesses }, witnessWait)) {
return asClaude;
}
// Headerless managed launches (including bypass mode) use the same readiness
// precedence as resume. This candidate result is returned only after the
// exact launch identity below is proved; screen text alone is insufficient.
@@ -623,13 +604,11 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const checkEvery = 5;
let attempts = windowAttempts;
let slowStart = false;
// #86: one record per wait; after an actual path-read timeout the rest of this wait skips the witness.
const witnessWait: VersionLabelWitnessWait = { pathReadTimedOut: false };
for (let attempt = 0; attempt < attempts; attempt++) {
const paneCommand = await this.tmux.getPaneCommand(tmuxSession);
const paneContent = (await this.tmux.capturePaneContent(tmuxSession, 40)) ?? "";
const probe = await this.assessManagedProbe(binding, paneCommand, paneContent, undefined, witnessWait);
const probe = await this.assessManagedProbe(binding, paneCommand, paneContent);
if (probe.code === "no_conversation_found") {
return {
@@ -716,7 +695,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const finalCommand = await this.tmux.getPaneCommand(tmuxSession);
const finalContent = (await this.tmux.capturePaneContent(tmuxSession, 40)) ?? "";
const finalProbe = await this.assessManagedProbe(binding, finalCommand, finalContent, undefined, witnessWait);
const finalProbe = await this.assessManagedProbe(binding, finalCommand, finalContent);
if (finalProbe.status === "resumed") {
return { ok: true };
+13 -18
View File
@@ -9,7 +9,7 @@ import { shellQuote } from "./shell-quote.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { claudePostureFlag, claudeClassicRendererEnvPrefix } from "./yolo-mode.js";
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
import { verifyClaudePaneProcess, verifyPaneIdleShell, witnessedClaudeVersionLabel, type ClaudeLaunchedProcess, type NativeProcessLister, type VersionLabelWitnessWait } from "../domain/native-process-lineage.js";
import { verifyClaudePaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { observeClaudePermission, type AppliedLaunchObservation } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
@@ -139,7 +139,7 @@ export class ClaudeResumeAdapter {
return { ok: false, code: "resume_failed", message: keyResult.message };
}
const result = await this.verifyResume(tmuxSessionName, resumeToken!, !managed).catch((error): ResumeResult => ({
const result = await this.verifyResume(tmuxSessionName, resumeToken!).catch((error): ResumeResult => ({
ok: false,
code: "attention_required",
message: `Claude resume observation unavailable; launch retained: ${error instanceof Error ? error.message : String(error)}`,
@@ -147,29 +147,20 @@ export class ClaudeResumeAdapter {
return result.ok ? { ...result, appliedLaunch, ...(tokenWarnings.length ? { warnings: tokenWarnings } : {}) } : result;
}
/** #86: as the launch adapter's probe, an unmanaged native install's version label reads as `claude` once its OS
* executable is witnessed. The witness runs only when the label would change the result. */
private async assessProbe(target: string, paneCommand: string | null, paneContent: string, unmanaged: boolean, witnessWait: VersionLabelWitnessWait) {
const input = { runtime: "claude-code", paneCommand, paneContent };
const probe = assessNativeResumeProbe(input);
const asClaude = assessNativeResumeProbe({ ...input, paneCommand: "claude" });
if (unmanaged && probe.status !== "resumed" && asClaude.status === "resumed"
&& await witnessedClaudeVersionLabel(paneCommand, { target, tmux: this.tmux, listProcesses: this.options.listProcesses }, witnessWait)) return asClaude;
return probe;
}
private async verifyResume(tmuxSessionName: string, resumeToken: string, unmanaged: boolean): Promise<ResumeResult> {
private async verifyResume(tmuxSessionName: string, resumeToken: string): Promise<ResumeResult> {
const pollMs = this.options.pollMs ?? 200;
const maxWaitMs = this.options.maxWaitMs ?? 5_000;
const sleepFn = this.options.sleep ?? sleep;
const attempts = Math.max(1, Math.floor(maxWaitMs / Math.max(pollMs, 1)) + 1);
// #86: one record per wait; after an actual path-read timeout the rest of this wait skips the witness.
const witnessWait: VersionLabelWitnessWait = { pathReadTimedOut: false };
for (let attempt = 0; attempt < attempts; attempt++) {
const paneCommand = await this.tmux.getPaneCommand(tmuxSessionName);
const paneContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
const probe = await this.assessProbe(tmuxSessionName, paneCommand, paneContent, unmanaged, witnessWait);
const probe = assessNativeResumeProbe({
runtime: "claude-code",
paneCommand,
paneContent,
});
if (probe.code === "no_conversation_found") {
return {
@@ -204,7 +195,11 @@ export class ClaudeResumeAdapter {
const finalCommand = await this.tmux.getPaneCommand(tmuxSessionName);
const finalContent = (await this.tmux.capturePaneContent(tmuxSessionName, 40)) ?? "";
const finalProbe = await this.assessProbe(tmuxSessionName, finalCommand, finalContent, unmanaged, witnessWait);
const finalProbe = assessNativeResumeProbe({
runtime: "claude-code",
paneCommand: finalCommand,
paneContent: finalContent,
});
if (finalProbe.code === "no_conversation_found") {
return { ok: false, code: "retry_fresh", message: "Claude resume failed: no conversation found for the requested session" };
+6 -8
View File
@@ -10,7 +10,7 @@ import type { TmuxAdapter } from "../adapters/tmux.js";
import type { TranscriptStore } from "./transcript-store.js";
import { startTmuxTranscriptCapture } from "./transcript-capture.js";
import { deriveResumeToken, type ResumeTokenCaptureDeps } from "./resume-token-capture.js";
import { verifyClaudePaneRuntime, verifyCodexPaneProcess, witnessedClaudeVersionLabel, type NativeProcessLister } from "./native-process-lineage.js";
import { verifyClaudePaneRuntime, verifyCodexPaneProcess, type NativeProcessLister } from "./native-process-lineage.js";
import {
observeSolePane,
paneObservationVerdict,
@@ -113,7 +113,7 @@ interface ClaimServiceDeps {
};
/** #421 — start time of the pane's current Claude process; a sidecar sampled earlier is skipped. */
claudeProcessStartedAt?: ResumeTokenCaptureDeps["claudeProcessStartedAt"];
/** Process census for the runtime proof in `reconcileSession` and #86's native version-label witness; defaults to the live process table. */
/** Process census for the runtime proof in `reconcileSession`; defaults to the live process table. */
listProcesses?: NativeProcessLister;
resumeTokenCapturer?: {
captureCodexThreadId(sessionName: string): Promise<string | undefined>;
@@ -575,10 +575,11 @@ export class ClaimService {
const projectionDrift: string[] = [];
const paneObservation = await this.observeBindingPane(sessionName);
if (!paneObservation.ok) projectionDrift.push(paneObservation.detail);
const paneTarget = paneObservation.ok ? paneObservation.pane : sessionName;
let paneCommand: string | null = null;
try {
paneCommand = await this.tmuxAdapter.getPaneCommand(paneTarget);
paneCommand = await this.tmuxAdapter.getPaneCommand(
paneObservation.ok ? paneObservation.pane : sessionName,
);
} catch { paneCommand = null; }
if (nodeRow.runtime) {
const expectation: Record<string, string[]> = {
@@ -590,10 +591,7 @@ export class ClaimService {
if (!paneCommand) {
projectionDrift.push(`runtime unverified: pane command unreadable; node declares runtime "${nodeRow.runtime}"`);
} else if (expected && expected.length > 0 && !expected.includes(paneCommand)
// #86: a native install's version label confirms claude-code only with its OS executable witness.
&& !(nodeRow.runtime === "claude-code" && await witnessedClaudeVersionLabel(paneCommand,
{ target: paneTarget, tmux: this.tmuxAdapter, listProcesses: this.listProcesses }))
&& !await this.runtimeProvenInPane(nodeRow.runtime, paneTarget)) {
&& !await this.runtimeProvenInPane(nodeRow.runtime, paneObservation.ok ? paneObservation.pane : sessionName)) {
// A script-launched runtime reads as its launcher (`sh`); only a pane whose process tree shows no such runtime is drift.
projectionDrift.push(`runtime unverified: pane command "${paneCommand}" does not confirm runtime "${nodeRow.runtime}"`);
}
@@ -18,16 +18,9 @@ function run(argv) {
}));
}`;
/** execFile's own timeout kill (Node sets killed with the kill signal); a maxBuffer overflow or a failed run isn't one. */
function isTimeoutKill(error: unknown): boolean {
const e = error as { killed?: unknown; signal?: unknown } | null;
return e?.killed === true && e.signal === "SIGTERM";
}
/** Optional OS executable witnesses. Unavailable, exited and over-budget PIDs
* have no witness; this never substitutes argv or a version string for a path.
* `onTimeout` hears only an actual read timeout, never any other missing path. */
export async function readNativeExecutablePaths(requested: number[], opts: { onTimeout?: () => void } = {}): Promise<Map<number, string>> {
* have no witness; this never substitutes argv or a version string for a path. */
export async function readNativeExecutablePaths(requested: number[]): Promise<Map<number, string>> {
const pids = [...new Set(requested.filter(pid => Number.isInteger(pid) && pid > 0 && pid <= 2147483647))].slice(0, 1024);
const result = new Map<number, string>();
if (pids.length === 0) return result;
@@ -49,9 +42,6 @@ export async function readNativeExecutablePaths(requested: number[], opts: { onT
if (typeof path === "string" && path.startsWith("/") && !path.includes("\0")
&& !path.split("/").some(part => part === "." || part === "..")) result.set(entry[0], path);
}
} catch (error) {
// A missing OS witness is unknown, not positive identity.
if (isTimeoutKill(error)) opts.onTimeout?.();
}
} catch { /* A missing OS witness is unknown, not positive identity. */ }
return result;
}
@@ -485,28 +485,22 @@ function mayBeClaudeRuntime(row: NativeProcessRow): boolean {
/** The same OS observation serves menu input, restore proof and periodic identity.
* Older callers may carry only pid/ppid/command; that is insufficient positive Codex proof. */
export async function listNativeProcesses(opts: NativeProcessListOptions = {}): Promise<NativeProcessRow[]> {
export async function listNativeProcesses(options: { claudeLaunchPaths?: boolean } = {}): Promise<NativeProcessRow[]> {
try {
// Copies: the table is shared with concurrent readers, and executable paths are added below.
const rows: NativeProcessRow[] = (await runAsyncSite("codex.runtime.list_processes", readProcessTable)).map((row) => ({ ...row }));
// `claudeLaunchPaths`: the caller is proving a launch through a `#!` script, which needs the OS path of the
// foreground path-named Claude too. Every other listing skips that read (on macOS it starts a helper process).
const candidates = rows.filter(row => needsClaudeExecutablePath(row) || (opts.claudeLaunchPaths === true && mayBeClaudeAfterScript(row)));
const candidates = rows.filter(row => needsClaudeExecutablePath(row) || (options.claudeLaunchPaths === true && mayBeClaudeAfterScript(row)));
if (candidates.length > 0) {
const pids = candidates.map(row => row.pid);
const onTimeout = opts?.onPathReadTimeout;
const paths = await (onTimeout ? readNativeExecutablePaths(pids, { onTimeout }) : readNativeExecutablePaths(pids));
const paths = await readNativeExecutablePaths(candidates.map(row => row.pid));
for (const row of candidates) row.executablePath = paths.get(row.pid);
}
return rows;
} catch { return []; }
}
/** `claudeLaunchPaths`: also read the OS path of a foreground path-named Claude (a `#!` script launch).
* `onPathReadTimeout` (#86): told when the OS path read actually timed out. */
export interface NativeProcessListOptions { claudeLaunchPaths?: boolean; onPathReadTimeout?: () => void }
export type NativeProcessLister = (opts?: NativeProcessListOptions) => NativeProcessRow[] | Promise<NativeProcessRow[]>;
export type NativeProcessLister = () => NativeProcessRow[] | Promise<NativeProcessRow[]>;
/** One OS process, by pid and `ps` start time: a reused pid has another start time. */
export interface ClaudeLaunchedProcess { pid: number; startedAt: string }
@@ -779,30 +773,6 @@ export async function verifyClaudePaneRuntime(input: Parameters<typeof observeCl
return second?.fingerprint === first.fingerprint ? second : null;
}
const NATIVE_VERSION_LABEL = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
/** One wait's record that the optional path witness timed out, so the rest of that wait skips it. */
export interface VersionLabelWitnessWait { pathReadTimedOut: boolean }
/** #86: the native installer names Claude's process, and so the pane label, after its versioned file. Whether that
* label may stand for `claude`: only when the pane's one stable foreground Claude is the version-named process
* itself, witnessed by its OS executable path (`claudeProcess`: argv0 claude, the installer's versions layout, a
* basename equal to the OS name), and the pane's pid is unchanged. The label alone is never identity, and a path
* read that fails or times out is no witness. Like a `claude` label, this says nothing about the conversation.
* Within one `wait`, once a path read actually times out, the witness isn't tried again; a new wait starts fresh. */
export async function witnessedClaudeVersionLabel(paneCommand: string | null,
input: Omit<Parameters<typeof observeClaudePaneRuntime>[0], "selectedExecutable">, wait?: VersionLabelWitnessWait): Promise<boolean> {
const label = paneCommand?.trim() ?? "";
if (!NATIVE_VERSION_LABEL.test(label) || wait?.pathReadTimedOut) return false;
const lister = input.listProcesses ?? listNativeProcesses;
const onPathReadTimeout = () => { if (wait) wait.pathReadTimedOut = true; };
const native = await verifyClaudePaneRuntime({ ...input, listProcesses: () => lister({ onPathReadTimeout }) });
// argv0 `claude` keeps the OS path the deciding evidence: a versioned argv0 matches the OS name without it.
if (!native || executableName(tokens(native.process.command)[0] ?? "") !== "claude"
|| native.process.executableName !== label || !native.process.executablePath) return false;
return await input.tmux.getPanePid(input.target).catch(() => null) === native.panePid;
}
/** The `ps` start time (lstart, local time) of the one stable Claude foreground
* process. This makes no claim about its conversation token. */
export async function observeClaudePaneStartedAt(input: Parameters<typeof observeClaudePaneRuntime>[0]): Promise<string | null> {
+1 -2
View File
@@ -28,7 +28,6 @@ import { migrate } from "./db/migrate.js";
import { ALL_MIGRATIONS } from "./db/all-migrations.js";
import { RigRepository } from "./domain/rig-repository.js";
import { SessionRegistry } from "./domain/session-registry.js";
import { NativePermissionStore } from "./domain/native-permission-store.js";
import { deriveCanonicalSessionName, isHumanSeatSessionRef, parseSessionName } from "./domain/session-name.js";
import { resolveExternal } from "./domain/gateway/external-admission.js";
import { makeTopologyDestinationCheck } from "./domain/queue-destination-check.js";
@@ -916,7 +915,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter, readFile: (p: string) => fs.readFileSync(p, "utf-8") });
const runtimeSettings = new ContextPackSettingsStore().resolveConfig();
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, resolvePermissionMode: (nodeId: string) => new NativePermissionStore(db).resolve(nodeId, "claude-code").permissionMode, restoreReadPolicy, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, restoreReadPolicy, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const codexAdapter = new CodexRuntimeAdapter({ prepareTeamWorkspace, prepareSeatFolder, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: daemonHome }, codexHome: configuredCodexHome, seatHome: (nodeId: string) => seatRuntimeHome(db, nodeId, "codex").home, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), resolveCorePluginDir: () => vendoredCorePluginDir(), resolveCorePluginSelected: (nodeId: string) => Boolean(seatCodexCorePluginDir({ id: nodeId, runtime: "codex" }, db)), resolveHooksEnabled: async () => {
const { SettingsStore } = await import("./domain/user-settings/settings-store.js");
return new SettingsStore().resolveOne("runtime.codex.hooks_enabled").value as boolean;
@@ -1,293 +0,0 @@
// #86 — Claude's native installer names its process, and so tmux's pane label, after its versioned file (2.1.300).
// An unmanaged seat whose label is a version reads as `claude` only when the OS executable path witnesses native
// Claude. Every case runs through existing seams, so the same file runs unchanged on main: the witnessed cases fail
// there (timeout, no verified resume, reconcile drift) and the refusals already hold.
import { describe, it, expect, vi } from "vitest";
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
import type { NativeProcessLister, NativeProcessRow } from "../src/domain/native-process-lineage.js";
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
import { ClaimService } from "../src/domain/claim-service.js";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
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 { NativePermissionStore } from "../src/domain/native-permission-store.js";
import { ContextUsageStore } from "../src/domain/context-usage-store.js";
import { ContextMonitor } from "../src/domain/context-monitor.js";
const VERSION = "2.1.300";
const NATIVE_PATH = `/fixture/.local/share/claude/versions/${VERSION}`;
const startedAt = "Fri Oct 16 01:00:00 2026";
// The staging subshell exec'd Claude, so Claude leads the pane's foreground group under the pane shell.
// Also used as a process lister, which may be handed list options: only a string names a command.
function rows(command?: unknown): NativeProcessRow[] {
if (typeof command !== "string") command = "claude --permission-mode acceptEdits --resume tok --name seat";
return [
{ pid: 10, ppid: 1, pgid: 10, tpgid: 12, executableName: "zsh", command: "-zsh", startedAt },
{ pid: 12, ppid: 10, pgid: 12, tpgid: 12, executableName: VERSION, executablePath: NATIVE_PATH, command: command as string, startedAt },
];
}
function mockTmux(label: string, panePid: () => Promise<number | null> = async () => 10): TmuxAdapter {
return {
sendText: vi.fn(async () => ({ ok: true as const })),
sendKeys: vi.fn(async () => ({ ok: true as const })),
sendShellCommand: vi.fn(async () => ({ ok: true as const })),
hasSession: vi.fn(async () => true),
getPaneCommand: vi.fn(async () => label),
// Claude hasn't drawn its prompt yet: no screen text can prove the resume.
capturePaneContent: vi.fn(async () => ""),
getPanePid: vi.fn(panePid),
listPanes: vi.fn(async () => []),
createSession: vi.fn(async () => ({ ok: true as const })),
killSession: vi.fn(async () => ({ ok: true as const })),
listSessions: vi.fn(async () => []),
listWindows: vi.fn(async () => []),
startPipePane: vi.fn(async () => ({ ok: true as const })),
stopPipePane: vi.fn(async () => ({ ok: true as const })),
setSessionOption: vi.fn(async () => ({ ok: true as const })),
getSessionOption: vi.fn(async () => null),
} as unknown as TmuxAdapter;
}
const fsOps = {
exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {}, listFiles: () => [],
} as unknown as ClaudeAdapterFsOps;
function binding(extra: Partial<NodeBinding> = {}): NodeBinding {
return { id: "b1", nodeId: "n1", tmuxSession: "dev-impl@rig", tmuxWindow: null, tmuxPane: null,
cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "/project", ...extra };
}
// The seat's persisted managed permission mode; by default the seat has no managed selection.
type PermissionModeReader = (nodeId: string) => string | undefined;
const adapter = (tmux: TmuxAdapter, listProcesses: () => NativeProcessRow[] | Promise<NativeProcessRow[]>,
resolvePermissionMode: PermissionModeReader | null = () => undefined) =>
new ClaudeCodeAdapter({ tmux, fsOps, listProcesses, sleep: async () => {}, ...(resolvePermissionMode ? { resolvePermissionMode } : {}) });
describe("#86 unmanaged launch wait", () => {
it.each([
["OpenRig's own resume command", rows()],
["a flag from the user's own claude, which exact proof can't parse", rows("claude --strict-mcp-config --permission-mode acceptEdits --resume tok --name seat")],
])("resumes a witnessed native version label: %s", async (_name, processRows) => {
const result = await adapter(mockTmux(VERSION), async () => processRows)
.launchHarness(binding(), { name: "seat", resumeToken: "tok" });
expect(result).toMatchObject({ ok: true, resumeToken: "tok" });
});
it("checkReady reads a witnessed native version label as Claude", async () => {
expect(await adapter(mockTmux(VERSION), rows).checkReady(binding())).toEqual({ ready: true });
});
});
describe("#86 refusals that stay refused", () => {
const awaiting = { ready: false, code: "awaiting_runtime" };
const cases: [string, NativeProcessRow[] | (() => NativeProcessRow[]), string?, (() => Promise<number | null>)?][] = [
["a path outside the native layout", rows().map(r => r.pid === 12 ? { ...r, executablePath: `/opt/other/${VERSION}` } : r)],
["a basename that isn't the OS name", rows().map(r => r.pid === 12 ? { ...r, executablePath: NATIVE_PATH.replace("300", "299") } : r)],
["argv0 that isn't claude", rows("node /fixture/cli.js --resume tok")],
["a version-only argv0", rows(`${VERSION} --resume tok`)],
["two candidates", [...rows(), { ...rows()[1]!, pid: 13 }]],
// listNativeProcesses leaves the path unset when the OS read fails or times out.
["a path read that failed or timed out", rows().map(r => ({ ...r, executablePath: undefined }))],
["argv0 naming the native path, with no OS path read", rows(`${NATIVE_PATH} --resume tok`).map(r => ({ ...r, executablePath: undefined }))],
["argv0 naming the native path, with an unrelated OS path", rows(`${NATIVE_PATH} --resume tok`).map(r => r.pid === 12 ? { ...r, executablePath: "/opt/other/claude" } : r)],
["a background process", rows().map(r => r.pid === 12 ? { ...r, pgid: 88 } : r)],
["a label naming a different version", rows(), "2.1.301"],
["a pane pid change after the samples", rows(), VERSION, vi.fn<() => Promise<number | null>>()
.mockResolvedValueOnce(10).mockResolvedValueOnce(10).mockResolvedValue(11)],
];
it.each(cases)("%s", async (_name, processRows, label = VERSION, panePid) => {
const list = typeof processRows === "function" ? processRows : () => processRows;
expect(await adapter(mockTmux(label, panePid), list).checkReady(binding())).toMatchObject(awaiting);
});
it("a process that changes between the two samples", async () => {
const list = vi.fn<() => NativeProcessRow[]>().mockReturnValueOnce(rows())
.mockReturnValue(rows().map(r => r.pid === 12 ? { ...r, startedAt: startedAt.replace("01:00:00", "01:00:01") } : r));
expect(await adapter(mockTmux(VERSION), list).checkReady(binding())).toMatchObject(awaiting);
});
});
describe("#86 results that don't change", () => {
it("a managed seat keeps its exact proof; the witness isn't consulted", async () => {
const list = vi.fn(rows);
expect(await adapter(mockTmux(VERSION), list).checkReady(binding({ permissionMode: "auto" })))
.toMatchObject({ ready: false, code: "awaiting_runtime" });
expect(list).not.toHaveBeenCalled();
});
it.each<[string, PermissionModeReader | null]>([
["a persisted managed selection, through a binding without permissionMode", () => "auto"],
["an unreadable selection", () => { throw new Error("Invalid persisted native permission selection; launch refused."); }],
["no selection reader", null],
])("the witness isn't consulted with %s", async (_name, resolvePermissionMode) => {
const list = vi.fn(rows);
expect(await adapter(mockTmux(VERSION), list, resolvePermissionMode).checkReady(binding()))
.toMatchObject({ ready: false, code: "awaiting_runtime" });
expect(list).not.toHaveBeenCalled();
});
it("a shell label over an idle shell stays a return to the shell", async () => {
// Claude has exited: the pane's shell leads the foreground group again, with nothing else in it.
const shellAlone = () => [{ ...rows()[0], tpgid: 10 }];
expect(await adapter(mockTmux("zsh"), shellAlone).checkReady(binding())).toMatchObject({ ready: false, code: "returned_to_shell" });
});
it("a claude label stays ready by name, with no process read", async () => {
const list = vi.fn(rows);
expect(await adapter(mockTmux("claude"), list).checkReady(binding())).toEqual({ ready: true });
expect(list).not.toHaveBeenCalled();
});
});
describe("#86 legacy restore resume", () => {
const resumeAdapter = (tmux: TmuxAdapter, listProcesses: () => NativeProcessRow[], extra = {}) =>
new ClaudeResumeAdapter(tmux, { listProcesses, pollMs: 1, maxWaitMs: 2, sleep: async () => {}, ...extra });
it("an unmanaged resume with a witnessed native version label is verified", async () => {
expect(await resumeAdapter(mockTmux(VERSION), rows).resume("dev-impl@rig", "claude_id", "tok", "/project"))
.toMatchObject({ ok: true });
});
it("an unwitnessed version label stays unverified", async () => {
const list = () => rows().map(r => ({ ...r, executablePath: undefined }));
expect(await resumeAdapter(mockTmux(VERSION), list).resume("dev-impl@rig", "claude_id", "tok", "/project"))
.toMatchObject({ ok: false, code: "attention_required" });
});
it("a managed resume keeps its exact proof; the witness isn't consulted", async () => {
const list = vi.fn(rows);
const claudeManagedLaunch = { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`,
assertCurrent: () => {}, configDir: "/fixture", executable: NATIVE_PATH }) } as unknown as ClaudeManagedLaunch;
const result = await resumeAdapter(mockTmux(VERSION), list, { claudeManagedLaunch })
.resume("dev-impl@rig", "claude_id", "tok", "/project", undefined, undefined, "auto", "n1");
expect(result).toMatchObject({ ok: false, code: "attention_required" });
expect(list).not.toHaveBeenCalled();
});
});
describe("#86 reconcile-session drift", () => {
async function reconcile(listProcesses: () => NativeProcessRow[]) {
const db = createFullTestDb();
const tmux = mockTmux(VERSION);
const setup = createTestApp(db, { tmux });
const deps = { db, rigRepo: setup.rigRepo, sessionRegistry: setup.sessionRegistry, discoveryRepo: setup.discoveryRepo,
eventBus: setup.eventBus, tmuxAdapter: tmux, listProcesses };
const claims = new ClaimService(deps as ConstructorParameters<typeof ClaimService>[0]);
const rig = setup.rigRepo.createRig("drift-rig");
const node = setup.rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: "/work/repo", podId: null });
setup.sessionRegistry.markDetached(setup.sessionRegistry.registerSession(node.id, "dev-impl@drift-rig").id);
setup.sessionRegistry.updateBinding(node.id, { tmuxSession: "dev-impl@drift-rig" });
const result = await claims.reconcileSession({ sessionName: "dev-impl@drift-rig" });
db.close();
if (!result.ok) throw new Error(result.message);
return result.result;
}
it("a witnessed native version label confirms the runtime; continuity stays unverified", async () => {
const result = await reconcile(rows);
expect(result.projectionDrift.join(" ")).not.toContain("runtime unverified");
expect(result.continuity).toBe("unverified");
});
it("an unwitnessed version label keeps the drift line", async () => {
const result = await reconcile(() => rows().map(r => r.pid === 12 ? { ...r, executablePath: `/opt/other/${VERSION}` } : r));
expect(result.projectionDrift.join(" ")).toContain(`runtime unverified: pane command "${VERSION}" does not confirm runtime "claude-code"`);
});
});
describe("#86 recovery readiness (ContextMonitor.pollOnce) follows the seat's persisted selection", () => {
async function poll(opts: { selection?: string; label?: string; rows: () => NativeProcessRow[] }): Promise<string> {
const db = createDb();
migrate(db, ALL_MIGRATIONS);
const rigRepo = new RigRepository(db);
const sessions = new SessionRegistry(db);
const node = rigRepo.addNode(rigRepo.createRig("recovery-rig").id, "dev.impl", { runtime: "claude-code", cwd: "/project" });
const session = sessions.registerSession(node.id, "dev-impl@recovery-rig");
db.prepare("UPDATE sessions SET status = 'running', startup_status = 'attention_required' WHERE id = ?").run(session.id);
const permissions = new NativePermissionStore(db);
if (opts.selection) permissions.write(node.id, { runtime: "claude-code", mode: opts.selection }, "test", "managed seat");
const stateDir = mkdtempSync(join(tmpdir(), "label-witness-"));
const claude = new ClaudeCodeAdapter({ tmux: mockTmux(opts.label ?? VERSION), fsOps, listProcesses: opts.rows, sleep: async () => {},
resolvePermissionMode: (nodeId) => permissions.resolve(nodeId, "claude-code").permissionMode });
const monitor = new ContextMonitor(db, new ContextUsageStore(db, { stateDir, codexHomeDir: stateDir }),
{ ensureContextCollector: () => {}, checkReady: (b) => claude.checkReady(b) });
try {
await monitor.pollOnce();
return (db.prepare("SELECT startup_status FROM sessions WHERE id = ?").get(session.id) as { startup_status: string }).startup_status;
} finally {
monitor.stop();
db.close();
rmSync(stateDir, { recursive: true, force: true });
}
}
it.each<[string, Parameters<typeof poll>[0], string]>([
["managed auto, a version label, the wrong conversation", { selection: "auto", rows: () => rows("claude --permission-mode auto --resume other --name seat") }, "attention_required"],
["managed auto, a version label, no OS path", { selection: "auto", rows: () => rows().map(r => ({ ...r, executablePath: undefined })) }, "attention_required"],
["managed auto, the ordinary claude label, as on main", { selection: "auto", label: "claude", rows }, "ready"],
["unmanaged, a witnessed version label", { rows }, "ready"],
])("%s", async (_name, opts, expected) => {
expect(await poll(opts)).toBe(expected);
});
});
describe("#86 an actual path-read timeout ends the witness for the rest of one wait", () => {
const unwitnessed = () => rows().map(r => ({ ...r, executablePath: undefined }));
// As listNativeProcesses does when the OS read times out: the path stays unset and the caller is told.
const timedOut: NativeProcessLister = (opts) => { opts?.onPathReadTimeout?.(); return unwitnessed(); };
const launch = (claude: ClaudeCodeAdapter) => claude.launchHarness(binding(), { name: "seat", resumeToken: "tok" });
// The witness's own reads carry its timeout listener; main's process checks call the same lister without one.
const witnessReads = (list: ReturnType<typeof vi.fn>) =>
list.mock.calls.filter(([opts]) => typeof opts === "object" && opts !== null && "onPathReadTimeout" in opts).length;
// Main's result for the same process evidence: a managed seat never consults the witness.
const mainResult = () => launch(adapter(mockTmux(VERSION), vi.fn(unwitnessed), () => "auto"));
it.each<[string, () => NativeProcessLister, number]>([
["a first-sample timeout", () => vi.fn(timedOut), 1],
["a second-sample timeout", () => vi.fn<NativeProcessLister>().mockImplementationOnce(() => rows()).mockImplementation(timedOut), 2],
])("the launch wait stops reading after %s and ends with main's result", async (_name, makeList, reads) => {
const list = makeList();
const result = await launch(adapter(mockTmux(VERSION), list));
expect(result).toEqual(await mainResult());
expect(result.ok).toBe(false);
expect(witnessReads(list as ReturnType<typeof vi.fn>)).toBe(reads);
});
it("a missing path without a timeout isn't one: every observation still tries", async () => {
const list = vi.fn(unwitnessed);
const tmux = mockTmux(VERSION);
expect(await launch(adapter(tmux, list))).toMatchObject({ ok: false });
const observations = vi.mocked(tmux.capturePaneContent).mock.calls.length;
expect(observations).toBeGreaterThan(16);
expect(witnessReads(list)).toBe(observations);
});
it("a later, independent launch wait uses a healthy witness", async () => {
let read: NativeProcessLister = timedOut;
const claude = adapter(mockTmux(VERSION), (opts) => read(opts));
expect(await launch(claude)).toMatchObject({ ok: false });
read = () => rows();
expect(await launch(claude)).toMatchObject({ ok: true, resumeToken: "tok" });
});
it("the legacy restore wait reads once after a timeout, and its next wait uses a healthy witness", async () => {
let read: NativeProcessLister = timedOut;
const list = vi.fn<NativeProcessLister>((opts) => read(opts));
const resume = new ClaudeResumeAdapter(mockTmux(VERSION), { listProcesses: list, sleep: async () => {} });
expect(await resume.resume("dev-impl@rig", "claude_id", "tok", "/project")).toMatchObject({ ok: false, code: "attention_required" });
expect(list).toHaveBeenCalledTimes(1);
read = () => rows();
expect(await resume.resume("dev-impl@rig", "claude_id", "tok", "/project")).toMatchObject({ ok: true });
});
});
@@ -34,24 +34,6 @@ describe("OS executable path witness", () => {
else execute.mockResolvedValue({ stdout: mode === "malformed" ? "not json" : JSON.stringify([[12, "relative"], [13, "/a/../b"], [14, "/a\0b"], [999, "/a"]]) });
expect((await readNativeExecutablePaths([12, 13, 14])).size).toBe(0);
});
it("tells a caller only about an actual read timeout (#86)", async () => {
setPlatform("darwin");
const onTimeout = vi.fn();
// Node's own timeout kill: killed by Node with the kill signal.
execute.mockRejectedValueOnce(Object.assign(new Error("Command failed"), { killed: true, signal: "SIGTERM", code: null }));
expect((await readNativeExecutablePaths([12], { onTimeout })).size).toBe(0);
expect(onTimeout).toHaveBeenCalledOnce();
for (const error of [
Object.assign(new Error("denied"), { killed: false, signal: null, code: 1 }),
Object.assign(new Error("too much output"), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }),
]) {
execute.mockRejectedValueOnce(error);
expect((await readNativeExecutablePaths([12], { onTimeout })).size).toBe(0);
}
execute.mockResolvedValueOnce({ stdout: "not json" });
expect((await readNativeExecutablePaths([12], { onTimeout })).size).toBe(0);
expect(onTimeout).toHaveBeenCalledOnce();
});
it("does no work for an empty request or unsupported platform, and caps batch size", async () => {
setPlatform("darwin");
expect((await readNativeExecutablePaths([])).size).toBe(0);
@@ -53,12 +53,4 @@ describe.each(Object.entries(SOURCES))("from %s", (source, serve) => {
expect(paths).toHaveBeenCalledExactlyOnceWith([20, 21]);
expect(rows.find(r => r.pid === 20)?.executablePath).toBe("/nix/store/x-claude-code-2.1.280/bin/.claude-unwrapped");
});
it("forwards a caller's path-read timeout listener only when one is given (#86)", async () => {
serve([row(15, "2.1.289", "claude --resume token")]);
const onPathReadTimeout = vi.fn();
paths.mockImplementation(async (_pids: number[], opts?: { onTimeout?: () => void }) => { opts?.onTimeout?.(); return new Map(); });
await listNativeProcesses({ onPathReadTimeout });
expect(paths).toHaveBeenCalledExactlyOnceWith([15], { onTimeout: onPathReadTimeout });
expect(onPathReadTimeout).toHaveBeenCalledOnce();
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"license": "Apache-2.0",
"type": "module",