Compare commits

...
Author SHA1 Message Date
dev-driver bcaf355e90 docs(whats-new): going back to 0.7.0 with a Slack subscription set
0.7.0 can't read a person file holding a Slack subscription, so its people
registry stops loading. The gap names the supported preparation: save the
choice, unset it, set it again after returning to 0.7.1, and never restore an
older gateway folder or database. Checked against 0.7.0's own registry loader.
2026-10-24 03:03:13 +00:00
dev-driver 8da2c91fe3 docs(changelog): add the 0.7.1 entry
The 0.7.1 release notes as an entry above 0.7.0, in the CHANGELOG's existing format. It names the tag it will be built from, as the 0.6.12 entry did.
2026-10-24 00:32:30 +00:00
dev-driver d28faf1053 chore(release): prepare 0.7.1: version, installer pins, openrig-core 0.1.18 and what's new
- Version fields 0.7.0 to 0.7.1 in the root, cli, daemon and ui package.json
  and package-lock.json, as 0.7.0's pins commit did.
- Installer pins v0.7.0 to v0.7.1 in README, getting-started and quickstart.
- openrig-core 0.1.17 to 0.1.18 in both plugin manifests. Since 0.7.0 the
  plugin's hook definitions, hook scripts, pre-compaction hook and three
  skills changed under the same version, 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.1,
  in the same commit as the version, as the package-content test requires.
2026-10-24 00:32:30 +00:00
dev-driver a325e901c8 Revert "fix(daemon): read a native install's version label as claude with an OS executable witness (#1345)"
This reverts commit 6c31f02706 on the 0.7.1
candidate line only, as 0.7.0's line did (5380ca10). Main keeps #1345.

One conflict: the native-process-lineage and managed-blocks imports in
claude-code-adapter.ts. Resolved by dropping only #1345's
witnessedClaudeVersionLabel and VersionLabelWitnessWait, and keeping #1840's
isPerSeatRoleGuidance and perSeatRoleSkipWarning.
2026-10-24 00:27:06 +00:00
21 changed files with 397 additions and 551 deletions

No files matched your search

+226
View File
@@ -8,6 +8,232 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.1]
OpenRig 0.7.1 gives you more say over how much your team does on its own. You
choose, per seat, how much automatic input reaches an agent's terminal and how
far other agents may act on it, and you can see the permissions each seat will
launch with. Questions to you land on the TUI's Feed, and Slack can carry every
ask or only urgent ones. Team skills stay out of your project, assistants such
as Claude Desktop can do much more through MCP, and a managed compaction
survives a daemon restart. It is built from the commit tagged `v0.7.1`. The
changes since 0.7.0:
### Before you upgrade
- Restart the daemon after installing (`npm install -g @openrig/[email protected]`, then
`rig daemon stop && rig daemon start`). Five database updates apply on its
first start.
- Startup replaces OpenRig's own plugin and skill files (openrig-core goes from
0.1.17 to 0.1.18). As in 0.7.0, 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.
- Claude seats need Claude Code 2.1.280 or newer to see their team skills
(`claude --version`).
- If an earlier version reset your worktree's Claude permission mode, set it
back once by hand. Launching a managed Claude seat used to rewrite
`defaultMode` in `.claude/settings.local.json` to `acceptEdits`; 0.7.1 stops,
but doesn't repair files already changed
([#1776](https://github.com/mvschwarz/openrig/pull/1776)).
### What's new and fixed
How much your team does on its own:
- `rig seat set-typing-guard <seat> --mode off|openrig-urgent|urgent-only|hold`
sets how much automatic input reaches a seat. `rig send --urgent` or an urgent
queue row marks a message urgent, naming who marked it. Held input stays
readable and is never replayed
([#1770](https://github.com/mvschwarz/openrig/pull/1770),
[#1781](https://github.com/mvschwarz/openrig/pull/1781)).
- `rig seat set-terminal-policy <seat> --preset
no-peer-interventions|help-within-instructions|full-control` sets how far
OpenRig and other agents may act on a seat, with exceptions for compaction and
model changes. Existing seats keep full control
([#1846](https://github.com/mvschwarz/openrig/pull/1846)).
- `rig seat status` shows the permissions a seat's next fresh launch will use
and where each comes from; `rig setup` asks before adding OpenRig's line to
your shared guidance files
([#1843](https://github.com/mvschwarz/openrig/pull/1843)).
Asks to you:
- An ask lands on the TUI's Feed. Every ask still posts to Slack by default;
`rig gateway human set <id> slack-subscription urgent-only` keeps Slack for
asks marked `--alert`, which stand out with a mention
([#1868](https://github.com/mvschwarz/openrig/pull/1868),
[#1878](https://github.com/mvschwarz/openrig/pull/1878)).
Seats keep their setup out of your project:
- Team skills live in each seat's own storage, not your project's
`.claude/skills` or `.agents/skills`; `rig skill loadout --seat <seat>` shows
them ([#1008](https://github.com/mvschwarz/openrig/pull/1008), @puniserv).
- A Claude member can bring its own agent file, system prompt file, plugin
folders and Advisor model
([#1737](https://github.com/mvschwarz/openrig/pull/1737),
[#1736](https://github.com/mvschwarz/openrig/pull/1736), @Aschenbath).
- A seat's role no longer overwrites the others' in a shared `CLAUDE.md` or
`AGENTS.md`, and `rig down` restores your guidance files byte for byte
([#1840](https://github.com/mvschwarz/openrig/pull/1840),
[#1854](https://github.com/mvschwarz/openrig/pull/1854),
[#1859](https://github.com/mvschwarz/openrig/pull/1859),
[#1867](https://github.com/mvschwarz/openrig/pull/1867)).
Assistants and other agents:
- OpenRig's MCP server grows from 23 to 34 tools, including identity, restore
status, a rig's team and lead, following a handoff, reading a result and
deciding inside a person's decision room. `rig mcp serve --http` serves an
assistant on another machine, each caller acting as its own seat
([#1831](https://github.com/mvschwarz/openrig/pull/1831),
[#1835](https://github.com/mvschwarz/openrig/pull/1835),
[#1842](https://github.com/mvschwarz/openrig/pull/1842),
[#1853](https://github.com/mvschwarz/openrig/pull/1853),
[#1860](https://github.com/mvschwarz/openrig/pull/1860),
[#1596](https://github.com/mvschwarz/openrig/pull/1596), @pdurlej).
- `rig acp run <row>` hands a row's task to an ACP agent such as Hermes or
OpenClaw; a new guide covers A2A agents
([#1823](https://github.com/mvschwarz/openrig/pull/1823),
[#1829](https://github.com/mvschwarz/openrig/pull/1829)).
- Community harnesses can report, name and reopen conversations, with examples
for Cursor, Hermes and OpenClaw
([#1768](https://github.com/mvschwarz/openrig/pull/1768),
[#1769](https://github.com/mvschwarz/openrig/pull/1769),
[#1771](https://github.com/mvschwarz/openrig/pull/1771),
[#1783](https://github.com/mvschwarz/openrig/pull/1783),
[#1784](https://github.com/mvschwarz/openrig/pull/1784),
[#1826](https://github.com/mvschwarz/openrig/pull/1826),
[#1870](https://github.com/mvschwarz/openrig/pull/1870)).
Running a team:
- A daemon restart, such as an upgrade, picks up a managed compaction where it
stopped ([#1866](https://github.com/mvschwarz/openrig/pull/1866)).
- `rig edge add` and `rig edge remove` change one edge on a running rig
([#1733](https://github.com/mvschwarz/openrig/pull/1733),
[#1746](https://github.com/mvschwarz/openrig/pull/1746)).
- `rig up <spec> --name <rig>` runs one spec as several rigs
([#1692](https://github.com/mvschwarz/openrig/pull/1692), @klueless-io).
- `rig watchdog update` retunes a job in place; the context watch takes token or
percent thresholds ([#1803](https://github.com/mvschwarz/openrig/pull/1803),
@AdamWhitehurst; [#1735](https://github.com/mvschwarz/openrig/pull/1735),
@akushonkamen).
- `policies.claude_compaction.preparation_root` puts restore maps where seats
may write ([#1585](https://github.com/mvschwarz/openrig/pull/1585),
@Aschenbath).
- Herdr: tiles come back after a Herdr restart (one over SSH needs reopening),
`--session` picks a Herdr session, and the TUI's terminal button works over
SSH ([#1796](https://github.com/mvschwarz/openrig/pull/1796),
[#1076](https://github.com/mvschwarz/openrig/pull/1076), @Aschenbath;
[#1748](https://github.com/mvschwarz/openrig/pull/1748)).
- New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`
([#1812](https://github.com/mvschwarz/openrig/pull/1812)).
- Off by default: once decisions are enabled, `rig project wake --decide` lets
your intake seat route stream observations with one bounded typed decision
each, and hands anything uncertain back to that seat
([#1879](https://github.com/mvschwarz/openrig/pull/1879),
[#1882](https://github.com/mvschwarz/openrig/pull/1882)).
Reliability:
- `rig daemon start` waits for a slow daemon instead of stopping it, and warns
rather than fails when only a secondary listener, such as Tailscale's, can't
be verified ([#1716](https://github.com/mvschwarz/openrig/pull/1716),
@dajiaohuang; [#1743](https://github.com/mvschwarz/openrig/pull/1743),
[#1793](https://github.com/mvschwarz/openrig/pull/1793)).
- A Claude seat whose startup turn is refused keeps its remaining startup
context as owed ([#1869](https://github.com/mvschwarz/openrig/pull/1869)).
- On Ubuntu 22.04's tmux 3.2a, a closing session no longer sends the query that
crashes tmux ([#1755](https://github.com/mvschwarz/openrig/pull/1755)).
- A rebuilt Codex seat keeps its narrower sandbox, and read-only Codex profiles
launch ([#1779](https://github.com/mvschwarz/openrig/pull/1779),
[#1791](https://github.com/mvschwarz/openrig/pull/1791),
[#1825](https://github.com/mvschwarz/openrig/pull/1825), @bradvin).
- Reminders and workflow keepalives survive a seat swap
([#1777](https://github.com/mvschwarz/openrig/pull/1777),
[#1782](https://github.com/mvschwarz/openrig/pull/1782)).
- OpenRig's hooks run without Node on the seat's PATH
([#1727](https://github.com/mvschwarz/openrig/pull/1727),
[#1728](https://github.com/mvschwarz/openrig/pull/1728),
[#1729](https://github.com/mvschwarz/openrig/pull/1729)).
- From contributors: launch probe evidence
([#1499](https://github.com/mvschwarz/openrig/pull/1499), @Aschenbath);
request IDs for creates and checked stops
([#1790](https://github.com/mvschwarz/openrig/pull/1790),
[#1792](https://github.com/mvschwarz/openrig/pull/1792), @bradvin); faster
cost history, ordered stream replay, kept web-UI drafts and guarded sends at
Claude's MCP prompts ([#1756](https://github.com/mvschwarz/openrig/pull/1756),
[#1757](https://github.com/mvschwarz/openrig/pull/1757),
[#1758](https://github.com/mvschwarz/openrig/pull/1758),
[#1864](https://github.com/mvschwarz/openrig/pull/1864),
[#1865](https://github.com/mvschwarz/openrig/pull/1865), @Nikhi00718); a
non-blocking preflight
([#1759](https://github.com/mvschwarz/openrig/pull/1759), @icey-zzz); clearer
compact-plan output ([#1629](https://github.com/mvschwarz/openrig/pull/1629),
@taljeri); exports keep culture files
([#1657](https://github.com/mvschwarz/openrig/pull/1657), @Totopo27); no flash
in terminal views ([#1663](https://github.com/mvschwarz/openrig/pull/1663),
@Vaishnavi220506); pod startup files for added seats
([#1701](https://github.com/mvschwarz/openrig/pull/1701), @AdamWhitehurst); Pi
idle heartbeats ([#1702](https://github.com/mvschwarz/openrig/pull/1702),
@okle100); the `rig ps` activity column
([#1614](https://github.com/mvschwarz/openrig/pull/1614),
@shravansumanthanan); full workflow trails
([#1732](https://github.com/mvschwarz/openrig/pull/1732), @psbanka).
### Behaviour that changed
- `rig ps --nodes` separates every column with a space and adds an ATTN column
before REASON; update scripts that read columns by position
([#1780](https://github.com/mvschwarz/openrig/pull/1780),
[#1876](https://github.com/mvschwarz/openrig/pull/1876)).
- `rig queue show --json` drops `lastNudgeInputBox`, and send results no longer
describe the input box
([#1738](https://github.com/mvschwarz/openrig/pull/1738)).
- `rig down` asks before stopping the rig you're in; without a terminal it stops
nothing and prints the `--yes` command
([#1838](https://github.com/mvschwarz/openrig/pull/1838)).
- `rig up --plan` no longer starts a stopped daemon; it exits with
`daemon_not_running`
([#1836](https://github.com/mvschwarz/openrig/pull/1836)).
- An urgent or critical row's wake starts with `Urgent (marked by …):`
([#1770](https://github.com/mvschwarz/openrig/pull/1770)).
- `rig provider moves` is back with a new job: filing and reading subscription
move reports. It switches nothing
([#1847](https://github.com/mvschwarz/openrig/pull/1847),
[#1849](https://github.com/mvschwarz/openrig/pull/1849)).
- `rig queue show` and `rig queue whoami` print human notices on stderr
([#1808](https://github.com/mvschwarz/openrig/pull/1808)).
- A seat joined with `rig attach` as an outside app stays attached through `rig
down` and restore ([#1827](https://github.com/mvschwarz/openrig/pull/1827),
[#1834](https://github.com/mvschwarz/openrig/pull/1834)).
### Known issues
- 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.
### Credits
Thank you to everyone whose work is in this release: @Aschenbath, @bradvin,
@Nikhi00718, @AdamWhitehurst, @puniserv, @pdurlej, @klueless-io,
@shravansumanthanan, @taljeri, @Totopo27, @Vaishnavi220506, @okle100,
@dajiaohuang, @icey-zzz, @akushonkamen, @psbanka and @adampog, and to @lev-gpt,
@XVVH, @Kero-66, @pr0xc3nt4ur1, @VltrnOne, @BillyOutlast, @gogabrielordonez,
@geoff-dhango, @alexveli1, @aanestesia, @team-sudarshana and @oneandonlydean,
whose reports and ideas led to fixes here.
## [0.7.0] - 2026-10-23
Published 2026-10-23 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.1/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.1/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.1` 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.1/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.1/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
@@ -184,11 +184,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.1/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.1/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.1`. 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.
+121 -113
View File
@@ -1,7 +1,7 @@
# What changed in OpenRig 0.7.0
# What changed in OpenRig 0.7.1
This note is for an agent on an install that was just upgraded to 0.7.0. It lists only what behaves differently from
0.6.14 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.1. It lists only what behaves differently from
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
@@ -9,134 +9,142 @@ after an upgrade it can still hold the previous version's note.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Six migrations run on the first start (116, 118,
119, 121, 122 and 123). Two settings are added, `terminal.history_limit` (default 10000) and
`terminal.inherit_tmux_environment` (default false), and `provider.subscription_moves` is removed.
- **The bundled openrig-core plugin goes from 0.1.15 to 0.1.17.** Startup replaces installed copies of managed plugins and
marked global skills. It first copies every file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`,
whether or not anyone edited it, and logs `[plugin-vendor] preserved previous contents of <file> at <copy> before
managed refresh` for each one. A stock upgrade from 0.6.14 copies about 22 files (about 250 KB), and a later start with
no new version copies none. The copies matter only for files someone changed: the copy keeps the bytes, but the change
may no longer be active after the refresh. Keep the log line, because it names the original file and the copy's folder
doesn't. If a copy fails, that plugin or skill isn't refreshed, its files are kept, and the log says so. Copies aren't
cleaned up.
- **If you gave new agents accounts or API keys through tmux's global environment** (`tmux set-environment -g …`), new
agents no longer inherit it (below). Put the values in the seat's `env`, pass `rig up --env KEY=VALUE`, or keep the old
behaviour with `rig config set terminal.inherit_tmux_environment true`. Running agents aren't affected.
- **If you write queue rows to another machine by its self-id** (`member@rig@<self-id>`), run `rig host doctor <alias>`
once per host: bindings learned before 0.7.0 have no address.
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Five migrations run on the first start (124, 125,
127, 128 and 129). Five settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), and `decisions.enabled` (false), `decisions.consumers` and
`decisions.template_dir`. None is removed.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
## New things you can do
- **Hold a person's routine asks while they're away:** `rig gateway human quiet set <id> --daily HH:MM-HH:MM` or
`--until <time>` (`--tz`), and `end`, `clear` or `show`. A held ask posts once, without a mention, when quiet ends.
Asks tagged `escalation` or `wake-escalation` are not held.
- **Apply a person's mode** ("asleep", "out of office"): `rig gateway human mode save <id> <name>` (with
`--availability`, `--expected-back`, `--decision-room "<their words>"`, `--exclude`, `--quiet`), then `apply <id>
<name> [--until …]`, `current`, `end` and `account`. Never tell the person "back to normal" while the account shows an
effect `kept`, `pending` or `failed`.
- **Decide an ask inside a person's decision room:** check `rig gateway human mode room <id>`, then `rig gateway human
mode decide <qitemId> --application <id> --reason "…" --reversible|--irreversible`. It's recorded as delegated, never
as their answer, and only the agent that asked can decide. Permission prompts are refused (`permission-prompt`).
- **Pass a rig's name instead of its id** to `archive`, `unarchive`, `release`, `shrink`, `expand`, `snapshot`,
`export` and `restore --rig`. A name two rigs share is refused and the matching ids are named: with `--json`,
`archive`, `unarchive`, `release`, `shrink`, `expand` and `restore status --rig` return `rig_ambiguous`.
- **Stop a managed compaction's follow-up after `/compact` was sent:** `rig compact --cancel` now also disarms the
restore and audit prompts that would follow. It sends no input and can't undo the `/compact`.
- **See your work candidates:** `rig queue whoami` adds `currentWorkChoice` when current work isn't settled.
- **See why a wake didn't land:** `rig queue show --json` adds `lastNudgeInputBox`.
- **See each seat's configured effort:** the TUI agent table's `CFG EFF` column, and `effort` and `effortSource` in
`rig ps --nodes --full --json`. It's the configured level, not what the model ran.
- **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.
## Behaviour that changed
- **New agents start from a clean environment, not tmux's global one.** A new seat or handover successor gets:
- the launch environment: `PATH`, `HOME`, `USER`, `LOGNAME`, `CODEX_HOME`, `OPENRIG_HOME`, `OPENRIG_URL`,
`OPENRIG_PORT`, `OPENRIG_HOST`, the seat's own identity values (such as `OPENRIG_SESSION_NAME`) and the allowed
provider sign-in variables. Not `OPENRIG_DB` or `TMUX_TMPDIR` (see Known gaps);
- the daemon's `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `TMPDIR`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME` and
`CLAUDE_CONFIG_DIR`;
- the values named in tmux's `update-environment`;
- its own `env`.
Shell startup still runs. `rig setup` warns when it sees a custom tmux configuration.
- **After a reboot, the daemon restores a kernel whose seats are all detached** (and still bound to their sessions), in
the background, as `rig up kernel --existing` would. `OPENRIG_NO_KERNEL=1` still skips it, and `rig start --json` reports `kernelRestore`.
- **A seat missing a required startup file no longer blocks its rig's restore:** `rig up <rig> --existing` holds that
seat and restores the others (`partially_restored`).
- **Seats added to a running rig** with `rig expand`, `rig grow` or add-member get its culture file and rig-level
startup, for rigs created on 0.7.0 or later. Older rigs get no backfill.
- **A busy daemon gets the real request.** The CLI refuses only when the daemon is stopped or stale, the connection is
refused or blocked, or the daemon answering belongs to another OpenRig home. A slow daemon is no longer read as down.
- **A new seat's first startup message** says it's OpenRig's identity context, not a task. A seat whose startup sends
nothing else is asked to run `rig whoami --json` and say it's ready for an assignment.
- **A new Claude seat's first startup text** waits briefly (about five seconds) for Claude's input box before it's
sent.
- **Codex seats that select openrig-core get the plugin installed into Codex.** While Codex hooks are on
(`runtime.codex.hooks_enabled`, default on), OpenRig installs it with Codex's own marketplace and plugin commands at
daemon start and at a fresh launch, unless the person's Codex configuration disables it.
- **A first install only** (no earlier install or plugin cache): OpenRig checks the installed hook files against its own
copy, then trusts the plugin's six shipped hooks by their exact definitions, so the seat starts without Codex's hook
review.
The file check happens only when trust is granted: a later edit to the cached hook scripts keeps that trust, as with any hook trusted in Codex.
- **An existing install keeps the person's choices.** A plugin or hook they disabled stays disabled. One they left
untrusted stays under Codex's review: Codex records nothing for "continue without trusting", so OpenRig doesn't read a
missing trust record as permission.
- **What it adds:** the four managed inline hooks stay the only activity reporters; the plugin adds refocus and its
skills.
- **Watchdog delivery is recorded as it happened:** `rig watchdog status` outcomes are `sent`, `refused`, `failed`,
`skipped` or `terminal` (0.6.14 had `sent`, `skipped` or `terminal`). Every reminder carries "Delivery may repeat after
an unconfirmed attempt." under its header line, and two refusals in a row at one seat's prompt raise that seat's prompt
alert. A park wake shows the continuation its park recorded (`Recorded continuation: …`), so record one when you park.
- **Slack:** a thread reply answers a parked question only if it was written after the question posted, and a message
Slack rejects as `invalid_blocks` isn't posted again.
- **Claude Code team agents are asked for permission less often.** Housekeeping commands such as `archive`, `attach`,
`bind`, `release`, `reconcile-session`, `config set` and `seat continue` no longer prompt. `seat restart` and `policy
permissions apply` now do.
- **`rig up --plan` waits as long as an apply** (120 seconds). An unanswered plan exits 1 and is safe to run again.
- **`rig setup` keeps 10,000 rows of tmux history**, down from 50,000 (`terminal.history_limit`, 2000 to 1000000).
- **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
instead of typing it.
- **`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.
## Output that changed shape
- **`rig gateway human list`** shows each person's availability (`available`, `focus`, `away` or `off`) where it showed
`away` or `available`, plus a `quiet:` line; JSON adds `availability` and `quiet`.
- **`rig attach`** prints `Sender:` (and `Tmux:` when the names differ), and `rig whoami` adds `identity.senderAddress`.
A clashing member name is refused with `address_conflict`.
- **Send results** carry `managedCompaction`, and the CLI prints a `Notice:` line.
- **`rig ps --rig <unknown>`** exits 1, and `--host H --rig X` filters by rig.
- **`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.
## What to stop doing
- **Don't use `rig provider moves`:** it's gone with its setting. Report a subscription move as a queue item to whoever
asked.
- **Don't steer a new seat's account or keys through tmux globals.** Use the seat's `env` or `rig up --env`.
- **Don't close a row `done` with `--closure-reason blocked_on`.** It's refused with `invalid_closure_reason`; use `rig
queue block <id> --on <child>`.
- **Don't treat "daemon did not respond" as down.** Check the effect before any retry or restart.
- **Don't look up a rig id first** for the commands above; pass the name.
- **Don't restart the daemon to stop a managed compaction's follow-up prompts.** Use `rig compact --cancel`.
- **Don't run `rig up kernel --existing` by hand after a reboot** before checking `rig status`.
- **Don't reuse a `deliveryId` to deduplicate a send,** and don't pass `--reason` or `--actor` to `rig slack verify`.
- **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`.
## Known gaps in 0.7.0
- **A new seat doesn't get the daemon's `OPENRIG_DB` or `TMUX_TMPDIR`.** A tool in the seat that reads `OPENRIG_DB`
directly can fall back to the default database. If you rely on either, pass it in the seat's `env` or with `rig up
--env`.
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
the clean environment, and the seat may lack OpenRig's variables. Check `env` in the seat before relying on them.
- **A Slack channel-mapping change needs a daemon restart** (`rig daemon stop`, then `rig daemon start`). Don't use `rig
slack disable` then `enable` for it: enabling marks any alerts waiting to post to Slack as already seen, so open
exchanges miss them.
## Known gaps in 0.7.1
- **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.
- **The TUI's terminal button refuses inside a Herdr pane reached over SSH.** Start the TUI with `env -u SSH_CONNECTION
-u SSH_CLIENT -u SSH_TTY rig tui`, or run `rig terminal open '<view>' --provider herdr`.
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
acting on it.
- **Ubuntu 22.04's stock tmux 3.2a can crash its whole server** when OpenRig checks a pane that has just closed. Avoid
tmux 3.2a for OpenRig (`tmux -V`).
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
marks any alerts waiting to post to Slack as already seen.
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
(`tmux -V`).
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
everything" wait. On a slow host, start the daemon yourself first.
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
gateway folder or database instead: that can lose newer people, bindings and delivery records.
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
--provider herdr`.
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
first. Pair a decision room with quiet hours, or decide before asking.
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.0",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.0",
"version": "0.7.1",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7069,7 +7069,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.0",
"version": "0.7.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7104,7 +7104,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.0",
"version": "0.7.1",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7146,7 +7146,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.0",
"version": "0.7.1",
"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.0",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.0",
"version": "0.7.1",
"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.18",
"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.18",
"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.0",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -15,7 +15,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";
@@ -63,7 +63,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;
@@ -85,9 +84,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;
@@ -110,7 +106,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;
@@ -420,13 +415,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. */
@@ -465,16 +453,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.
@@ -594,13 +575,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 {
@@ -687,7 +666,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
@@ -6,7 +6,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";
@@ -114,7 +114,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)}`,
@@ -122,29 +122,20 @@ export class ClaudeResumeAdapter {
return result.ok ? { ...result, appliedLaunch } : 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 {
@@ -179,7 +170,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
@@ -24,7 +24,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";
@@ -851,7 +850,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, 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, 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, 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, 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.0",
"version": "0.7.1",
"private": true,
"license": "Apache-2.0",
"type": "module",