Compare commits

...
12 Commits
Author SHA1 Message Date
dev-driver 1e392d56c3 fix(status): a seat waiting at a prompt only the pane shows needs attention, on every surface
A Codex seat at an approval prompt that no hook reported served agentActivity needs_input (selection_prompt, from the
cached pane reading), but its needsAttention was false: attachTerminalActivityAndWork derived the flag from a fresh
hook-store read instead of the activity the row serves, and the rig count read only the hook store. Since `rig ps`
renders the served flag (#1876), the table said "working, —" with no reason or ATTN.

The ladder's hook-or-pane step moves into hookOrPaneActivity, unchanged: a positive hook keeps its meaning, a current
usage limit stays, else the cached pane reading. The per-seat flag now reads the served activity, and the rig count
reads the same step (with the pane-reading service wired in), so the seat API, the rig and TUI count, the CLI table
and the compact JSON agree. The arbitrated signal still comes first, so an observed clear from an authoritative rung
still wins. Stale hooks still read unknown, and the pane cache drops stale readings, so nothing stale is revived. A
confirmed-missing session serves neither hook nor pane, on the row and in the count. Capture-free as before.
2026-10-24 23:36:02 +00:00
dev-driver 9c08efb59b test: a prompt only the pane reading sees counts as attention in the seat API, rig count, CLI and TUI 2026-10-24 23:36:02 +00:00
dev-driver e436cff224 fix: require exact plugin tree before fresh hook grants 2026-10-24 23:24:59 +00:00
dev-driver a52abd2b0c fix: preserve hook-file install gate with strict content renewal 2026-10-24 23:14:03 +00:00
dev-driver acea3bf39e fix: validate Codex renewal edits before writing owner config 2026-10-24 23:11:33 +00:00
dev-driver b8b95ca1df fix: preserve config formatting when renewing hook content approval 2026-10-24 22:50:13 +00:00
dev-driver ed8ca39666 fix: renew content approval for verified OpenRig Codex plugin upgrades 2026-10-24 22:47:44 +00:00
dev-driver 32633a3d8f docs: the capability map teaches 0.7.1
The reviewed change from PR 1928 (675b77ef and a13d8199, combined), applied to the 0.7.1
candidate without main ancestry: one file,
packages/daemon/context-packs-src/onboarding-width/public-what-you-can-do.md.
2026-10-24 16:42:00 +00:00
dev-driver efcbd23109 fix(substance): allow the startup guide's exact preparation-root sentence
The release substance gate refused docs/reference/agent-startup-guide.md:529, an accurate
description of the default compaction preparation root, because it contains the
.openrig/compaction/ prefix. Add that one sentence to allowed_context_lines, the existing
exact-line mechanism: the complete line must match, so appended text or a concrete path
still fails. The deny tokens, path prefixes and the doc are unchanged.

Test: the shipped guide scans clean under the product rules, and the sentence with appended
text or a substituted cwd is still refused.
2026-10-24 16:38:59 +00:00
dev-driver a6320e5986 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 14:45:12 +00:00
dev-driver 9a83123415 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 14:44:44 +00:00
dev-driver ef07562d02 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 14:42:50 +00:00
31 changed files with 1110 additions and 657 deletions

No files matched your search

+295
View File
@@ -8,6 +8,301 @@ 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`). Eight 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)).
- If you might go back to 0.7.0, undo Slack subscription choices first. 0.7.0
can't read a person file that holds one, so its people registry stops loading.
Save each choice (`rig gateway human show <id> --json > <id>.json`), then run
`rig gateway human set <id> slack-subscription unset`: someone on
`urgent-only` gets every ask in Slack again, as 0.7.0 does. Set it again after
you return to 0.7.1. The unset removes only the Slack subscription, so a
person file that still holds 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.
### 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)).
- `rig setup --providers` is remembered (`provider.selected`), so the kernel
starts seats only for the harnesses you chose
([#1817](https://github.com/mvschwarz/openrig/pull/1817)).
- Optional `daemon.db_conflict: stop` keeps a second daemon off a database
another live daemon holds; the default still only warns
([#1890](https://github.com/mvschwarz/openrig/pull/1890)).
- `rig stream subscribe <seat>` and `rig stream read` give a seat bounded pages
of the observations meant for it, and `rig stream show <id>` says what became
of one ([#1891](https://github.com/mvschwarz/openrig/pull/1891),
[#1888](https://github.com/mvschwarz/openrig/pull/1888)).
- Stream retention is on: items emitted after the upgrade are deleted after 30
days, or beyond the newest 20,000, unless a record names them
(`retention.stream_days`, `retention.stream_max_items`). History from before
the upgrade is kept ([#1891](https://github.com/mvschwarz/openrig/pull/1891),
[#1897](https://github.com/mvschwarz/openrig/pull/1897)).
- 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),
[#1894](https://github.com/mvschwarz/openrig/pull/1894)).
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)).
- After a daemon restart that can't see the seats' tmux server, running agents
are no longer detached and relaunched beside themselves
([#1906](https://github.com/mvschwarz/openrig/pull/1906)).
- Provider credentials and the activity-hook token stay off tmux, shell and
`env` command lines; @pr0xc3nt4ur1 first raised it in
[#1360](https://github.com/mvschwarz/openrig/pull/1360)
([#1907](https://github.com/mvschwarz/openrig/pull/1907)).
- An answer retried from Slack or Steering after an interrupted close is used
once, and a replayed Slack reply stays absorbed
([#1896](https://github.com/mvschwarz/openrig/pull/1896),
[#1898](https://github.com/mvschwarz/openrig/pull/1898)).
- One stuck row on a removed rig no longer stops the stuck sweep from flagging
the rest, and stopped watchdog jobs release their old schedules
([#1903](https://github.com/mvschwarz/openrig/pull/1903),
[#1900](https://github.com/mvschwarz/openrig/pull/1900)).
- Health diagnoses still list when an unrelated diagnosis row holds a malformed
field ([#1920](https://github.com/mvschwarz/openrig/pull/1920)).
- 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)).
- `rig send --dangerously-interact --reason` into a seat's input box stops and
shows text already typed or pasted there, apart from suggestions, instead of
pasting over it; clear it and retry, or pass `--type-without-checking`. Keys
for a recognized native menu are sent as before
([#1892](https://github.com/mvschwarz/openrig/pull/1892)).
- `rig send` to a terminal seat types exactly your text, without the From/To
lines, and queue wakes type nothing into it
([#1100](https://github.com/mvschwarz/openrig/pull/1100)).
- A stream body over 16 KiB is stored truncated and marked, and `rig stream list
--after` from a pruned item returns `stream_cursor_expired` instead of an
empty list ([#1891](https://github.com/mvschwarz/openrig/pull/1891)).
- A send held for a draft in the seat's input box reports `inputPhase:
"not_reached"`, and a managed compaction prompt held that way counts as not
sent ([#1884](https://github.com/mvschwarz/openrig/pull/1884),
[#1889](https://github.com/mvschwarz/openrig/pull/1889)).
### 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.
- 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` for any other.
- 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](https://github.com/mvschwarz/openrig/issues/14) remains open.
### 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.
+6 -4
View File
@@ -37,10 +37,12 @@ launched before this change, a manual run) the hooks do what they always did. Th
the seat's own `settings.local.json` copy only when that entry is exactly OpenRig's and the script it names is the same
script; an entry from an older install doesn't match, so both report until the seat's next launch refreshes it.
OpenRig automatically installs its Codex plugin for seats that select it. On its first installation, the six exact shipped
hook definitions are trusted before launch, after checking the installed hook files against this daemon's shipped copy.
Existing plugin disables and hook choices are retained. An existing untrusted installation stays under native review:
Codex's "continue without trusting" leaves no record, so OpenRig does not assume an absent trust record is permission.
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.
hook definitions and bundle content are trusted before launch, after checking the entire installed plugin against this daemon's shipped copy.
On upgrade, OpenRig renews content approval only for a previously approved OpenRig-owned bundle with unchanged approved hook definitions.
It keeps explicit disables, refusals, unknown config layers and other plugins' state. An installation without a proven prior approval stays under native review.
Codex's "continue without trusting" leaves no record: a skip of an otherwise eligible OpenRig-owned update may therefore be superseded on retry.
OpenRig records its approved bundle in `CODEX_HOME/.openrig-core-hook-trust.json` and leaves Codex's immutable `.hook-trust` baseline untouched.
Codex 0.164 checks bundle contents when hooks run; a later edit requires native approval again. Older Codex versions enforce their own trust rules.
The Codex
column above then applies only to a Codex seat whose plugin selection includes openrig-core: every Codex hook command
exits 0 before running anything unless OpenRig gave that seat `OPENRIG_SEAT_PROJECT_DIR` and an explicit
+162 -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,183 @@ 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`). Eight migrations run on the first start (124, 125,
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
`daemon.db_conflict` (`warn`). None is removed.
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
## 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; 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.
## 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.
- **`--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.
## 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.
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
`--since` to resume, instead of an empty list.
## What to stop doing
- **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`.
- **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.
## 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.
- **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.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",
@@ -10,9 +10,11 @@ already does it, or two verbs crossed already answer it. **You cannot look up a
not know exists.** So the point of what follows is not skill. It is recognition: that when a task
lands, something rhymes, and you go check instead of building.
Eighty-eight top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.0`). This marker describes the pack's teaching, not publication or live
Ninety top-level verbs ship (capability canon refreshed through
`capability-delta-v0.7.1`). This marker describes the pack's teaching, not publication or live
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
What changed in the installed version, and what to do about it, is one read away:
`rig context get reference/whats-new.md`.
Model-divergence proclamations are live product (trust them; pins use canonical
model IDs).
@@ -36,7 +38,10 @@ one command away. Your memory of the fleet is a claim about the past.
source and time), or unknown; it never shows the configured model in its place. A Claude seat at
Claude Code's MCP-server approval menu reads as needing input (`mcp_gate`), a Codex seat at its
folder-trust panel reads `trust_gate`, and an OMP seat whose turn ended with a typed transient
provider failure reads idle and takes an ordinary retry.
provider failure reads idle and takes an ordinary retry. The `ATTN` column (`yes`, `no` or `?`)
says which seats need attention (`needsAttention` in `--json`; `--full` shows every field). Cells
are one space apart, so read `--json`, with `--fields` to pick fields, rather than column
positions.
**`rig ps --resources`** shows this host's load and transcript capture cost.
- **`rig ps --all-hosts --nodes -A`** — the same across every registered machine. Rollup-only
unless you ask for nodes.
@@ -45,7 +50,10 @@ one command away. Your memory of the fleet is a claim about the past.
When the seat's typed rows name exactly one work node, its JSON also carries `currentWork` with
the mission, slice, and path the refocus hook uses.
- **`rig seat status <seat>`** — what the system already believes about a seat's handover state,
and whether the thing you did an hour ago actually landed.
and whether the thing you did an hour ago actually landed. Before a relaunch, it shows what the
seat's next fresh managed launch will ask for, labelled planned rather than observed: the native
permission argument, where each part comes from, what OpenRig adds and the commands that change
it, plus the seat's terminal policy.
- **`rig view list` / `rig view show <lens>`** — named lenses over coordination state: use
`view show escalations` for owner attention, `view show pickup` for claimed-row state, and
`view show execution` for done/now/next and current authored planning guidance with its
@@ -98,20 +106,32 @@ unseen — which makes messages the one delivery channel that never gets skipped
producer-link advisory reading `no_activity_signal` means activity could not be determined;
confirm by effect, never relaunch on it. A send or wake to a seat whose latest hook is a
question waiting for a person is refused with `target_needs_input`, even when its screen
can't be read: read it with `rig capture` and get the question answered instead of retrying.
can't be read, and so is one to a Claude seat whose hook just reported it running while its
screen shows an MCP enablement menu, a question or a permission prompt: read it with
`rig capture` and get the question answered instead of retrying.
`--wait-for-idle N` waits before it takes the seat's delivery lease, so other sends and wakes to
that seat aren't held behind it; N is a deadline from the start of the wait.
Over `--host` to an http-registered host, a send that may have reached the remote reads
`remote-outcome-unknown` ("Delivery UNCONFIRMED"): check the target with `rig capture <session>
--host <id>` before any resend. Only a host that was never reached reads as unreachable. A send
whose caller goes away (Ctrl-C, or its own timeout) before input starts is refused with
`caller_cancelled` and never types later.
`caller_cancelled` and never types later. **`--urgent`** marks a message urgent, as does a row
at priority `urgent` or `critical`: its wake starts `Urgent (marked by …):`, and it gets through a
seat whose typing guard lets only urgent input type (`openrig-urgent` or `urgent-only`, below).
- **`rig send --raw`** — send exact text or keystrokes without the From/To envelope;
the interactive-prompt guard still applies. To deliberately drive an interactive prompt,
**`--dangerously-interact --reason "<why>"`** is the explicit override and implies raw text.
Inspect the prompt and establish authority for its effect before using that override. It types
exactly the bytes and keys you give it, in any state: Enter is added only for a complete answer
exactly the bytes and keys you give it: Enter is added only for a complete answer
staged in a recognised Claude Code or Codex input box, so anywhere else put CR in the payload.
**On a managed seat it stops before typing into an input box that already holds typed or pasted
text**, and shows you that text, apart from a recognised dim suggestion; it types nothing. Clear
the box and retry, or add `--type-without-checking`, which reads and clears nothing and may join
or submit the existing text. Keys for a recognised native menu are sent as before, and collapsed
paste contents or an unrecognised input can't be read. Not yet checked on a live seat whose box
holds only a suggestion, or typed text beside one. Don't use it to type over a half-written box:
to reach a seat with something that can't wait, use `--urgent` where its typing-guard level lets
urgent input through.
A send to a pane in tmux copy or view mode leaves the mode once and delivers; a pane that can't
leave is refused with `pane_in_mode`. With `transport.verify_input_box` on (off by default),
`--verify` to Claude Code or Codex reads verified only when the text was seen in the input box
@@ -144,7 +164,23 @@ unseen — which makes messages the one delivery channel that never gets skipped
including an error answer from the daemon such as a removed rig, still end the wait.
- **`rig stream emit` / `list` / `watch` / `archive`** — drop an observation somewhere the *next*
agent will find it. Emitting costs nothing and does not interrupt anyone; the value appears when
someone lists the stream before starting.
someone lists the stream before starting. **`rig stream show <id>`** says what became of one:
its routing, linked work and that work's state. Items emitted from 0.7.1 on are kept 30 days or
the newest 20,000 (`retention.stream_days`, `retention.stream_max_items`) unless a record names
them, and a body over 16 KiB is stored truncated.
- **`rig stream subscribe <seat>`** — let a seat read only what concerns it: items addressed to it
or matching its topics (`--tags`, any of; `--type` for one hint type; subscribing again keeps its
place). **`rig stream read`** then returns the calling seat's next bounded page (at most 20 items
and 64 KiB, with `more`, `unread` and any `gap`); `--reader <session>` names the seat from outside
one. `rig stream subscriptions` lists readers with their unread counts, and `rig stream
unsubscribe <seat>` stops one.
- **`rig acp run <row> --cwd <dir> -- <command>`** — hand one queue row's task to an outside agent
that speaks ACP (for example `openclaw acp`) as a single turn. The row's notes get the agent and
session, progress, each permission request and its answer, and the outcome (completed, stopped,
cancelled, failed or unknown): the agent's report, not a judgment, so the row stays open for you to
judge and close. Each permission request is a row to `--permissions-to` (default: you). The guide
is `docs/reference/acp-agents.md` in the source repo, and `docs/reference/a2a-agents.md` covers
A2A agents.
- **`rig terminal open <view>`** — open a desktop terminal with live agent tiles; a window is the
default. **Opening `rig terminal open saved:kernel --window` is the step after install or start**:
say you're opening it, run it on the daemon's desktop, then tell the person where it opened. Don't
@@ -167,7 +203,9 @@ unseen — which makes messages the one delivery channel that never gets skipped
tiles means an existing view was reused, which is success.
Add `--session <name>` to select a Herdr session for that request; `--session default`
explicitly selects its default session. `rig terminal status --session <name>` inspects
the same endpoint. Omitting the flag keeps the daemon's configured endpoint.
the same endpoint. Omitting the flag keeps the daemon's configured endpoint. Herdr tiles come
back attached after a Herdr restart, except a tile over SSH: reopen that one with
`rig terminal open <view> --provider herdr`.
In the TUI, **TERMINALS** keeps Saved views prominent and Derived groups collapsed until
expanded. Names load before detailed readiness. Select a view to inspect its members,
layout and pages; preview is passive. Saved membership requires deliberate setup.
@@ -235,7 +273,8 @@ your context, your compaction and your replacement — and it is the only thing
- **`rig queue update`** — record what happened in a form the rest of the system can act on. A
`--note` does not reopen a terminal row; terminal-to-active repair requires explicit `--reopen`
with both `--state` and `--note`. `--summary` on its own corrects a row's subject in any state,
and a close with `--resolution "<outcome>"` stores the outcome on the row.
and a close with `--resolution "<outcome>"` stores the outcome on the row; `--state done
--evidence-ref <path>` also keeps the artifact's pointer on it.
- **`rig queue handoff`** — pass work so the close and the create are **one transaction**, instead
of closing yours, failing to create theirs, and stranding the work in between. A standing sweep
checks recently closed rows (a 24-hour lookback) and flags a `handed_off_to` closure whose
@@ -276,7 +315,11 @@ you that is still running.
asleep, compacted or finished. For a context wall, use the exact
`--policy context-usage-threshold` rather than a hand-rolled transcript timer. A scheduled
reminder arrives as the authored message under an explicit scheduler identity, not as YAML
syntax or anonymous agent input.
syntax or anonymous agent input. The context watch takes `--threshold-tokens` or
`--threshold-percent` (of the seat's context window), measured from the seat's own usage sample.
- **`rig watchdog update <jobId>`** (`--interval-seconds`, `--message`, `--spec`) — retune a live
job in place. It keeps the job id, so a queue row's attachment still holds; don't stop and
re-register a job to change it. A stopped or finished job is refused, not restarted.
- **`rig watchdog list` / `show` / `status` / `stop`** — did it fire, is it still live, did someone
stop it. `list` defaults to active, compact, and 100; `--all --full` is the complete history.
**Quiet skips are not recorded**, so a healthy idle job and a job that never ran look identical
@@ -426,8 +469,18 @@ not have to tear one down to change it, and you rarely have to start from nothin
`CODEX_HOME`, `OPENRIG_*` and the allowlisted provider auth variables) and its own `env`. Put an
account or key in the seat's `env` or `rig up --env`, or set the key to true. Running seats are
untouched, and `rig setup` warns when it sees a custom tmux configuration.
**`rig up <spec> --name <rig>`** runs one spec as another rig under that name; the rig keeps the
name for `--existing`, snapshots and restore, and the spec file isn't changed.
- **A Claude member can bring 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 own storage, never the project, so one seat in a shared repository can have its own
persona, prompt or plugins. A bundle's before-install view lists them.
- **`rig community-harness list`**: list installed community-harness presets and declared limits
(not tested by OpenRig; launch-only).
(not tested by OpenRig). A preset can declare how its app reports the conversation each launch
made (`launch_result`), reopens one on resume (`resume`) or lets OpenRig name it
(`names_conversation`); without those it stays launch-only. A conversation
that's gone reads `missing`, never quietly replaced. Examples for Cursor, Hermes and OpenClaw are
in `docs/reference/community-harness-examples/`.
- **`rig down`** — stop a rig's seats and take it out of the running set. It ends the agents'
sessions and any work in progress; don't run it on a live team unless the person asked. In
human mode it first prints one line with each agent's last known activity, or “unknown”: a
@@ -462,6 +515,10 @@ not have to tear one down to change it, and you rarely have to start from nothin
- **`rig add`** / **`rig expand`** — graft one more member, or a whole new pod, onto a rig that is
already running. `rig add`, `rig grow` and `rig remove` take the rig's name or ID; a name two active rigs share is refused
with `rig_ambiguous` and the candidates. `rig grow --new-pod` and `rig expand` are one ingress; choose by input shape.
- **`rig edge add <rig> <from> <to> --kind <kind>`** / **`rig edge remove <rig> <edge-id>`** —
change one edge on a running rig, such as a replaced seat's, instead of taking the rig down.
`--plan` shows the change without writing, and a `delegates_to` change says when it moves who
work routes to.
- **A rig's name works wherever a rig is named.** `archive`, `unarchive`, `release`, `shrink`,
`expand`, `snapshot` (and `snapshot list`), `export`, `restore --rig` and `restore status --rig`
take its name, as `launch`, `add`, `grow` and `remove` do, and so do the MCP tools `rig_down`,
@@ -513,7 +570,10 @@ version, from checking the environment to a useful report to the OpenRig team.
Stop distinguishes process/listener exit from a clean asynchronous drain: inspect the matching
`daemon-shutdown.json` and log when completion is incomplete or unverified. No target is a
distinct no-op, and a timeout never proves down. Use command help for exact limits and recovery.
`/healthz` reports the daemon's effective UID as `euid`, beside `pid`.
`/healthz` reports the daemon's effective UID as `euid`, beside `pid`. Running several OpenRig
homes on one machine on purpose: `rig config set daemon.db_conflict stop` refuses a start whose
database another live daemon already holds (the default, `warn`, only warns), and
`rig daemon start --allow-db-conflict` allows one deliberate start.
- **A busy daemon isn't a down one.** The CLI refuses only when the daemon is confirmed down
(stopped, stale, a refused probe), its probe is blocked, or it's a sibling home; a busy daemon
gets the real request. A write that then times out says its outcome is **unknown**: check its
@@ -617,13 +677,27 @@ version, from checking the environment to a useful report to the OpenRig team.
touching its spec or its peers; nothing restarts. **`rig seat effort <seat>`** reports what was
requested, what's saved, what the running process was launched with and what the session
recorded (NOT VERIFIED until the seat takes a turn). It works for Pi seats too, sent as `--thinking`.
- **`rig seat set-typing-guard <seat> --mode off|draft-aware|hold --reason <text>`** — choose
ordinary automatic delivery, immediate retention when input is busy/unreadable, or holding all automatic
input. `--enabled true|false` remains compatible: true selects hold, false selects off.
- **`rig seat set-typing-guard <seat> --mode <mode> --reason <text>`** — choose how much automatic
input reaches a seat, for example while a person works in its terminal: `off` (ordinary
delivery), `draft-aware` (retained at once when the input box is busy or unreadable),
`sends-only` (deliberate sends from people and agents type; OpenRig's own notices are held),
`openrig-urgent` (OpenRig's own messages and anything marked urgent type), `urgent-only` (only
urgent messages type) or `hold` (no automatic input; one held notice per wake instead of
re-nudges). `--enabled true|false` remains compatible: true selects hold, false selects off.
Known limit: in `draft-aware`, a multi-line message to a Claude seat can stay unsubmitted when the
paste changes Claude's footer hint, and later sends wait behind it; check the seat's composer.
`rig seat status` shows the requested/effective mode; wait for pending activation to finish.
**`rig seat held-messages`** lists what was retained and **`rig seat retire-held-message`**
retires one; changing mode does not replay old held messages. Draft-aware returns the exact
refusal and original ID immediately; callers decide further action, with no daemon retry timer.
- **`rig seat set-terminal-policy <seat> --preset no-peer-interventions|help-within-instructions|full-control
--reason <text>`** — choose how far OpenRig and peers may act on a seat: `--helper <addresses…>`
names helpers covered by the instruction, `--rig-default` sets the rig's choice for seats added
later, and exceptions take `allow` or `deny` (`--peer-compaction`, `--self-model`,
`--openrig-model` and the like; model covers effort too). So a seat can keep its saved model while
still allowing managed compaction. Managed compaction and saved model or effort changes are
checked, and a denial reads `terminal_policy_refused`; other interventions are guidance only. It
changes no native permissions or typing-guard level. `rig seat status` shows it.
- **`rig compact-plan`** → **`rig compact`** — who is near the context wall, then act on it.
**Ordering matters: running `compact` without the plan is guessing which seat needed it.**
Managed compaction waits for the seat's restore map before compacting; `rig compact --state`,
@@ -641,6 +715,10 @@ version, from checking the environment to a useful report to the OpenRig team.
hooks; a seat can give a slow refocus more time with `OPENRIG_REFOCUS_BUDGET_MS` (4500 to 52000).
The refocus work packet stays off by default for Claude Code and Codex;
`OPENRIG_REFOCUS_WORK_PACKET=1` turns it on, with each field bounded.
`policies.claude_compaction.preparation_root` (an absolute path) puts restore maps where seats
may write. A daemon restart picks a managed compaction up 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.
- **Codex seats get `openrig-core` too: its refocus after a compaction and its skills, with no
first-launch review stop.** OpenRig installs the plugin into Codex on boot and on a fresh launch.
On a first install, with no existing registration or cache, it trusts the plugin's six hooks by
@@ -785,6 +863,14 @@ scheme, and nothing downstream can see it.
Write it with this command, not a file tool, so earlier recaps are kept.
- **`rig project classify` / `list` / `show`** — turn a raw observation into a routed, typed,
deduped record instead of hand-creating a row from a hunch.
- **`rig project wake --decide`** (opt-in, off by default) — route stream observations to owned
work with typed decisions. Once `decisions.enabled` is true, `decisions.consumers` includes
`stream` and the intake seat has `OPENROUTER_API_KEY` in its process, each eligible observation
gets 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 what a run would do without making a call. Without `--decide`, or with decisions
off, nothing changes, so intake stays by hand.
## Making a shape exist somewhere else
@@ -875,6 +961,13 @@ reaching them is ordinary work rather than an escalation.
to the external participant, not a separate outbound route.
- **`rig gateway human add`** — register a person and their connector binding. Registration or
connector changes do not themselves authorize a message.
- **Every ask to a person lands on the TUI's Feed**, where they can answer it (and on Steering for
anyone running the Steering app). Whether it also posts to Slack is their subscription:
`rig gateway human set <id> slack-subscription every-ask|urgent-only|unset` (`every-ask` is the
default). Under `urgent-only`, only an ask
created or handed off with `--alert` posts. `--alert` also makes an ask stand out in Slack (an
interrupt with a mention) under either setting, so keep it for something only they can do, or an
approval whose wait costs now. Don't assume every ask pings them on Slack.
- **`rig gateway human quiet set <id> --daily HH:MM-HH:MM`** (or `--until <HH:MM|ISO>`, with
`--tz`) — hold a person's routine asks while they're away, and post each one once when they're
back. While quiet, a routine ask is held: nothing posts and nobody is mentioned, the row notes
@@ -975,7 +1068,9 @@ your circumstances is configuration, and the ones that are not, another agent ca
- **Project World declares the human authority boundary.** Read the current project and mission
policy for actions requiring approval; the system mechanics do not impose a universal list.
- **`rig auth list` / `validate` / `seats`** — is this seat's runtime actually logged in, which
accounts exist, and which account each seat is *supposed* to be on.
accounts exist, and which account each seat is *supposed* to be on. To act on a Codex home on
another machine, `rig auth status|list|switch` take `--host <id>` and `--codex-home <dir>`, and
`rig seat restart` takes `--host <id>`.
- **`rig provider accounts` / `bindings` / `signals` / `switch`** — which seats are bound to which
accounts, what the usage signals say, and whether a seat can be moved without stranding the
conversation it is in the middle of. **`provider signals` reports anomalies rather than a
@@ -990,8 +1085,9 @@ your circumstances is configuration, and the ones that are not, another agent ca
because nothing links a seat's reading to the login it used: read each window as that seat's
reading, and don't tell a person an account has room from it. Each account row also carries a
sign-in fact (`usable`, `needs sign-in` or `unknown`); a signed-in Claude login never reads
better than `unknown`. A Codex account's last reading, its reset and its last observed exhaustion
survive a daemon restart as history (`retained: "before_restart"`), never a current reading; a
better than `unknown`. A Claude seat signed in with a long-lived token (`claude setup-token`)
gets no usage reading (`not_subscription_login`). A Codex account's last reading, its reset and
its last observed exhaustion survive a daemon restart as history (`retained: "before_restart"`), never a current reading; a
window whose reset has passed shows an `expectation`, which is planning, not a reading. Run `rig provider status --refresh`
before telling anyone an account has room. An agent that moves a Codex home between subscriptions files
what it did with `rig provider moves report` (a queue item to whoever asked, checked and never a move itself), and
@@ -1005,6 +1101,9 @@ your circumstances is configuration, and the ones that are not, another agent ca
views fall back to plain tmux) and on a Mac offers Ghostty once (`--ghostty` / `--no-ghostty`).
It no longer installs cmux; an existing cmux still works. `--providers claude`, `codex` or
`both` sets which harnesses it installs and checks; one not chosen reads `skipped`, never failed.
The choice is saved as `provider.selected`, and the kernel starts seats only for those providers
(empty, the default, means every signed-in one). `--router-line yes|no` decides whether OpenRig
adds its one line to shared guidance files (`skills.router_line.enabled`).
- **`rig usage series`** — what a seat's token curve has looked like over time: climbing steadily,
reset, or stopped reporting entirely. The last one is a signal, not a gap.
- **`rig usage top`** — which of this machine's seats used the most tokens over a window (`--window
@@ -1073,7 +1172,13 @@ your circumstances is configuration, and the ones that are not, another agent ca
shell command. Five queue tools (`rig_queue_create`, `rig_queue_show`, `rig_queue_list`,
`rig_queue_claim`, `rig_queue_update`) act as the seat named by `OPENRIG_SESSION_NAME`, so an
agent with no shell can hand work to the team and read the closing note as its answer. Nothing
wakes such an agent; it sees new work when it checks.
wakes such an agent; it sees new work when it checks. Its tools cover the whole delegation loop:
find its seat, team and lead (`rig_whoami`, `rig_team`), hand work on and follow the handoff's
successor, find finished work and read its artifact, decide inside a person's decision room,
follow a restore it started (`rig_restore_status`) and read the event log
(`rig_telemetry_events`). **`rig mcp serve --http`** serves the same tools to a client on another
host; `rig mcp token add <name> --seat <session>` gives each caller its own grant, acting as that
seat.
- **`rig skill loadout --runtime <claude-code|codex>`** — inspect the exact catalog revision,
selectors, target, and current/missing/shadowed/conflicting state for one working directory.
`--apply` writes only the managed ownership set, is idempotent, and refuses local edits or
@@ -1102,9 +1207,11 @@ database. It is one more set of primitives on a machine full of them.
owes. **Neither knows the interesting cell.** Cross them and *alive, holding work, and not
moving* falls out — which is the actual question behind every "is the rig stuck."
- **"Is that seat stuck or thinking?"** — the row face answers this now (S04 pickup receipts):
every list/show projection carries a derived `pickup` state — `working`, `stalled-after-claim`
(with its evidence named: time since meaningful queue change and current activity confidence), `parked`, or
`unclaimed` — and `rig view show pickup` lists every claimed row with it. The old by-hand join
every list/show projection carries a derived `pickup` state — `working`, `needs-input` (claimed,
and its owner is waiting on input, with the reason: check that before waking it),
`stalled-after-claim` (with its evidence named: time since meaningful queue change and current
activity confidence), `parked`, or `unclaimed` — and `rig view show pickup` lists every claimed
row with it. The old by-hand join
(capture + `claimedAt` arithmetic + `queue transitions`) is RETIRED as a first move; `rig
capture` remains the second question (is the pane alive), never the state derivation.
- **"Is this a park or a strand?"** — the row face answers the pickup half: `parked` means the row is blocked,
@@ -1153,6 +1260,6 @@ deliberate writing.
**`rig --help`**, then `rig <verb> --help`.
Eighty-eight verbs ship, and this page named most of them once. **You will not remember which — you
Ninety verbs ship, and this page named most of them once. **You will not remember which — you
are meant to remember only that the list is long enough to be worth reading before you build
anything.** The question is never *how do I write this*; it is **does this already exist**.
+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,6 +6,7 @@ import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import os from "node:os";
import { isDeepStrictEqual } from "node:util";
import Database from "better-sqlite3";
import { parse as parseToml } from "smol-toml";
import type { TmuxAdapter } from "./tmux.js";
@@ -66,6 +67,7 @@ const codexHookTrust = createRequire(import.meta.url)("../../assets/plugins/open
import { listNativeProcesses, observeCodexPaneProcess, observeCodexForkProcess, verifyPaneIdleShell, type NativeProcessRow } from "../domain/native-process-lineage.js";
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
import { codexPluginContentHash, planOwnedCodexContentApproval, recordOwnedCodexApproval } from "../domain/codex-owned-plugin-trust.js";
// Shared by all probes of ONE launch, never reset by a delayed screen or an
// ambiguous transport result. A separately requested launch gets a new attempt.
@@ -311,9 +313,9 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
/**
* Install the vendored openrig-core plugin into Codex through Codex's own commands, so a seat that selected it gets its
* refocus and skills, trusting only exact shipped hooks on its first install. It runs only when the person's config
* refocus and skills, trusting exact shipped hooks on first install and renewing proven owned content approvals. It runs only when the person's config
* doesn't disable the plugin (codexCorePluginEnablement), and only when the installed version differs from the vendored
* one. Codex owns its plugin cache; OpenRig writes the marketplace manifest and first-install exact-hook trust.
* one. Codex owns its plugin cache; its immutable legacy trust baseline is never rewritten.
*/
async ensureCodexCorePlugin(): Promise<{ outcome: "installed" | "current" | "skipped"; reason: string }> {
const report = (outcome: "installed" | "current" | "skipped", reason: string) => {
@@ -343,13 +345,19 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
catch { return false; }
});
if (!matchesShipped(pluginDir)) return report("skipped", "vendored hook files differ from this daemon's shipped plugin; existing choices are left alone");
// Installation retains the hook-file predicate; any approval requires the complete shipped tree.
const matchesShippedTree = (dir: string) => {
try { return codexPluginContentHash(dir, true) === codexPluginContentHash(shipped, true); }
catch { return false; }
};
const codexHome = nodePath.dirname(this.resolveCodexConfigPath());
const layers: CodexConfigLayerRead[] = codexReadableConfigLayers(codexHome, this.codexSystemConfigDir).map((layer) => {
const readLayers = (): CodexConfigLayerRead[] => codexReadableConfigLayers(codexHome, this.codexSystemConfigDir).map((layer) => {
if (!this.fs.exists(layer.path)) return { ...layer, state: "absent" as const };
try { return { ...layer, state: "read" as const, content: this.fs.readFile(layer.path) }; }
catch (error) { return { ...layer, state: "unreadable" as const, reason: (error as Error).message }; }
});
const layers = readLayers();
const enablement = codexCorePluginEnablement(layers);
if (enablement.decision !== "allow") return report("skipped", enablement.reason);
@@ -370,11 +378,33 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
const cachedDir = nodePath.join(cacheRoot, version);
const cached = this.fs.exists(cachedDir);
// Codex's "continue without trusting" choice writes no state. An existing install/configuration may therefore be a
// decline even with no hooks.state entry. Only the very first installation earns automatic trust; reinstalls and
// upgrades retain whatever native choices already exist, including an unrecorded decline.
// decline even with no hooks.state entry. Absent grants still stay under native review. A proven owned grant may
// renew its content approval; an unrecorded skip of that exact owned upgrade cannot be distinguished from pending approval.
const firstInstall = !this.fs.exists(cacheRoot) && !layers.some(layer => layer.state === "read"
&& Object.hasOwn((parseToml(layer.content).plugins ?? {}) as object, CODEX_CORE_PLUGIN_KEY));
const renewContentApproval = (freshGrant = false) => {
const configPath = this.resolveCodexConfigPath();
const currentLayers = readLayers();
if (codexCorePluginEnablement(currentLayers).decision !== "allow") return;
const before = this.fs.readFile(configPath);
const currentSource = recordedMarketplaceSource(before);
if (currentSource == null || canonical(currentSource) !== canonical(pluginsDir) || !userLayerEnablesCorePlugin(before)) return;
let content = before;
const planned = planOwnedCodexContentApproval({ configPath, content, layers: currentLayers, shipped, installed: cachedDir, freshGrant });
if (!planned) return;
for (const [key, hash] of Object.entries(planned.hashes)) content = upsertCodexHookTrust(content, key, hash, "trusted_content_hash");
// The line writer must not alter examples in strings or duplicate quoted keys.
try {
const expected = parseToml(before) as { hooks: { state: Record<string, Record<string, unknown>> } };
for (const [key, hash] of Object.entries(planned.hashes)) expected.hooks.state[key]!.trusted_content_hash = hash;
if (!isDeepStrictEqual(parseToml(content), expected)) return;
} catch { return; } // Leave native review and owner bytes intact for unsupported TOML spellings.
if (this.fs.readFile(configPath) !== before) return; // A concurrent native choice wins.
if (content !== before) this.fs.writeFile(configPath, content);
recordOwnedCodexApproval(configPath, planned.approval);
};
if (cached && recorded !== undefined && userLayerEnablesCorePlugin(userConfig)) {
renewContentApproval();
return report("current", `${CODEX_CORE_PLUGIN_KEY} ${version} is installed and registered`);
}
const manifestPath = nodePath.join(pluginsDir, CODEX_MARKETPLACE_MANIFEST_PATH);
@@ -390,9 +420,11 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
const installed = await this.runCodexCli(["plugin", "add", CODEX_CORE_PLUGIN_KEY], env);
if (installed.status !== 0) return report("skipped", `codex plugin add failed: ${(installed.stderr || installed.stdout).trim() || `exit ${installed.status}`}`);
// Trust is over normalized definitions, not script contents. Byte equality is an additional pre-grant check;
// Codex remains responsible for later native review if definitions change. No cache files are rewritten here.
if (firstInstall && matchesShipped(cachedDir)) {
// First install grants declarations; Codex0.164 also requires full-bundle content approval. Existing declaration
// choices are never rewritten. No cache files or native baseline files are rewritten here.
let freshGrants = 0;
const grantLayers = readLayers();
if (firstInstall && codexCorePluginEnablement(grantLayers).decision === "allow" && matchesShippedTree(cachedDir)) {
let content = this.fs.readFile(this.resolveCodexConfigPath());
const hookManifest = JSON.parse(fs.readFileSync(nodePath.join(shipped, "hooks/codex.json"), "utf8"));
for (const [event, groups] of Object.entries(hookManifest.hooks) as Array<[string, Array<{ matcher?: string; hooks: Array<{ command: string; timeout: number }> }>]> ) {
@@ -407,13 +439,15 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
const value = doc as { hooks?: { state?: Record<string, unknown> } };
return Object.hasOwn(value.hooks?.state ?? {}, key) || Object.values(doc).some(hasChoice);
};
if (!hasChoice(parseToml(content)) && !layers.some(layer => layer.state === "read" && hasChoice(parseToml(layer.content)))) {
if (!hasChoice(parseToml(content)) && !grantLayers.some(layer => layer.state === "read" && hasChoice(parseToml(layer.content)))) {
content = upsertCodexHookTrust(content, key, hash);
freshGrants++;
}
}));
}
this.fs.writeFile(this.resolveCodexConfigPath(), content);
}
renewContentApproval(freshGrants === 6);
return report("installed", `${CODEX_CORE_PLUGIN_KEY} ${version} through Codex's own install (${enablement.reason})`);
}
@@ -1741,11 +1775,11 @@ export function computeCodexHookTrust(
* `[hooks.state."<key>"] trusted_hash = "<hash>"` record. Mirrors upsertCodexProjectTrust:
* find/create the exact table header, splice ONLY its `trusted_hash` line, leave every other
* `[hooks.state]` / `[projects]` entry and the managed hook block byte-identical. Same key+hash
* ⇒ no-op. Only called for our authored inline or exact first-install plugin keys (never blanket trust).
* ⇒ no-op. Only called for our authored inline or verified owned plugin keys (never blanket trust).
*/
export function upsertCodexHookTrust(content: string, key: string, hash: string): string {
export function upsertCodexHookTrust(content: string, key: string, hash: string, field: "trusted_hash" | "trusted_content_hash" = "trusted_hash"): string {
const header = `[hooks.state.${JSON.stringify(key)}]`;
const trustLine = `trusted_hash = ${JSON.stringify(hash)}`;
const trustLine = `${field} = ${JSON.stringify(hash)}`;
const lines = content.length > 0 ? content.split("\n") : [];
const headerIndex = lines.findIndex((line) => line.trim() === header);
@@ -1764,7 +1798,7 @@ export function upsertCodexHookTrust(content: string, key: string, hash: string)
}
const hashIndex = lines.findIndex(
(line, index) => index > headerIndex && index < nextSectionIndex && line.trim().startsWith("trusted_hash"),
(line, index) => index > headerIndex && index < nextSectionIndex && line.trim().startsWith(field),
);
if (hashIndex >= 0) {
lines[hashIndex] = trustLine;
@@ -1772,7 +1806,7 @@ export function upsertCodexHookTrust(content: string, key: string, hash: string)
lines.splice(headerIndex + 1, 0, trustLine);
}
return `${lines.join("\n").replace(/\n*$/, "\n")}`;
return field === "trusted_content_hash" ? lines.join("\n") : `${lines.join("\n").replace(/\n*$/, "\n")}`;
}
export function isCodex013xOrLater(version: string): boolean {
+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}"`);
}
@@ -0,0 +1,141 @@
import fs from "node:fs";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import { parse } from "smol-toml";
import { CODEX_CORE_PLUGIN_KEY, type CodexConfigLayerRead } from "./codex-core-plugin-install.js";
const { computeCodexHookTrust } = createRequire(import.meta.url)("../../assets/plugins/openrig-core/hooks/scripts/codex-hook-trust.cjs");
// Published openrig-core0.1.17: Codex0.164's native baseline and the complete shipped tree agree.
const LEGACY_CONTENT = process.platform === "win32" ? "sha256:f94b92a51872b7a5c0ba57d762a8cd68208a10f4e4ffed701220feafab626889" : "sha256:3317076e0df0d10402c17f26a17f26a21264980a6f0b551a1e21a3c0f785efca";
const LEGACY_DECLARATIONS: Record<string, string> = {
session_start: "a746b5abb568d50725b08bf39660bbf08d15fa3d3a5961208862e7beaadba43d",
user_prompt_submit_0: "f56dfb2a70982d6a7ae4a6c4ace58b943e16dcb08fe3bc87d3c8803477f4a5c1",
user_prompt_submit_1: "c648714b3a02675ba4ea029b8e62dbcd77569afc317ad4f1bc3e8cbfbe7b34ee",
stop: "bb444b151db38aff570d0ceadc2cc84b21afb021f00b15b326aef55a27d87d40",
post_compact: "f4dd49518599c67ac23c80daef3537bb03b02e23a1e8c1da1210534766afefae",
permission_request: "75c66b08f505a22f84def73ecf6d61e98dd1b24ed8ecdcc05306eba3013e64d4",
};
const legacyDeclarations = Object.fromEntries(Object.entries(LEGACY_DECLARATIONS).map(([event, hash]) => {
const match = /^(user_prompt_submit)_([01])$/.exec(event);
return [`${CODEX_CORE_PLUGIN_KEY}:hooks/codex.json:${match?.[1] ?? event}:${match?.[2] ?? 0}:0`, `sha256:${hash}`];
}));
const readFlags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0);
/** Codex rust-v0.164.0 plugin_fingerprint.rs. Strict comparison also includes entries Codex excludes as generated files. */
export function codexPluginContentHash(root: string, strict = false): string {
if (!fs.lstatSync(root).isDirectory()) throw new Error("plugin root is not a directory");
const hash = createHash("sha256").update("codex-plugin-bundle-v1");
const length = (value: number) => { const bytes = Buffer.alloc(8); bytes.writeBigUInt64LE(BigInt(value)); hash.update(bytes); };
const visit = (file: string) => {
const name = path.basename(file);
if (file !== root && !strict && (name === "__pycache__" || name === ".venv" || name.endsWith(".pyc") || name.endsWith(".egg-info"))) return;
const relative = Buffer.from(path.relative(root, file)); length(relative.length); hash.update(relative);
const before = fs.lstatSync(file);
if (before.isDirectory()) {
hash.update("directory");
for (const child of fs.readdirSync(file).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) visit(path.join(file, child));
} else if (before.isFile()) {
hash.update("file"); length(before.size);
const fd = fs.openSync(file, readFlags);
try {
const opened = fs.fstatSync(fd);
if (!opened.isFile() || opened.ino !== before.ino || opened.dev !== before.dev) throw new Error("plugin file changed while fingerprinting");
const buffer = Buffer.alloc(65536); let total = 0;
while (total <= before.size) {
const count = fs.readSync(fd, buffer, 0, Math.min(buffer.length, before.size + 1 - total), null);
if (!count) break;
total += count; hash.update(buffer.subarray(0, count));
}
if (total !== before.size) throw new Error("plugin file changed while fingerprinting");
} finally { fs.closeSync(fd); }
} else throw new Error("unsupported plugin entry type");
};
visit(root);
return `sha256:${hash.digest("hex")}`;
}
function readRecord(file: string): string | undefined {
let fd: number;
try { fd = fs.openSync(file, readFlags); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
try {
const info = fs.fstatSync(fd);
if (!info.isFile() || info.size > 65536 || (process.platform !== "win32" && (info.mode & 0o022) !== 0)
|| (process.getuid && info.uid !== process.getuid())) throw new Error("unreadable hook approval record");
return fs.readFileSync(fd, "utf8");
} finally { fs.closeSync(fd); }
}
export function codexLegacyPluginTrustPath(configPath: string): string {
const hash = createHash("sha256");
for (const part of [configPath, CODEX_CORE_PLUGIN_KEY]) {
const bytes = Buffer.from(part), size = Buffer.alloc(8); size.writeBigUInt64LE(BigInt(bytes.length)); hash.update(size); hash.update(bytes);
}
return path.join(path.dirname(configPath), ".hook-trust", hash.digest("hex"));
}
interface OwnedApproval { schema: 1; plugin: string; contentHash: string; declarations: Record<string, string> }
export const ownedCodexApprovalPath = (configPath: string) => path.join(path.dirname(configPath), ".openrig-core-hook-trust.json");
/** No native baseline is changed. Existing declarations must already be approved; only content approval may advance. */
export function planOwnedCodexContentApproval(input: {
configPath: string; content: string; layers: CodexConfigLayerRead[]; shipped: string; installed: string; freshGrant?: boolean;
}): { hashes: Record<string, string>; approval: OwnedApproval } | undefined {
try {
if (codexPluginContentHash(input.shipped, true) !== codexPluginContentHash(input.installed, true)) return;
const contentHash = codexPluginContentHash(input.installed);
const declarations: Record<string, string> = {};
const manifest = JSON.parse(fs.readFileSync(path.join(input.shipped, "hooks/codex.json"), "utf8"));
for (const [event, groups] of Object.entries(manifest.hooks) as Array<[string, Array<{ matcher?: string; hooks: Array<{ command: string; timeout: number }> }>]> ) {
groups.forEach((group, groupIndex) => group.hooks.forEach((handler, handlerIndex) => {
const { key, hash } = computeCodexHookTrust(event, { ...handler, matcher: group.matcher, timeoutSec: handler.timeout,
keySource: `${CODEX_CORE_PLUGIN_KEY}:hooks/codex.json`, groupIndex, handlerIndex });
declarations[key] = hash;
}));
}
if (Object.keys(declarations).length !== 6) return;
const docs = input.layers.map(layer => {
if (layer.state === "unreadable") throw new Error("unknown config layer");
return { ...layer, doc: layer.state === "read" ? parse(layer.content) : {} };
});
const doc = parse(input.content) as any;
const otherChoice = (value: any, key: string, checkRoot = true): boolean => value !== null && typeof value === "object"
&& ((checkRoot && Object.hasOwn(value.hooks?.state ?? {}, key)) || Object.values(value).some(child => otherChoice(child, key)));
const nestedPluginChoice = (value: any, nested = false): boolean => value !== null && typeof value === "object"
&& ((nested && (Object.hasOwn(value.plugins ?? {}, CODEX_CORE_PLUGIN_KEY) || Object.hasOwn(value.plugins ?? {}, "_default")))
|| Object.values(value).some(child => nestedPluginChoice(child, true)));
if (docs.some(layer => nestedPluginChoice(layer.doc))) return;
const states = doc.hooks?.state ?? {};
for (const [key, hash] of Object.entries(declarations)) {
const state = states[key];
if (!state || (state.enabled !== undefined && state.enabled !== true) || state.trusted_hash !== hash
|| Object.keys(state).some(field => !["enabled", "trusted_hash", "trusted_content_hash"].includes(field))
|| !input.content.split("\n").some(line => line.trim() === `[hooks.state.${JSON.stringify(key)}]`)
|| docs.some(layer => otherChoice(layer.doc, key, layer.path !== input.configPath))) return;
}
const approval: OwnedApproval = { schema: 1, plugin: CODEX_CORE_PLUGIN_KEY, contentHash, declarations };
const hashes = Object.fromEntries(Object.keys(declarations).map(key => [key, contentHash]));
if (input.freshGrant) return { hashes, approval };
if (Object.keys(declarations).every(key => states[key].trusted_content_hash === contentHash)) return { hashes: {}, approval };
const baseline = readRecord(codexLegacyPluginTrustPath(input.configPath));
if (Object.keys(declarations).every(key => (states[key].trusted_content_hash ?? baseline) === contentHash)) return { hashes, approval };
const saved = readRecord(ownedCodexApprovalPath(input.configPath));
const previous: OwnedApproval = saved === undefined ? { schema: 1, plugin: CODEX_CORE_PLUGIN_KEY,
contentHash: LEGACY_CONTENT, declarations: legacyDeclarations } : JSON.parse(saved);
if (previous.schema !== 1 || previous.plugin !== CODEX_CORE_PLUGIN_KEY || !/^sha256:[a-f0-9]{64}$/.test(previous.contentHash)
|| Object.keys(previous.declarations).length !== 6) return;
for (const [key, hash] of Object.entries(declarations)) {
if (previous.declarations[key] !== hash || (states[key].trusted_content_hash ?? baseline) !== previous.contentHash) return;
}
return { hashes, approval };
} catch { return undefined; } // Unknown ownership or config leaves native review in place.
}
export function recordOwnedCodexApproval(configPath: string, approval: OwnedApproval): void {
const file = ownedCodexApprovalPath(configPath), content = `${JSON.stringify(approval, null, 2)}\n`;
if (readRecord(file) === content) return;
const temporary = `${file}.${randomUUID()}.tmp`;
try { fs.writeFileSync(temporary, content, { flag: "wx", mode: 0o600 }); fs.renameSync(temporary, file); }
finally { try { fs.unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
}
@@ -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> {
+67 -52
View File
@@ -1164,10 +1164,11 @@ const NEEDS_INPUT_RUNGS: readonly EvidenceRungId[] = ["needs-input-chrome", "lif
* startupStatus; `latestError` may legitimately be null here
* - `needs_input`: the ARBITRATED seat state is the first answer when it has one
* (`arbitratedNeedsInput` — the same oracle the per-seat row renders, pane chrome
* included), and the hook store's live `needs_input` is the fallback when the
* arbitrated signal is `null` (stale hooks arrive as `unknown` and contribute
* nothing — never guessed). The rig total and the seat row must not disagree
* about the same seat.
* included), and the served activity's `needs_input` is the fallback when the
* arbitrated signal is `null`: a live hook, else the cached pane reading
* (`hookOrPaneActivity`), which sees a prompt no hook reported. Stale hooks
* arrive as `unknown` and contribute nothing — never guessed. The rig total
* and the seat row must not disagree about the same seat.
* - a held seat (`heldReason` present)
* - a recorded startup error (`latestError` present)
*/
@@ -1270,7 +1271,9 @@ export function attachTerminalActivityAndWork(
terminalActive,
lastActivityAt,
activityState,
needsAttention: seatNeedsAttention(entry, hook, arbitratedNeedsInput),
// The activity this row serves (a live hook, else the cached pane reading) when it was attached, so a prompt
// only the pane shows counts; the hook store alone where it wasn't.
needsAttention: seatNeedsAttention(entry, entry.agentActivity ?? hook, arbitratedNeedsInput),
arbitratedNeedsInput,
needsInputEvidence: arb?.needsInputEvidence ?? null,
hasAssignedWork: work.assignedWorkCount > 0,
@@ -1386,6 +1389,59 @@ export function readAssignedWorkBySession(db: Database.Database): Map<string, As
return out;
}
/**
* The capture-free answer from a seat's newest hook and its cached pane reading, before motion is applied, or `null`
* when neither answers. The per-seat activity below and the rig attention count both read it, so a prompt that only
* the pane shows counts in both.
*/
export function hookOrPaneActivity(
hookActivity: AgentActivity | null | undefined,
structural: StructuralObservation | null,
/** A positive idle hook gives way to a current complete idle frame (D2). */
idleYieldsToFrame = false,
): { source: "hook" | "limit" | "pane"; activity: AgentActivity } | null {
// Running/needs_input hooks keep their meaning.
if (hookActivity && hookActivity.state !== "unknown" && !(hookActivity.state === "idle" && idleYieldsToFrame)) {
return { source: "hook", activity: hookActivity };
}
// #763: a typed provider-interruption row (at_limit) is positive evidence
// even though its state is unknown by construction (it must never count
// as waiting-on-a-person). Deliver it instead of the cached pane reading
// so a limited seat shows the limit, not idle — but only while current.
// Terminal redraws are not recovery evidence and must not erase the limit.
// Only a strictly newer, current attention observation outranks it.
// A demoted row (aged out or generation-mismatched) falls through to the
// structural reading like any other stale hook.
if (hookActivity?.rawEvent === "at_limit" && hookActivity.stale !== true && !(
structural?.state === "attention" &&
Date.parse(structural.observedAt) > Date.parse(hookActivity.eventAt ?? "")
)) {
return { source: "limit", activity: hookActivity };
}
// Hook ABSENT or unknown/stale → consult the CACHED structural observation. This read is
// capture-FREE: the pane capture already happened on the background SeatStructuralActivityService
// tick, never here (the healthz-wedge no-per-request-capture invariant is preserved). A structural
// motion verdict is a LIVENESS signal that OVERRIDES an absent/stale hook (constraint 2 — liveness
// beats hook-arrival age) and makes ACTIVITY real on the DEFAULT path for hook-less / Codex /
// just-finished-a-turn seats: the exact fleet-blindness this fix targets. Structural discrimination
// is STRUCTURAL (spinner shapes / esc-to-interrupt / idle prompt), never a verb allowlist.
if (structural && structural.state !== "unknown") {
return {
source: "pane",
activity: {
state: mapPaneState(structural.state),
reason: structural.reason,
evidenceSource: "pane_heuristic",
sampledAt: structural.observedAt,
evidence: structural.evidence,
},
};
}
return null;
}
export async function attachAgentActivity(
entries: NodeInventoryEntry[],
deps: {
@@ -1541,53 +1597,12 @@ export async function attachAgentActivity(
&& (!motionRunning || (Number.isFinite(motionMs)
&& Math.floor(capturedMs / 1000) > Math.floor(motionMs / 1000)));
// Running/needs_input hooks keep their meaning. An idle hook alone still yields to motion
// (D2); when a current complete idle frame exists, report that frame's provenance instead.
if (hookActivity && hookActivity.state !== "unknown"
&& !(hookActivity.state === "idle" && motionRunning && currentIdleFrame)) {
return {
...entry,
agentActivity: withMotion(hookActivity),
};
}
// #763: a typed provider-interruption row (at_limit) is positive evidence
// even though its state is unknown by construction (it must never count
// as waiting-on-a-person). Deliver it instead of the cached pane reading
// so a limited seat shows the limit, not idle — but only while current.
// Terminal redraws are not recovery evidence and must not erase the limit.
// Only a strictly newer, current attention observation outranks it.
// A demoted row (aged out or generation-mismatched) falls through to the
// structural reading like any other stale hook.
if (hookActivity?.rawEvent === "at_limit" && hookActivity.stale !== true && !(
structural?.state === "attention" &&
Date.parse(structural.observedAt) > Date.parse(hookActivity.eventAt ?? "")
)) {
return {
...entry,
agentActivity: hookActivity,
};
}
// Hook ABSENT or unknown/stale → consult the CACHED structural observation. This read is
// capture-FREE: the pane capture already happened on the background SeatStructuralActivityService
// tick, never here (the healthz-wedge no-per-request-capture invariant is preserved). A structural
// motion verdict is a LIVENESS signal that OVERRIDES an absent/stale hook (constraint 2 — liveness
// beats hook-arrival age) and makes ACTIVITY real on the DEFAULT path for hook-less / Codex /
// just-finished-a-turn seats: the exact fleet-blindness this fix targets. Structural discrimination
// is STRUCTURAL (spinner shapes / esc-to-interrupt / idle prompt), never a verb allowlist.
if (structural && structural.state !== "unknown") {
return {
...entry,
agentActivity: withMotion({
state: mapPaneState(structural.state),
reason: structural.reason,
evidenceSource: "pane_heuristic",
sampledAt: structural.observedAt,
evidence: structural.evidence,
}, currentIdleFrame),
};
}
// An idle hook alone still yields to motion (D2); when a current complete idle frame exists, report
// that frame's provenance instead.
const resolved = hookOrPaneActivity(hookActivity, structural, motionRunning && currentIdleFrame);
if (resolved?.source === "hook") return { ...entry, agentActivity: withMotion(resolved.activity) };
if (resolved?.source === "limit") return { ...entry, agentActivity: resolved.activity };
if (resolved?.source === "pane") return { ...entry, agentActivity: withMotion(resolved.activity, currentIdleFrame) };
// No positive hook and no structural verdict. A stale/unknown hook, if one exists, is delivered
// HONESTLY as-is (unknown/stale) — never upgraded to a quiet-seat verdict on arrival age alone.
+18 -4
View File
@@ -5,12 +5,14 @@ import {
countAssignedWorkForEntry,
seatNeedsAttention,
arbitratedNeedsInputSignal,
hookOrPaneActivity,
getNodeInventoryForAllRigs,
readAssignedWorkBySession,
type AssignedWorkCounts,
} from "./node-inventory.js";
import { archiveWhereClause, type RigArchiveFilter } from "./rig-repository.js";
import type { SeatActivityService } from "./seat-activity-service.js";
import type { SeatStructuralActivityService } from "./seat-structural-activity-service.js";
import type { AgentActivityStore } from "./agent-activity-store.js";
export interface PsEntry {
@@ -146,13 +148,19 @@ export class PsProjectionService {
* predicate's `needs_input` signal. Optional: absent → the signal
* contributes false (honest degrade), never a guess. */
private readonly agentActivity: AgentActivityStore | null;
/** The cached pane reading, so the count sees a prompt only the pane shows, as the seat row does. */
private readonly structuralActivity: Pick<SeatStructuralActivityService, "getStructuralActivity"> | null;
private periodicSnapshotActive = false;
private periodicSnapshotIntervalSeconds = 0;
constructor(deps: { db: Database.Database; seatActivity?: SeatActivityService; agentActivity?: AgentActivityStore }) {
constructor(deps: {
db: Database.Database; seatActivity?: SeatActivityService; agentActivity?: AgentActivityStore;
structuralActivity?: Pick<SeatStructuralActivityService, "getStructuralActivity">;
}) {
this.db = deps.db;
this.seatActivity = deps.seatActivity ?? null;
this.agentActivity = deps.agentActivity ?? null;
this.structuralActivity = deps.structuralActivity ?? null;
}
setPeriodicSnapshotState(active: boolean, intervalSeconds: number): void {
@@ -259,8 +267,9 @@ export class PsProjectionService {
// OPR.0.4.4.21 — attention fold, same inventory pass. `needs_input` is
// read from the ARBITRATED seat state first — the same oracle the
// per-seat row renders (`activityState` on node-inventory), which also
// sees pane chrome a hook-silent gated seat never reports. The hook
// store is the fallback when the arbitrated signal is `null` — service
// sees pane chrome a hook-silent gated seat never reports. The served
// activity (the hook store, else the cached pane reading, as the seat row
// reads it) is the fallback when the arbitrated signal is `null` — service
// unwired, session not in the ladder, or a `count: 0` that no authoritative
// needs-input evidence supplied (#180 review; discussion #879); `now` keeps its
// staleness honest (stale hooks come back `unknown` and contribute
@@ -274,9 +283,14 @@ export class PsProjectionService {
&& typeof this.seatActivity.getSeatStateBySession === "function"
? this.seatActivity.getSeatStateBySession(session)
: null;
const activity = this.agentActivity && session
const hook = this.agentActivity && session
? this.agentActivity.getLatestForNode({ sessionName: session, now: nowDate })
: null;
// The row's served activity: a live hook, else the cached pane reading. A confirmed-missing session serves
// neither, as on the row.
const structural = this.structuralActivity && session ? this.structuralActivity.getStructuralActivity(session) : null;
const activity = identityVerdictConfirmsSessionMissing(node.identityVerdict) ? null
: hookOrPaneActivity(hook, structural)?.activity ?? hook;
if (seatNeedsAttention(node, activity, arbitratedNeedsInputSignal(arbitrated))) attentionCount++;
}
+3 -4
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";
@@ -866,7 +865,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;
@@ -1396,7 +1395,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
seatIdentityReconciler,
// OPR.0.4.4.21 — agentActivity feeds the rig-rollup attention
// predicate's needs_input signal (synchronous events lookup only).
psProjectionService: new PsProjectionService({ db, seatActivity: seatActivityService, agentActivity: agentActivityStore }),
psProjectionService: new PsProjectionService({ db, seatActivity: seatActivityService, agentActivity: agentActivityStore, structuralActivity: seatStructuralActivityService }),
upRouter: new UpCommandRouter({
fsOps: {
exists: (p: string) => fs.existsSync(p),
@@ -1477,7 +1476,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
askService: (() => {
// P19 A5 (finding graduated): inject seatActivity like the attention path
// (line ~919) — one terminalActive truth, never two divergent projections.
const psProjectionService = new PsProjectionService({ db, seatActivity: seatActivityService, agentActivity: agentActivityStore });
const psProjectionService = new PsProjectionService({ db, seatActivity: seatActivityService, agentActivity: agentActivityStore, structuralActivity: seatStructuralActivityService });
const execDep = (cmd: string, args: string[]): Promise<{ stdout: string; exitCode: number }> =>
new Promise((resolve) => {
execFile(cmd, args, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
@@ -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 });
});
});
@@ -4,9 +4,11 @@ import os from "node:os";
import path from "node:path";
import http from "node:http";
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { parse } from "smol-toml";
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import { codexPluginContentHash, codexLegacyPluginTrustPath, planOwnedCodexContentApproval } from "../src/domain/codex-owned-plugin-trust.js";
const shipped = path.resolve(import.meta.dirname, "../assets/plugins/openrig-core");
const roots: string[] = [];
@@ -50,6 +52,7 @@ it("a fresh install has all six exact plugin hooks trusted before the first seat
"openrig-core@openrig:hooks/codex.json:permission_request:0:0": "sha256:75c66b08f505a22f84def73ecf6d61e98dd1b24ed8ecdcc05306eba3013e64d4"
};
expect(Object.fromEntries(Object.entries(states).map(([key,value]:[string,any])=>[key,value.trusted_hash]))).toEqual(expected);
expect(Object.values(states).every((state: any) => state.trusted_content_hash === codexPluginContentHash(shipped))).toBe(true);
expect(f.calls.map(a=>a.slice(0,2))).toEqual([["plugin","marketplace"],["plugin","add"]]);
});
it.each(['[plugins."openrig-core@openrig"]\nenabled = false\n','[plugins._default]\nenabled = false\n'])("a disabled plugin stays disabled on restart and upgrade: %s", async config => {
@@ -69,7 +72,7 @@ it("a recorded hook decline and outside hook state survive restart and upgrade b
expect(f.read().hooks.state[key]).toEqual({enabled:false});
expect(f.read().hooks.state["outside:stop:0:0"]).toEqual({trusted_hash:"person-choice"});
});
it.each(["hooks/codex.json","hooks/scripts/refocus.cjs"])("modified vendored %s is not automatically installed or trusted", async relative => {
it.each(["hooks/codex.json","hooks/scripts/refocus.cjs",".codex-plugin/plugin.json"])("modified vendored %s is not automatically installed or trusted", async relative => {
const f=fixture(); fs.appendFileSync(path.join(f.plugin,relative),"\n ");
await f.adapter().ensureCodexCorePlugin(); expect(f.read().hooks?.state ?? {}).toEqual({}); expect(f.calls).toEqual([]);
});
@@ -112,3 +115,141 @@ it("a same-named marketplace owned by someone else is not installed or trusted",
const f=fixture(config);await f.adapter().ensureCodexCorePlugin();
expect(fs.readFileSync(f.file,"utf8")).toBe(config);expect(f.calls).toEqual([]);
});
// The interrupted-upgrade state: the new bundle is already installed, but Codex0.164
// retains the declaration-only grants and immutable content baseline from published0.7.0.
async function legacyUpgrade() {
const f = fixture();
await f.adapter().ensureCodexCorePlugin();
fs.writeFileSync(f.file, fs.readFileSync(f.file, "utf8").replace(/^trusted_content_hash = .*\n/gm, ""));
fs.rmSync(path.join(f.home, ".openrig-core-hook-trust.json"), { force: true });
const identity = createHash("sha256");
for (const part of [f.file, "openrig-core@openrig"]) {
const bytes = Buffer.from(part), length = Buffer.alloc(8); length.writeBigUInt64LE(BigInt(bytes.length));
identity.update(length); identity.update(bytes);
}
const baseline = path.join(f.home, ".hook-trust", identity.digest("hex"));
fs.mkdirSync(path.dirname(baseline), { recursive: true });
fs.writeFileSync(baseline, process.platform === "win32" ? "sha256:f94b92a51872b7a5c0ba57d762a8cd68208a10f4e4ffed701220feafab626889" : "sha256:3317076e0df0d10402c17f26a17f26a21264980a6f0b551a1e21a3c0f785efca");
return { ...f, baseline };
}
it("an already-installed owned upgrade renews all six content approvals without changing other choices or the native baseline", async () => {
const f = await legacyUpgrade();
fs.appendFileSync(f.file, '\n[hooks.state."outside:stop:0:0"]\nenabled = false\ntrusted_hash = "outside-choice"\n\n\n');
const before = fs.readFileSync(f.file, "utf8"), baseline = fs.readFileSync(f.baseline, "utf8"), calls = f.calls.length;
const expectedContent = codexPluginContentHash(shipped);
await f.adapter().ensureCodexCorePlugin();
const states = Object.entries(f.read().hooks.state).filter(([key]) => key.startsWith("openrig-core@openrig:"));
expect(states).toHaveLength(6);
for (const [, state] of states as Array<[string, any]>) {
expect(state.trusted_content_hash).toBe(expectedContent);
}
expect(fs.readFileSync(f.file, "utf8").replace(/^trusted_content_hash = .*\n/gm, "")).toBe(before);
expect(fs.readFileSync(f.baseline, "utf8")).toBe(baseline);
expect(f.calls).toHaveLength(calls);
const renewed = fs.readFileSync(f.file, "utf8");
await f.adapter().ensureCodexCorePlugin();
expect(fs.readFileSync(f.file, "utf8")).toBe(renewed);
expect(JSON.parse(fs.readFileSync(path.join(f.home, ".openrig-core-hook-trust.json"), "utf8"))).toMatchObject({
contentHash: expectedContent,
});
});
it.each(["disabled", "refused", "unknown baseline", "changed script", "changed skill", "extra file", ...(process.platform === "win32" ? [] : ["symlink"]), "unknown layer", "profile refusal", "profile plugin disable", "invalid provenance"])(
"an owned upgrade preserves native review for %s", async condition => {
const f = await legacyUpgrade();
const header = '[hooks.state."openrig-core@openrig:hooks/codex.json:stop:0:0"]';
if (condition === "disabled") fs.writeFileSync(f.file, fs.readFileSync(f.file, "utf8").replace(header, `${header}\nenabled = false`));
if (condition === "refused") fs.writeFileSync(f.file, fs.readFileSync(f.file, "utf8").replace(/trusted_hash = "sha256:bb444[^"]+"/, 'trusted_hash = "not-the-owned-declaration"'));
if (condition === "unknown baseline") fs.writeFileSync(f.baseline, "unknown");
if (condition === "changed script") fs.appendFileSync(path.join(f.cache, "hooks/scripts/refocus.cjs"), "\nmodified");
if (condition === "changed skill") fs.appendFileSync(path.join(f.cache, "skills/openrig-user/SKILL.md"), "\nmodified");
if (condition === "extra file") fs.writeFileSync(path.join(f.cache, "unexpected"), "extra");
if (condition === "symlink") fs.symlinkSync(f.plugin, path.join(f.cache, "unexpected-link"));
if (condition === "unknown layer") { fs.mkdirSync(path.join(f.home, "../etc")); fs.writeFileSync(path.join(f.home, "../etc/config.toml"), "not valid toml"); }
if (condition === "profile refusal") fs.appendFileSync(f.file, '\n[profiles.special.hooks.state."openrig-core@openrig:hooks/codex.json:stop:0:0"]\nenabled = false\n');
if (condition === "profile plugin disable") fs.appendFileSync(f.file, '\n[profiles.special.plugins."openrig-core@openrig"]\nenabled = false\n');
if (condition === "invalid provenance") fs.writeFileSync(path.join(f.home, ".openrig-core-hook-trust.json"), "broken");
const before = fs.readFileSync(f.file, "utf8");
await f.adapter().ensureCodexCorePlugin();
expect(fs.readFileSync(f.file, "utf8")).toBe(before);
if (condition !== "invalid provenance") expect(fs.existsSync(path.join(f.home, ".openrig-core-hook-trust.json"))).toBe(false);
});
it("the owned receipt carries approval into a later bundle while preserving the previous declarations", async () => {
const f = fixture(); await f.adapter().ensureCodexCorePlugin();
const next = path.join(f.home, "next-shipped"); fs.cpSync(shipped, next, { recursive: true });
fs.appendFileSync(path.join(next, "skills/openrig-user/SKILL.md"), "\nnext shipped revision");
fs.cpSync(next, f.cache, { recursive: true });
const plan = planOwnedCodexContentApproval({ configPath: f.file, content: fs.readFileSync(f.file, "utf8"),
layers: [{ name: "user config", path: f.file, state: "read", content: fs.readFileSync(f.file, "utf8") }], shipped: next, installed: f.cache });
expect(plan).toBeDefined();
expect(Object.values(plan!.hashes)).toEqual(Array(6).fill(codexPluginContentHash(next)));
expect(f.read().hooks.state["openrig-core@openrig:hooks/codex.json:stop:0:0"].trusted_hash).toBe(plan!.approval.declarations["openrig-core@openrig:hooks/codex.json:stop:0:0"]);
});
it("records an already-approved current legacy bundle for subsequent upgrades", async () => {
const f = await legacyUpgrade(), current = codexPluginContentHash(shipped);
fs.writeFileSync(f.baseline, current);
await f.adapter().ensureCodexCorePlugin();
expect(Object.values(f.read().hooks.state).every((state: any) => state.trusted_content_hash === current)).toBe(true);
expect(JSON.parse(fs.readFileSync(path.join(f.home, ".openrig-core-hook-trust.json"), "utf8"))).toMatchObject({ contentHash: current });
expect(fs.readFileSync(f.baseline, "utf8")).toBe(current);
});
it("uses the native bundle fingerprint and legacy identity recipes", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-fingerprint-")); roots.push(root);
fs.mkdirSync(path.join(root, "nested")); fs.writeFileSync(path.join(root, "hello.txt"), "hello\n");
fs.writeFileSync(path.join(root, "nested/other.txt"), "world\n");
// Independent Python implementation of the rust-v0.164.0 byte stream, not the grant planner.
expect(codexPluginContentHash(root)).toBe(process.platform === "win32" ? "sha256:e7690d54fade38332715024d03cf7c7e5cb26f77dbba1c60e589e2be9ef42d5e" : "sha256:bc4d2c619163c3c9e4fd0a0740b7de1e5b39b3eb2cef3d52b8a8f582a1f13ce5");
fs.mkdirSync(path.join(root, ".venv")); fs.writeFileSync(path.join(root, ".venv/ignored"), "generated");
expect(codexPluginContentHash(root)).toBe(process.platform === "win32" ? "sha256:e7690d54fade38332715024d03cf7c7e5cb26f77dbba1c60e589e2be9ef42d5e" : "sha256:bc4d2c619163c3c9e4fd0a0740b7de1e5b39b3eb2cef3d52b8a8f582a1f13ce5");
expect(codexPluginContentHash(root, true)).not.toBe(codexPluginContentHash(root));
expect(path.basename(codexLegacyPluginTrustPath("/home/operator/.codex/config.toml"))).toBe("5107db44b2124b5303069076c8cbcc824811a62710c30a9188b2f8465e5e7cb4");
});
// Independent review controls. All homes and plugin copies belong to fixture().
it("R2 preserves a quoted content-approval key without invalidating TOML", async () => {
const f = await legacyUpgrade();
const previous = fs.readFileSync(f.baseline, "utf8");
fs.writeFileSync(f.file, fs.readFileSync(f.file, "utf8").replace(/^(trusted_hash = .*\n)/gm, `$1"trusted_content_hash" = "${previous}"\n`));
expect(Object.values(f.read().hooks.state).every((s: any) => s.trusted_content_hash === previous)).toBe(true);
const before = fs.readFileSync(f.file, "utf8");
await f.adapter().ensureCodexCorePlugin();
expect(() => f.read()).not.toThrow();
expect(fs.readFileSync(f.file, "utf8")).toBe(before);
expect(fs.existsSync(path.join(f.home, ".openrig-core-hook-trust.json"))).toBe(false);
});
it("R2 keeps an unrelated multiline instruction containing table examples exact", async () => {
const f = await legacyUpgrade();
const sample = Object.keys(f.read().hooks.state).map(key => `[hooks.state.${JSON.stringify(key)}]`).join("\n");
fs.writeFileSync(f.file, `developer_instructions = '''\n${sample}\n'''\n` + fs.readFileSync(f.file, "utf8"));
const before = f.read().developer_instructions;
await f.adapter().ensureCodexCorePlugin();
expect(f.read().developer_instructions).toBe(before);
expect(fs.existsSync(path.join(f.home, ".openrig-core-hook-trust.json"))).toBe(false);
});
it("an unshipped vendored file permits the upgrade but leaves content approval under native review", async () => {
const f = await legacyUpgrade(), before = fs.readFileSync(f.file, "utf8"), calls = f.calls.length;
const older = path.join(path.dirname(f.cache), "previous-version");
fs.renameSync(f.cache, older);
fs.writeFileSync(path.join(f.plugin, "unshipped-file"), "fixture extra");
expect(await f.adapter().ensureCodexCorePlugin()).toMatchObject({ outcome: "installed" });
expect(f.calls).toHaveLength(calls + 1);
expect(fs.existsSync(older)).toBe(true);
expect(fs.readFileSync(path.join(f.cache, "unshipped-file"), "utf8")).toBe("fixture extra");
expect(fs.readFileSync(f.file, "utf8")).toBe(before);
expect(fs.existsSync(path.join(f.home, ".openrig-core-hook-trust.json"))).toBe(false);
});
it("R2 oa fresh nonmatching bundle gains no declaration-only legacy approval", async () => {
const f = fixture();
fs.writeFileSync(path.join(f.plugin, "unshipped-file"), "fixture extra");
expect(await f.adapter().ensureCodexCorePlugin()).toMatchObject({outcome: "installed"});
expect(fs.readFileSync(path.join(f.cache, "unshipped-file"), "utf8")).toBe("fixture extra");
expect(fs.existsSync(codexLegacyPluginTrustPath(f.file))).toBe(false);
expect(fs.existsSync(path.join(f.home, ".openrig-core-hook-trust.json"))).toBe(false);
expect(Object.keys(f.read().hooks?.state ?? {})).toHaveLength(0);
});
@@ -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();
});
});
@@ -24,7 +24,13 @@ describe("served seat attention across the rig, CLI and TUI", () => {
{ name: "failed startup survives observed clear", signal: false, hook: "idle", cause: "failed", expected: true },
{ name: "held seat survives observed clear", signal: false, hook: "idle", cause: "held", expected: true },
{ name: "recorded error survives observed clear", signal: false, hook: "idle", cause: "error", expected: true },
])("$name", async ({ signal, hook, cause, expected }) => {
// A Codex seat at an approval prompt that no hook reports: only the cached pane reading sees it (ring 2, ovh07).
{ name: "a prompt only the pane reading sees", signal: null, hook: null, cause: "none", pane: true, expected: true },
{ name: "the pane reading after a stale hook", signal: null, hook: "unknown", cause: "none", pane: true, expected: true },
{ name: "after a daemon restart, with no arbitrated state yet", signal: null, hook: null, cause: "none", pane: true, restart: true, expected: true },
{ name: "an observed clear overrides the pane reading", signal: false, hook: null, cause: "none", pane: true, expected: false },
{ name: "a current running hook outranks the pane reading", signal: null, hook: "running", cause: "none", pane: true, expected: false },
])("$name", async ({ signal, hook, cause, expected, pane = false, restart = false }) => {
const db = createFullTestDb(); closes.push(() => db.close());
db.exec(`INSERT INTO rigs(id,name) VALUES('rig','team');
INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES('node','rig','worker','codex');
@@ -40,19 +46,21 @@ describe("served seat attention across the rig, CLI and TUI", () => {
db.exec(`INSERT INTO events(rig_id,node_id,type,payload) VALUES('rig','node','node.startup_failed','{"error":"launch error"}')`);
}
const evidence = signal == null ? null : { rung: "needs-input-chrome", observedAt: "2026-10-23T20:00:00Z" };
const seatActivity = { getSeatActivity: () => null, getSeatStateBySession: () => ({
const seatActivity = { getSeatActivity: () => null, getSeatStateBySession: () => restart ? null : ({
activity: "working", needsInput: { count: signal ? 1 : 0, reason: signal ? "visible dialog" : null },
needsInputEvidence: evidence, decidedBy: "window-sampling", seq: 1, lastSwap: null,
}) } as unknown as SeatActivityService;
const structuralActivity = { getStructuralActivity: () => pane
? { state: "attention", reason: "selection_prompt", evidence: "› 1. Yes, proceed", observedAt: new Date().toISOString() } : null };
const activityStore = { getLatestForNode: () => hook == null ? null : ({
state: hook, reason: "hook reason", evidenceSource: "runtime_hook", sampledAt: new Date().toISOString(), evidence: null,
}) } as unknown as AgentActivityStore;
const ps = new PsProjectionService({ db, seatActivity, agentActivity: activityStore });
const ps = new PsProjectionService({ db, seatActivity, agentActivity: activityStore, structuralActivity });
const tmux = { getPaneCommand: vi.fn(async () => "node"), capturePaneContent: vi.fn(async () => null) };
const app = new Hono();
app.use("*", async (c, next) => {
for (const [key, value] of Object.entries({ rigRepo: new RigRepository(db), tmuxAdapter: tmux,
seatActivityService: seatActivity, agentActivityStore: activityStore })) c.set(key as never, value);
seatActivityService: seatActivity, agentActivityStore: activityStore, seatStructuralActivityService: structuralActivity })) c.set(key as never, value);
await next();
});
app.route("/api/rigs/:rigId/nodes", nodesRoutes);
@@ -64,6 +72,11 @@ describe("served seat attention across the rig, CLI and TUI", () => {
expect(ps.getEntries()[0]!.attentionCount).toBe(nodes.filter((n: { needsAttention: boolean }) => n.needsAttention).length);
const cli = compactNodeProjection(nodes)[0]!;
expect(cli).toMatchObject({ needsAttention: expected });
if (pane && expected) {
// The served activity and the attention flag tell the same truth, and the reason survives into the compact read.
expect(nodes[0].agentActivity).toMatchObject({ state: "needs_input", reason: "selection_prompt", evidenceSource: "pane_heuristic" });
expect(cli).toMatchObject({ agentActivity: { state: "needs_input", reason: "selection_prompt" } });
}
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
vi.stubEnv("ATTENTION_FIXTURE_TOKEN", "fixture-only");
const command = new Command().addCommand(psCommand({
@@ -75,6 +88,13 @@ describe("served seat attention across the rig, CLI and TUI", () => {
}));
await command.parseAsync(["node", "rig", "ps", "--host", "fixture", "--nodes", "--rig", "team", "--json", "--limit", "10", "--fields", "needsAttention,arbitratedNeedsInput,needsInputEvidence"]);
expect(JSON.parse(String(stdout.mock.calls.at(-1)![0])).entries[0]).toMatchObject({ needsAttention: expected, arbitratedNeedsInput: signal, needsInputEvidence: evidence });
if (pane && expected) {
// The table a person reads: the reason and ATTN, not "working, —".
stdout.mockClear();
await command.parseAsync(["node", "rig", "ps", "--host", "fixture", "--nodes", "--rig", "team"]);
const row = stdout.mock.calls.map(call => String(call[0])).find(line => line.includes("worker@team"));
expect(row).toMatch(/\byes\b.*selection_prompt\s*$/);
}
stdout.mockRestore();
// The TUI's actual HTTP reader consumes the exact node response, not an independently authored node fixture.
const client = new DaemonClient({ baseUrl: "http://fixture", fetchImpl: (async (url: string) => {
+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",
+22
View File
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const COMPACTION_TEMPLATE = " `<launch cwd>/.openrig/compaction/preparation/<session>/<attempt>/RESTORE-MAP.md`; its parent";
const STARTUP_GUIDE_LINE = "OpenRig uses `<cwd>/.openrig/compaction/preparation/`, falling back to its instance home when";
test("scanner blocks internal paths before reading content", async () => {
const scanner = await loadScanner();
@@ -134,6 +135,27 @@ test("generated rules allow the shipped compaction template and still detect con
}
});
test("generated rules allow the startup guide's preparation-root sentence and nothing derived from it", async () => {
const { scanInternalLeaks } = await loadScanner();
const rules = JSON.parse(readFileSync(new URL("./internal-tokens.generated.json", import.meta.url), "utf8"));
const bytes = readFileSync(new URL("../docs/reference/agent-startup-guide.md", import.meta.url));
const text = bytes.toString("utf8");
assert.ok(text.split("\n").includes(STARTUP_GUIDE_LINE));
assert.ok(rules.allowed_context_lines.includes(STARTUP_GUIDE_LINE));
assert.ok(rules.path_prefixes.includes(".openrig/compaction/"));
for (const path of ["docs/reference/agent-startup-guide.md", "daemon/docs/reference/agent-startup-guide.md"]) {
assert.deepEqual(scanInternalLeaks({ path, bytes, rules }), []);
for (const modified of [
`${STARTUP_GUIDE_LINE} appended detail`,
STARTUP_GUIDE_LINE.replace("<cwd>", "/home/example/project"),
]) {
const changed = Buffer.from(text.replace(STARTUP_GUIDE_LINE, modified));
assert.ok(scanInternalLeaks({ path, bytes: changed, rules }).some(({ token }) =>
token === ".openrig/compaction/"));
}
}
});
test("scanner returns findings in deterministic path/token/line order", async () => {
const scanner = await loadScanner();
const findings = scanner.scanInternalLeaks({
+2 -1
View File
@@ -68,6 +68,7 @@
"internal-only (example)"
],
"allowed_context_lines": [
" `<launch cwd>/.openrig/compaction/preparation/<session>/<attempt>/RESTORE-MAP.md`; its parent"
" `<launch cwd>/.openrig/compaction/preparation/<session>/<attempt>/RESTORE-MAP.md`; its parent",
"OpenRig uses `<cwd>/.openrig/compaction/preparation/`, falling back to its instance home when"
]
}