mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e392d56c3 | ||
|
|
9c08efb59b | ||
|
|
e436cff224 | ||
|
|
a52abd2b0c | ||
|
|
acea3bf39e | ||
|
|
b8b95ca1df | ||
|
|
ed8ca39666 | ||
|
|
32633a3d8f | ||
|
|
efcbd23109 | ||
|
|
a6320e5986 | ||
|
|
9a83123415 | ||
|
|
ef07562d02 |
No files matched your search
+295
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
Generated
+5
-5
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user