Compare commits

..
Author SHA1 Message Date
dev-driver 4f61586bb6 docs(network): say what a failed replace can leave in the key folder
The replace section said a replace that fails after the account takes
the code changes nothing here. The enrollment's record doesn't change,
but the key swap is a file move outside that record's rollback. If
putting the old key back also fails, the old key is left as
key.json.prev, and a staged key.json.next can remain. The section now
says where those files are and what to do with them before enrolling
again.
2026-11-12 18:25:39 +00:00
70 changed files with 191 additions and 2520 deletions

No files matched your search

+3 -250
View File
@@ -8,252 +8,6 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.7.13]
OpenRig 0.7.13 is a patch for people running their own agent teams. When you answer a runner's permission request, you now get a
note back saying who decides it, and the runner wakes on the decision. The
agent that reads your answers is told what an answer typed at your machine
means and how to ask you back. `rig daemon start` tells the agent setting
OpenRig up to offer to register you when nobody is registered yet, and a Claude
seat whose usage-limit message states a reset time waits for it instead of being
retried. There are fixes across delivery,
handover and recovery, the TUI and speed, many contributed by people using
OpenRig. In our tests, a first Claude user (with Codex absent) and a first Codex
user each went from a fresh install to a built, independently reviewed and
committed change. Two repairs aren't in this release: see Known limits.
### Before you upgrade
- **Back up the database, then restart the daemon:** `rig daemon stop`, copy the
database file (`rig config get db.path` prints its path), then
`rig daemon start`. Your agents keep running through a daemon restart; don't
use `rig down`, which ends them. Three migrations run on the daemon's first start: 160 and 161 (for the experimental hub connector below)
and 162 (indexes for session reads). Going back to 0.7.12 after them isn't
established. ([#2993](https://github.com/mvschwarz/openrig/pull/2993), [#2997](https://github.com/mvschwarz/openrig/pull/2997))
- **openrig-core is 0.1.29, and the `openrig` plugin now carries the CLI's
version, 0.7.13.** A Codex seat can ask for hook approval after the
upgrade; see Codex hook review after an upgrade below. ([#3034](https://github.com/mvschwarz/openrig/pull/3034), [#3064](https://github.com/mvschwarz/openrig/pull/3064))
### Answering your team
- A runner's permission answer gets a note back to you: who decides it and in
what state, or that it's kept and the tool hasn't run. A runner waiting on
the decision gets it within about half a second. Read that note before answering again. If
it says your answer couldn't be passed on, follow its retry instruction or use
the `rig seat decide-permission` command it names.
([#3001](https://github.com/mvschwarz/openrig/pull/3001))
- The answer a receiving agent reads now says what an answer typed at this
machine's terminal or TUI means, to act on a clear answer with the request's
own decision command, and how to ask you back with a new ask when it isn't
clear. When the default receiving agent asks you back, the question reaches
your Feed, and your reply decides. Closing a runner's request without
deciding permits nothing. ([#3030](https://github.com/mvschwarz/openrig/pull/3030))
- `rig daemon start` ends by telling the agent setting OpenRig up to offer to
register you as the team's person, when the person registry can be read and
holds nobody. The rigs skill's start step makes the same offer, and asking to
be reachable registers you as the default person directly. In our
fresh-install tests the setup agent passed the offer on in 5 of 6 installs,
and a no registered nothing. Nobody is registered without your yes, and the
exit code is unchanged.
([#3028](https://github.com/mvschwarz/openrig/pull/3028))
- In the change's own tests, before a first team launches, the kernel operator
says which commands it will ask permission for and reuses a choice you've
already made. Otherwise it makes one offer: allow just the command families
your goal repeats, for this project only, applied on your yes. On a no, the
team's default stays and the offer isn't repeated. It hasn't been shown to
make a first team ask you fewer times. ([#3029](https://github.com/mvschwarz/openrig/pull/3029))
- A connected assistant (a hosted member; experimental and off by default) shows
whether it's checking in, against a cadence its roster entry states
(`check_in_minutes`, or `check_in_on_request: true`): checking in, overdue, no
check-in yet, on request, or unknown. `rig roster show` and `find` print it as
a Check-in line. It's a status only: work for a member that isn't checking in
still queues and rings as before. ([#3023](https://github.com/mvschwarz/openrig/pull/3023))
### Seats, handover and recovery
- A Claude seat stopped by a usage limit whose message states a reset time (for
example "resets Nov 10, 4pm") parks its pending work until then, instead of
being retried every few minutes. ([#2917](https://github.com/mvschwarz/openrig/pull/2917))
- A handover whose successor was launched but not ready says it may be running,
instead of "retry": look at the pane first. A handover also cleans up the
retiring occupant's own claims and jobs. ([#3003](https://github.com/mvschwarz/openrig/pull/3003), [#3024](https://github.com/mvschwarz/openrig/pull/3024))
- After `rig unclaim`, the guidance for an adopted session says to end its
process and its tmux session, and `rig seat stop`'s help matches. A fresh
launch after `rig unclaim` ended the server's last session reopens the server.
([#2996](https://github.com/mvschwarz/openrig/pull/2996), [#3006](https://github.com/mvschwarz/openrig/pull/3006), [#3014](https://github.com/mvschwarz/openrig/pull/3014), [#3009](https://github.com/mvschwarz/openrig/pull/3009))
- A handover whose successor was launched into the seat's own tmux session
can reclaim a claim left by a removed owner, or by an archived owner whose
latest session for that node is superseded, detached or exited; a live owner
still refuses.
When a seat
with no declared route can't be woken, the finding goes to the item's creator,
skipping a creator that is a terminal seat. ([#2869](https://github.com/mvschwarz/openrig/pull/2869), [#2987](https://github.com/mvschwarz/openrig/pull/2987), [#2995](https://github.com/mvschwarz/openrig/pull/2995))
### Fixes
- **Delivery:** Claude's auto-update failure footers, including one clipped form,
no longer hold a send to an idle Claude seat; an unrecognised wake result reads unknown, not
delivered; an ordinary one-time park wake is kept and retried
while its park is current and the failed attempt typed nothing into the seat
(an attempt whose effect is unknown needs checking, and repeating waits and
usage-limit expiry keep their own lifecycle); a `custody-verified:` note whose target is followed by a
period, comma or other closing punctuation now verifies it; the Pi runner pane keeps the operator's
draft out of the stream. ([#2948](https://github.com/mvschwarz/openrig/pull/2948), [#3016](https://github.com/mvschwarz/openrig/pull/3016), [#3011](https://github.com/mvschwarz/openrig/pull/3011), [#3038](https://github.com/mvschwarz/openrig/pull/3038), [#3004](https://github.com/mvschwarz/openrig/pull/3004), [#2842](https://github.com/mvschwarz/openrig/pull/2842))
- **Codex and Claude:** completed Codex 0.167 turns read idle, and unsupported
versions stay unknown; a usage-limit banner with a curly apostrophe is
detected; refocus bounds Codex context and records what it left out; an
unreadable Claude status-line cache gets its own reason. ([#2954](https://github.com/mvschwarz/openrig/pull/2954), [#2879](https://github.com/mvschwarz/openrig/pull/2879), [#2951](https://github.com/mvschwarz/openrig/pull/2951), [#2880](https://github.com/mvschwarz/openrig/pull/2880))
- **TUI and CLI:** instance rows are grouped by rig; for a quiet seat the
daemon explicitly reads as unknown, the list, explorer and agent-detail status
rows show unknown, not idle (screen motion still shows active, a daemon that
serves no activity keeps the screen fallback, and the topology graph is
unchanged); Feed times use the configured time zone; the
CLI doesn't call a daemon stopped when the shell can't see its process (a
sandbox), and says not to start another; ssh options are separated from the
target, and the generated attach command quotes the session name. ([#2857](https://github.com/mvschwarz/openrig/pull/2857), [#3012](https://github.com/mvschwarz/openrig/pull/3012), [#3022](https://github.com/mvschwarz/openrig/pull/3022), [#2913](https://github.com/mvschwarz/openrig/pull/2913), [#2973](https://github.com/mvschwarz/openrig/pull/2973), [#2971](https://github.com/mvschwarz/openrig/pull/2971))
- **Scope and workflows:** qualified slice reads and proof discovery are scoped
by mission; workflow spec reads are recorded across restarts; the spec library skips
synthetic alias entries. ([#2960](https://github.com/mvschwarz/openrig/pull/2960), [#2986](https://github.com/mvschwarz/openrig/pull/2986), [#2999](https://github.com/mvschwarz/openrig/pull/2999))
- **Guidance and plugins:** guidance cleanup in a repository's `AGENTS.md` or
`CLAUDE.md` removes redundant recognized copies of an OpenRig-managed block,
including after an editor rewrapped a marker, and keeps distinct edited
copies. A saved write record lets OpenRig identify and keep your edits; on a
fresh start, a differing block with no write record is refreshed to OpenRig's
text and named, while an exact resume or restore keeps it; a bundled plugin's files,
including a binary icon, are copied byte for byte; the plugins carry https
repository URLs and a PNG icon for Claude's plugin directory, and the Codex
manifests link the privacy policy and terms. ([#3020](https://github.com/mvschwarz/openrig/pull/3020), [#3025](https://github.com/mvschwarz/openrig/pull/3025), [#3021](https://github.com/mvschwarz/openrig/pull/3021), [#3064](https://github.com/mvschwarz/openrig/pull/3064))
- **Speed:** the daemon is much more responsive while it reads a project's
proof, which now yields between missions; current-session reads for the node inventory
use an index; the daemon's recorder and Linux clipboard discovery no longer
wait on the main thread.
([#2970](https://github.com/mvschwarz/openrig/pull/2970), [#2997](https://github.com/mvschwarz/openrig/pull/2997), [#3019](https://github.com/mvschwarz/openrig/pull/3019))
- **Docs:** what `rig seat clear-attention`'s check proves, how managed
compaction notices are routed, when the keyboard moves in herdr-app, a
complete periodic-reminder registration, and starting one stopped kernel seat
with `rig launch`. ([#2964](https://github.com/mvschwarz/openrig/pull/2964), [#2984](https://github.com/mvschwarz/openrig/pull/2984), [#2991](https://github.com/mvschwarz/openrig/pull/2991), [#3002](https://github.com/mvschwarz/openrig/pull/3002), [#3031](https://github.com/mvschwarz/openrig/pull/3031))
### Experimental
- **`rig network`** connects one local seat to a hub you already have.
Experimental, unsupported and at your own risk. The connector uses the MCP
SDK, which the installed package already included in 0.7.12. ([#2993](https://github.com/mvschwarz/openrig/pull/2993))
### Behaviour that changed
- **A runner's answer is confirmed to you.** Through 0.7.12 no note came back,
and the runner waited for its next read. ([#3001](https://github.com/mvschwarz/openrig/pull/3001))
- **A usage-limited Claude seat waits for its stated reset**; don't release it
by hand on a timer. **A failed handover may have left its successor
running**; look at the pane before retrying. ([#2917](https://github.com/mvschwarz/openrig/pull/2917), [#3003](https://github.com/mvschwarz/openrig/pull/3003))
- **Passive SSH host status reads `unknown`.** Routine host listings no longer
open a TCP connection to SSH hosts, because repeated probes closed without a
handshake can make OpenSSH block real logins for a while. Hosts that used to
read reachable now read unknown; `rig host doctor` still checks actively.
([#2979](https://github.com/mvschwarz/openrig/pull/2979))
- **`rig package install` leaves a guidance file alone when a recognized
managed block has no complete marker pair,** lists it under "Left as it is:" and installs the rest, instead
of appending a second copy. Launch and restore name such a file
(`guidance_block_markers_damaged`). ([#3020](https://github.com/mvschwarz/openrig/pull/3020))
### Known limits
- **Not in this release:** a Codex snapshot restore or `rig up --existing` can
write OpenRig's managed guidance into a tracked `AGENTS.md`, leaving the
worktree modified; check `git status` after either operation.
- **Not in this release:** a person who talks to the kernel operator first, with
no other agent setting OpenRig up, isn't offered registration. `rig up`,
`rig start` and `rig context` start the daemon without printing the offer
either. Register with `rig gateway human add`. While nobody is registered, a
later `rig daemon start` offers again, even after a no; nothing is registered
without your yes. ([#3028](https://github.com/mvschwarz/openrig/pull/3028), [#3041](https://github.com/mvschwarz/openrig/pull/3041) isn't in this release)
- **The hub connector isn't inert:** the daemon starts it, sweeps leftover
credential folders and schedules local rounds even with no binding; only the
pass to a hub is absent. Its migrations apply on upgrade. ([#2993](https://github.com/mvschwarz/openrig/pull/2993))
- **The recorder:** a queued begin record can be lost before the durable append,
and a stalled worker has no acknowledgement timeout. ([#3019](https://github.com/mvschwarz/openrig/pull/3019))
- **Speed and memory:** On one performance host, whole `rig queue create`
invocations had a median about 10.4% slower than 0.7.12 (about +29 ms). Send
latency stayed within the comparison's unchanged range. The cause remains
unidentified. The standard idle-memory comparison was inconclusive. A post-hoc
analysis of the same six runs found 7.9–8.6 MiB (4.2–5.3%) higher daemon RSS
across three matched idle windows, using the median of three arm medians per
version. The cause and steady-state behavior remain unestablished.
- **Draft-aware delivery ([#3076](https://github.com/mvschwarz/openrig/issues/3076)):** an automatic OpenRig notice can be left beside a
draft started during delivery. This also concerns notices admitted by
openrig-urgent and urgent-only modes. In the reproduced 0.7.12 Claude Code and
Codex cases, the person's draft remained intact and no Enter was sent, but
OpenRig's text stayed in the input. The guard and paste path are unchanged in
this cut; a native 0.7.13 reproduction has not run.
- **Native model pickers:** with a /model picker open, an ordinary send can act
on the picker instead of delivering the message. In 0.7.12 tests it confirmed
Claude Code's already-selected default and advanced Codex's model selection;
neither message reached the model. Close or deliberately resolve the picker
before ordinary delivery. The native 0.7.13 case has not run.
- **Codex hook review after an upgrade:** in one test whose Codex home already
held earlier hook approvals, `rig up --existing` stopped for hook review in
each Codex pane; approving the hooks resumed all three conversations. A
separate test of the same upgrade on a fresh Codex home, on another host,
restored without review. The cause of the difference is not established.
- **A first team with Codex installed but signed out:** a Claude user's starter
team includes a Codex reviewer that waits for a Codex sign-in, and you aren't
told beforehand. If Codex is installed, tell the person about the possible sign-in wait
before the team starts; signing in first was not tested as a remedy.
- **A first team's permission asks:** a command joined with `&&`, `;` or a pipe
still asks unless every part is allowed, and OpenRig's own lifecycle commands
still ask on Claude seats. ([#3029](https://github.com/mvschwarz/openrig/pull/3029))
- **Usage-limit parking** was tested with the limit message from `claude -p`. If
an interactive seat's limit report doesn't carry the message, the seat is
retried as before. A reset in the past or more than 8 days away is ignored.
([#2917](https://github.com/mvschwarz/openrig/pull/2917))
- **Handover:** two other failures can still say "retry" while a successor may
be running: a context delivery failure, and a Codex launch failure after an
update prompt wasn't dismissed. Look at the pane first. ([#3003](https://github.com/mvschwarz/openrig/pull/3003))
- **Answers:** when a receiving agent asks you back, it can stop at Claude
Code's own permission prompt, which you don't see. A receiving agent you
choose can still decline to act on a clear answer. A pairing requester's
description of itself is quoted for the receiving agent, but still shown
unquoted in the request's summary and the notices about it. The TUI's
one-line notice after you answer can clear within a frame. ([#3030](https://github.com/mvschwarz/openrig/pull/3030), [#3001](https://github.com/mvschwarz/openrig/pull/3001))
- **Proof reads** still pause briefly, and a whole read takes slightly longer.
Yielding isn't a hard bound on latency or a single atomic snapshot of the
proof. ([#2970](https://github.com/mvschwarz/openrig/pull/2970))
- **`rig down`** keeps no stored host or boot identity, doesn't guard against
another program running as you, and a daemon restart between losing the tmux
server and the stop wasn't exercised. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
- **Codex working counter:** a capture that doesn't name its pane keeps the old
comparison, a restart that keeps both the pane and its process id isn't seen,
and a leftover live frame can count once until it expires. ([#2980](https://github.com/mvschwarz/openrig/pull/2980))
- **Custom blocker IDs** explain the wait, but their progress gives no event
wake; the timer remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
- **Delivery:** delivery evidence records what was known, not that the agent read
the message, and events are best effort. A footer row OpenRig doesn't
recognize can still hold a delivery ([#2925](https://github.com/mvschwarz/openrig/issues/2925)), and a held message's full envelope
can stay held; clearing a draft doesn't deliver it ([#2515](https://github.com/mvschwarz/openrig/pull/2515)).
- **Runner permission answers** were run end to end with a real receiving agent
and the example runner, whose requesting agent was a stand-in; a model-driven
runner waiting on a person's answer hasn't been run. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
- **A receiving agent you choose** (`agents.operator_session`) that runs in an
explicit or auto permission mode gets no OpenRig allowance for
`rig host approve` or `rig host deny`, so Claude's review can refuse them.
- **An already-running tmux server** keeps its old environment, daemon
credentials included, until it restarts. ([#2850](https://github.com/mvschwarz/openrig/pull/2850))
- **herdr-app views** are one-shot; following isn't claimed. **Pull relay** needs
a connector that implements pull. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2844](https://github.com/mvschwarz/openrig/pull/2844))
- **From 0.7.12:** [its known limits](https://github.com/mvschwarz/openrig/releases/tag/v0.7.12)
still apply, except that [#2970](https://github.com/mvschwarz/openrig/pull/2970) and [#2948](https://github.com/mvschwarz/openrig/pull/2948) are now in this release,
runner answers are confirmed and reach a waiting runner at once ([#3001](https://github.com/mvschwarz/openrig/pull/3001)),
and `rig seat stop` no longer suggests `rig down` for an adopted session
([#3006](https://github.com/mvschwarz/openrig/pull/3006)). They weren't re-checked for this release unless named here.
### Credits
Thanks to @Aummadour, @Kayhusk, @Coder8124, @DeryFerd, @EZotoff, @hgreene624,
@cavalcanticampos, @ziadeh, @dajiaohuang and @billbenetti for merged
contributions. Thanks also for the reports behind these fixes: @jamesloosli
([#2879](https://github.com/mvschwarz/openrig/pull/2879)), @Kayhusk ([#2880](https://github.com/mvschwarz/openrig/pull/2880) and [#2951](https://github.com/mvschwarz/openrig/pull/2951)), @CaptObvious ([#2948](https://github.com/mvschwarz/openrig/pull/2948) and
[#3016](https://github.com/mvschwarz/openrig/pull/3016)), @alperta ([#2917](https://github.com/mvschwarz/openrig/pull/2917)), @ziadeh ([#2960](https://github.com/mvschwarz/openrig/pull/2960)) and @todd-dhango ([#2984](https://github.com/mvschwarz/openrig/pull/2984)).
## [0.7.12]
OpenRig 0.7.12 is a patch for people running their own agent teams, cut from
@@ -324,10 +78,9 @@ Known limits.
- Queue content can be erased for closed-work retention and account deletion,
and a retried create can't bring erased work back. ([#2837](https://github.com/mvschwarz/openrig/pull/2837), [#2843](https://github.com/mvschwarz/openrig/pull/2843))
- A send records what's known about its delivery in one
- Each send records what's known about its delivery in one
`transport.delivery_evidence` event: what was admitted, what reached the
input, what the screen check showed, and for which occupant. The record is
best effort, so a missing one doesn't show there was no send. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
input, what the screen check showed, and for which occupant. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
- `rig queue create` advises on an unresolved scope, slice lists show observed
queue counts, a proof policy can require every listed judge, the execution
ladder shows each slice's recorded status, `rig queue show` names a handed-off
@@ -415,7 +168,7 @@ Known limits.
It doesn't keep a stored host or boot identity, doesn't guard against another
program running as you, and a daemon restart between losing the tmux server
and the stop wasn't exercised. For an adopted session, such as a seat joined
with `rig attach --self` from a tmux pane, `rig seat stop` still suggests `rig down`, which
with `rig attach --self`, `rig seat stop` still suggests `rig down`, which
leaves that terminal running too. Release it with `rig unclaim <session>`
(that leaves its process running), then end that process. If it runs in tmux,
also end the underlying tmux session, including any remaining shell; closing
@@ -82,10 +82,9 @@ optional `evidence` (last-N pane lines, flowed through to
`RestoreNodeResult.attentionEvidence` for `attention_required` outcomes,
`:99–102`). This is the honest-failure shape, not a smoothed optional `error`.
Readiness has its own attention codes (`ATTENTION_REQUIRED_READINESS_CODES` in `runtime-adapter.ts`):
Readiness has its own attention codes (`runtime-adapter.ts:74–87`):
`trust_gate`, `hook_trust_gate`, `update_gate`, `login_required`, `mcp_gate`,
`bypass_consent_gate`, `claude_resume_selection_prompt`, `claude_gated_grants_gate`,
`codex_auth_refusal` and `codex_client_incompatible`.
`bypass_consent_gate`, `codex_auth_refusal` and `codex_client_incompatible`.
When `checkReady` reports one, the readiness wait returns at once and startup
ends `attention_required`.
@@ -477,8 +476,7 @@ command + pane content and returns one of four honest statuses
conversation"; it is *distinct* from `inconclusive` and `failed` (comment
`:4–6`).
Other codes include `bypass_consent_gate`, `claude_gated_grants_gate` (the
"gated grants" trust check, which is never auto-driven), `mcp_gate`, `login_required` and
Other codes include `bypass_consent_gate`, `mcp_gate`, `login_required` and
`returned_to_shell` for Claude, and `codex_client_incompatible`,
`no_saved_session`, `codex_auth_refusal` and `model_selection_gate` for Codex;
an unknown runtime gets `unsupported_runtime`. For Claude, `assessManagedProbe`
-3
View File
@@ -116,6 +116,3 @@ When a regression reaches a release, add one line: date, issue, artery, and whic
- 2026-10-24, B6 (0.7.1 candidate `7badb557`), launch and resume (Codex hook trust): after an upgrade from 0.7.0, a Codex seat stopped at "Hooks need review: 6", because Codex 0.164 asks to approve a plugin's whole content again. Caught at the cut, before publication, by a native upgrade run with a real Codex. Missed by source tests and stub scenarios (a stub can't see the real Codex binary) and by the installed-upgrade check (no real Codex). Fixed in #1948. Scenario owed: a cheap real-agent Codex upgrade with approval renewal.
- 2026-10-24, pane-only attention (#1876 on the 0.7.1 candidate), native observation: a Codex seat waiting at a prompt that only its pane showed read as working, with no attention signal. Caught at the cut, before publication, by real work on a test machine. Missed by fixtures that all carried hook signals. Fixed in #1946. Scenario owed: a stub pane showing a selector with no hook event must read as needing attention.
- 2026-10-25, #1955, delivery (send readiness): approval-prompt text that a working Claude seat quoted in its own output read as a waiting prompt, so sends and queue wakes to that seat were refused. This one was present since 0.7.0, so it did reach a release; it was found during the 0.7.1 cut by real work on a test machine. Missed by classifier fixtures that had no working pane quoting prompt text. Fixed in #1955 for 0.7.2.
- 2026-11-10, F4 (0.7.11, #2662 leg (a)), delivery (draft-aware, under Claude's update banner): beneath Claude's bare update banner, a draft-aware delivery pasted the message and withheld Enter, leaving it in the composer, where 0.7.10 held it. Caught at the cut by a native delivery run and accepted with disclosure, so it reached 0.7.11. Missed by footer-grammar fixtures that had no paste hint beside the update notice. Fixed in #2786 for 0.7.12.
- 2026-11-10, Pi case 1c (0.7.11, #2630), launch (Pi account home): a Pi seat with its own Pi login and a declared absolute `PI_CODING_AGENT_DIR` stopped at launch even with an `auth.json` there, with a misleading missing-account-file error, where 0.7.10 started it. Found at the cut by source and function-level tests, not natively, and accepted with disclosure, so it reached 0.7.11. Not fixed: #2803 (0.7.12) makes the launch error name the real cause, and the stop stands. Scenario owed: a Pi seat with its own login and a declared account home.
- 2026-11-12, a tracked `AGENTS.md` (0.7.12, repaired by #3039), restore and relaunch (Codex guidance blocks): on a Codex seat, a snapshot `rig restore`, and `rig down` followed by `rig up --existing`, each wrote OpenRig's managed guidance blocks back into the person's tracked `AGENTS.md` (249 B to 17,316 B), leaving the worktree modified. Seen at the cut by a native Codex run through restore and `up --existing`, and accepted with disclosure, so it reached 0.7.12: its release text says to check `git status` after either. Snapshot restore was already a disclosed 0.7.11 limit; `up --existing` was newly observed, and whether it regressed isn't established. The check that would tell, the same `up --existing` run on the previous release, hasn't run. #3039 merged after 0.7.13's cut, so the repair is on main for 0.7.14, not in 0.7.13. Scenario owed: a Codex seat in a repository with a tracked `AGENTS.md`, checked with `git status` after a restore and after `up --existing`.
+4 -13
View File
@@ -35,17 +35,8 @@ command. `rig ps --nodes` shows it under "Startup details (<seat>)", and
The attention codes that stop startup this way are `trust_gate`,
`hook_trust_gate`, `update_gate`, `login_required`, `mcp_gate`,
`bypass_consent_gate`, `claude_resume_selection_prompt`,
`claude_gated_grants_gate`, `codex_auth_refusal` and `codex_client_incompatible`.
`bypass_consent_gate`, `codex_auth_refusal` and `codex_client_incompatible`.
They are recognized from each harness's English prompt text.
`claude_gated_grants_gate` is Claude Code's workspace trust check that warns "This folder
pre-approves N tool permissions" (from the folder's `.claude/settings.json`): OpenRig never
answers it, not even with `recovery.auto_drive_provider_prompts`, because its default choice
declines those permissions (or exits) and approving them is your call. With that setting on,
the plain workspace trust prompt is answered only when its options are exactly "No, exit" and
"Yes, I trust this folder": Claude Code 2.1.322 opens it on "No, exit", so OpenRig moves the
selection down once and presses Enter only after it reads the focus on "Yes". Any other option
set, a prompt drawn without a cursor or a focus that does not reach "Yes" gets no confirmation.
## Continue a Codex fork whose child ID arrived late
@@ -423,10 +414,10 @@ Each startup file and action specifies when it applies:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
**A file keeps one copy of each distinct block text, and nothing you wrote is removed.**
**A file keeps one copy of each block.**
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
- **Collected copies:** a copy that repeats an earlier one exactly is removed. Beside a copy you edited, OpenRig's own unedited copies are removed too, and every different edited copy stays (`guidance_block_copies_removed`).
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write treats that block like any other. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
- **Collected copies:** they're reduced to the first. Beside a copy you edited, OpenRig's own unedited copies and exact repeats are removed, and every different edited copy stays (`guidance_block_copies_removed`).
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write keeps one block. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
+1 -2
View File
@@ -202,8 +202,7 @@ LIFECYCLE); see the
its startup context is still pending for `rig seat continue`.
- **Oriented:** `verified`, `missing`, `rejected`, `n-a`, from the startup proof.
- **Attention codes:** `trust_gate`, `hook_trust_gate`, `update_gate`, `login_required`,
`mcp_gate`, `bypass_consent_gate`, `claude_resume_selection_prompt`,
`claude_gated_grants_gate`, `codex_auth_refusal`, `codex_client_incompatible`.
`mcp_gate`, `bypass_consent_gate`, `codex_auth_refusal`, `codex_client_incompatible`.
- **Restore outcome:** `resumed`, `fresh-primed`, `awaiting-decision`, `attention_required`,
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`). A seat joined with
`rig attach` as an external CLI reads `left_attached`: restore leaves it as it is.
+2 -2
View File
@@ -880,8 +880,8 @@ What it costs:
## Stop your teams
`rig down <rig-name>` ends that rig's agent sessions and work in progress.
A terminal OpenRig didn't start, such as a seat joined with `rig attach --self`
from a tmux pane, is left running: `rig down` names it with the commands to stop it by hand, and
A terminal OpenRig didn't start, such as a seat joined with `rig attach --self`,
is left running: `rig down` names it with the commands to stop it by hand, and
exits 2.
Read `rig ps --nodes --rig <rig-name>` first so you can see who will stop;
`rig down` also prints the rig's recorded agent sessions and their activity
+2 -15
View File
@@ -141,9 +141,7 @@ reason when it's unknown); `rig roster show` and `find` show it as `Check-in:`,
without `--nodes`, and when it has no rigs or nodes to show) lists every connected assistant after its table, one line
each. That's on this host, and in the node view of an HTTP host; an SSH host's `rig ps` prints its own. An HTTP host's
rig view and the multi-host fan-out leave the section out. When the list can't be read, for example from an older
daemon, it says check-in is unavailable instead. Its `--json` output is unchanged. The TUI's
instance and rig tables list them the same way after their seats, with times in the viewer's zone; when the list can't be read,
they say check-in is unavailable, as `rig ps` does.
daemon, it says check-in is unavailable instead. Its `--json` output is unchanged.
When the member has unclaimed work, the standing notice described above states the same status in its `check-ins:`
line; when that notice is filed doesn't change.
@@ -180,18 +178,7 @@ and nothing else is offered:
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
- **Messages are items:** a message is an item the receiver reads and closes, with any reply in its `resolution`. The
sender reads that reply on the same item. Nothing wakes the sender or posts a new item for it. A separate new message is
new work.
- **A fair-use limit on new work:** the service can add `throttle: { new_work: true, retry_after_seconds: <n> }` to an
active answer.
- While it does, a `rig_queue_create` that would open a new item writes nothing. It returns `fair_use_retry_later` with
`retryAfterSeconds` (1 to 600) and the `qitemId` to retry with.
- A retry of a create that already landed is answered from the stored item, as before.
- Everything else is served: reading, claiming and closing existing items (a reply in `resolution` included), and
answering the team's questions. A separate new message or revision waits like any other new item.
- It's never a credential refusal, and a `throttle` that can't be read is ignored.
- The service is asked once per HTTP request (initialize and tool listing included), not once per tool call.
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
- **What the sender can tell, from the item it reads:**
- **Queued:** the item exists and is `pending` with no claim.
- **Picked up:** the receiver's claim (`in-progress`).
+7 -5
View File
@@ -8,8 +8,6 @@ with one grant per participant. The network connector puts one selected local se
The connector opens no port: your daemon makes every connection outward, and the hub never connects to your machine. Work the hub
sends that participant runs on your seat, under your accounts, and the result goes back to the hub item.
**The network connector is experimental and unsupported.** Use it only if you choose to, at your own risk.
## Enroll in an account, then connect by name
When your hub is an account's network, enroll this daemon once with the one-time code its page shows, then connect
@@ -60,9 +58,13 @@ waits for any other change to this enrollment to finish, within the same 30 seco
waiting says so and doesn't send the code. The account makes a new installation, with a new key, for the new code. Here,
that key takes the old one's place and the bindings made through the old installation end, in one step:
- If that step fails after the account accepted the code, nothing here changes, and the error says so. The old
enrollment keeps working only while the account keeps the old installation, and the account now has a new
installation whose key nothing here holds: remove it on the account page, then enroll again with a new code.
- If that step fails after the account accepted the code, the enrollment's record here doesn't change, and the error
says so. The key is put back too, but moving key files happens outside that record's rollback. If putting it back also
fails (on a full or read-only disk, for example), the old key is left as `key.json.prev` beside `key.json` in the
installation's folder under `network-secrets/` in the OpenRig home: move it back to `key.json` before you go on. A
`key.json.next` there is a staged new key, and the next replace removes it. The old enrollment keeps working only
while the account keeps the old installation, and the account now has a new installation whose key nothing here
holds: remove it on the account page, then enroll again with a new code.
- Once the step is done, `enroll` names the bindings it ended. The old installation may still list their names, and the
new key can't drop them, so remove them on the account page. `rig network status` marks each of them as possibly still
listed under the old installation, as it does a binding that ended earlier without the account confirming the drop:
+5 -7
View File
@@ -24,7 +24,7 @@ A queue row is erased in one of two ways:
- events whose payload names it;
- its mission-control audit rows.
**Copies held with someone else go only when they're the owner's.** These are outbox and inbox entries that point at the row or wake for it, and stream reader actions and dispositions that name it (including `duplicate_of`). For account deletion, the owner is the account: both of a copy's sessions must be members. Otherwise the owner is the row's own source and destination. OpenRig's own sessions (`daemon@kernel`, and any session starting `system:`), which send its wakes and notices, aren't counted when a copy has another session, so OpenRig's wake to the owner is the owner's. Any other copy is kept and listed under `keptCopies`:
**Copies held with someone else go only when they're the owner's.** These are outbox and inbox entries that point at the row or wake for it, and stream reader actions and dispositions that name it (including `duplicate_of`). For account deletion, the owner is the account: both of a copy's sessions must be members. Otherwise the owner is the row's own source and destination. Any other copy is kept and listed under `keptCopies`:
- `mixed`: one of its sessions is the owner's;
- `foreign`: none of them are.
@@ -36,7 +36,7 @@ Name exactly one.
| Scope | Erases | Action |
|---|---|---|
| `--members <file>` | Account deletion. Every row whose source and destination are both in the file, or one in the file and the other OpenRig's own session, in any state, open work included. Add `--rig <id>` for the account rig's own records | delete |
| `--members <file>` | Account deletion. Every row whose source and destination are both in the file, in any state, open work included. Add `--rig <id>` for the account rig's own records | delete |
| `--ids <file>` | Exactly the listed rows. A report's `qitems` entries, or a ledger's lines, keep each entry's own action. A ledger's entries are replayed only for the batches that committed (below) | `--action delete` (default for plain ids) or `--action scrub` |
| `--routine` | Closed rows past this daemon's retention windows (below) | scrub, then delete |
@@ -44,10 +44,8 @@ A file is one entry per line (blank lines and `#` comments are skipped; a line s
**Account deletion is exact.**
- The members file is the account's recorded membership: the exact session names of its seats, including departed ones. List every address form the account used, such as a host-suffixed form. Names are compared as given, never by pattern, so a row addressed through an unlisted form is neither erased nor reported.
- **OpenRig's own sessions:** `daemon@kernel` and any `system:` session send OpenRig's own wakes and notices, such as the wake for a sweep finding, a wake escalation or a health finding. They belong to no account, so a row or entry between one of them and a member is the member's and is erased. One with no member endpoint, such as an OpenRig wake to another account's seat or an entry between two of OpenRig's own sessions, is never touched.
- **Mixed rows:** a row with one member endpoint and the other outside the account is reported under `mixed` (with which endpoint is the member) and left untouched. Deciding who owns it is a separate decision. A row rerouted out of the account while the run is under way (by a fallback, say) is kept too, and reported as `changed-during-run`.
- **After the rows:** account deletion also removes outbox and inbox entries that no row owns between two members, or between a member and OpenRig's own session, and the members' stream items. An entry with one member endpoint and the other outside the account, or a stream item something still references, is counted and left.
- **Runs on earlier versions** kept OpenRig's wake for a row they erased (as a mixed copy, then a mixed entry) and reported OpenRig's rows to a member as mixed. Rerunning the same command removes them.
- **Mixed rows:** a row with exactly one member endpoint is reported under `mixed` (with which endpoint is the member) and left untouched. Deciding who owns it is a separate decision. A row rerouted out of the account while the run is under way (by a fallback, say) is kept too, and reported as `changed-during-run`.
- **After the rows:** account deletion also removes outbox and inbox entries between two members that no row owns, and the members' stream items. An entry with one member endpoint, or a stream item something still references, is counted and left.
**The account's rig.** A rig's own records outlive the rig. Events are kept past rig and node deletion by design, and snapshots, node and seat records and watchdog jobs have no key that cascades. `--rig <id>`, given with `--members`, erases them:
- the rig's events, including those of its nodes;
@@ -96,7 +94,7 @@ The command prints one JSON report (`--json` makes it a single line):
| `clean` | `complete`, and nothing was left: no `mixed` rows, mixed `keptCopies`, `gaps`, `unreadableEvents`, left-over entries, or rig records left or unknown. Only a clean report means the scope is fully erased. It's about this run's scope: a routine or members run's `clean` isn't affected by the ledger's other batches, and only a replay (`--ids` with a ledger) reports a batch it can't show committed, as the gap `ledger-unresolved` |
| `error` | why the run stopped (`stage` and `message`). Everything listed in `qitems` was committed before it; the command exits 2 |
| `qitems` | each row erased (or that would be), with its `action` and `state` |
| `mixed` | members scope: rows with one member endpoint and the other outside the account, left untouched |
| `mixed` | members scope: rows with one member endpoint, left untouched |
| `keptCopies` | copies of erased rows held with someone else, kept: `table`, `id`, `qitemId`, `ownership` (`mixed` or `foreign`) |
| `gaps` | rows not erased and why: `workflow-reference`; `not-found` for an id to scrub that doesn't exist; `changed-during-run` for a row that no longer qualified when its batch ran (reopened, rerouted out of the account, or written to so it left its window) or an absent id created since selection; `unreadable-event` for a row an unreadable event names; `ledger-unresolved` for a replayed entry whose batch can't be shown to have committed |
| `skipped` | ids scope, a ledger replay: entries whose batch rolled back, with the batch id; not replayed |
+2 -8
View File
@@ -61,12 +61,6 @@ Existing OpenRig session options, such as `terminal.status_bar`, still apply.
An existing server does not reread its configuration merely because the daemon
restarts. Settings and sessions in a separate personal server are left alone.
When OpenRig creates a tmux server on macOS or Linux, its creating client tries
to raise the soft open-file limit to 8192, bounded by the inherited hard limit.
An already higher soft limit is preserved. A failed raise produces an advisory
and the start continues with the inherited limit. Connecting to an existing
server does not change that server's limit or restart its sessions.
## Existing teams and deferred migration
At startup, the daemon checks the previously selected server before switching.
@@ -116,8 +110,8 @@ To move a team that runs on an older server:
on it, yours included, has ended.
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
turn in progress stops. Queue rows, rigs and seats stay as they are. A session
OpenRig didn't start, such as a seat joined with `rig attach --self` from a tmux
pane, is left running and `rig down` exits 2; stop it with the commands it prints, or it stays on
OpenRig didn't start, such as a seat joined with `rig attach --self`, is left
running and `rig down` exits 2; stop it with the commands it prints, or it stays on
the old server.
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
`rig terminal status --json` should show `migrationPending: false`. If it's still
+3 -7
View File
@@ -4,8 +4,7 @@ import { readOpenRigEnv } from "../openrig-compat.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { spawnSync } from "node:child_process";
import { reportTmuxServerLimit, tmuxServerStartCommand } from "@openrig/daemon/tmux-server-start";
import { execFileSync } from "node:child_process";
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
@@ -194,11 +193,8 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
const sessionName = `chatroom@${rig}`;
try {
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
const command = tmuxServerStartCommand("tmux", chatroomWatchTmuxArgs(rig));
const result = spawnSync(command.file, command.args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
reportTmuxServerLimit(result.stderr);
if (result.error || result.status !== 0) throw result.error ?? new Error("tmux session creation failed");
const created = result.stdout.trim() || sessionName;
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.trim() || sessionName;
console.log(`Started watch in tmux session: ${created}`);
console.log(`Attach with: tmux attach -t ${created}`);
} catch {
+1 -1
View File
@@ -76,7 +76,7 @@ Keys:
health.context_pressure.* context-pressure warning/critical percentages (defaults 95/99)
files.allowlist name:/abs/path,name:/abs/path
progress.scan_roots name:/abs/path,name:/abs/path
ui.timezone IANA timezone for the TUI's times, the Feed's included (default America/Los_Angeles; reopen TUI after changing)
ui.timezone TUI IANA timezone (default America/Los_Angeles; reopen TUI after changing)
ui.enabled serve the web UI and its terminal WebSocket (default off; restart the daemon)
ui.preview.* refresh_interval_seconds, max_pins, default_lines
ui.terminal.max_live_terminals
+1 -2
View File
@@ -142,8 +142,7 @@ const LONG_RUNNING_TIMEOUT_MS = 45_000;
export function downCommand(depsOverride?: DownDeps): Command {
const cmd = new Command("down").description("Tear down a rig")
.addHelpText("after", `
rig down stops every agent OpenRig started in the rig: their sessions end, and any work in progress stops
with them. A session OpenRig didn't start (adopted, bound or discovered) is left running, and rig down names it.
rig down stops every agent in the rig: their sessions end, and any work in progress stops with them.
Before running it, check who is working (rig ps --nodes --rig <rig>) and that the person asked for it;
don't stop a team you didn't start unless asked. OpenRig snapshots the rig first (best-effort), and
rig up <rig> --existing brings it back, resuming each seat's conversation where its harness allows. With
+2 -2
View File
@@ -54,7 +54,7 @@ function line(b: Binding): string {
* another OpenRig hub), see where each connection stands, and disconnect one. */
export function networkCommand(depsOverride?: NetworkDeps): Command {
const cmd = new Command("network")
.description("Connect selected local seats to a remote hub, see their status, and disconnect them. Experimental and unsupported: use it only if you choose to, at your own risk");
.description("Connect selected local seats to a remote hub, see their status, and disconnect them");
const getDeps = (): NetworkDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const client = async () => {
@@ -157,7 +157,7 @@ export function networkCommand(depsOverride?: NetworkDeps): Command {
const b = raw as unknown as Binding;
if (b.accountRemoval === "confirmed") return `Disconnected ${line(b)}\nThe account stopped listing ${b.participantName}, which revokes it there.`;
if (b.accountRemoval) return `Disconnected ${line(b)}`;
return `Disconnected ${line(b)}\nRemove the participant on the hub too: until then, the hub still lists it.`;
return `Disconnected ${line(b)}\nRemove the participant on the hub too, so the account stops listing it.`;
});
});
-3
View File
@@ -72,8 +72,6 @@ export interface NodeEntry {
lifecycleState?: "running" | "detached" | "recoverable" | "attention_required";
tmuxAttachCommand: string | null;
resumeCommand: string | null;
/** What the resume command couldn't include, when something the seat chose couldn't be read. */
resumeCommandNote?: string;
latestError: string | null;
/** Slice 15 — `terminal-active` primitive. true=producing output,
* false=silent past threshold, null=no signal. NEVER derived from
@@ -233,7 +231,6 @@ const ALLOWED_NODE_FIELDS = new Set([
"lifecycleState",
"tmuxAttachCommand",
"resumeCommand",
"resumeCommandNote",
"latestError",
"terminalActive",
"hasAssignedWork",
+2 -4
View File
@@ -2189,10 +2189,8 @@ Examples:
"Name exactly one scope. What each one erases:",
" --members every row whose source and destination are both members, in any state, deleted with its transitions,",
" outbox and inbox copies, stream actions, events and audit rows; then member-to-member outbox and inbox",
" entries no row owns, and the members' stream items. OpenRig's own sessions (daemon@kernel, system:*)",
" belong to no account, so its wakes and notices to a member are erased too. A row with one member",
" endpoint and the other outside the account is reported under mixed and left untouched. With --rig,",
" also the rig's own events, snapshots and node and seat records.",
" entries no row owns, and the members' stream items. A row with one member endpoint is reported under",
" mixed and left untouched. With --rig, also the rig's own events, snapshots and node and seat records.",
" --ids the listed rows, deleted or scrubbed (a report's qitems entries keep their action); a deleted id",
" that's already gone keeps (or regains) its id marker.",
" --routine closed rows (done, canceled, denied, handed-off) past the windows, aged from when they closed.",
+2 -2
View File
@@ -1022,8 +1022,8 @@ Example:
}
if (opts.json) return;
const state = res.data.state;
console.log(state === "answered" ? `Decided: ${res.data.optionId}. A runner waiting on the request gets it now; one that isn't gets it at its next read.`
: state === "refused" ? "Decided: refused. A runner waiting on the request gets the refusal now; one that isn't gets it at its next read."
console.log(state === "answered" ? `Decided: ${res.data.optionId}. The runner gives it to its agent at its next read.`
: state === "refused" ? "Decided: refused. The runner gives its agent the refusal at its next read."
: `Nothing was decided: the request is ${state}.`);
});
+2 -20
View File
@@ -9,8 +9,7 @@
//
// With --grant-service, grants come from an external grant service instead and the token file is never read: each
// request's key is checked with that service (openrig.grant/v1) for this daemon's instance, every answer but active is
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts). An active answer's
// fair-use throttle holds back only that caller's new work (rig_queue_create), never the request.
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts).
//
// The endpoint serves both protocol revisions: 2026-07-28 per request, and 2025's initialize and exchanges statelessly.
import http from "node:http";
@@ -232,18 +231,6 @@ const unavailable = (log: string): Refusal => ({
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
/** An active answer's optional fair-use limit on new work (`throttle`): the seconds until new work is accepted again,
* rounded up and kept within 1..600, undefined when there is none, or "unusable" when it can't be read. */
function fairUseThrottle(value: unknown): number | undefined | "unusable" {
if (value === undefined || value === null) return undefined;
if (typeof value !== "object" || Array.isArray(value)) return "unusable";
const { new_work: newWork, retry_after_seconds: seconds } = value as Record<string, unknown>;
if (typeof newWork !== "boolean") return "unusable";
if (!newWork) return undefined;
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return "unusable";
return Math.min(600, Math.max(1, Math.ceil(seconds)));
}
/** One request's caller from the grant service's answer for its key. A refusal never falls back to the token file or
* the env's seat. */
async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: string, instance: string): Promise<{ seat: string; access: McpAccess; principal: McpPrincipal } | Refusal> {
@@ -287,16 +274,11 @@ async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: s
if (person !== undefined && person !== null && !personId) {
console.error(`rig mcp serve --http: grant ${grant.id} names an unusable person.entity_id, so this connection isn't offered the person tools (rig_question_*)`);
}
// The service's fair-use limit, when it sets one, holds back only new work (rig_queue_create). One that can't be read is
// ignored: it never refuses the request or reads as a credential problem.
const throttle = fairUseThrottle(body.throttle);
if (throttle === "unusable") console.error(`rig mcp serve --http: grant ${grant.id} answered an unusable throttle, so it's ignored`);
// The principal the daemon records beside this caller's rows. A version that isn't a finite number is left out (a v1
// answer sends a number).
const version = Number.isFinite(grant.version) ? { version: grant.version as number } : {};
return { seat, access: { principal: principal.id, owner: false, rigs, roots, workspace: false,
external: { objects, grant: { id: grant.id, ...version }, ...(personId ? { person: personId } : {}),
...(typeof throttle === "number" ? { throttle: { retryAfterSeconds: throttle } } : {}) } },
external: { objects, grant: { id: grant.id, ...version }, ...(personId ? { person: personId } : {}) } },
principal: { source: "grant-service", id: principal.id, grant: grant.id, ...version } };
}
+3 -29
View File
@@ -432,17 +432,6 @@ function outOfScope(what: string, hint?: string): TextResult {
return errorResult(JSON.stringify({ ok: false, error: "out_of_scope", message }), "out_of_scope", message);
}
/** A new create the grant service's fair-use limit holds back: nothing was written, and it says when to retry with the same
* id. `unread`: whether an earlier create with this id was saved couldn't be read. */
function fairUseRetryLater(qitemId: string, retryAfterSeconds: number, unread: boolean): TextResult {
const earlier = unread ? ` Whether an earlier attempt with this id was saved is unknown: check rig_queue_show with qitemId ${qitemId} before retrying.` : "";
const message = `OpenRig: this account has reached its fair-use limit for new work just now. Nothing was created by this call.${earlier}`
+ ` Retry after ${retryAfterSeconds} seconds with the same qitemId ${qitemId}. Work you already have continues as usual: read, claim, answer and`
+ " close it, and to reply to an item addressed to you, put the reply in its resolution when you close it.";
return errorResult(message, "fair_use_retry_later", message,
{ retryable: true, retryAfterSeconds, qitemId, outcome: "not_written", ...(unread ? { earlierAttempt: "unknown" } : {}) });
}
/** A file the daemon read through a shared root, as text from offset: at most CONTEXT_TEXT_LIMIT characters, with
* nextOffset when more of what the daemon read remains, and its byte cut when the file was longer than the daemon reads. */
function artifactView(source: string, read: FileRead, offset: number): Record<string, unknown> {
@@ -518,10 +507,8 @@ export type McpAccessClass = "work" | "workspace" | "owner" | "person";
export type McpAccess = {
principal: string; owner: boolean; rigs: readonly string[]; roots: readonly string[]; workspace: boolean;
/** An external caller's grant: the object/action pairs it names (openrig.grant/v1 `objects`), the grant it answered
* with, `person`, the gateway entity id of the person the grant service vouches owns this connection, and `throttle`,
* the service's fair-use limit on new work for this request: the seconds before a new create is accepted again. */
external?: { objects: Readonly<Record<string, readonly string[]>>; grant?: { id: string; version?: number | string }; person?: string;
throttle?: { retryAfterSeconds: number } };
* with, and `person`, the gateway entity id of the person the grant service vouches owns this connection. */
external?: { objects: Readonly<Record<string, readonly string[]>>; grant?: { id: string; version?: number | string }; person?: string };
};
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
@@ -651,7 +638,7 @@ const EXTERNAL_PERSON_INSTRUCTIONS = [
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it and close it done, with your reply in resolution when it needs one; its sender reads that reply on the same item. Nothing promises to wake you, so check your items again.",
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
].join("\n");
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
@@ -1415,19 +1402,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
}
}
const id = qitemId ?? `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
// The grant service's fair-use limit holds back new work only. A retry of a create that already landed isn't new
// work: the daemon answers it from the stored row, so it goes through as before.
const throttle = access?.external?.throttle;
if (throttle) {
let landed: boolean | undefined = false;
if (qitemId) {
try {
const existing = await client.get(`/api/queue/${encodeURIComponent(qitemId)}`);
landed = existing.status < 400 ? true : existing.status === 404 ? false : undefined;
} catch { landed = undefined; }
}
if (landed !== true) return fairUseRetryLater(id, throttle.retryAfterSeconds, landed === undefined);
}
// With no seat, the task goes to its destination as from the unknown sender, with the note at the top of its body.
const note = unknownSenderNote("task");
try {
+1 -12
View File
@@ -4,7 +4,6 @@ import { homedir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { reportTmuxServerLimit, tmuxServerStartCommand } from "@openrig/daemon/tmux-server-start";
import { terminalAuthHeaders, type DaemonClient } from "./client.js";
import { shellQuote } from "./cross-host-executor.js";
import { prepareHerdrLaunchConfig } from "./herdr-launch-config.js";
@@ -44,17 +43,7 @@ export function defaultWindowDeps(): WindowDeps {
const run = promisify(execFile);
return {
platform: process.platform, env, exists: existsSync,
exec: async (file, args, timeoutMs = 10_000) => {
const command = path.basename(file) === "tmux" ? tmuxServerStartCommand(file, args) : { file, args };
try {
const result = await run(command.file, command.args, { env, encoding: "utf8", timeout: timeoutMs });
reportTmuxServerLimit(result.stderr);
return result.stdout;
} catch (error) {
reportTmuxServerLimit((error as { stderr?: unknown }).stderr);
throw error;
}
},
exec: async (file, args, timeoutMs = 10_000) => (await run(file, args, { env, encoding: "utf8", timeout: timeoutMs })).stdout,
launch: (file, args) => new Promise((resolve, reject) => {
const child = spawn(file, args, { env, detached: true, stdio: "ignore" });
child.once("error", reject);
+1 -3
View File
@@ -3,7 +3,6 @@ import { readFileSync, realpathSync } from "node:fs";
import { hostname, networkInterfaces } from "node:os";
import path from "node:path";
import type { DaemonClient } from "./client.js";
import { tmuxServerStartCommand } from "@openrig/daemon/tmux-server-start";
export interface TmuxAttachment {
session: string;
@@ -87,8 +86,7 @@ export function attachTmux(target: TmuxAttachment): Promise<number> {
const env = { ...process.env };
delete env.TMUX;
delete env.TMUX_PANE;
const command = tmuxServerStartCommand("tmux", [...server, "attach-session", ...(target.readOnly ? ["-r"] : []), "-t", `=${target.session}`]);
const child = spawn(command.file, command.args,
const child = spawn("tmux", [...server, "attach-session", ...(target.readOnly ? ["-r"] : []), "-t", `=${target.session}`],
{ stdio: "inherit", env, windowsHide: true });
child.once("error", reject);
child.once("exit", code => resolve(code ?? 1));
@@ -1045,94 +1045,6 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
expect((await asExternal("rig_queue_create", { destination: "lead@team-a", body: "far", qitemId: far })).text).not.toContain("other-host");
});
describe("a fair-use throttle on an active answer", () => {
const ALL = { queue: ["create", "show", "list", "claim", "update"] };
const throttled = (throttle: unknown) => (): [number, unknown] => [200, active({ objects: ALL, throttle })];
it("holds back only new creates, which write nothing and say when to retry with the same id; a replay, claim, update, show and list are served", async () => {
answer = () => [200, active({ objects: ALL })];
const { url } = await listen();
const ask = await connect(url);
const landed = { destination: "lead@team-a", body: "First task", qitemId: "qitem-landed" };
expect((await ask("rig_queue_create", landed)).isError).toBe(false);
const theirs = (await create("lead@team-a", CALLER, "A task for the assistant")).qitemId;
answer = throttled({ new_work: true, retry_after_seconds: 30 });
const before = rows();
for (const args of [
{ destination: "lead@team-a", body: "New work" },
{ destination: "lead@team-a", body: "New work", qitemId: "qitem-new" },
{ destination: "lead@team-a", body: "Improve it", revises: landed.qitemId },
] as Array<Record<string, string>>) {
const held = await ask("rig_queue_create", args);
expect(held.isError).toBe(true);
expect(held.structured).toMatchObject({ ok: false, code: "fair_use_retry_later", retryable: true, retryAfterSeconds: 30, outcome: "not_written" });
expect(held.structured.earlierAttempt).toBeUndefined();
expect(held.text).toContain("Nothing was created by this call.");
expect(held.text).toContain(`Retry after 30 seconds with the same qitemId ${held.structured.qitemId}.`);
if (args.qitemId) expect(held.structured.qitemId).toBe(args.qitemId);
}
expect(rows()).toBe(before);
expect(repo.getById("qitem-new")).toBeFalsy();
const replay = await ask("rig_queue_create", landed);
expect(replay.isError).toBe(false);
expect(replay.structured.qitemId).toBe(landed.qitemId);
expect((await ask("rig_queue_claim", { qitemId: theirs })).isError).toBe(false);
expect((await ask("rig_queue_update", { qitemId: theirs, state: "done", closureReason: "no-follow-on", resolution: "Done" })).isError).toBe(false);
expect(repo.getById(theirs)).toMatchObject({ state: "done", resolution: "Done" });
expect((await ask("rig_queue_show", { qitemId: landed.qitemId })).isError).toBe(false);
expect((await ask("rig_queue_list", {})).isError).toBe(false);
expect(rows()).toBe(before);
// Once the service lifts it, the same retry lands.
answer = () => [200, active({ objects: ALL })];
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "New work", qitemId: "qitem-new" })).isError).toBe(false);
expect(rows()).toBe(before + 1);
});
it.each([
["a string", "yes"],
["a list", [true]],
["new_work as text", { new_work: "true", retry_after_seconds: 30 }],
["a delay as text", { new_work: true, retry_after_seconds: "30" }],
["no delay", { new_work: true }],
])("ignores a throttle it can't read (%s): nothing is refused, and the log says so", async (_label, throttle) => {
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
answer = throttled(throttle);
const { url } = await listen();
const ask = await connect(url);
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Served" })).isError).toBe(false);
expect(rows()).toBe(1);
expect(logged.mock.calls.some(([line]) => String(line).includes("answered an unusable throttle, so it's ignored"))).toBe(true);
logged.mockRestore();
});
it("serves everything when new_work is false, and keeps a delay within 1 to 600 seconds, rounded up", async () => {
answer = throttled({ new_work: false, retry_after_seconds: 30 });
const { url } = await listen();
const ask = await connect(url);
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Served" })).isError).toBe(false);
for (const [given, kept] of [[0, 1], [-5, 1], [12.2, 13], [9000, 600]]) {
answer = throttled({ new_work: true, retry_after_seconds: given });
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Held" })).structured.retryAfterSeconds).toBe(kept);
}
expect(rows()).toBe(1);
});
it.each([
["answers an error", () => new Response(JSON.stringify({ error: "boom" }), { status: 500, headers: { "Content-Type": "application/json" } })],
["answers a body it can't parse", () => new Response("{", { status: 200, headers: { "Content-Type": "application/json" } })],
])("writes nothing, and says an earlier attempt is unknown, when the check of whether the id landed %s", async (_label, failure) => {
answer = throttled({ new_work: true, retry_after_seconds: 5 });
const { url } = await listen();
const ask = await connect(url);
broken = (req) => req.method === "GET" && new URL(req.url).pathname === "/api/queue/qitem-unread" ? failure() : undefined;
const held = await ask("rig_queue_create", { destination: "lead@team-a", body: "Maybe", qitemId: "qitem-unread" });
broken = undefined;
expect(held.structured).toMatchObject({ code: "fair_use_retry_later", outcome: "not_written", earlierAttempt: "unknown", qitemId: "qitem-unread" });
expect(held.text).toContain("Whether an earlier attempt with this id was saved is unknown");
expect(rows()).toBe(0);
});
});
it("a task parked on an internal child reads blocked, with no child id, owner or state anywhere", async () => {
const { asExternal, asLead, mine } = await externalAndLead();
const child = await create("lead@team-a", "qa@team-b", "SECRET child");
@@ -50,8 +50,6 @@ describe("agent network increment A: OAuth-connected assistants work with each o
let serviceUrl: string;
/** What each connection's grant names, by key; a key set to "revoked" is answered revoked. */
let grants: Map<string, Record<string, string[]> | "revoked">;
/** The keys whose active answer carries the grant service's fair-use throttle on new work. */
let throttled: Set<string>;
const closers: Array<() => Promise<void>> = [];
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
@@ -67,7 +65,6 @@ describe("agent network increment A: OAuth-connected assistants work with each o
daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
setSelfHostId(INSTANCE);
grants = new Map([[A.key, NETWORK], [B.key, NETWORK]]);
throttled = new Set();
service = http.createServer((req, res) => {
req.resume();
req.on("end", () => {
@@ -75,8 +72,7 @@ describe("agent network increment A: OAuth-connected assistants work with each o
const who = key === A.key ? A : key === B.key ? B : null;
const objects = who ? grants.get(who.key) : undefined;
const body = !who || !objects ? { schema: SCHEMA, status: "unknown" }
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" }
: { ...active(who, objects), ...(throttled.has(who.key) ? { throttle: { new_work: true, retry_after_seconds: 20 } } : {}) };
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" } : active(who, objects);
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body));
});
});
@@ -117,7 +113,7 @@ describe("agent network increment A: OAuth-connected assistants work with each o
const result = await client.callTool({ name, arguments: args });
return { isError: result.isError === true, structured: result.structuredContent as Record<string, any>, text: (result.content as Array<{ text: string }>)[0]?.text ?? "" };
};
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort(), instructions: () => client.getInstructions() });
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort() });
}
const rows = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items").get() as { n: number }).n;
@@ -178,42 +174,6 @@ describe("agent network increment A: OAuth-connected assistants work with each o
expect((await a("rig_queue_show", { qitemId: reply })).text).toContain("Agreed; I'll review the fix.");
});
it("under a fair-use throttle, B answers A's message on the same item and A reads that answer through its own grant; B's separate new message waits", async () => {
const served = await listen();
const a = await connect(served.url, A);
const b = await connect(served.url, B);
expect(b.instructions()).toContain("with your reply in resolution when it needs one; its sender reads that reply on the same item");
expect(b.instructions()).not.toContain("with a new item to its sender");
const message = createdId(await a("rig_queue_create", { destination: B.seat, summary: "Which fix first?", body: "Smaller fix or the full one?", tags: ["message"] }));
throttled.add(B.key);
const before = rows();
expect((await b("rig_queue_list", { owned: true })).text).toContain(message);
expect((await b("rig_queue_claim", { qitemId: message })).isError).toBe(false);
expect((await b("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "The smaller fix first." })).isError).toBe(false);
const separate = await b("rig_queue_create", { destination: A.seat, summary: "Also", body: "One more thing.", tags: ["message"] });
expect(separate).toMatchObject({ isError: true, structured: { code: "fair_use_retry_later", retryAfterSeconds: 20, outcome: "not_written" } });
expect(rows()).toBe(before);
// A, the sender, isn't throttled, and reads B's answer on its own item through its own grant's ordinary show and list.
const answered = await a("rig_queue_show", { qitemId: message });
expect(answered.structured.item).toMatchObject({ state: "done", resolution: "The smaller fix first.", destinationSession: B.seat });
expect((await a("rig_queue_list", { mine: true, all: true })).text).toContain("The smaller fix first.");
});
it("under a fair-use throttle, A answers the lead's message on the same item and the lead reads it on its ordinary read; A's new item to the lead waits", async () => {
const served = await listen();
const a = await connect(served.url, A);
const message = (await repo.create({ sourceSession: LEAD, destinationSession: A.seat, body: "Can you take the review?", nudge: false } as never)).qitemId;
throttled.add(A.key);
const before = rows();
expect((await a("rig_queue_claim", { qitemId: message })).isError).toBe(false);
expect((await a("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "Yes, after lunch." })).isError).toBe(false);
expect((await a("rig_queue_create", { destination: LEAD, body: "Starting the review." })).structured).toMatchObject({ code: "fair_use_retry_later", outcome: "not_written" });
expect(rows()).toBe(before);
const read = await fetch(`${daemonUrl}/api/queue/${encodeURIComponent(message)}`, { headers: { "X-OpenRig-Session": LEAD } });
expect(read.status).toBe(200);
expect(await read.json()).toMatchObject({ sourceSession: LEAD, state: "done", resolution: "Yes, after lunch." });
});
it("B hands work on to the team and reads the team's result; A's task and B's task are separate items, each with its own result", async () => {
const served = await listen();
const b = await connect(served.url, B);
@@ -1,40 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultWindowDeps } from "../src/terminal-window.js";
const calls = vi.hoisted(() => [] as Array<{ file: string; args: string[] }>);
vi.mock("node:child_process", async importOriginal => {
const actual = await importOriginal<typeof import("node:child_process")>();
const { promisify } = await import("node:util");
const execFile = Object.assign(vi.fn(), {
[promisify.custom]: async (file: string, args: string[]) => {
calls.push({ file, args });
return { stdout: "fixture output", stderr: "" };
},
});
return { ...actual, execFile };
});
afterEach(() => { calls.length = 0; });
describe.skipIf(process.platform === "win32")("default window execution with a resolved tmux", () => {
it("raises the creating child's limit and preserves the absolute executable and literal argv", async () => {
const binary = "/fixture binaries/tmux";
const args = ["-S", "/socket with spaces", "new-session", "-d", "-s", "viewer ' $HOME ;"];
await expect(defaultWindowDeps().exec(binary, args)).resolves.toBe("fixture output");
expect(calls).toHaveLength(1);
expect(calls[0]!.file).toBe("/bin/sh");
expect(calls[0]!.args[1]).toContain("ulimit -S -n");
expect(calls[0]!.args.slice(2)).toEqual([binary, ...args]);
});
it("keeps a non-creating capture on that executable direct", async () => {
const args = ["capture-pane", "-p", "-t", "%1"];
await defaultWindowDeps().exec("/fixture binaries/tmux", args);
expect(calls).toEqual([{ file: "/fixture binaries/tmux", args }]);
});
it("does not wrap another executable whose data names a tmux startup verb", async () => {
const args = ["new-session"];
await defaultWindowDeps().exec("/fixture binaries/other", args);
expect(calls).toEqual([{ file: "/fixture binaries/other", args }]);
});
});
+2 -6
View File
@@ -14,13 +14,9 @@ describe("tmux attachment", () => {
const child = new EventEmitter();
vi.mocked(spawn).mockReturnValue(child as ReturnType<typeof spawn>);
const result = attachTmux({ session: "-seat ' quoted", serverArgs: ["-S", "/private/instance socket"], readOnly });
const [file, args, options] = vi.mocked(spawn).mock.calls[0]!;
expect(file).toBe(process.platform === "win32" ? "tmux" : "/bin/sh");
expect(process.platform === "win32" ? args : args!.slice(2)).toEqual([
...(process.platform === "win32" ? [] : ["tmux"]),
expect(spawn).toHaveBeenCalledWith("tmux", [
"-S", "/private/instance socket", "attach-session", ...(readOnly ? ["-r"] : []), "-t", "=-seat ' quoted",
]);
expect(options!.stdio).toBe("inherit");
], expect.objectContaining({ stdio: "inherit" }));
const env = vi.mocked(spawn).mock.calls[0]![2]!.env!;
expect(env.TMUX).toBeUndefined();
expect(env.TMUX_PANE).toBeUndefined();
@@ -148,10 +148,9 @@ unseen — which makes messages the one delivery channel that never gets skipped
A seat's typing guard and `--wait-for-idle` retain their checks. Managed compaction follows the
ordinary draft default and explicit operator protections; it separately reconciles its own
uncertain `/compact` write before sending restore input. Draft detection isn't complete.
**Each send records one `transport.delivery_evidence` event**, best effort: what was admitted, what
reached the input, what the screen check showed, and for which occupant. Read it as what was known
about the delivery, not proof the agent read it; where the input landed can read unknown, and a
missing record doesn't show there was no send.
**Each send records one `transport.delivery_evidence` event**: what was admitted, what reached
the input, what the screen check showed, and for which occupant. Read it as what was known about
the delivery, not proof the agent read it; where the input landed can read unknown.
- **`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.
@@ -686,7 +685,7 @@ not have to tear one down to change it, and you rarely have to start from nothin
someone edited it.
**Since 0.7.12 it stops only a tmux session that carries the seat's own `OPENRIG_NODE_ID`**, on the
server and session it read. A terminal OpenRig didn't create, a mismarked or replaced one, or a seat
joined with `rig attach --self` from a tmux pane is left running, and `rig down` exits 2 naming it and the manual way
joined with `rig attach --self` is left running, and `rig down` exits 2 naming it and the manual way
to stop it. Teardown is incomplete for those terminals: if one is yours, look at it and stop it
yourself, then run `rig down` again.
- **`rig launch <rig> [seat]`** — one seat is down; start just that one, without disturbing the
@@ -792,9 +791,6 @@ version, from checking the environment to a useful report to the OpenRig team.
`Daemon starting: launch reserved by pid N since T; not listening yet` in `rig daemon status`, not stopped.
A start reservation left from an earlier boot is archived by the next start; don't clear it by
hand.
- **`rig config set daemon.seat_poll_interval_ms <1000-2000>`** slows how often the daemon reads each seat's
activity (default 1000), trading activity freshness for daemon CPU on a large fleet. It's read at daemon start,
so restart the daemon to apply it. Near 2000, a slow tmux read can let a working Codex seat briefly read idle.
- **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
@@ -1275,11 +1271,6 @@ reaching them is ordinary work rather than an escalation.
a person can decide directly in the TUI's pairing settings. A relay connector that can't accept
incoming connections can pull instead (`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push
stays the default, and a person's connector binding can be unset.
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
--binding "relay:<connectorRef>:file:<absolute env file>:primary"`), and the bridge, with its reach to
any messaging platform, is yours (`docs/reference/gateway-relay.md`).
- **`rig gateway channel add <id> --display-name <name> --binding
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
`<id>@external` (Slack only). Anyone writing there reaches the team, registered or not. Each row
@@ -1449,12 +1440,6 @@ your circumstances is configuration, and the ones that are not, another agent ca
- **Move to an existing account home** with `rig provider switch --seat <seat> --to-account <name>
--account-home <dir> --reason "<why>"` (preview with `--dry-run`). It starts a fresh conversation with a handover
and retains the seat's address and work. `--clear-account-home --reason "<why>"` returns to configured defaults.
- **A Pi seat switches accounts** with `rig provider switch --seat <pi seat> --to-account <name> --account-home <dir>
--reason "<why>"` (`--dry-run` shows the plan), through a confirmed successor that keeps its model, effort and
wrapper. A Pi seat that declares an absolute `PI_CODING_AGENT_DIR` in its `env:` uses that folder as its account
home on launch, restart, resume and restore, and needs a readable `auth.json` there, or the launch stops; remove
the declaration to keep the managed seat login. A seat that has its own Pi login as well stops at launch even with
an `auth.json` there, and the error says why (for example, that the existing login isn't a managed account link).
- **Claude subscription/API-key switching resumes the same conversation and interrupts work:**
`rig provider switch --seat <seat> --to-account api --api-key-file <abs>`; return with
`--to-account plan --token-file <abs>`. The API-key path remains unqualified; verify the resulting
@@ -1596,9 +1581,6 @@ your circumstances is configuration, and the ones that are not, another agent ca
selected plugin's skills there (into `.claude/skills/` or `.agents/skills/` only for a seat
without that storage), so don't copy them by hand; read the `plugin_skill_*` warnings if one
is missing. TDD is no longer a default skill.
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry refreshes that skill from its source when
a Claude or Codex seat is restored, keeping its conversation; `keep_saved`, the default, keeps the saved copy, but a
skill whose source changed after `rig down` comes back missing, named in the restore's warnings.
- **`rig startup-proof submit`** — answer an explicitly selected authenticated startup challenge.
Startup adds no proof exercise by default. Declare `startup_proof` with `authenticated` or
`none` in the applicable startup layers; see `docs/reference/rig-spec.md#startup-block/startup-proof-selection`.
-1
View File
@@ -5,7 +5,6 @@
"license": "Apache-2.0",
"type": "module",
"exports": {
"./tmux-server-start": { "types": "./dist/tmux-server-start-surface.d.ts", "import": "./dist/tmux-server-start-surface.js" },
"./bundle-behaviour": { "types": "./dist/bundle-behaviour-surface.d.ts", "import": "./dist/bundle-behaviour-surface.js" },
"./router-line": { "types": "./dist/router-line-surface.d.ts", "import": "./dist/router-line-surface.js" },
"./spec-validation": {
@@ -16,7 +16,7 @@ import type {
} from "../domain/runtime-adapter.js";
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
import { assessNativeResumeProbe, claudeTrustPromptFocus, hasClaudeDrawnInput } from "../domain/native-resume-probe.js";
import { assessNativeResumeProbe, hasClaudeDrawnInput } 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, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
@@ -709,10 +709,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const checkEvery = 5;
let attempts = windowAttempts;
let slowStart = false;
// #2820 item 2: the trust prompt's selection was moved from "No, exit" (once: its two options wrap), and the prompt
// was confirmed (once: a frame drawn before Claude took the Enter still shows the prompt).
let trustMoved = false;
let trustEntered = false;
for (let attempt = 0; attempt < attempts; attempt++) {
const paneCommand = await this.tmux.getPaneCommand(tmuxSession);
@@ -753,25 +749,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
slowStart = false;
break;
}
// #2820 item 2: Enter confirms the focused option, and Claude Code 2.1.322 opens this prompt on "No, exit". So
// Enter goes once, and only with the focus read on "Yes, I trust this folder" of the plain prompt's two options.
// On "No, exit" the selection moves down once (the two options wrap: a second Down would go back) and the next
// polls wait to see the focus on "Yes". A screen with no key to send (another option set, no cursor, a focus
// that never reaches "Yes") gets no confirmation; in the longer wait it ends at once, as without the auto-drive.
const focus = claudeTrustPromptFocus(paneContent);
const key = focus === "yes" && !trustEntered ? "Enter" : focus === "no" && !trustMoved ? "Down" : null;
if (!key) {
if (slowStart && !trustMoved && !trustEntered) {
slowStart = false;
break;
}
await this.sleep(200);
continue;
}
if (key === "Enter") trustEntered = true; else trustMoved = true;
const keyResult = await this.tmux.sendKeys(tmuxSession, [key]);
if (!keyResult.ok) {
return { ok: false, error: `Claude trust prompt auto-drive failed: ${keyResult.message}`,
const enterResult = await this.tmux.sendKeys(tmuxSession, ["Enter"]);
if (!enterResult.ok) {
return { ok: false, error: `Claude trust prompt auto-drive failed: ${enterResult.message}`,
probe: await resumeProbeEvidence(probe, paneContent, this.tmux, tmuxSession) };
}
await this.sleep(200);
@@ -668,7 +668,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
return;
}
const command = `node "${relay}"`;
const keySource = codexHookSourcePath(hooksPath);
const keySource = canonicalCodexHookSource(hooksPath);
const stripped = upsertCodexFeaturesHooksEnabled(stripCodexActivityHooks(existing, command));
let before: unknown = null;
let expected: { hooks?: { state?: CodexHookState } } | null = null;
@@ -678,7 +678,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
return;
}
if (!expected) return;
const inlineSource = codexHookSourcePath(configPath);
const inlineSource = canonicalCodexHookSource(configPath);
let config = stripped;
for (const event of OPENRIG_ACTIVITY_HOOK_EVENTS) {
const groups = document.hooks[event] ?? [];
@@ -714,13 +714,22 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
/**
* OPR.0.4.3.33 — compute + splice Codex's `[hooks.state."<key>"] trusted_hash` for our 4
* authored activity hooks into `content`. Codex 0.167.2 resolves the source folder but
* keeps the file name, including a linked file, as returned by app-server hooks/list. The command
* authored activity hooks into `content`. `key_source` is the canonicalized config path
* (Codex keys trust by `std::fs::canonicalize(config.toml).display()`); we best-effort
* `realpathSync` the config path to match — on a miss (file not yet on real disk, or a mock
* fs in tests) we fall back to the plain absolute path, and any resulting key mismatch just
* degrades to the launch-time trust gate (Layer-2 floor), never a broken run. The command
* string is the value Codex deserializes from our TOML literal `'node "<relay>"'` — i.e.
* `node "<relay>"` WITHOUT the outer TOML quote delimiters. Timeout=5, matcher/status None.
*/
private applyCodexActivityHookTrust(content: string, configPath: string, relay: string): string {
const keySource = codexHookSourcePath(configPath);
let keySource = configPath;
try {
keySource = fs.realpathSync.native(configPath);
} catch {
// config.toml not on the real filesystem (first write / unit-test mock fs) — the plain
// absolute path is the honest best guess; a canonicalization delta is fail-safe (gate reappears).
}
const command = `node "${relay}"`;
// upsertCodexActivityHooks has just written our marked block. Codex keys by the
// group's position among this event's groups, not its position inside our block.
@@ -763,7 +772,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
try {
const original = this.fs.readFile(hooksPath);
const document = parseCodexHooksJson(original);
const keySource = codexHookSourcePath(hooksPath);
const keySource = canonicalCodexHookSource(hooksPath);
let config = updated ?? "";
const expected = parseToml(config) as { hooks?: { state?: CodexHookState } };
for (const event of OPENRIG_ACTIVITY_HOOK_EVENTS) {
@@ -2442,10 +2451,9 @@ function tomlSections(text: string, current?: string): TomlSection[] {
return sections;
}
function codexHookSourcePath(file: string): string {
// Resolve a linked home folder, but retain a linked config.toml/hooks.json file's own name.
try { return nodePath.join(fs.realpathSync.native(nodePath.dirname(file)), nodePath.basename(file)); }
catch { return file; } // The folder may not exist yet, including with an injected filesystem.
function canonicalCodexHookSource(path: string): string {
// Codex keys hook trust by the canonical source path; a file not on the real filesystem keeps its plain path.
try { return fs.realpathSync.native(path); } catch { return path; }
}
/** A hooks.json document as Codex reads it: `hooks` maps each event to matcher groups, each with a `hooks` list. */
+8 -38
View File
@@ -3,7 +3,6 @@ import { accessSync, constants, statSync } from "node:fs";
import { isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import type { ArgvExecFn, ExecFn } from "./tmux.js";
import { mayStartTmuxServer, reportTmuxServerLimit, TMUX_SERVER_LIMIT_PREFIX, tmuxServerStartCommand } from "../tmux-server-start.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -94,31 +93,19 @@ function withExecOutput(err: unknown): unknown {
*/
export const execCommand: ExecFn = async (cmd: string): Promise<string> => {
try {
const { stdout, stderr } = await execAsync(tmuxStartShell(cmd), { env: /^tmux(?:\s|$)/.test(cmd) ? tmuxClientEnvironment() : process.env });
reportTmuxServerLimit(stderr);
const { stdout } = await execAsync(cmd, { env: /^tmux(?:\s|$)/.test(cmd) ? tmuxClientEnvironment() : process.env });
return stdout;
} catch (err) {
reportTmuxServerLimit((err as { stderr?: unknown }).stderr);
throw withExecOutput(err);
}
};
function tmuxStartShell(cmd: string, replaceShell = false): string {
const creating = process.platform !== "win32" && /^tmux\s/.test(cmd)
&& /(?:^|\s)(?:new-session|start-server|attach-session|-D)(?:\s|$)/.test(cmd.replaceAll("'", ""))
? TMUX_SERVER_LIMIT_PREFIX : "";
return creating + (replaceShell && /^tmux\s/.test(cmd) ? `exec ${cmd}` : cmd);
}
/** Replace the shell only for tmux clients; other adapter commands keep shell builtins such as kill. */
export const execTmuxCommand: ExecFn = async (cmd: string): Promise<string> => {
const started = performance.now();
try {
const result = await execFileAsync("/bin/sh", ["-c", tmuxStartShell(cmd, true)], { ...tmuxExecOptions, env: tmuxClientEnvironment() });
reportTmuxServerLimit(result.stderr);
return result.stdout;
return (await execFileAsync("/bin/sh", ["-c", /^tmux\s/.test(cmd) ? `exec ${cmd}` : cmd], { ...tmuxExecOptions, env: tmuxClientEnvironment() })).stdout;
} catch (err) {
reportTmuxServerLimit((err as { stderr?: unknown }).stderr);
// Tokens are diagnostic labels only; the command is executed by the shell without rewriting its arguments.
const labels = (cmd.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map(word => word.replace(/^(['"])(.*)\1$/, "$2"));
warnTmuxDeadline(err, started, labels);
@@ -128,10 +115,10 @@ export const execTmuxCommand: ExecFn = async (cmd: string): Promise<string> => {
/**
* Production ArgvExecFn for TmuxAdapter.
* Ordinary commands run directly via execFile. Server-creating POSIX commands
* use a child shell to raise only their inherited soft file limit, then exec the
* unchanged argv. Other POSIX commands use a resolved path to avoid libuv's
* repeated PATH spawns on the event loop. Windows keeps the psmux alias on PATH.
* Runs argv[0] directly via execFile: no shell, no quoting layer. On POSIX
* argv[0] is started by its resolved path: with a bare name, libuv tries each
* PATH entry with a spawn on the event loop. psmux ships a `tmux` alias on
* PATH, so Windows passes the bare name to the spawn.
*/
export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<string> => {
const [bin, ...args] = argv;
@@ -140,14 +127,7 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
// Nothing ran when no file was found: a shell also finds tmux through an entry the lookup skips, such as a literal
// `~/bin`, and reports a missing one as the shell transport does.
const opts = { ...tmuxExecOptions, env: tmuxClientEnvironment() };
const run = async (file: string | null) => {
const command = tmuxServerStartCommand(mayStartTmuxServer(args) ? bin : file ?? bin, args);
const result = command.file !== "/bin/sh" && !file
? await execFileAsync("/bin/sh", ["-c", 'exec "$0" "$@"', bin, ...args], opts)
: await execFileAsync(command.file, command.args, opts);
reportTmuxServerLimit(result.stderr);
return result;
};
const run = (file: string | null) => file ? execFileAsync(file, args, opts) : execFileAsync("/bin/sh", ["-c", 'exec "$0" "$@"', bin, ...args], opts);
try {
if (process.platform === "win32") return (await execFileAsync(bin, args, opts)).stdout;
const file = resolvedOnPath(bin);
@@ -161,7 +141,6 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
return (await run(null)).stdout;
}
} catch (err) {
reportTmuxServerLimit((err as { stderr?: unknown }).stderr);
warnTmuxDeadline(err, started, argv);
throw withExecOutput(err);
}
@@ -175,16 +154,7 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
export async function spawnTmuxClient(args: string[]): Promise<ChildProcessWithoutNullStreams> {
const opts = { stdio: "pipe" as const, windowsHide: true, env: tmuxClientEnvironment() };
const started = (file: string | null) => new Promise<ChildProcessWithoutNullStreams>((resolve, reject) => {
const command = tmuxServerStartCommand(mayStartTmuxServer(args) ? "tmux" : file ?? "tmux", args);
const child = command.file !== "/bin/sh" && !file
? spawn("/bin/sh", ["-c", 'exec "$0" "$@"', "tmux", ...args], opts) : spawn(command.file, command.args, opts);
if (mayStartTmuxServer(args)) {
let notice = "";
child.stderr.on("data", data => {
notice = (notice + String(data)).slice(-512);
reportTmuxServerLimit(notice);
});
}
const child = file ? spawn(file, args, opts) : spawn("/bin/sh", ["-c", 'exec "$0" "$@"', "tmux", ...args], opts);
child.once("error", reject);
child.once("spawn", () => {
child.off("error", reject);
+1 -2
View File
@@ -10,7 +10,6 @@ import { parentSessionEnvKeys, removeParentSessionEnv } from "../domain/parent-s
import { addSeatRuntimeDir, seatRuntimeDir } from "../domain/seat-runtime-dir.js";
import type { TmuxControlExec } from "./tmux-control.js";
import { tmuxOnDaemonServer } from "./tmux-server.js";
import { TMUX_SERVER_LIMIT_PREFIX } from "../tmux-server-start.js";
export type ExecFn = (cmd: string) => Promise<string>;
@@ -733,7 +732,7 @@ export class TmuxAdapter {
this.scopeSkipLogged = true;
return fallback(unavailable);
}
const server = TMUX_SERVER_LIMIT_PREFIX + posixJoinArgv(["tmux", ...this.serverArgs, "-D"]);
const server = posixJoinArgv(["tmux", ...this.serverArgs, "-D"]);
// systemd-run 254+ expands ${VAR} in the command's arguments (by default from 258, also for --scope): the
// socket path must reach /bin/sh exactly as given, so expansion is turned off where the option exists.
const scoped = (oomPolicy: boolean, literal: boolean) => posixJoinArgv(["systemd-run", "--user", "--scope",
@@ -1,33 +0,0 @@
/** The program's own model picker (`/model`), open now at the bottom of the pane. A positive reading establishes a
* dialog for the native send guard, never permission to answer or cancel it: an Enter there confirms the highlighted
* model. A later composer, quoted or fenced text, a partial menu or an unknown footer is not this surface. */
const CLAUDE_MODEL_FOOTERS = ["Enter to set as default · s to use this session only · Esc to cancel"];
const CODEX_MODEL_FOOTERS = ["enter select · esc back", "enter default · s session · esc back"];
/** The choice rows between a picker's instruction and its footer hold exactly one highlighted, numbered row. */
function oneHighlightedChoice(body: string[], pointer: string): boolean {
const choice = new RegExp(`^(?:[↑↓]\\s*)?(?:${pointer}\\s*)?\\d+\\.\\s+\\S`);
const highlighted = new RegExp(`^(?:[↑↓]\\s*)?${pointer}\\s*\\d+\\.\\s+\\S`);
return body.some(line => choice.test(line)) && body.filter(line => highlighted.test(line)).length === 1;
}
/** Claude Code's current model picker (as drawn by 2.1.282 and 2.1.322). */
export function currentClaudeModelMenu(pane: string): string | null {
const lines = pane.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
// OpenRig-launched Claude may draw its named seat rule below the menu footer.
if (/^─{3,} [\w.-]+@[\w.-]+ ─+$/.test(lines.at(-1) ?? "")) lines.pop();
if (!CLAUDE_MODEL_FOOTERS.includes(lines.at(-1) ?? "")) return null;
const header = lines.lastIndexOf("Select model");
if (header < 1 || !/^─{3,}$/.test(lines[header - 1]!) || !lines[header + 1]?.startsWith("Switch between Claude models.")) return null;
return oneHighlightedChoice(lines.slice(header + 2, -1), "❯") ? lines[header]! : null;
}
/** Codex's current model picker and the reasoning-level step it leads to (as drawn by 0.167.2). */
export function currentCodexModelPicker(pane: string): string | null {
const lines = pane.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
if (!CODEX_MODEL_FOOTERS.includes(lines.at(-1) ?? "")) return null;
const header = lines.map(line => line === "Select Model and Effort" || /^Select Reasoning Level for \S.*$/.test(line)).lastIndexOf(true);
if (header < 0) return null;
return oneHighlightedChoice(lines.slice(header + 1, -1), "›") ? lines[header]! : null;
}
@@ -2,8 +2,6 @@ import { currentClaudeMcpMenu } from "./claude-mcp-menu.js";
import { shellQuote } from "../adapters/shell-quote.js";
import { claudePostureFlag, codexPostureArg } from "../adapters/yolo-mode.js";
import { claudeMcpArgs, type ClaudeMcp } from "./claude-mcp.js";
import { codexMemberEnvelope, type CodexMemberPermissions } from "./codex-member-permissions.js";
import { codexEnvelopeArgs } from "./codex-rebuild-permissions.js";
// L3 adds `attention_required` for the Claude resume-selection prompt proxy.
// Distinct from `inconclusive` (we don't know yet) and `failed` (terminal
@@ -54,8 +52,6 @@ export function buildNativeResumeCommand(
/** Codex: the seat's recorded daemon choice; only a positive true adds --no-daemon (absent and false print as before). */
codexDaemonOptOut?: boolean,
claudeMcp?: ClaudeMcp | null,
/** Codex: the seat's own saved permission choice; it replaces the posture decision, as on the seat's managed resume. */
codexPermissions?: CodexMemberPermissions,
): string | null {
if (!resumeToken) return null;
if (runtime === "claude-code") {
@@ -64,19 +60,11 @@ export function buildNativeResumeCommand(
return `claude ${claudePostureFlag(process.env)} --resume ${shellQuote(resumeToken)}${nameSuffix}${mcpSuffix}`;
}
if (runtime === "codex") {
return buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined,
codexSeatPostureArg(codexConfigProfile, codexPermissions), codexDaemonOptOut === true);
return buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, undefined, codexDaemonOptOut === true);
}
return null;
}
/** A Codex seat's own permission choice as the managed resume emits it: the profile, then the choice's flags, in place
* of the posture decision. Undefined without a choice, so the posture decision stands. */
export function codexSeatPostureArg(codexConfigProfile: string | null | undefined, permissions: CodexMemberPermissions | undefined): string | undefined {
if (!permissions) return undefined;
return `${codexConfigProfile ? ` -p ${shellQuote(codexConfigProfile)}` : ""}${codexEnvelopeArgs(codexMemberEnvelope(permissions))}`;
}
/** Managed Codex seats run tool commands in a non-login shell, so they keep the seat's own environment, including the
* OpenRig command folder at the front of PATH. Codex otherwise gives each command the user's login-shell environment
* and puts back only its own directories, so a login file that sets PATH outright drops that folder. */
@@ -148,24 +136,6 @@ export function assessNativeResumeProbe(
detail: "Claude is at a resume-selection prompt; an operator must choose the conversation to continue.",
};
}
// #2820 item 2: the gated-grants backstop is never answered by OpenRig (not even with
// recovery.auto_drive_provider_prompts): its default choice declines the folder's pre-approved tool permissions or
// exits, and approving them is the person's call. Checked before the plain trust prompt, whose texts it shares.
const gatedGrants = claudeGatedGrantsPrompt(paneContent);
if (gatedGrants === "live") {
return {
status: "attention_required",
code: "claude_gated_grants_gate",
detail: `Claude is at its workspace trust check that warns "This folder pre-approves N tool permissions" (from the folder's .claude/settings.json)${focusedChoice(paneContent)}. Review and choose in the seat's pane. OpenRig does not approve or bypass this review. No input was sent.`,
};
}
if (gatedGrants === "partial") {
return {
status: "inconclusive",
code: "awaiting_runtime",
detail: "Claude is still drawing its workspace trust check for the folder's pre-approved tool permissions.",
};
}
if (looksLikeClaudeTrustPrompt(paneContent)) {
return {
status: "inconclusive",
@@ -373,112 +343,9 @@ function looksLikeClaudeBypassConsentPrompt(paneContent: string): boolean {
return /\b(?:in|running in) Bypass Permissions mode\./.test(lines.slice(0, -3).join(" ").replace(/\s+/g, " "));
}
// Claude Code's "gated grants" backstop (2.1.319+, #2820 item 2). Its marks, which the plain workspace trust prompt
// lacks: the warning that the folder's .claude/settings.json pre-approves tool permissions, and the option that
// declines them. Its default choice declines them ("No, continue without these permissions") or exits ("No, exit").
// The marks are read on the whitespace-joined screen too, so a row that a narrow pane wraps still counts.
const CLAUDE_GATED_GRANTS_MARK = /This folder pre-approves \d+ tool permissions?\b|No, continue without these permissions/;
// One row of the backstop's marks, or of a part of one that a narrow pane wrapped.
const CLAUDE_GATED_GRANTS_MARK_ROW = /This folder pre-approves\b|No, continue without\b|without these permissions\b/;
const CLAUDE_TRUST_FOOTER = "Enter to confirm · Esc to cancel";
const CLAUDE_TRUST_YES = "Yes, I trust this folder";
const CLAUDE_TRUST_EXIT = "No, exit";
// An option row of Claude's select: an optional cursor (❯ or ›), an optional "1." and a "Yes, …" or "No, …" label.
const CLAUDE_TRUST_OPTION_ROW = /^\s*([❯›]\s*)?(?:\d+\.\s+)?((?:Yes|No), .*?)\s*$/;
/** The gated-grants backstop on Claude's current screen (claudeCurrentRows): "live" when it is complete ("Enter to
* confirm · Esc to cancel" is the last drawn row, with "Yes, I trust this folder" above it); "partial" while it is
* still being drawn; null when the current screen has none. A backstop left in the scrollback above a session that
* started is not current. The "Accessing workspace:" heading is not required: a long permission list can push it
* out of the capture. */
function claudeGatedGrantsPrompt(paneContent: string): "live" | "partial" | null {
const rows = claudeCurrentRows(paneContent);
if (!hasClaudeGatedGrantsMark(rows)) return null;
const mark = rows.map(row => CLAUDE_GATED_GRANTS_MARK_ROW.test(row)).lastIndexOf(true);
const drawn = rows.slice(Math.max(mark, 0)).filter(row => row.trim()).map(row => row.trim());
if (drawn.at(-1) !== CLAUDE_TRUST_FOOTER) return "partial";
return drawn.some(row => CLAUDE_TRUST_OPTION_ROW.exec(row)?.[2] === CLAUDE_TRUST_YES) ? "live" : "partial";
}
/** A mark of the gated-grants backstop in these rows: on their whitespace-joined text, so a row that a narrow pane
* wrapped still counts, or on a single row, so a warning still being drawn ("This folder pre-approves 2 tool" before
* the next row) counts too. */
function hasClaudeGatedGrantsMark(rows: string[]): boolean {
return CLAUDE_GATED_GRANTS_MARK.test(rows.join(" ").replace(/\s+/g, " "))
|| rows.some(row => CLAUDE_GATED_GRANTS_MARK_ROW.test(row));
}
/** The rows Claude drew after its last own element (input frame, header or reply): the screen a prompt is on now.
* Text above a session that started (a scrollback, a reply quoting a prompt's words) never decides what is current,
* and neither does a draft in the input box: an input frame ends at its closing rule. */
function claudeCurrentRows(paneContent: string): string[] {
const rows = claudeWithoutDrafts(paneContent.trimEnd().split(/\r?\n/));
let current = rows;
for (let at = rows.length - 1; at >= 0; at--) {
if (!claudeDrawnAfter(rows.slice(at))) continue;
const framed = CLAUDE_INPUT_RULE.test(rows[at]!) && /^\s*❯/.test(rows[at + 1] ?? "");
const closing = framed ? rows.findIndex((row, index) => index > at + 1 && CLAUDE_INPUT_RULE.test(row)) : -1;
current = rows.slice((closing >= 0 ? closing : at) + 1);
break;
}
// An input frame still being drawn (its opening rule and the ❯ row, no closing rule yet) holds a draft, not a dialog.
const opening = current.findIndex((row, index) => CLAUDE_INPUT_RULE.test(row) && /^\s*❯/.test(current[index + 1] ?? ""));
return opening >= 0 ? current.slice(0, opening) : current;
}
/** These rows with the text of every draft in Claude's input frames blanked (the ❯ kept), so a rule, a reply or a
* header pasted into a draft never reads as Claude's own UI. Claude draws the frame's rules from column 0 and a
* draft's continuation rows indented under the ❯, so a frame opens at a column-0 rule over the ❯ row and the first
* column-0 rule after it closes the frame; a frame with no closing rule yet holds a draft to the end. A frame quoted
* in a reply is indented like the reply: it blanks nothing. */
function claudeWithoutDrafts(rows: string[]): string[] {
const shown = [...rows];
for (let at = 0; at < rows.length; at++) {
if (!CLAUDE_FRAME_RULE.test(rows[at]!) || !/^\s*❯/.test(rows[at + 1] ?? "")) continue;
const closing = rows.findIndex((row, index) => index > at + 1 && CLAUDE_FRAME_RULE.test(row));
const end = closing >= 0 ? closing : rows.length;
shown[at + 1] = rows[at + 1]!.replace(/❯.*$/, "❯");
for (let index = at + 2; index < end; index++) shown[index] = "";
at = end - 1;
}
return shown;
}
/** ", with \"<label>\" highlighted" for the detail of the gated-grants gate, or nothing when no option is focused. */
function focusedChoice(paneContent: string): string {
const label = claudeFocusedOption(paneContent);
return label ? `, with "${label}" highlighted` : "";
}
/** The label of the focused option on Claude's current screen (the row with ❯ or ›), if one is drawn. */
function claudeFocusedOption(paneContent: string): string | null {
const focused = claudeCurrentRows(paneContent).map(row => CLAUDE_TRUST_OPTION_ROW.exec(row)).filter(match => match?.[1]);
return focused.at(-1)?.[2] ?? null;
}
/** The focus of Claude's plain workspace trust prompt (#2820 item 2), read from its last 12 drawn rows. An allowlist:
* "yes" or "no" only when the prompt's options are exactly "No, exit" and "Yes, I trust this folder" (in either
* order, numbered or not) and one of them carries the cursor (❯ or ›): "yes" on "Yes, I trust this folder", "no" on
* "No, exit" (Claude Code 2.1.322 opens the plain prompt there). Anything else is "unknown": another option set (the
* gated-grants backstop's "No, continue without these permissions" included), no cursor, or more than one. */
export function claudeTrustPromptFocus(paneContent: string): "yes" | "no" | "unknown" {
const rows = paneContent.trimEnd().split(/\r?\n/).filter(row => row.trim()).slice(-12);
const options = rows.map(row => CLAUDE_TRUST_OPTION_ROW.exec(row)).filter(match => match !== null);
const labels = options.map(match => match[2]);
if (labels.length !== 2 || !labels.includes(CLAUDE_TRUST_YES) || !labels.includes(CLAUDE_TRUST_EXIT)) return "unknown";
const focused = options.filter(match => match[1]);
if (focused.length !== 1) return "unknown";
return focused[0]![2] === CLAUDE_TRUST_YES ? "yes" : "no";
}
function looksLikeClaudeTrustPrompt(paneContent: string): boolean {
// #2820 item 2: read on Claude's current screen only, and a screen with the backstop's marks is never the plain
// trust prompt, even still drawn: pressing Enter on it would decline the folder's grants or exit.
const current = claudeCurrentRows(paneContent);
const text = current.join("\n");
return text.includes("Accessing workspace:")
&& text.includes("Yes, I trust this folder")
&& !hasClaudeGatedGrantsMark(current);
return paneContent.includes("Accessing workspace:")
&& paneContent.includes("Yes, I trust this folder");
}
// Claude's resume-selection prompt appears when `claude --resume` finds multiple
@@ -561,8 +428,6 @@ function claudeDrawnAfter(rows: string[]): boolean {
// On a named session Claude labels the frame's top rule (`─────…── probe-claude@rig ─`), so a rule may carry text after a space.
const CLAUDE_INPUT_RULE = /^\s*─{3,}(?:\s.*)?$/;
// The same rule drawn from column 0, as Claude draws its frame: a rule indented like a draft's row is the draft's.
const CLAUDE_FRAME_RULE = /^─{3,}(?:\s.*)?$/;
/** Claude's input frame: a rule, the ❯ row directly under it, and another rule below. A composer holding a draft that
* starts with "1." looks like a numbered option row; the frame is what tells them apart. */
+10 -55
View File
@@ -24,8 +24,7 @@ import { RigRepository, findLatestUsableSnapshot, findLatestUsableSnapshotsForAl
import { hasCompleteGuidanceBlock, seatGuidanceFile } from "./restore-guidance.js";
import { resolveNodeWorkspace } from "./workspace/workspace-resolver.js";
import { deriveCanonicalSessionName } from "./session-name.js";
import { buildNativeResumeCommand, buildCodexResumeCore, codexSeatPostureArg } from "./native-resume-probe.js";
import { validateCodexMemberPermissions, type CodexMemberPermissions } from "./codex-member-permissions.js";
import { buildNativeResumeCommand, buildCodexResumeCore } from "./native-resume-probe.js";
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
import { readReadyFreshLaunchFences } from "./fresh-occupant-relation.js";
import { daemonTmuxServerArgs, daemonTmuxServerInfo, tmuxAttachCommand } from "../adapters/tmux-server.js";
@@ -115,38 +114,13 @@ interface BindingRow {
// -- Helpers --
function computeResumeCommand(runtime: string | null, resumeToken: string | null, codexConfigProfile?: string | null, codexDaemonOptOut?: boolean, claudeMcp?: ClaudeMcp | null, codexChoice?: CodexSeatChoice): string | null {
return buildNativeResumeCommand(runtime, resumeToken, null, codexConfigProfile, codexDaemonOptOut, claudeMcp,
codexChoice && "permissions" in codexChoice ? codexChoice.permissions : undefined);
function computeResumeCommand(runtime: string | null, resumeToken: string | null, codexConfigProfile?: string | null, codexDaemonOptOut?: boolean, claudeMcp?: ClaudeMcp | null): string | null {
return buildNativeResumeCommand(runtime, resumeToken, null, codexConfigProfile, codexDaemonOptOut, claudeMcp);
}
const CODEX_DAEMON_CHOICE_NOT_RECORDED = "Not recorded whether this seat's last managed launch used --no-daemon. If `codex --help` lists --no-daemon, "
+ "add it right after `codex`, as the managed launch does.";
/** Shown beside a Codex resume command when the seat's saved permission choice couldn't be read: the command still shows. */
const CODEX_PERMISSIONS_NOT_INCLUDED = "This seat's saved Codex permission choice couldn't be read, so the resume command doesn't include it and "
+ "carries the default posture instead. Repair it with rig seat set-permissions <seat> --codex-permissions <file> --reason <reason>.";
/** A Codex seat's saved permission choice: the choice, or unreadable. A seat with none has no entry. */
type CodexSeatChoice = { permissions: CodexMemberPermissions } | { unreadable: true };
/** Every seat's saved Codex permission choice, in one read. A schema without the column (a minimal fixture) has none. */
function readCodexSeatChoices(db: Database.Database): Map<string, CodexSeatChoice> {
const choices = new Map<string, CodexSeatChoice>();
let rows: Array<{ id: string; codex_permissions_json: string }>;
try {
rows = db.prepare("SELECT id, codex_permissions_json FROM nodes WHERE codex_permissions_json IS NOT NULL").all() as typeof rows;
} catch (error) {
if ((error as { code?: string }).code === "SQLITE_ERROR" && /no such column: codex_permissions_json\b/.test((error as Error).message)) return choices;
throw error;
}
for (const row of rows) {
try { choices.set(row.id, { permissions: validateCodexMemberPermissions(JSON.parse(row.codex_permissions_json)) }); }
catch { choices.set(row.id, { unreadable: true }); }
}
return choices;
}
function computeRecoveryGuidance(input: {
runtime: string | null;
resumeToken: string | null;
@@ -156,10 +130,8 @@ function computeRecoveryGuidance(input: {
/** Codex: the current generation's recorded daemon choice; undefined when none was recorded. */
codexDaemonOptOut?: boolean;
claudeMcp?: ClaudeMcp;
/** Codex: the seat's saved permission choice; undefined when it has none. */
codexChoice?: CodexSeatChoice;
}): NodeRecoveryGuidance | null {
const { runtime, resumeToken, cwd, sessionName, codexConfigProfile, codexDaemonOptOut, claudeMcp, codexChoice } = input;
const { runtime, resumeToken, cwd, sessionName, codexConfigProfile, codexDaemonOptOut, claudeMcp } = input;
if (runtime === "claude-code") {
const commands: string[] = [];
@@ -193,20 +165,14 @@ function computeRecoveryGuidance(input: {
const notes: string[] = [];
const daemonOptOut = codexDaemonOptOut === true;
// The seat's own permission choice replaces the posture decision, as on its managed resume.
const seatPosture = codexSeatPostureArg(codexConfigProfile, codexChoice && "permissions" in codexChoice ? codexChoice.permissions : undefined);
const lastCommand = buildCodexResumeCore("", codexConfigProfile, true, undefined, undefined, undefined, seatPosture, daemonOptOut);
if (resumeToken) {
commands.push(buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, seatPosture, daemonOptOut));
commands.push(buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, undefined, daemonOptOut));
}
if (cwd) {
commands.push(`cd ${cwd}`);
}
if (!resumeToken) {
commands.push(lastCommand);
}
if (codexChoice && "unreadable" in codexChoice) {
notes.push(CODEX_PERMISSIONS_NOT_INCLUDED);
commands.push(buildCodexResumeCore("", codexConfigProfile, true, undefined, undefined, undefined, undefined, daemonOptOut));
}
notes.push("Use workspace and recent prompt text to identify the right conversation.");
@@ -220,11 +186,8 @@ function computeRecoveryGuidance(input: {
notes.push(CODEX_DAEMON_CHOICE_NOT_RECORDED);
}
const ownChoice = "it carries this seat's own permission choice, as its managed launch does.";
return {
summary: seatPosture
? (resumeToken ? `Try native Codex resume first; ${ownChoice}` : `No stored Codex resume token. Try ${lastCommand}; ${ownChoice}`)
: resumeToken
summary: resumeToken
? "Try native Codex resume first; the managed launch sets the explicit -s workspace-write floor flag unless a named config profile is present."
: `No stored Codex resume token. Try codex${daemonOptOut ? " --no-daemon" : ""} -s workspace-write resume --last; the managed launch sets the explicit -s workspace-write floor flag unless a named config profile is present.`,
commands,
@@ -649,12 +612,11 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
const restoreOutcomes = buildRestoreOutcomeMap(db, rigId);
const orienteds = buildOrientedMap(db, rigId);
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts();
const codexSeatChoices = readCodexSeatChoices(db);
const rows = queryInventoryRows(db, rigId);
return rows.map((row) =>
buildInventoryEntry(db, row, { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts, codexSeatChoices }),
buildInventoryEntry(db, row, { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts }),
);
}
@@ -769,8 +731,6 @@ interface InventoryBuildContext {
orienteds: Map<string, NodeOriented>;
/** Each node's current recorded Codex daemon choice (one read per projection); absent = not recorded. */
codexDaemonOptOuts: Map<string, boolean>;
/** Each seat's saved Codex permission choice (one read per projection); absent = none. */
codexSeatChoices: Map<string, CodexSeatChoice>;
}
/**
@@ -783,8 +743,7 @@ function buildInventoryEntry(
row: InventoryRow,
ctx: InventoryBuildContext,
): NodeInventoryEntry {
const { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts, codexSeatChoices } = ctx;
const codexChoice = row.runtime === "codex" ? codexSeatChoices.get(row.node_id) : undefined;
const { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts } = ctx;
const launchWrapper = parseStoredLaunchWrapper(row.launch_wrapper, `Seat ${row.node_id} launch_wrapper`);
if (launchWrapper && !isLaunchWrapperRuntime(row.runtime)) {
throw new Error(`Seat ${row.node_id} launch_wrapper: only valid when runtime is "pi", "codex", "claude-code" or a supported community harness`);
@@ -864,8 +823,7 @@ function buildInventoryEntry(
? { tmuxServerArgs: [...daemonTmuxServerArgs()] } : {}),
...(row.binding_attachment_type === "tmux" && row.session_name && daemonTmuxServerInfo()
? { tmuxServerMode: daemonTmuxServerInfo()!.mode } : {}),
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile, codexDaemonOptOuts.get(row.node_id), row.claude_mcp, codexChoice),
...(row.resume_token && codexChoice && "unreadable" in codexChoice ? { resumeCommandNote: CODEX_PERMISSIONS_NOT_INCLUDED } : {}),
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile, codexDaemonOptOuts.get(row.node_id), row.claude_mcp),
// OPR.0.4.0.26: recoveryGuidance is NOT inlined per node in the LIST
// payload. It duplicated ~47KB of templated prose across all nodes and
// no node-list consumer reads it. The full guidance is recomputed on
@@ -963,7 +921,6 @@ function buildInventoryMapFromRows(db: Database.Database, rows: InventoryRow[]):
const restoreOutcomes = buildRestoreOutcomeMap(db); // one fleet restore-outcome scan
const orienteds = buildOrientedMap(db); // one fleet startup-orientation scan
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts(); // one launch-fact read
const codexSeatChoices = readCodexSeatChoices(db); // one permission-choice read
const out = new Map<string, NodeInventoryEntry[]>();
for (const row of rows) {
const entry = buildInventoryEntry(db, row, {
@@ -973,7 +930,6 @@ function buildInventoryMapFromRows(db: Database.Database, rows: InventoryRow[]):
restoreOutcomes,
orienteds,
codexDaemonOptOuts,
codexSeatChoices,
});
let list = out.get(row.rig_id);
if (!list) { list = []; out.set(row.rig_id, list); }
@@ -1181,7 +1137,6 @@ export function getNodeDetail(
codexConfigProfile: entry.codexConfigProfile,
codexDaemonOptOut: entry.runtime === "codex" ? new AppliedLaunchObservationStore(db).readCurrent(nodeId)?.daemonOptOut : undefined,
claudeMcp: entry.runtime === "claude-code" ? readSeatClaudeMcp(db, nodeId) : undefined,
codexChoice: entry.runtime === "codex" ? readCodexSeatChoices(db).get(nodeId) : undefined,
}),
binding,
startupFiles,
+20 -36
View File
@@ -1,7 +1,6 @@
import { createHash, randomUUID } from "node:crypto";
import { appendFileSync, closeSync, openSync, readSync, statSync } from "node:fs";
import type Database from "better-sqlite3";
import { isOpenRigSender, OPENRIG_SENDER } from "../lib/pane-envelope.js";
/**
* Queue erasure: the maintained way to remove queue content, for closed-work retention and account deletion on a
@@ -14,13 +13,11 @@ import { isOpenRigSender, OPENRIG_SENDER } from "../lib/pane-envelope.js";
* The row's own copies follow it: its transitions, live and archived; events whose payload names it; its
* mission-control audit rows. Copies another party holds (outbox and inbox entries, stream reader actions and
* dispositions) go only when they're the owner's: both endpoints the account's (members scope) or the row's own
* (other scopes). Others are kept and reported. OpenRig's own sessions (`daemon@kernel`, `system:*`), which send its
* wakes and notices, are no one's: a row, copy or entry is judged by its other sessions, and needs one of them.
* (other scopes). Others are kept and reported.
*
* Scope is exact. Account deletion takes the account's recorded membership (exact session names): a row between two
* members, or between a member and OpenRig, is deleted, whatever its state; a row with one member endpoint and the
* other outside the account is reported as mixed and left untouched. Named with the account's rig, it also removes
* the rig's own records that outlive the rig. Routine
* Scope is exact. Account deletion takes the account's recorded membership (exact session names): a row whose two
* endpoints are both members is deleted, whatever its state; a row with one member endpoint is reported as mixed and
* left untouched. Named with the account's rig, it also removes the rig's own records that outlive the rig. Routine
* retention touches only closed rows (done, canceled, denied, handed-off; never failed or open work), aged from the
* start of their final closed state. A row a workflow still references is reported, never erased.
*
@@ -98,8 +95,7 @@ export interface EraseReport {
error?: { stage: Stage; message: string };
/** rows erased (applied, committed) or that would be (dry run) */
qitems: Array<{ qitemId: string; action: EraseAction; state: string }>;
/** members scope: rows with one member endpoint and the other outside the account (OpenRig's own isn't), left
* untouched */
/** members scope: rows with exactly one member endpoint, left untouched */
mixed: Array<{ qitemId: string; memberEndpoint: "source" | "destination" }>;
/** rows not erased, and why: a workflow references them; (ids scope) no such row to scrub; the row changed after
* selection so it no longer qualifies (reopened, rerouted out of the account, written to out of its window, or an
@@ -120,8 +116,7 @@ export interface EraseReport {
unreadableEvents: Array<{ seq: number; qitemIds: string[] }>;
/** copies erased with the rows, or that would be */
linked: { transitions: number; archivedTransitions: number; outbox: number; inbox: number; events: number; streamActions: number; missionControl: number };
/** members scope: entries no queue row owns between members, or a member and OpenRig; mixed ones are counted and
* left */
/** members scope: entries between members that no queue row owns; mixed ones are counted and left */
unlinked?: { outbox: number; inbox: number; streamItems: number; mixedEntries: number; referencedStreamItems: number };
/** members scope with rig: whether the rig was found and verified, its records erased and left per table, the
* tables whose records can't be counted (an unverified removed rig), and when its erasure completed */
@@ -430,9 +425,6 @@ export async function eraseQueue(db: Database.Database, opts: EraseOptions): Pro
const ledgerReason = (action: EraseAction) => scope.kind === "routine" && action === "scrub" ? "retention-content" : reason;
const memberSet = new Set(scope.kind === "members" ? scope.members : []);
const members = JSON.stringify([...memberSet]);
// Members scope: a row is the account's when one endpoint is a member and the other is a member or OpenRig's own.
const accountRow = (source: string, destination: string) => (memberSet.has(source) || memberSet.has(destination))
&& (memberSet.has(source) || isOpenRigSender(source)) && (memberSet.has(destination) || isOpenRigSender(destination));
// A ledger replay applies only the batches that committed, by their own commit line or this database's record of
// them. A rolled-back batch is skipped; one that is neither is reported and never replayed.
let listed = scope.kind === "ids" ? scope.entries : [];
@@ -496,8 +488,10 @@ export async function eraseQueue(db: Database.Database, opts: EraseOptions): Pro
ORDER BY qitem_id LIMIT @limit`).all({ members, after, limit: batchSize }) as Array<Omit<Candidate, "action">>;
const rows: Candidate[] = [];
for (const row of found) {
if (accountRow(row.source, row.destination)) rows.push({ ...row, action: "delete" });
else report.mixed.push({ qitemId: row.qitemId, memberEndpoint: memberSet.has(row.source) ? "source" : "destination" });
const fromMember = memberSet.has(row.source);
const toMember = memberSet.has(row.destination);
if (fromMember && toMember) rows.push({ ...row, action: "delete" });
else report.mixed.push({ qitemId: row.qitemId, memberEndpoint: fromMember ? "source" : "destination" });
}
return { rows, last: found.at(-1)?.qitemId ?? null, seen: found.length };
}
@@ -517,14 +511,10 @@ export async function eraseQueue(db: Database.Database, opts: EraseOptions): Pro
// Whose copies these are: the account's (members scope), or the row's own participants (other scopes).
const ownerOf = (candidate: Candidate) => scope.kind === "members" ? memberSet : new Set([candidate.source, candidate.destination]);
// OpenRig's own sessions (its wake for the row, say) aren't counted when the copy has any other: an OpenRig wake to
// the owner is the owner's, and one to no owner is foreign.
const ownership = (copy: Copy, owners: Set<string>): "owned" | "mixed" | "foreign" => {
const sessions = copy.sessions.filter((session): session is string => session !== null);
const others = sessions.filter((session) => !isOpenRigSender(session));
const counted = others.length > 0 ? others : sessions;
const owned = counted.filter((session) => owners.has(session)).length;
return owned === counted.length ? "owned" : owned > 0 ? "mixed" : "foreign";
const owned = sessions.filter((session) => owners.has(session)).length;
return owned === sessions.length ? "owned" : owned > 0 ? "mixed" : "foreign";
};
// Stream actions and dispositions this run deletes, so the unlinked pass plans on the same references either way.
const plannedActions: string[] = [];
@@ -645,7 +635,7 @@ export async function eraseQueue(db: Database.Database, opts: EraseOptions): Pro
else plan.dropped.push({ code: "changed-during-run", qitemId: candidate.qitemId });
} else if (referenced.has(candidate.qitemId)) {
plan.dropped.push({ code: "workflow-reference", qitemId: candidate.qitemId });
} else if (scope.kind === "members" && !accountRow(now.source, now.destination)) {
} else if (scope.kind === "members" && !(memberSet.has(now.source) && memberSet.has(now.destination))) {
// Rerouted since selection (a fallback, say): no longer the account's own row.
plan.dropped.push({ code: "changed-during-run", qitemId: candidate.qitemId });
} else if (scope.kind === "routine" && routineAction(now) !== candidate.action) {
@@ -797,11 +787,10 @@ export async function eraseQueue(db: Database.Database, opts: EraseOptions): Pro
return report;
}
/** Account deletion's second pass: outbox and inbox entries no queue row owns between two members, or between a
* member and OpenRig (its wake for a row erased earlier, say), and the members' stream items. An entry with one
* member endpoint and the other outside the account, or a stream item something still references, is counted and
* left. Rows and references this run erases are counted as they were before it, so a dry run and an apply report the
* same set. */
/** Account deletion's second pass: outbox and inbox entries between two members that no queue row owns, and the
* members' stream items. An entry with one member endpoint, or a stream item something still references, is counted
* and left. Rows and references this run erases are counted as they were before it, so a dry run and an apply report
* the same set. */
function eraseUnlinked(db: Database.Database, st: Statements,
plan: { members: string; plannedActions: string[]; plannedDispositions: string[]; erasedRows: string[] }, apply: boolean): NonNullable<EraseReport["unlinked"]> {
// A row this run erased still owns its copies here: they were reported with it (keptCopies), not as unowned.
@@ -810,14 +799,9 @@ function eraseUnlinked(db: Database.Database, st: Statements,
const ownedOutbox = `(COALESCE(audit_pointer, '') IN ${rows}
OR (outbox_id LIKE 'wake-intent-%' AND substr(outbox_id, 13) IN ${rows}))`;
const ownedInbox = `(COALESCE(absorbed_qitem_id, '') IN ${rows} OR COALESCE(audit_pointer, '') IN ${rows})`;
// A member, or OpenRig's own session: isOpenRigSender in SQL. The prefix is compared exactly, since LIKE ignores
// ASCII case and reads _ as a wildcard.
const accountSide = (column: string) =>
`(${column} IN ${MEMBERS} OR ${column} = '${OPENRIG_SENDER}' OR substr(${column}, 1, 7) = 'system:')`;
const both = `${accountSide("sender_session")} AND ${accountSide("destination_session")}
AND (sender_session IN ${MEMBERS} OR destination_session IN ${MEMBERS})`;
const fromMember = `sender_session IN ${MEMBERS} AND NOT ${accountSide("destination_session")}`;
const toMember = `destination_session IN ${MEMBERS} AND NOT ${accountSide("sender_session")}`;
const both = `sender_session IN ${MEMBERS} AND destination_session IN ${MEMBERS}`;
const fromMember = `sender_session IN ${MEMBERS} AND destination_session NOT IN ${MEMBERS}`;
const toMember = `destination_session IN ${MEMBERS} AND sender_session NOT IN ${MEMBERS}`;
// The same references the stream pass keeps an item for (queue-retention pruneStreamItems), less this run's own.
const classificationColumns = new Set((db.prepare("PRAGMA table_info(project_classifications)").all() as Array<{ name: string }>)
.map((column) => column.name));
@@ -540,8 +540,7 @@ function deliveryResolutionEvidence(
if (kind === "unclaimed-obligation" && !quiet && deps.queueRepo.isRegisteredHumanUpdate(row) && !deps.queueRepo.deliveryOutcomeFor(qitemId)) {
return "informational update to a registered human requires no claim";
}
// A temporary hold resolves a failed wake, but unclaimed work still needs its existing standing finding.
if (kind === "undelivered-wake" && row.lastNudgeResult === "retained:typing_guard") {
if (kind !== "undelivered-human-notice" && row.lastNudgeResult === "retained:typing_guard") {
return "source wake held by the seat typing guard";
}
return null;
@@ -1766,7 +1765,7 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
AND NOT (json_valid(tags) AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?))`)
.all(seat, onlyFrom, onlyFrom, STUCK_SWEEP_FINDING_TAG) as Array<{ qitem_id: string }>).map((r) => r.qitem_id));
// #2988: rows this notice lets go of only because something else stands for them now (a finding of their own,
// another parent's or the seat's own notice, a temporary typing hold, or this pass's route for them). Nothing resolved them, so a notice the
// another parent's or the seat's own notice, or this pass's route for them). Nothing resolved them, so a notice the
// sweep closes doesn't hold them back: they leave its list before it closes.
const routedAway = new Set<string>();
// Read within the sweep's bounded yield (a rebuilt list can be long); a notice changed meanwhile is left for the next pass.
@@ -1779,14 +1778,7 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
}
const row = indexed(() => deps.queueRepo.getById(id, { waiting: false }));
// Gone, someone else's now, or resolved at its source.
if (!row || row.destinationSession !== seat) continue;
// A typing hold is temporary custody, not a dismissal of this work. Let the closed notice release the row:
// when the hold clears, it can qualify again even without a newer pending timestamp or wake attempt.
if (row.state === "pending" && !row.claimedAt && row.lastNudgeResult === "retained:typing_guard") {
routedAway.add(id);
continue;
}
if (deliveryResolutionEvidence(deps, "unclaimed-obligation", id, now)) continue;
if (!row || row.destinationSession !== seat || deliveryResolutionEvidence(deps, "unclaimed-obligation", id, now)) continue;
// Held by a finding or recovery of its own (one actually written, so a create that failed this pass leaves the row
// here): that finding holds it back from now on, a dismissed one too. The notice lets go of it rather than also
// holding it once closed: a fold into a parent's notice (#2988) can release that finding's custody, and the row
@@ -1829,8 +1821,8 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
state: "done",
closureReason: "no-follow-on",
transitionNote: parent
? `stuck-sweep resolved: unclaimed-backlog on ${seat} for ${parent}: every row it stood for is claimed, parked, closed, temporarily held, has its own finding or no longer goes to ${parent}`
: `stuck-sweep resolved: unclaimed-backlog on ${seat}: every row it stood for is claimed, parked, closed, temporarily held, has its own finding or is in its delegation parent's notice`,
? `stuck-sweep resolved: unclaimed-backlog on ${seat} for ${parent}: every row it stood for is claimed, parked, closed, has its own finding or no longer goes to ${parent}`
: `stuck-sweep resolved: unclaimed-backlog on ${seat}: every row it stood for is claimed, parked, closed, has its own finding or is in its delegation parent's notice`,
});
othersMayHaveRun();
findings.push({ kind: "unclaimed-backlog", qitemId: key, findingQitemId: last.qitem_id, action: "closed" });
@@ -115,8 +115,6 @@ export const ATTENTION_REQUIRED_READINESS_CODES = new Set([
"login_required",
"mcp_gate",
"bypass_consent_gate",
// Claude's gated-grants backstop (#2820 item 2): approving the folder's pre-approved tool permissions is the person's call.
"claude_gated_grants_gate",
// Claude's resume chooser or picker waits for a person to choose a conversation; startup stops waiting and says so.
"claude_resume_selection_prompt",
// Codex auth refusal (stored OAuth token can no longer be refreshed).
@@ -4,7 +4,6 @@ import type { ContextUsageStore } from "./context-usage-store.js";
import { physicalSessionName } from "./session-registry.js";
import { currentClaudeMcpMenu } from "./claude-mcp-menu.js";
import { currentClaudeThemeMenu } from "./claude-theme-menu.js";
import { currentClaudeModelMenu, currentCodexModelPicker } from "./native-model-picker.js";
import { currentClaudeResumePicker } from "./native-resume-probe.js";
import stringWidth from "string-width";
import { CODEX_LIVE_WORKING_FRAME_PATTERN, CODEX_TURN_STATUS_PATTERN } from "./codex-pane-patterns.js";
@@ -843,10 +842,7 @@ export function classifyPaneActivity(paneContent: string, options: ClassifyPaneO
const claudePicker = options.runtime === "codex" || options.runtime === "pi" ? null : currentClaudeResumePicker(paneContent);
const themeMenu = options.runtime === undefined || options.runtime === "claude-code"
? currentClaudeThemeMenu(paneContent) : null;
// The program's own /model picker: its highlighted row can sit above the scan window, and Enter there confirms it.
const modelPicker = options.runtime === "codex" ? currentCodexModelPicker(paneContent)
: options.runtime === undefined || options.runtime === "claude-code" ? currentClaudeModelMenu(paneContent) : null;
const selectionPromptEvidence = modelPicker ?? themeMenu ?? claudePicker ?? currentClaudeMcpMenu(paneContent) ?? findCurrentClaudeQuestion(paneContent) ??
const selectionPromptEvidence = themeMenu ?? claudePicker ?? currentClaudeMcpMenu(paneContent) ?? findCurrentClaudeQuestion(paneContent) ??
currentComposerSelection ?? (options.currentClaudeDialogueOnly ? null
: findPatternEvidence(selectionLines, [COMPOSER_SELECTION_SCAN_PATTERN]));
if (selectionPromptEvidence) {
@@ -4201,8 +4197,7 @@ export class SessionTransport {
// dialog. Opening a picker or a permission selector after Stop fires no new hook, so a fresh idle
// hook gets the same current-dialog check as a fresh running one.
if (this.hookFreshForSend(hookActivity, now)) {
// A fresh SessionStart reads unknown, and a picker can open after it just as after Stop.
if ((hookActivity.state === "running" || hookActivity.state === "idle" || hookActivity.state === "unknown") && input.runtime === "claude-code") {
if ((hookActivity.state === "running" || hookActivity.state === "idle") && input.runtime === "claude-code") {
const paneVeto = await probeSessionActivity({
communityHarness, communityHarnessSendProbe: true,
composerSnapshot,
@@ -4219,24 +4214,6 @@ export class SessionTransport {
if (paneVeto.state === "needs_input" && (hookActivity.state === "running"
|| paneVeto.reason === "selection_prompt" || paneVeto.reason === "permission_prompt")) return paneVeto;
}
// Codex's /model picker fires no hook either, and Codex opens it during a running turn too. After any fresh
// hook short of a permission request, only a current selector recognized in full refuses (the dialogue-only
// read skips the numbered-row scan, so history, drafts and a busy pane with no picker still deliver).
if ((hookActivity.state === "idle" || hookActivity.state === "running" || hookActivity.state === "unknown") && input.runtime === "codex") {
const paneVeto = await probeSessionActivity({
communityHarness, communityHarnessSendProbe: true,
composerSnapshot,
currentClaudeDialogueOnly: true,
sessionName: input.sessionName,
runtime: input.runtime,
attachmentType: input.attachmentType as "tmux" | "external_cli" | null | undefined,
tmuxAdapter: this.tmuxAdapter,
now,
captureObserver: privateInput ? undefined : this.captureObserver,
binding,
});
if (paneVeto.state === "needs_input" && paneVeto.reason === "selection_prompt") return paneVeto;
}
return hookActivity;
}
// OPR.0.4.3.28 Part A — a stale-but-latest `idle` hook (older than the 15s
-3
View File
@@ -814,9 +814,6 @@ export interface NodeInventoryEntry {
/** Effective mode; inherited non-default sockets also have server arguments. */
tmuxServerMode?: "inherit" | "isolated";
resumeCommand: string | null;
/** Present when the resume command couldn't include something the seat chose (its saved Codex permission choice
* couldn't be read): the command still shows, and this says what it lacks. */
resumeCommandNote?: string;
recoveryGuidance: NodeRecoveryGuidance | null;
latestError: string | null;
// Extended fields
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, isAbsolute } from "node:path";
import { getOpenRigHome } from "../../openrig-compat.js";
import { SETTINGS_VALID_KEYS, describeSettingKey, type SettingKeyDescription, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
import { SETTINGS_VALID_KEYS, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
import { connectionsProjection, readConnectionConfiguration } from "../gateway/connections-projection.js";
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION, LEGACY_INBOUND_DESTINATION } from "../gateway/slack/config.js";
import { projectionPath } from "../gateway/human-registry.js";
@@ -13,13 +13,7 @@ import { describeDefaultReceiver, type DefaultReceiver } from "../default-receiv
type Value = string | number | boolean | null;
type State = "available" | "missing" | "malformed" | "unavailable";
type Kind = "scalar" | "text" | "path" | "identity" | "url" | "names" | "paths" | "withheld";
/** Each is optional and separate: the rules a value must meet, whether a write route exists for the key, when a change is
* known to take effect, and (above, on the entry) whether this caller is shown the value. None implies another. */
export interface BrowserEntryMetadata extends SettingKeyDescription {
/** "daemon_restart" when the application guidance says so; otherwise "unknown": OpenRig does not guess. */
appliesAt: "daemon_restart" | "unknown";
}
export interface BrowserEntry extends BrowserEntryMetadata {
export interface BrowserEntry {
key: string;
group: "general" | "slack" | "people" | "hosts" | "health";
value: Value;
@@ -96,8 +90,6 @@ function sourceFile(file: string): { state: State; bytes: string | null } {
try { return { state: "available", bytes: readFileSync(file, "utf8") }; }
catch (error) { return { state: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unavailable", bytes: null }; }
}
/** The keys whose guidance below says a daemon restart or startup applies them. The browser test ties this to that text. */
const DAEMON_RESTART_KEYS = new Set(["terminal.tmux_server", "retention.enabled"]);
function application(key: string): string {
if (key === "terminal.tmux_server") return "Selected at daemon restart; surviving sessions keep their previous server until deliberately stopped. See terminal status for the effective choice.";
if (key === "ui.terminal.max_live_terminals") return "Legacy web client; unset resolver value, consumer fallback 2. No TUI limit implied.";
@@ -127,8 +119,7 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
const d = safeValue(defaultValue, kind, redact);
entries.push({ key, group, value: v.value, defaultValue: d.value, defaultKnown: defaultValue !== undefined && !["people", "hosts"].includes(group), source,
visibility: source === "unavailable" ? "unavailable" : v.value === null && v.reason ? "withheld" : "shown",
reason: v.reason ?? d.reason, scope, application: applied,
...describeSettingKey(key), appliesAt: DAEMON_RESTART_KEYS.has(key) ? "daemon_restart" : "unknown" });
reason: v.reason ?? d.reason, scope, application: applied });
}
const file = sourceFile(store.configPath);
let generalState = file.state;
@@ -842,20 +842,6 @@ function coerceValue(key: SettingsValidKey, raw: string, workspaceRoot: string):
// in `set()`. Lockstep with cli/src/config-store.ts KEY_CONSTRAINTS so the
// daemon's HTTP write surface (/api/config POST) rejects the same input
// the CLI rejects.
/** What a validator can say about the values it accepts, kept on the validator itself so it cannot drift from it. */
interface ConstraintMeta { choices?: readonly string[]; format?: string }
type KeyConstraint = ((raw: string, coerced: string | number | boolean) => void) & { meta?: ConstraintMeta };
function described(meta: ConstraintMeta, check: (raw: string, coerced: string | number | boolean) => void): KeyConstraint {
return Object.assign(check, { meta });
}
/** A closed list of accepted strings: the list is both what is enforced and what the browser view reports. */
function choiceConstraint(choices: readonly string[], message: (raw: string) => string, trim = false): KeyConstraint {
return described({ choices }, (raw) => {
if (!choices.includes(trim ? (raw ?? "").trim() : raw)) throw new Error(message(raw));
});
}
const PROVIDER_SELECTED_CHOICES: readonly string[] = ["", "claude", "codex", "claude,codex", "codex,claude"];
function positiveIntegerConstraint(key: string) {
return (raw: string, coerced: string | number | boolean): void => {
if (!/^\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced <= 0) {
@@ -864,6 +850,12 @@ function positiveIntegerConstraint(key: string) {
};
}
function wakeHumanRungConstraint(raw: string): void {
if (raw !== "always" && raw !== "explicit-only") {
throw new Error(`Invalid value for queue.wake_human_rung: must be "always" or "explicit-only", got "${raw}"`);
}
}
function percentageConstraint(key: string) {
return (raw: string, coerced: string | number | boolean): void => {
if (!/^\d+$/.test((raw ?? "").trim())
@@ -876,10 +868,15 @@ function percentageConstraint(key: string) {
};
}
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
"terminal.tmux_server": choiceConstraint(["inherit", "isolated"],
(raw) => `Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`),
"daemon.db_conflict": choiceConstraint(["warn", "stop"], () => "daemon.db_conflict must be warn or stop"),
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
"terminal.tmux_server": (raw) => {
if (raw !== "inherit" && raw !== "isolated") {
throw new Error(`Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`);
}
},
"daemon.db_conflict": (raw) => {
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
},
"daemon.seat_poll_interval_ms": (raw, value) => {
// Startup-only: the seat pollers read it once when the daemon starts. Only slower than the 1 s default, up to the
// ceiling the structural observation's expiry allows (seat-activity-service.ts MAX_SEAT_POLL_INTERVAL_MS).
@@ -887,8 +884,9 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
throw new Error("Invalid daemon.seat_poll_interval_ms: must be a whole number of milliseconds in [1000, 2000]");
}
},
"transport.draft_delivery": choiceConstraint(["refuse", "deliver"],
(raw) => `Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`),
"transport.draft_delivery": (raw) => {
if (raw !== "refuse" && raw !== "deliver") throw new Error(`Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`);
},
"transcripts.lines": (raw, value) => {
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) {
throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]");
@@ -930,34 +928,37 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
throw new Error(`Invalid value for daemon.port: must be an integer in [0, 65535], got "${raw}"`);
}
},
"ui.timezone": described({ format: "iana-timezone" }, (_raw, value) => {
"ui.timezone": (_raw, value) => {
try {
if (typeof value !== "string" || !value || /^[+-]/.test(value)) throw new Error();
new Intl.DateTimeFormat("en-US", { timeZone: value });
} catch { throw new Error("Invalid ui.timezone: use an IANA timezone such as America/Los_Angeles or Europe/London"); }
}),
},
"health.context_pressure.warning_percent": percentageConstraint("health.context_pressure.warning_percent"),
"health.context_pressure.critical_percent": percentageConstraint("health.context_pressure.critical_percent"),
"policies.idle_gate_qitem.scan_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.scan_interval_seconds"),
"policies.idle_gate_qitem.active_wake_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.active_wake_interval_seconds"),
"policies.idle_gate_qitem.auto_register": choiceConstraint(["off", "all"],
(raw) => `Invalid value for policies.idle_gate_qitem.auto_register: must be "off" or "all", got "${raw}"`, true),
"policies.idle_gate_qitem.auto_register": (raw) => {
const v = (raw ?? "").trim();
if (v !== "off" && v !== "all") {
throw new Error(`Invalid value for policies.idle_gate_qitem.auto_register: must be "off" or "all", got "${raw}"`);
}
},
// Policy threshold: integer in [1, 100]. Documented contract from
// slice 27 README. parseInt's permissive coercion ("80abc" → 80;
// "80.5" → 80) is not safe for a key the daemon's compaction trigger
// reads at every poll tick; runtime validator rejects what the
// contract forbids per banked feedback_static_gates_mirror_runtime_validators.
// Checks the typed value too: a config.json array would otherwise pass as its String() form.
"provider.selected": described({ choices: PROVIDER_SELECTED_CHOICES }, (_raw, value) => {
if (typeof value !== "string" || !PROVIDER_SELECTED_CHOICES.includes(value)) {
"provider.selected": (_raw, value) => {
if (typeof value !== "string" || !/^(|claude|codex|claude,codex|codex,claude)$/.test(value)) {
throw new Error("Invalid provider.selected: use claude, codex, claude,codex, or an empty string for no recorded choice");
}
}),
"policies.claude_compaction.preparation_root": described({ format: "absolute-path-or-empty" }, (_raw, value) => {
},
"policies.claude_compaction.preparation_root": (_raw, value) => {
if (typeof value !== "string" || (value !== "" && (!path.isAbsolute(value) || value.includes("\0")))) {
throw new Error("Invalid policies.claude_compaction.preparation_root: use an absolute path or an empty string for the default");
}
}),
},
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
const trimmed = (raw ?? "").trim();
if (!/^-?\d+$/.test(trimmed)) {
@@ -1048,12 +1049,12 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
}
},
// Empty (the default) keeps no erasure ledger; otherwise an absolute file path, kept outside the backed-up data.
"retention.erasure_ledger": described({ format: "absolute-path-or-empty" }, (raw) => {
"retention.erasure_ledger": (raw) => {
const value = (raw ?? "").trim();
if (value !== "" && !value.startsWith("/")) {
throw new Error(`Invalid value for retention.erasure_ledger: must be empty or an absolute file path, got "${raw}"`);
}
}),
},
"retention.watchdog_keep_per_job": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
throw new Error(`Invalid value for retention.watchdog_keep_per_job: must be an integer >= 0, got "${raw}"`);
@@ -1069,12 +1070,12 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
}
},
"stream.intake": described({ format: "queue.worker, off, or member@rig" }, (raw) => {
"stream.intake": (raw) => {
const val = (raw ?? "").trim();
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
}
}),
},
"retention.batch_size": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
@@ -1088,19 +1089,18 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
"queue.wake_retry_cap": positiveIntegerConstraint("queue.wake_retry_cap"),
"queue.wake_unconfirmed_window_minutes": positiveIntegerConstraint("queue.wake_unconfirmed_window_minutes"),
"queue.wake_swap_grace_seconds": positiveIntegerConstraint("queue.wake_swap_grace_seconds"),
"queue.wake_human_rung": choiceConstraint(["always", "explicit-only"],
(raw) => `Invalid value for queue.wake_human_rung: must be "always" or "explicit-only", got "${raw}"`),
"decisions.template_dir": described({ format: "absolute-path-or-empty" }, (_raw, value) => {
"queue.wake_human_rung": wakeHumanRungConstraint,
"decisions.template_dir": (_raw, value) => {
if (typeof value !== "string" || (value !== "" && (!path.isAbsolute(value) || value.includes("\0")))) {
throw new Error("Invalid decisions.template_dir: use an absolute path, or an empty string for the shipped templates only");
}
}),
},
// rig acp serve's receiver when --to isn't given: a team seat, member@rig, or empty for none.
"acp.to": described({ format: "member@rig-or-empty" }, (_raw, value) => {
"acp.to": (_raw, value) => {
if (typeof value !== "string" || (value !== "" && !/^[^@\s]+@[^@\s]+$/.test(value))) {
throw new Error("Invalid acp.to: use the seat that receives your ACP tasks as member@rig, or an empty string for none");
}
}),
},
};
function validateKeyConstraints(key: SettingsValidKey, raw: string, coerced: string | number | boolean): void {
@@ -1126,58 +1126,6 @@ function validateTypedFileValue(key: SettingsValidKey, value: string | number |
check(raw, value);
}
/** What the browser view reports about a key's values, read from the rules that govern it. */
export interface SettingKeyDescription {
/** Whether POST and DELETE /api/config/:key accept this key. */
writeRoute: boolean;
type?: "boolean" | "integer" | "string" | "enum";
choices?: string[];
range?: { min?: number; max?: number };
format?: string;
}
const ACCEPTED_INTEGER_LIMIT = Number.MAX_SAFE_INTEGER;
/** The interval of whole numbers a key's validator accepts, found by asking the validator itself; a side it does not bound is left out. */
function acceptedIntegerRange(key: SettingsValidKey, start: number): { min?: number; max?: number } | undefined {
const check = KEY_CONSTRAINTS[key];
if (!check) return undefined;
const accepts = (n: number): boolean => {
try { check(String(n), n); return true; } catch { return false; }
};
if (!accepts(start)) return undefined;
const range: { min?: number; max?: number } = {};
if (!accepts(ACCEPTED_INTEGER_LIMIT)) {
let lo = start, hi = ACCEPTED_INTEGER_LIMIT;
while (hi - lo > 1) { const mid = Math.floor(lo + (hi - lo) / 2); if (accepts(mid)) lo = mid; else hi = mid; }
range.max = lo;
}
if (!accepts(-ACCEPTED_INTEGER_LIMIT)) {
let lo = -ACCEPTED_INTEGER_LIMIT, hi = start;
while (hi - lo > 1) { const mid = Math.floor(lo + (hi - lo) / 2); if (accepts(mid)) hi = mid; else lo = mid; }
range.min = hi;
}
return range.min === undefined && range.max === undefined ? undefined : range;
}
/** Derived from coerceValue (the type), KEY_CONSTRAINTS (choices, format, range) and the key grammar (write route); no second list. */
export function describeSettingKey(key: string): SettingKeyDescription {
if (parseFeedHostSubscriptionKey(key)) return { writeRoute: true, type: "boolean" };
if (!isSettingsValidKey(key)) return { writeRoute: false };
const fallback = getDefaultValue(key, "");
const meta = KEY_CONSTRAINTS[key]?.meta;
const out: SettingKeyDescription = { writeRoute: true };
if (typeof fallback === "boolean") out.type = "boolean";
else if (typeof fallback === "number") {
out.type = "integer";
const range = acceptedIntegerRange(key, fallback);
if (range) out.range = range;
} else out.type = meta?.choices ? "enum" : "string";
if (meta?.choices) out.choices = [...meta.choices];
if (meta?.format) out.format = meta.format;
return out;
}
/**
* Slice 27 — projected Claude auto-compaction policy. ContextMonitor
* consumes this snapshot per-poll; the PreCompact hook reads the same
@@ -1 +0,0 @@
export * from "./tmux-server-start.js";
-37
View File
@@ -1,37 +0,0 @@
/** tmux 3.3a–3.6 inherits its creating client's limits. This runs in that child only:
* never lower an already higher soft limit, change the hard limit, or restart a server. */
export const TMUX_SERVER_LIMIT_WARNING = "[tmux] Could not raise the open-file limit for a new terminal server; continuing with the inherited limit.";
export const TMUX_SERVER_LIMIT_PREFIX = `
openrig_tmux_nofile() {
openrig_tmux_soft=$(ulimit -S -n) || return 1
case "$openrig_tmux_soft" in unlimited) return 0;; ''|*[!0-9]*) return 1;; esac
openrig_tmux_hard=$(ulimit -H -n) || return 1
openrig_tmux_target=8192
case "$openrig_tmux_hard" in
unlimited) ;;
''|*[!0-9]*) return 1;;
*) if [ "$openrig_tmux_hard" -lt "$openrig_tmux_target" ]; then openrig_tmux_target=$openrig_tmux_hard; fi;;
esac
[ "$openrig_tmux_soft" -ge "$openrig_tmux_target" ] || ulimit -S -n "$openrig_tmux_target"
}
openrig_tmux_nofile 2>/dev/null || printf '%s\\n' '${TMUX_SERVER_LIMIT_WARNING}' >&2
`;
/** Production callers use full command names. A matching data argument merely gets the same harmless child limit. */
export function mayStartTmuxServer(args: readonly string[]): boolean {
return args.some(arg => ["new-session", "start-server", "attach-session", "-D"].includes(arg));
}
export function tmuxServerStartCommand(file: string, args: string[]): { file: string; args: string[] } {
if (process.platform === "win32" || !mayStartTmuxServer(args)) return { file, args };
return { file: "/bin/sh", args: ["-c", TMUX_SERVER_LIMIT_PREFIX + 'exec "$0" "$@"', file, ...args] };
}
let warned = false;
/** execFile captures stderr even on success; surface this advisory once per process. */
export function reportTmuxServerLimit(stderr: unknown): void {
if (!warned && String(stderr ?? "").includes(TMUX_SERVER_LIMIT_WARNING)) {
warned = true;
console.warn(TMUX_SERVER_LIMIT_WARNING);
}
}
@@ -160,9 +160,8 @@ describe("ordinary transport under current and historical questions", () => {
const staleWaiting = hook.name === "send_stale_permission" || hook.name === "display_stale_permission";
const currentQuestion = ["picker160", "picker100", "picker12", "picker13"].includes(shape)
|| shape === "footer_in_multiline_draft";
// A fresh running, idle or unknown (SessionStart) hook reads the pane once for a current dialog: a picker
// opened after Stop or SessionStart fires no hook.
const freshDialogCheck = hook.name === "fresh_running" || hook.name === "fresh_idle" || hook.name === "fresh_unknown";
// A fresh running or idle hook reads the pane once for a current dialog: a picker opened after Stop fires no hook.
const freshDialogCheck = hook.name === "fresh_running" || hook.name === "fresh_idle";
const freshCurrentQuestion = freshDialogCheck && currentQuestion;
const sent = hook.name === "fresh_permission" || freshCurrentQuestion ? false
: hook.name.startsWith("fresh_") ? true : state !== "attention" && !(staleWaiting && state === "unknown");
@@ -6,7 +6,6 @@ import nodePath from "node:path";
import { describe, it, expect, vi, afterEach } from "vitest";
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
import type { NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
import { isAttentionRequiredReadinessCode } from "../src/domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../src/domain/projection-planner.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import { claudePostureFlag } from "../src/adapters/yolo-mode.js";
@@ -1102,7 +1101,7 @@ describe("Claude Code runtime adapter", () => {
});
describe("with provider-prompt auto-driving on, a trust prompt in the longer wait", () => {
const trust = "Accessing workspace:\n/fixture\n❯ 1. Yes, I trust this folder\n 2. No, exit";
const trust = "Accessing workspace:\n/fixture\n1. Yes, I trust this folder\n2. No, exit";
it.each([["a Codex", codex], ["another conversation's Claude", otherClaude]])(
"from %s that took the pane is not answered: main's result, no Enter", async (_label, arrival) => {
@@ -1124,16 +1123,6 @@ describe("Claude Code runtime adapter", () => {
expect((tmux.sendKeys as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(1);
});
it("with no key to send (a prompt drawn without a cursor) ends the longer wait as it does without the auto-drive", async () => {
const cursorless = "Accessing workspace:\n/fixture\n1. Yes, I trust this folder\n2. No, exit";
const screen = (read: number) => (read < 20 ? "" : cursorless);
const driven = await run({ autoDrive: true, screen });
const plain = await run({ screen });
expect(driven.result).toEqual(plain.result);
expect(driven.reads).toBe(plain.reads);
expect(driven.tmux.sendKeys).toHaveBeenCalledTimes(1);
});
it("in the quick window is answered as before, whoever holds the pane", async () => {
const { tmux } = await run({ autoDrive: true, rows: () => [...booting, codex], screen: (read) => (read < 3 ? trust : "") });
expect((tmux.sendKeys as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(1);
@@ -1470,127 +1459,3 @@ describe("Claude Code runtime adapter", () => {
// grep confirms provisionActivityHooks/upsertCommandHook/etc. removed
// from adapter source.
});
// #2820 item 2: with recovery.auto_drive_provider_prompts on, the plain workspace trust prompt is answered with Enter,
// but Claude Code's "gated grants" backstop never is: its default choice declines the folder's pre-approved tool
// permissions (or exits), and approving them is the person's call. The launch stops as attention_required, with the
// screen as evidence, and no key goes to the pane after the launch command.
describe("Claude gated grants backstop is never auto-driven (#2820 item 2)", () => {
const capture = JSON.parse(fs.readFileSync(new URL("./fixtures/claude-trust-git-root/trust-checks.json", import.meta.url), "utf8")) as { gatedGrantsScreen: string[] };
const backstop = capture.gatedGrantsScreen.join("\n");
async function launch(screens: string[], cwd = "/project") {
const tmux = mockTmux();
(tmux.getPaneCommand as ReturnType<typeof vi.fn>).mockResolvedValue("claude");
const capturePane = tmux.capturePaneContent as ReturnType<typeof vi.fn>;
for (const screen of screens.slice(0, -1)) capturePane.mockResolvedValueOnce(screen);
capturePane.mockResolvedValue(screens.at(-1));
const adapter = new ClaudeCodeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {}, autoDriveProviderPrompts: true });
const result = await adapter.launchHarness(makeBinding(cwd), { name: "dev-impl@test-rig", resumeToken: "abc-123" });
return { tmux, result };
}
it("stops as attention_required and sends nothing after the launch command", async () => {
const { tmux, result } = await launch([backstop]);
expect(result).toMatchObject({ ok: false, recovery: "attention_required" });
// The launch command is the only call (it ends with its own Enter); the plain trust prompt takes a 2nd one.
expect(tmux.sendKeys).toHaveBeenCalledTimes(1);
});
it("sends nothing when the seat's git root is the home folder", async () => {
const { tmux, result } = await launch([backstop.replace("<lab>/repo/cadeiras/rig/seat", "/home/agent")], "/home/agent");
expect(result).toMatchObject({ ok: false, recovery: "attention_required" });
expect(tmux.sendKeys).toHaveBeenCalledTimes(1);
});
it("sends nothing while the backstop is still being drawn, nor once it is complete", async () => {
const partial = capture.gatedGrantsScreen.slice(0, -3).join("\n");
const { tmux, result } = await launch([partial, partial, backstop]);
expect(result).toMatchObject({ ok: false, recovery: "attention_required" });
expect(tmux.sendKeys).toHaveBeenCalledTimes(1);
});
// Claude Code 2.1.322's plain trust prompt (no pre-approved permissions) opens with the focus on "No, exit": an
// Enter there quits Claude. The auto-drive moves the selection down once and presses Enter only once the focused row
// reads "Yes, I trust this folder".
const plainCapture = fs.readFileSync(new URL("./fixtures/claude-trust-git-root/plain-trust-2.1.322.txt", import.meta.url), "utf8")
.split("\n").filter(row => !row.startsWith("# ")).join("\n");
const plainNo = plainCapture;
const plainYes = plainCapture.replace("❯ No, exit", " No, exit").replace(" Yes, I trust this folder", "❯ Yes, I trust this folder");
const ready = ["Claude Code v2.1.322", "❯ Ready"].join("\n");
const keys = (tmux: TmuxAdapter) => (tmux.sendKeys as ReturnType<typeof vi.fn>).mock.calls.map(call => (call[1] as string[]).join(" "));
it("moves the plain trust prompt's focus from 'No, exit' to 'Yes' before pressing Enter (2.1.322)", async () => {
const { tmux, result } = await launch([plainNo, plainYes, ready]);
expect(result).toMatchObject({ ok: true });
expect(keys(tmux)).toEqual(["Enter", "Down", "Enter"]);
});
it("never presses Enter on 'No, exit', and moves it only once: a 2nd Down would wrap back to 'No, exit'", async () => {
const { tmux, result } = await launch([plainNo]);
expect(result).toMatchObject({ ok: false });
expect(keys(tmux)).toEqual(["Enter", "Down"]);
});
// A Claude-like select: Down moves the focus and wraps after the last option, and each key is drawn only some reads
// later (stale frames). Enter on "No, exit" quits Claude; Enter on "Yes" goes on to the session.
it("with stale frames and a wrapping select, sends one Down and presses Enter on 'Yes' only", async () => {
const tmux = mockTmux();
let calls = 0, focusYes = false, entered: "yes" | "no" | null = null, staleReads = 0, last = "";
(tmux.capturePaneContent as ReturnType<typeof vi.fn>).mockImplementation(async () => {
if (staleReads > 0 && last) { staleReads -= 1; return last; }
last = entered === "yes" ? ready : entered === "no" ? "" : focusYes ? plainYes : plainNo;
return last;
});
(tmux.getPaneCommand as ReturnType<typeof vi.fn>).mockImplementation(async () => (entered === "no" ? "zsh" : "claude"));
(tmux.sendKeys as ReturnType<typeof vi.fn>).mockImplementation(async (_session: string, keysSent: string[]) => {
calls += 1;
if (calls === 1) return { ok: true as const }; // the launch command's own Enter
if (keysSent.join(" ") === "Down") { focusYes = !focusYes; staleReads = 3; }
if (keysSent.join(" ") === "Enter" && entered === null) { entered = focusYes ? "yes" : "no"; staleReads = 2; }
return { ok: true as const };
});
const adapter = new ClaudeCodeAdapter({ tmux, fsOps: mockFs(), sleep: async () => {}, autoDriveProviderPrompts: true });
const result = await adapter.launchHarness(makeBinding("/project"), { name: "dev-impl@test-rig", resumeToken: "abc-123" });
expect(entered).toBe("yes");
expect(keys(tmux).slice(1)).toEqual(["Down", "Enter"]);
expect(result).toMatchObject({ ok: true });
});
it("drives a plain prompt drawn below an earlier session that quoted the backstop's words", async () => {
const earlier = ["⏺ The folder's backstop reads \"This folder pre-approves 2 tool permissions\" and focuses",
" \"No, continue without these permissions\".", "", "────────────────", "❯ ", "────────────────", ""].join("\n");
const { tmux, result } = await launch([earlier + plainNo, earlier + plainYes, ready]);
expect(result).toMatchObject({ ok: true });
expect(keys(tmux)).toEqual(["Enter", "Down", "Enter"]);
});
it("sends nothing to the 40-column backstop whose warning a long permission list scrolled out", async () => {
const rows = fs.readFileSync(new URL("./fixtures/claude-trust-git-root/backstop-40col-2.1.322.txt", import.meta.url), "utf8")
.split("\n").filter(row => !row.startsWith("# "));
const from = rows.findIndex(row => row.includes("⚠ This folder pre-approves"));
const to = rows.findIndex(row => row.includes("configuration."));
const { tmux, result } = await launch([[...rows.slice(0, from), ...rows.slice(to + 1)].join("\n")]);
expect(result).toMatchObject({ ok: false, recovery: "attention_required" });
expect(keys(tmux)).toEqual(["Enter"]);
});
it("sends nothing to a trust prompt drawn without a cursor: the label cannot be checked", async () => {
const cursorless = plainCapture.replace("❯ No, exit", " No, exit");
const { tmux, result } = await launch([cursorless]);
expect(result).toMatchObject({ ok: false });
expect(keys(tmux)).toEqual(["Enter"]);
});
it("sends nothing to the backstop in a 40-column pane, whose rows wrap and cut its marks (real capture)", async () => {
const narrow = fs.readFileSync(new URL("./fixtures/claude-trust-git-root/backstop-40col-2.1.322.txt", import.meta.url), "utf8")
.split("\n").filter(row => !row.startsWith("# ")).join("\n");
const { tmux, result } = await launch([narrow]);
expect(result).toMatchObject({ ok: false, recovery: "attention_required" });
expect(keys(tmux)).toEqual(["Enter"]);
});
it("is a readiness code that stops startup waiting for attention", () => {
expect(isAttentionRequiredReadinessCode("claude_gated_grants_gate")).toBe(true);
});
});
+8 -40
View File
@@ -9,12 +9,11 @@
// values THIS reproduction emits and the perturbation tests prove the identity fields are folded in
// — but the DEFINITIVE correctness check is a byte-for-byte read-back of a real Codex `[hooks.state]`
// after `/hooks`→"Trust all" (the QA VM proof). Until that read-back confirms them, the exact
// key_source (resolved folder plus file name) and positional indices are PROVISIONAL. A mismatch is
// key_source (canonicalized config path) and positional indices are PROVISIONAL. A mismatch is
// fail-safe (gate reappears + Layer-2 keystroke floor), never a false-trusted run.
import { describe, it, expect, vi } from "vitest";
import nodeFs from "node:fs";
import nodePath from "node:path";
import nodeOs from "node:os";
import { parse as parseToml } from "smol-toml";
import {
CodexRuntimeAdapter,
@@ -30,8 +29,8 @@ const COMMAND = `node "${RELAY}"`;
const EVENTS = ["SessionStart", "UserPromptSubmit", "Stop", "PermissionRequest"] as const;
// PIN TO VM READ-BACK (qa gate) — PROVISIONAL from open-source reproduction (PR #20321 / commits
// 0452dca, ffcc9cc; codex-rs fingerprint.rs version_for_toml). Codex0.167.2 hooks/list confirms
// the source folder is resolved, while the config file keeps its own name even when it is linked.
// 0452dca, ffcc9cc; codex-rs fingerprint.rs version_for_toml). keySource here is the NON-canonical
// mock path `/home/test/.codex/config.toml`; the real key_source is std::fs::canonicalize(config.toml).
const PROVISIONAL_FIXTURE: Record<(typeof EVENTS)[number], { key: string; hash: string }> = {
SessionStart: {
key: `${CONFIG}:session_start:0:0`,
@@ -268,53 +267,22 @@ describe("OPR.0.4.3.33 — provisioning-seam coupling (ensureCodexActivityHooks
expect(fs._store[CONFIG]).toBe(before);
});
it("resolves the source folder while retaining the config file name", () => {
const canonicalHome = nodePath.join("/resolved", "codex");
const nativeRealpath = vi.spyOn(nodeFs.realpathSync, "native").mockReturnValue(canonicalHome);
it("uses native canonical path casing for Codex hook trust keys", () => {
const canonicalConfigPath = "C:\\Users\\Test\\.codex\\config.toml";
const nativeRealpath = vi.spyOn(nodeFs.realpathSync, "native").mockReturnValue(canonicalConfigPath);
try {
const fakeFs = mockCodexFs({ [RELAY]: "// relay" });
makeAdapter(fakeFs).ensureCodexActivityHooks();
const configPath = Object.keys(fakeFs._store).find((path) => path.endsWith("config.toml"))!;
const config = fakeFs._store[configPath]!;
expect(config).toContain(`[hooks.state.${JSON.stringify(`${nodePath.join(canonicalHome, "config.toml")}:session_start:0:0`)}]`);
expect(config).not.toContain(`[hooks.state.${JSON.stringify(`${configPath}:session_start:0:0`)}]`);
expect(nativeRealpath).toHaveBeenCalledWith(configPath);
expect(config).toContain(`[hooks.state.${JSON.stringify(`${canonicalConfigPath}:session_start:0:0`)}]`);
} finally {
nativeRealpath.mockRestore();
}
});
it.skipIf(process.platform === "win32").each([
{ json: false, linkedHome: false }, { json: true, linkedHome: false },
{ json: false, linkedHome: true }, { json: true, linkedHome: true },
])("trusts file names inside the resolved home (JSON: $json, linked home: $linkedHome)", ({ json, linkedHome }) => {
const root = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), "codex-linked-trust-"));
try {
const home = nodePath.join(root, "home"); nodeFs.mkdirSync(home);
const selectedHome = linkedHome ? nodePath.join(root, "linked-home") : home;
if (linkedHome) nodeFs.symlinkSync(home, selectedHome);
const source = nodePath.join(root, "source.toml"); nodeFs.writeFileSync(source, 'model = "fixture"\n');
nodeFs.symlinkSync(source, nodePath.join(home, "config.toml"));
const relay = nodePath.join(root, "relay.cjs"); nodeFs.writeFileSync(relay, "// never executed");
if (json) {
const hooks = nodePath.join(root, "source-hooks.json"); nodeFs.writeFileSync(hooks, '{"hooks":{}}\n');
nodeFs.symlinkSync(hooks, nodePath.join(home, "hooks.json"));
}
const adapter = new CodexRuntimeAdapter({ tmux: mockTmux(), activityRelayPath: relay, fsOps: {
exists: nodeFs.existsSync, readFile: p => nodeFs.readFileSync(p, "utf8"),
writeFile: (p,c) => nodeFs.writeFileSync(p,c), mkdirp: p => { nodeFs.mkdirSync(p,{ recursive:true }); },
} });
adapter.ensureCodexActivityHooks(selectedHome);
const hooks = parseToml(nodeFs.readFileSync(source,"utf8")).hooks;
if (!hooks || typeof hooks !== "object") throw new Error("missing hook table");
const states: unknown = Reflect.get(hooks, "state");
if (!states || typeof states !== "object") throw new Error("missing trust table");
expect(Object.keys(states).sort()).toEqual(["permission_request", "session_start", "stop", "user_prompt_submit"]
.map(event => `${nodePath.join(nodeFs.realpathSync.native(home), json ? "hooks.json" : "config.toml")}:${event}:0:0`).sort());
expect(nodeFs.lstatSync(nodePath.join(home, "config.toml")).isSymbolicLink()).toBe(true);
} finally { nodeFs.rmSync(root,{recursive:true,force:true}); }
});
it("is idempotent — re-running produces byte-identical config (no duplicate trust tables)", () => {
const fs = mockCodexFs({ [RELAY]: "// relay" });
const adapter = makeAdapter(fs);
@@ -1,120 +0,0 @@
// The Codex resume command OpenRig shows a person for a seat (`rig ps --nodes` resumeCommand and the node detail's
// recovery guidance) carries the seat's own saved permission choice, as its managed resume does. A choice that can't be
// read still shows the command, with a note that it doesn't include it.
import { mockShellCommand } from "./helpers/shell-command-mock.js";
import type Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFullTestDb } from "./helpers/test-app.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
import { observeCodexSandbox, type AppliedLaunchObservation } from "../src/domain/permission-drift.js";
import { getNodeDetail, getNodeInventory } from "../src/domain/node-inventory.js";
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import type { CodexMemberPermissions } from "../src/domain/codex-member-permissions.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
// No real Codex, shell or tmux runs here.
const processMocks = vi.hoisted(() => ({
execFile: vi.fn(),
execSync: vi.fn<() => string>(() => { throw new Error("Unexpected synchronous process execution"); }),
}));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()), execFile: processMocks.execFile, execSync: processMocks.execSync,
}));
const workspace: CodexMemberPermissions = { sandbox: "workspace-write", approval: "on-request", network_access: true, writable_roots: ["/data"] };
const WORKSPACE_ARGS = " -s workspace-write -a on-request -c 'sandbox_workspace_write.writable_roots=[\"/data\"]'"
+ " -c 'sandbox_workspace_write.network_access=true' -c 'sandbox_workspace_write.exclude_tmpdir_env_var=false'"
+ " -c 'sandbox_workspace_write.exclude_slash_tmp=false'";
const NOT_INCLUDED = "This seat's saved Codex permission choice couldn't be read, so the resume command doesn't include it and "
+ "carries the default posture instead. Repair it with rig seat set-permissions <seat> --codex-permissions <file> --reason <reason>.";
describe("the Codex resume command a person is shown carries the seat's own permission choice", () => {
let db: Database.Database;
let registry: SessionRegistry;
let sessionId: string;
beforeEach(() => {
db = createFullTestDb();
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run("rig-2", "test-rig");
db.prepare("INSERT INTO pods (id, rig_id, namespace, label) VALUES (?, ?, ?, ?)").run("pod-2", "rig-2", "dev", "Dev");
db.prepare(
"INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd, pod_id, agent_ref, profile, resolved_spec_name, resolved_spec_version, resolved_spec_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run("node-codex", "rig-2", "dev.qa", "codex", "/workspace/app", "pod-2", "local:agents/qa", "default", "qa", "1.0.0", "hash");
registry = new SessionRegistry(db);
sessionId = registry.registerSession("node-codex", "dev-qa@test-rig").id;
});
afterEach(() => db.close());
const choose = (stored: string) => db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?").run(stored, "node-codex");
const shown = () => {
const entry = getNodeInventory(db, "rig-2").find((e) => e.logicalId === "dev.qa")!;
const detail = getNodeDetail(db, "rig-2", "dev.qa")!;
return { entry, guidance: detail.recoveryGuidance! };
};
describe("with a stored conversation", () => {
beforeEach(() => registry.updateResumeToken(sessionId, "codex_id", "sess-456", "hook"));
it("a workspace-write choice replaces the default posture, in the command and the guidance", () => {
choose(JSON.stringify(workspace));
const { entry, guidance } = shown();
expect(entry.resumeCommand).toBe(`codex${WORKSPACE_ARGS} resume 'sess-456'`);
expect(entry).not.toHaveProperty("resumeCommandNote");
expect(guidance.commands).toEqual([`codex${WORKSPACE_ARGS} resume 'sess-456'`, "cd /workspace/app"]);
expect(guidance.summary).toBe("Try native Codex resume first; it carries this seat's own permission choice, as its managed launch does.");
expect(guidance.notes).not.toContain(NOT_INCLUDED);
});
it("a read-only choice keeps the seat's profile and a recorded --no-daemon", () => {
choose(JSON.stringify({ sandbox: "read-only", approval: "never" }));
db.prepare("UPDATE nodes SET codex_config_profile = ? WHERE id = ?").run("team", "node-codex");
const generation = registry.currentOccupantTenure("node-codex")!.generationUuid;
new AppliedLaunchObservationStore(db).recordGeneration(generation, { ...observeCodexSandbox("-s workspace-write"), daemonOptOut: true } as AppliedLaunchObservation);
expect(shown().entry.resumeCommand).toBe("codex --no-daemon -p 'team' -s read-only -a never resume 'sess-456'");
});
it("prints the same permission arguments the seat's managed resume sends", async () => {
choose(JSON.stringify(workspace));
const sendText = vi.fn(async () => ({ ok: true as const }));
const tmux = mockShellCommand({
sendText, hasSession: vi.fn(async () => true), getPaneCommand: vi.fn(async () => "codex"),
capturePaneContent: vi.fn(async () => "OpenAI Codex (v0.0.0)\n› Ask Codex to do anything"),
sendKeys: vi.fn(async () => ({ ok: true as const })), getPanePid: vi.fn(async () => null),
listSessions: vi.fn(async () => []), listWindows: vi.fn(async () => []), listPanes: vi.fn(async () => []),
} as unknown as TmuxAdapter);
const adapter = new CodexResumeAdapter(tmux, { sleep: async () => {}, maxWaitMs: 0 });
const result = await adapter.resume("dev-qa@test-rig", "codex_id", "sess-456", "/workspace/app", null, undefined,
undefined, undefined, undefined, undefined, undefined, undefined, workspace);
expect(result.ok).toBe(true);
const managed = (sendText.mock.calls as unknown[][]).map((call) => String(call[1])).find((text) => text.includes(" resume "))!;
expect(managed).toContain(`codex${WORKSPACE_ARGS}`);
expect(shown().entry.resumeCommand).toBe(`codex${WORKSPACE_ARGS} resume 'sess-456'`);
});
it("a choice that can't be read still shows the command, with a note that it doesn't include it", () => {
choose(JSON.stringify({ sandbox: "danger-full-access", approval: "never" }));
const { entry, guidance } = shown();
expect(entry.resumeCommand).toBe("codex -s workspace-write resume 'sess-456'");
expect(entry.resumeCommandNote).toBe(NOT_INCLUDED);
expect(guidance.commands).toEqual(["codex -s workspace-write resume 'sess-456'", "cd /workspace/app"]);
expect(guidance.notes).toContain(NOT_INCLUDED);
});
it("a seat with no choice shows the command as before, with no note", () => {
const { entry, guidance } = shown();
expect(entry.resumeCommand).toBe("codex -s workspace-write resume 'sess-456'");
expect(entry).not.toHaveProperty("resumeCommandNote");
expect(guidance.notes).not.toContain(NOT_INCLUDED);
});
});
it("without a stored conversation, the picker command and the summary carry the choice", () => {
choose(JSON.stringify(workspace));
const { entry, guidance } = shown();
expect(entry.resumeCommand).toBeNull();
expect(entry).not.toHaveProperty("resumeCommandNote");
expect(guidance.commands).toEqual(["cd /workspace/app", `codex${WORKSPACE_ARGS} resume --last`]);
expect(guidance.summary).toBe(`No stored Codex resume token. Try codex${WORKSPACE_ARGS} resume --last; it carries this seat's own permission choice, as its managed launch does.`);
});
});
-19
View File
@@ -1,19 +0,0 @@
❯ /model
────────────────────────────────────────────────────────── worker-a@demo ─
────────────────────────────────────────────────────────────────────────────────
Select model
Switch between Claude models. Your pick becomes the default for new
sessions. For other/previous model names, specify with --model.
↑ 2. Sonnet Sonnet 5 · Efficient for routine tasks
3. Fable Fable 5.1 · Most capable for your hardest and
longest-running tasks
4. Opus Opus 5.5 · Best for everyday, complex tasks
5. Haiku Haiku 4.5 · Fastest for quick answers
❯ 6. claude-haiku-5-5 ✔ Custom model
… +1 model
● High effort (default) ←/→ to adjust
Enter to set as default · s to use this session only · Esc to cancel
-23
View File
@@ -1,23 +0,0 @@
❯ /model
─────────────────────────────────────────────────────────────────── p-seat@dlv ─
────────────────────────────────────────────────────────────────────────────────
Select model
Switch between Claude models. Your pick becomes the default for new
sessions. For other/previous model names, specify with --model.
❯ 1. Default (recommended) ✔ Use the default model (currently Opus 5.5) ·
$4/$20 per Mtok
2. Opus Opus 5.5 · Best for everyday, complex tasks ·
$4/$20 per Mtok
3. Fable Fable 5.2 · Most capable for your hardest and
longest-running tasks · $8/$40 per Mtok
4. Sonnet Sonnet 5.5 · Efficient for routine tasks ·
$2/$10 per Mtok
5. Haiku Haiku 5.5 · Fastest for quick answers ·
$0.10/$0.50 per Mtok ($0.50/$2.50 for prompts
over 100k)
◐ Medium effort (default) ←/→ to adjust
Enter to set as default · s to use this session only · Esc to cancel
@@ -1,32 +0,0 @@
# Claude Code 2.1.322, the gated-grants backstop in a 40-column tmux pane (T-005154 lab: fake HOME, no credential,
# no key sent): the warning and the decline option wrap across rows. Captured 2026-11-12 with tmux capture-pane -p.
────────────────────────────────────────
Accessing workspace:
<lab>/backstop/rep
o/cadeiras/rig/seat
Quick safety check: Is this a project
you created or one you trust? (Like
your own code, a well-known open
source project, or work from your
team). If not, take a moment to review
what's in this folder first.
Claude Code'll be able to read, edit,
and execute files here.
⚠ This folder pre-approves 2 tool
permissions in .claude/settings.json:
Bash(ls:*) and Bash(git status:*)
These will apply without asking. Only
proceed if you trust this
configuration.
Security guide
❯ No, continue without these
Yes, I trust this folder
Enter to confirm · Esc to cancel
@@ -1,47 +0,0 @@
# Real capture (12/11/2026): Claude Code 2.1.322, fresh HOME after OpenRig's pre-launch seed with no trust, a seat
# without .claude/settings.json; tmux 150x45. The plain workspace trust prompt opens with the focus on 'No, exit'.
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Accessing workspace:
<lab>/simples/repo/cadeiras/rig/seat
Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team).
If not, take a moment to review what's in this folder first.
Claude Code'll be able to read, edit, and execute files here.
Security guide
❯ No, exit
Yes, I trust this folder
Enter to confirm · Esc to cancel
-24
View File
@@ -1,24 +0,0 @@
OpenRig: this seat's skills. This list replaces any earlier OpenRig skill
list in this conversation.
No OpenRig skills are kept for this seat.
• ack (no marker)
Worked for <1s • 16:36
Select Model and Effort
› 1. GPT-6.1-Sol Latest workhorse model for coding and everyday work.
2. GPT-6-Astra Frontier intelligence for the most demanding work.
3. GPT-6-Sol Previous generation workhorse model.
4. GPT-6-Luna Fast and affordable model for easier tasks.
5. GPT-5.6-Sol Older generation workhorse model.
6. GPT-6.1 Terra GPT-6.1 Terra Codex model.
7. GPT-5.6-Terra Older balanced model for straightforward work.
8. GPT-5.6-Luna Older fast and efficient model.
↓
enter select · esc back
@@ -1,24 +0,0 @@
lists them all.
End of generated identity context.
OpenRig: this seat's skills. This list replaces any earlier OpenRig skill
list in this conversation.
No OpenRig skills are kept for this seat.
• ack (no marker)
Worked for <1s • 16:36
Select Reasoning Level for GPT-6.1-Sol
› 1. Low (default) Fast responses with lighter reasoning
2. Medium Balances speed and reasoning depth for everyday tasks
3. High Greater reasoning depth for complex problems
4. Extra high Extra high reasoning depth for complex problems
5. More reasoning… Max and Ultra consume usage limits faster
enter default · s session · esc back
@@ -1,133 +0,0 @@
// The program's own /model picker, from real captures of OpenRig seats on a stand-in model: Claude Code 2.1.282 (the
// transcript above it removed, a synthetic seat label) and 2.1.322, and Codex 0.167.2's model and reasoning-level steps.
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { classifyPaneActivity, SessionTransport } from "../src/domain/session-transport.js";
import { currentClaudeModelMenu, currentCodexModelPicker } from "../src/domain/native-model-picker.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { EventBus } from "../src/domain/event-bus.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { createFullTestDb } from "./helpers/test-app.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
const fixture = (name: string) => readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8");
const claude = fixture("claude-model-2.1.282.txt");
// The highlight on the first model, with wrapped descriptions: its row is above the last 12 lines a generic scan reads.
const claudeDefault = fixture("claude-model-2.1.322.txt");
const codexModel = fixture("codex-model-0.167.2.txt");
const codexReasoning = fixture("codex-reasoning-0.167.2.txt");
const rule = "─".repeat(60);
const claudeComposer = `${rule}\n❯ \n${rule}\n ⏵⏵ accept edits on (shift+tab to cycle)`;
const codexComposer = "› Ask Codex to do anything\n\n gpt-6.1-sol default · ~/work";
describe("the program's own model picker", () => {
it.each([
["Claude 2.1.282, as captured", claude, "claude-code", "Select model"],
["Claude 2.1.322, the highlight above the scan window", claudeDefault, "claude-code", "Select model"],
["Codex 0.167.2, the model popup", codexModel, "codex", "Select Model and Effort"],
["Codex 0.167.2, the reasoning-level step", codexReasoning, "codex", "Select Reasoning Level for GPT-6.1-Sol"],
] as const)("recognizes %s as a current selector, dialogue-only reads included", (_name, pane, runtime, header) => {
expect(classifyPaneActivity(pane, { runtime })).toMatchObject({ state: "attention", reason: "selection_prompt", evidence: header });
expect(classifyPaneActivity(pane, { runtime, currentClaudeDialogueOnly: true })).toMatchObject({ reason: "selection_prompt", evidence: header });
});
it.each([
["a later empty composer", claude + claudeComposer, codexModel + codexComposer],
["a later draft", claude + claudeComposer.replace("❯ ", "❯ keep my draft"), codexModel + codexComposer.replace("Ask Codex to do anything", "keep my draft")],
["quoted", claude.split("\n").map(line => "> " + line).join("\n"), codexModel.split("\n").map(line => "> " + line).join("\n")],
["fenced output", "```text\n" + claude + "```", "```text\n" + codexModel + "```"],
["an unknown footer", claude.replace("Enter to set as default", "Enter to approve"), codexModel.replace("enter select · esc back", "enter approve · esc back")],
["no header", claude.replace("Select model", "Select tools"), codexModel.replace("Select Model and Effort", "Select Tools")],
["no highlighted row", claude.replace("❯ 6.", " 6."), codexModel.replace("› 1.", " 1.")],
["two highlighted rows", claude.replace(" 4. Opus", " ❯ 4. Opus"), codexModel.replace(" 4. GPT-6-Luna", "› 4. GPT-6-Luna")],
])("doesn't read %s as a current picker", (_name, claudePane, codexPane) => {
expect(currentClaudeModelMenu(claudePane)).toBeNull();
expect(currentCodexModelPicker(codexPane)).toBeNull();
});
it("reads each program's picker only on that program's pane", () => {
expect(classifyPaneActivity(codexModel, { runtime: "claude-code", currentClaudeDialogueOnly: true }).reason).not.toBe("selection_prompt");
expect(classifyPaneActivity(claude, { runtime: "codex", currentClaudeDialogueOnly: true }).reason).not.toBe("selection_prompt");
});
});
type Hook = "Stop" | "UserPromptSubmit" | "SessionStart";
async function send(runtime: "claude-code" | "codex", pane: string, hook: Hook | null) {
const db = createFullTestDb();
try {
const repo = new RigRepository(db), registry = new SessionRegistry(db), rig = repo.createRig("picker");
const node = repo.addNode(rig.id, "worker.a", { runtime, role: "worker" });
const name = "worker-a@picker";
const session = registry.registerSession(node.id, name);
registry.updateStatus(session.id, "running");
registry.updateBinding(node.id, { tmuxSession: name });
const generation = registry.currentOccupantTenure(node.id)!.generationUuid;
const now = new Date("2026-11-12T17:00:00Z");
const store = new AgentActivityStore({ db, eventBus: new EventBus(db), now: () => now,
resolveOccupantGeneration: (id) => registry.currentOccupantTenure(id)?.generationUuid ?? null,
isRegisteredOccupantGeneration: (id, g) => Boolean(db.prepare(
"SELECT 1 FROM occupant_tenures WHERE node_id=? AND generation_uuid=?").get(id, g)),
});
if (hook) expect(store.recordHookEvent({ runtime, sessionName: name, hookEvent: hook, generation,
occurredAt: new Date(now.getTime() - 1_000).toISOString() }).ok).toBe(true);
const paste = vi.fn(async () => ({ ok: true as const })), keys = vi.fn(async () => ({ ok: true as const }));
const adapter = { hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
getPaneCommand: async () => (runtime === "codex" ? "codex" : "claude"), getPanePid: async () => null, listPanes: async () => [],
hasSessionEnv: async () => false, capturePaneContent: async () => pane, sendText: paste, sendKeys: keys,
} as unknown as TmuxAdapter;
const transport = new SessionTransport({ db, rigRepo: repo, sessionRegistry: registry, tmuxAdapter: adapter,
agentActivityStore: store, now: () => now, sleep: async () => undefined, activityEndpointFile: () => null });
const result = await transport.send(name, "Ordinary synthetic peer message");
return { result, written: paste.mock.calls.length + keys.mock.calls.length };
} finally { db.close(); }
}
describe("an ordinary send never types into an open model picker", () => {
it.each([
["Claude, no hooks", "claude-code", claudeDefault, null],
["Claude, a fresh Stop hook", "claude-code", claudeDefault, "Stop"],
["Codex, no hooks", "codex", codexModel, null],
["Codex, a fresh Stop hook", "codex", codexModel, "Stop"],
["Codex, the reasoning-level step after a fresh Stop hook", "codex", codexReasoning, "Stop"],
// Codex opens /model during a running turn too, and a fresh SessionStart reads unknown on both programs.
["Codex, a fresh UserPromptSubmit hook (a running turn)", "codex", codexModel, "UserPromptSubmit"],
["Codex, a fresh SessionStart hook", "codex", codexModel, "SessionStart"],
["Claude, a fresh SessionStart hook", "claude-code", claudeDefault, "SessionStart"],
["Claude, a fresh UserPromptSubmit hook (a running turn)", "claude-code", claudeDefault, "UserPromptSubmit"],
] as const)("%s: refused before any input", async (_name, runtime, pane, hook) => {
const sent = await send(runtime, pane, hook);
// One assertion, so a failure shows both what the send answered and what it wrote.
expect({ ok: sent.result.ok, reason: (sent.result as { reason?: string }).reason, written: sent.written })
.toEqual({ ok: false, reason: "target_needs_input", written: 0 });
expect(sent.result).toMatchObject({ sent: false });
});
it.each([
["Codex", "an empty composer", "codex", codexComposer],
["Codex", "an empty composer under a numbered message in the history", "codex", "› 1. check the build\n• ok\n\n" + codexComposer],
["Codex", "a picker's rows in the history, under a later composer", "codex", codexModel + "\n• Model changed\n\n" + codexComposer],
["Claude", "an empty composer", "claude-code", claudeComposer],
["Claude", "a picker's rows in the history, under a later composer", "claude-code", claudeDefault + "\n⎿ Set model to Opus 5.5\n\n" + claudeComposer],
] as const)("%s after a fresh Stop hook still delivers to %s", async (_program, _name, runtime, pane) => {
const sent = await send(runtime, pane, "Stop");
expect(sent.result.ok).toBe(true);
expect(sent.written).toBeGreaterThan(0);
});
it.each([
["Codex, a fresh SessionStart hook, an empty composer", "codex", codexComposer, "SessionStart"],
["Claude, a fresh SessionStart hook, an empty composer", "claude-code", claudeComposer, "SessionStart"],
] as const)("%s: still delivers", async (_name, runtime, pane, hook) => {
const sent = await send(runtime, pane, hook);
expect(sent.result.ok).toBe(true);
expect(sent.written).toBeGreaterThan(0);
});
it("Codex mid-turn with no picker still delivers, with the busy advisory, as before", async () => {
const sent = await send("codex", "• Working (4s • esc to interrupt)\n\n" + codexComposer, "UserPromptSubmit");
expect(sent.result).toMatchObject({ ok: true });
expect((sent.result as { warning?: string }).warning).toContain("mid-task");
expect(sent.written).toBeGreaterThan(0);
});
});
@@ -4,7 +4,6 @@ import {
assessNativeResumeProbe,
buildNativeResumeCommand,
buildCodexResumeCore,
claudeTrustPromptFocus,
isProbeShellReady,
} from "../src/domain/native-resume-probe.js";
@@ -1419,185 +1418,3 @@ describe("Claude's Resume session picker", () => {
.toMatchObject({ status: "resumed", code: "active_runtime" });
});
});
// #2820 item 2: Claude Code's "gated grants" backstop (2.1.319+). It looks like the workspace trust prompt
// ("Accessing workspace:", "Yes, I trust this folder"), but it also asks to approve the tool permissions the folder's
// .claude/settings.json pre-approves, and its default choice declines them ("No, continue without these permissions")
// or exits ("No, exit"). OpenRig must never answer it, not even with recovery.auto_drive_provider_prompts.
describe("Claude gated grants backstop (#2820 item 2)", () => {
// A real capture: Claude Code 2.1.319 in a lab git repo (see claude-trust-git-root.test.ts).
const capture = JSON.parse(fs.readFileSync(new URL("./fixtures/claude-trust-git-root/trust-checks.json", import.meta.url), "utf8")) as { gatedGrantsScreen: string[] };
const screen = capture.gatedGrantsScreen.join("\n");
const probe = (paneContent: string) => assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "claude", paneContent });
const gate = { status: "attention_required", code: "claude_gated_grants_gate" };
it("the captured screen, focus on 'No, continue without these permissions', is its own gate, never trust_gate", () => {
expect(probe(screen)).toMatchObject(gate);
expect(probe(screen).detail).toContain("OpenRig does not approve or bypass this review. No input was sent.");
});
it("is the same gate with the focus moved to 'Yes, I trust this folder'", () => {
const yes = screen.replace("❯ No, continue without these permissions", " No, continue without these permissions")
.replace(" Yes, I trust this folder", "❯ Yes, I trust this folder");
expect(probe(yes)).toMatchObject(gate);
});
it("is the same gate in the variant with nothing trusted yet, whose default choice is 'No, exit'", () => {
expect(probe(screen.replace("❯ No, continue without these permissions", "❯ No, exit"))).toMatchObject(gate);
});
it("is the same gate when the git root is the home folder (the case item 1 deliberately does not seed)", () => {
expect(probe(screen.replace("<lab>/repo/cadeiras/rig/seat", "/home/agent"))).toMatchObject(gate);
});
it("is the same gate drawn with blank rows between its parts and a long permission list", () => {
const rows = capture.gatedGrantsScreen.flatMap(row => [row, ""]);
rows.splice(rows.findIndex(row => row.includes("Bash(ls:*)")) + 1, 0, ...Array.from({ length: 30 }, (_, n) => ` Bash(tool-${n}:*)`));
expect(probe(rows.join("\n"))).toMatchObject(gate);
});
it("a backstop still being drawn is not a trust prompt to drive: it waits for the next read", () => {
const partial = capture.gatedGrantsScreen.slice(0, capture.gatedGrantsScreen.findIndex(row => row.includes("Security guide")));
expect(probe(partial.join("\n"))).toMatchObject({ status: "inconclusive", code: "awaiting_runtime" });
const noFooter = capture.gatedGrantsScreen.slice(0, -1).join("\n");
expect(probe(noFooter)).toMatchObject({ status: "inconclusive", code: "awaiting_runtime" });
});
it("a backstop left in the scrollback above a session that started is neither this gate nor trust_gate", () => {
const later = [screen, "", " ▐▛███▜▌ Claude Code v2.1.319", "", "────────────────", "❯ ", "────────────────"].join("\n");
expect(probe(later).code).not.toBe("claude_gated_grants_gate");
expect(probe(later).code).not.toBe("trust_gate");
});
it("is the same gate in a 40-column pane, whose rows wrap and cut its marks (real capture), never trust_gate", () => {
const narrow = fs.readFileSync(new URL("./fixtures/claude-trust-git-root/backstop-40col-2.1.322.txt", import.meta.url), "utf8")
.split("\n").filter(row => !row.startsWith("# ")).join("\n");
expect(narrow).not.toMatch(/No, continue without these permissions/);
expect(probe(narrow)).toMatchObject(gate);
expect(claudeTrustPromptFocus(narrow)).toBe("unknown");
});
it("a 40-column backstop captured before its wrapped warning is complete waits, never resumed", () => {
const rows = fs.readFileSync(new URL("./fixtures/claude-trust-git-root/backstop-40col-2.1.322.txt", import.meta.url), "utf8")
.split("\n").filter(row => !row.startsWith("# "));
const partial = rows.slice(0, rows.findIndex(row => row.includes("⚠ This folder pre-approves")) + 1).join("\n");
expect(partial).not.toMatch(/tool permissions/);
expect(probe(partial)).toMatchObject({ status: "inconclusive", code: "awaiting_runtime" });
});
it("is the same gate in 40 columns when a long permission list scrolled the whole warning out", () => {
const rows = fs.readFileSync(new URL("./fixtures/claude-trust-git-root/backstop-40col-2.1.322.txt", import.meta.url), "utf8")
.split("\n").filter(row => !row.startsWith("# "));
const from = rows.findIndex(row => row.includes("⚠ This folder pre-approves"));
const to = rows.findIndex(row => row.includes("configuration."));
const scrolled = [...rows.slice(0, from), ...rows.slice(to + 1)].join("\n");
expect(scrolled).not.toMatch(/pre-approves/);
expect(probe(scrolled)).toMatchObject(gate);
});
it("is the same gate drawn with numbered options", () => {
const numbered = screen.replace("❯ No, continue without these permissions", "❯ 1. No, continue without these permissions")
.replace(" Yes, I trust this folder", " 2. Yes, I trust this folder");
expect(probe(numbered)).toMatchObject(gate);
});
it("a reply quoting the backstop's words above a fresh plain prompt does not hide that prompt", () => {
const plain = fs.readFileSync(new URL("./fixtures/claude-trust-git-root/plain-trust-2.1.322.txt", import.meta.url), "utf8")
.split("\n").filter(row => !row.startsWith("# ")).join("\n");
const earlier = ["⏺ It reads \"This folder pre-approves 2 tool permissions\" and \"No, continue without these permissions\".",
"", "────────────────", "❯ ", "────────────────", ""].join("\n");
expect(probe(earlier + plain)).toMatchObject({ status: "inconclusive", code: "trust_gate" });
});
it("a draft in Claude's input box that reads like the backstop's option is never the gate", () => {
const started = [" ▐▛███▜▌ Claude Code v2.1.322", "", "⏺ Done.", "", "────────────────────────────────",
"❯ No, continue without the tests", "────────────────────────────────", " ⏵⏵ auto mode on (shift+tab to cycle)"].join("\n");
expect(probe(started)).toMatchObject({ status: "resumed" });
});
it("nor is that draft while the input frame's closing rule is not captured yet", () => {
const drawing = [" ▐▛███▜▌ Claude Code v2.1.322", "", "⏺ Done.", "", "────────────────────────────────",
"❯ No, continue without the tests"].join("\n");
expect(probe(drawing)).toMatchObject({ status: "resumed" });
});
// A draft's text never moves the cut: Claude draws its frame's rules from column 0 and a draft's continuation rows
// indented under the ❯, so a rule, a reply or a header pasted into a draft is the draft's, not Claude's.
const header = " ▐▛███▜▌ Claude Code v2.1.322";
const rule = "────────────────────────────────";
it.each([
["a pasted rule", " ────────"],
["a pasted reply", "⏺ Earlier output"],
["a pasted header", " Claude Code v2.1.322"],
])("a multi-line draft with %s above the backstop's words is never the gate", (_case, pasted) => {
const draft = ["❯ Review this example:", pasted, " No, continue without the tests"];
const closed = [header, "", "⏺ Done.", "", rule, ...draft, rule, " ⏵⏵ auto mode on (shift+tab to cycle)"].join("\n");
expect(probe(closed)).toMatchObject({ status: "resumed" });
const drawing = [header, "", "⏺ Done.", "", rule, ...draft].join("\n");
expect(probe(drawing)).toMatchObject({ status: "resumed" });
});
it("a draft that mentions a Claude Code version reads resumed, closed or still open (maintainer's case)", () => {
const draft = ["Claude Code v2.1.322", rule, "❯ Explain this Claude Code v2.1.322 message:", " No, continue without the tests"];
expect(probe([...draft, rule, " ⏵⏵ accept edits on (shift+tab to cycle)"].join("\n"))).toMatchObject({ status: "resumed" });
expect(probe(draft.join("\n"))).toMatchObject({ status: "resumed" });
});
it("a draft that starts with a pasted header is never the gate either", () => {
const started = [header, "", "⏺ Done.", "", rule, "❯ Claude Code v2.1.322 asks:", " No, continue without the tests", rule].join("\n");
expect(probe(started)).toMatchObject({ status: "resumed" });
});
it("is the same gate below an old reply that quotes an input frame, with no input frame after it", () => {
// a quoted frame is indented like the reply: it is no frame of Claude's, so it never blanks the rows below it
const quoted = [header, "", "⏺ The screen looked like:", " ────────", " ❯ hi", " ────────", "", "$ claude --resume x", ""].join("\n");
expect(probe(`${quoted}\n${capture.gatedGrantsScreen.join("\n")}`)).toMatchObject(gate);
});
it("is the same gate below an old session whose input frame holds a draft (N1)", () => {
const earlier = [header, "", "⏺ Done.", "", rule, "❯ Review this example:", " ────────", " more text", rule, ""].join("\n");
expect(probe(`${earlier}\n${screen}`)).toMatchObject(gate);
});
it("names the focused choice in the gate's detail", () => {
expect(probe(screen).detail).toContain("\"No, continue without these permissions\"");
const yes = screen.replace("❯ No, continue without these permissions", " No, continue without these permissions")
.replace(" Yes, I trust this folder", "❯ Yes, I trust this folder");
expect(probe(yes).detail).toContain("\"Yes, I trust this folder\"");
});
it("the plain workspace trust prompt is still trust_gate (it stays auto-drivable)", () => {
const plain = ["Accessing workspace:", "/some/workspace", "", "Quick safety check: Is this a project you created or one you trust?",
"❯ 1. Yes, I trust this folder", " 2. No, exit"].join("\n");
expect(probe(plain)).toMatchObject({ status: "inconclusive", code: "trust_gate" });
});
});
// #2820 item 2: the auto-drive presses a key on the trust prompt only for an allowlisted screen.
describe("claudeTrustPromptFocus (#2820 item 2)", () => {
const plain = fs.readFileSync(new URL("./fixtures/claude-trust-git-root/plain-trust-2.1.322.txt", import.meta.url), "utf8")
.split("\n").filter(row => !row.startsWith("# ")).join("\n");
const backstop = (JSON.parse(fs.readFileSync(new URL("./fixtures/claude-trust-git-root/trust-checks.json", import.meta.url), "utf8")) as { gatedGrantsScreen: string[] }).gatedGrantsScreen.join("\n");
it("reads 'no' on the real 2.1.322 prompt, focused on 'No, exit'", () => {
expect(claudeTrustPromptFocus(plain)).toBe("no");
});
it("reads 'yes' once the focus is on 'Yes, I trust this folder'", () => {
expect(claudeTrustPromptFocus(plain.replace("❯ No, exit", " No, exit").replace(" Yes, I trust this folder", "❯ Yes, I trust this folder"))).toBe("yes");
});
it("reads numbered prompts and the › cursor", () => {
expect(claudeTrustPromptFocus("Accessing workspace:\n/w\n❯ 1. Yes, I trust this folder\n 2. No, exit")).toBe("yes");
expect(claudeTrustPromptFocus("Accessing workspace:\n/w\n 1. Yes, I trust this folder\n› 2. No, exit")).toBe("no");
});
it("is 'unknown' without a cursor: the label cannot be checked", () => {
expect(claudeTrustPromptFocus("Accessing workspace:\n/w\n1. Yes, I trust this folder\n2. No, exit")).toBe("unknown");
});
it("is 'unknown' on the backstop's options, even with the focus moved to 'Yes'", () => {
expect(claudeTrustPromptFocus(backstop)).toBe("unknown");
expect(claudeTrustPromptFocus(backstop.replace("❯ No, continue without these permissions", " No, continue without these permissions")
.replace(" Yes, I trust this folder", "❯ Yes, I trust this folder"))).toBe("unknown");
});
it("is 'unknown' on any other option set", () => {
expect(claudeTrustPromptFocus(plain.replace(" Yes, I trust this folder", " Yes, I trust this folder\n No, ask me later"))).toBe("unknown");
});
});
+3 -112
View File
@@ -39,13 +39,11 @@ beforeEach(() => {
});
afterEach(() => db.close());
/** A row whose every content field and linked copy carries `secret-<id>`, with its history at chosen ages. `openrig`:
* OpenRig created it (a sweep finding, an escalation), so the create stages its wake from OpenRig's own session. */
async function row(id: string, source: string, destination: string, history: Array<{ state: string; daysAgo: number }> = [],
wakeTrigger?: "openrig") {
/** A row whose every content field and linked copy carries `secret-<id>`, with its history at chosen ages. */
async function row(id: string, source: string, destination: string, history: Array<{ state: string; daysAgo: number }> = []) {
const secret = `secret-${id}`;
await repo.create({ qitemId: id, sourceSession: source, destinationSession: destination, body: `${secret} body`, summary: `${secret} summary`,
tags: [`${secret}-tag`], wakeTrigger });
tags: [`${secret}-tag`] });
db.prepare("UPDATE queue_transitions SET ts = ?, transition_note = ? WHERE qitem_id = ?").run(ago(400), `${secret} created`, id);
for (const step of history) {
const { transitionId } = log.append({ qitemId: id, state: step.state, actorSession: destination, transitionNote: `${secret} note` });
@@ -684,110 +682,3 @@ describe("round 3 controls", () => {
expect(everything()).toContain("secret-l2-new body");
});
});
describe("OpenRig's own wakes and notices", () => {
const DAEMON = "daemon@kernel";
const HEALTH = "system:health";
const members = { kind: "members" as const, members: [A, B] };
const wakeOf = (id: string) => db.prepare("SELECT sender_session AS from_, destination_session AS to_ FROM outbox_entries WHERE outbox_id = ?")
.get(`wake-intent-${id}`);
const loose = (table: "outbox_entries" | "inbox_entries", id: string, from: string, to: string, body: string) => {
if (table === "outbox_entries") {
db.prepare(`INSERT INTO outbox_entries (outbox_id, sender_session, destination_session, body, ts_dispatched) VALUES (?, ?, ?, ?, ?)`)
.run(id, from, to, body, NOW);
} else {
db.prepare(`INSERT INTO inbox_entries (inbox_id, sender_session, destination_session, body, ts_dropped) VALUES (?, ?, ?, ?, ?)`)
.run(id, from, to, body, NOW);
}
};
it("an OpenRig-triggered finding to a member erases clean, with OpenRig's wake for it and its notices", async () => {
await row("f-finding", A, A, [], "openrig");
// The premise: the finding's wake is staged from OpenRig, not from the member named as its source.
expect(wakeOf("f-finding")).toEqual({ from_: DAEMON, to_: A });
await row("f-escalation", DAEMON, B, [], "openrig");
await row("f-health", HEALTH, A, [{ state: "done", daysAgo: 2 }], "openrig");
loose("outbox_entries", "o-notice", DAEMON, A, "secret-loose-notice");
loose("inbox_entries", "i-notice", HEALTH, B, "secret-loose-inbox");
const dry = await eraseQueue(db, { scope: members, apply: false, nowIso: NOW });
const report = await eraseQueue(db, { scope: members, apply: true, nowIso: NOW });
expect({ ...report, applied: false }).toEqual(dry);
expect(report.qitems.map((q) => q.qitemId)).toEqual(["f-escalation", "f-finding", "f-health"]);
expect(report.mixed).toEqual([]);
expect(report.keptCopies).toEqual([]);
expect(report.unlinked).toMatchObject({ outbox: 1, inbox: 1, mixedEntries: 0 });
expect(report.clean).toBe(true);
const all = everything();
expect(all).not.toContain("secret-f-");
expect(all).not.toContain("secret-loose-");
});
it("an OpenRig wake to another account's seat is kept, and a row OpenRig woke with one member endpoint stays mixed", async () => {
await row("f-own", A, B, [], "openrig");
await row("x-finding", OUT, OUT, [], "openrig");
await row("x-escalation", DAEMON, OUT, [], "openrig");
await row("x-mixed", OUT, A, [], "openrig");
loose("outbox_entries", "o-elsewhere", DAEMON, OUT, "secret-loose-elsewhere");
const report = await eraseQueue(db, { scope: members, apply: true, nowIso: NOW });
expect(report.qitems.map((q) => q.qitemId)).toEqual(["f-own"]);
expect(report.mixed).toEqual([{ qitemId: "x-mixed", memberEndpoint: "destination" }]);
expect(report.unlinked).toMatchObject({ outbox: 0, mixedEntries: 0 });
expect(report.clean).toBe(false);
const all = everything();
for (const id of ["x-finding", "x-escalation", "x-mixed"]) {
expect(exists(id)).toBe(true);
expect(all).toContain(`secret-${id} body`);
}
expect(wakeOf("x-finding")).toEqual({ from_: DAEMON, to_: OUT });
expect(wakeOf("x-mixed")).toEqual({ from_: DAEMON, to_: A });
expect(all).toContain("secret-loose-elsewhere");
expect(all).not.toContain("secret-f-own");
});
it("OpenRig's entries with itself are untouched, and a session that only looks like OpenRig's is outside the account", async () => {
await row("f-own", A, B, [], "openrig");
loose("outbox_entries", "o-self", DAEMON, DAEMON, "secret-loose-self");
loose("outbox_entries", "o-health", HEALTH, DAEMON, "secret-loose-health");
loose("inbox_entries", "i-self", HEALTH, DAEMON, "secret-loose-inbox-self");
// isOpenRigSender is exact: "System:" isn't OpenRig's prefix.
loose("outbox_entries", "o-lookalike", "System:health", A, "secret-loose-lookalike");
const report = await eraseQueue(db, { scope: members, apply: true, nowIso: NOW });
expect(report.qitems.map((q) => q.qitemId)).toEqual(["f-own"]);
expect(report.unlinked).toMatchObject({ outbox: 0, inbox: 0, mixedEntries: 1 });
expect(report.clean).toBe(false);
const all = everything();
for (const body of ["secret-loose-self", "secret-loose-health", "secret-loose-inbox-self", "secret-loose-lookalike"]) expect(all).toContain(body);
expect(all).not.toContain("secret-f-own");
});
it("rerunning the same plan over what an earlier daemon left removes it: the erased row's OpenRig wake, and its OpenRig rows", async () => {
await row("r-finding", A, A, [], "openrig");
await row("r-escalation", DAEMON, B, [], "openrig");
// What an earlier daemon's run leaves: it deleted r-finding but kept OpenRig's wake for it as mixed, and left
// r-escalation untouched as a mixed row. Built here by deleting r-finding and restoring its wake as it was.
const wake = db.prepare("SELECT * FROM outbox_entries WHERE outbox_id = 'wake-intent-r-finding'").get() as Record<string, unknown>;
await eraseQueue(db, { scope: { kind: "ids", entries: [{ qitemId: "r-finding", action: "delete" }] }, apply: true, nowIso: NOW });
const columns = Object.keys(wake);
db.prepare(`INSERT OR REPLACE INTO outbox_entries (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`)
.run(...columns.map((c) => wake[c]));
expect([exists("r-finding"), marker("r-finding") !== undefined, exists("r-escalation")]).toEqual([false, true, true]);
expect(everything()).toContain("secret-r-finding summary");
const rerun = await eraseQueue(db, { scope: members, apply: true, nowIso: NOW });
expect(rerun.qitems.map((q) => q.qitemId)).toEqual(["r-escalation"]);
expect(rerun.unlinked).toMatchObject({ outbox: 1, mixedEntries: 0 });
expect(rerun.clean).toBe(true);
expect(everything()).not.toContain("secret-r-");
});
it("routine retention deletes OpenRig's wake for a row with the row, and OpenRig's own rows keep theirs together", async () => {
await row("t-old", A, B, [{ state: "done", daysAgo: 40 }], "openrig");
await row("t-self", DAEMON, DAEMON, [{ state: "done", daysAgo: 40 }], "openrig");
expect(wakeOf("t-old")).toEqual({ from_: DAEMON, to_: B });
const report = await eraseQueue(db, { scope: { kind: "routine", contentDays: 0, metadataDays: 30 }, apply: true, nowIso: NOW });
expect(report.qitems.map((q) => [q.qitemId, q.action])).toEqual([["t-old", "delete"], ["t-self", "delete"]]);
expect(report.keptCopies).toEqual([]);
expect(report.clean).toBe(true);
expect(everything()).not.toContain("secret-t-");
});
});
@@ -244,46 +244,6 @@ describe("stuck sweep: unclaimed rows routed to a delegation parent (#2988)", ()
expect(openFor(q).length).toBeGreaterThan(0);
});
it.each(["parent", "own"] as const)("a readable %s notice reports its row again after a transient typing hold clears", async (kind) => {
if (kind === "parent") lane("dev1"); else seat("lane", "pool.dev1", "dev1@lane");
const to = kind === "parent" ? "owner@lane" : "dev1@lane";
const row = await work("dev1@lane", 90);
await sweep();
const [notice] = findingsTo(to);
expect(notice!.body).toContain(`Every row this notice stands for: ["${row.qitemId}"]`);
const before = queue.getById(row.qitemId)!;
db.prepare("UPDATE queue_items SET last_nudge_result = 'retained:typing_guard' WHERE qitem_id = ?").run(row.qitemId);
await sweep();
expect(queue.getById(notice!.qitemId)).toMatchObject({ state: "done" });
expect(findingsTo(to).filter(isOpen)).toHaveLength(0);
// No new row transition, timestamp or wake attempt: ending the temporary custody is the new fact.
db.prepare("UPDATE queue_items SET last_nudge_result = NULL WHERE qitem_id = ?").run(row.qitemId);
await sweep();
await sweep();
const open = findingsTo(to).filter(isOpen);
expect(open).toHaveLength(1);
expect(open[0]!.body).toContain(row.qitemId);
expect(open[0]!.qitemId).not.toBe(notice!.qitemId);
expect(queue.getById(row.qitemId)).toMatchObject({ state: "pending", claimedAt: null,
tsUpdated: before.tsUpdated, lastNudgeAttempt: before.lastNudgeAttempt });
});
it.each(["parent", "own"] as const)("a dismissed %s notice still holds back its row across a transient typing hold", async (kind) => {
if (kind === "parent") lane("dev1"); else seat("lane", "pool.dev1", "dev1@lane");
const to = kind === "parent" ? "owner@lane" : "dev1@lane";
const row = await work("dev1@lane", 90);
await sweep();
const [notice] = findingsTo(to);
await queue.update({ qitemId: notice!.qitemId, actorSession: to, state: "done",
closureReason: "no-follow-on", transitionNote: "seen" });
db.prepare("UPDATE queue_items SET last_nudge_result = 'retained:typing_guard' WHERE qitem_id = ?").run(row.qitemId);
await sweep();
db.prepare("UPDATE queue_items SET last_nudge_result = NULL WHERE qitem_id = ?").run(row.qitemId);
await sweep();
expect(findingsTo(to)).toHaveLength(1);
expect(findingsTo(to).filter(isOpen)).toHaveLength(0);
});
// Review of #3049: the route is rechecked at the fold, not only the finding's state: a parent resolution that changes
// or becomes ambiguous while the notice is being written leaves the per-row finding open and unmarked.
it.each(["ambiguous", "gone"] as const)("a fold rechecks the route: a parent that becomes %s while the notice is written folds nothing", async (change) => {
@@ -121,34 +121,6 @@ describe("stuck sweep: unclaimed rows of a seat with no escalation route (#2430)
expect(queue.getById(notice!.qitemId)).toMatchObject({ state: "pending" });
});
it.each([false, true])("a per-row finding keeps its open or dismissed disposition across a typing hold (dismissed: %s)", async (dismissed) => {
const door = seat("front", "door.root", "root@front");
seat("ops", "watch.desk", "desk@ops");
rigs.addEdge(door.rig.id, door.node.id, "desk@ops", "escalates_to");
let sends = 0;
queue.attachTransport({ send: async () => { sends++; return { ok: true, verified: false }; } });
const row = await work("root@front");
await sweep();
const [finding] = findingsTo("desk@ops");
expect(finding!.tags).toContain(`stuck-sweep:unclaimed-obligation:${row.qitemId}`);
if (dismissed) await queue.update({ qitemId: finding!.qitemId, actorSession: "desk@ops", state: "done",
closureReason: "no-follow-on", transitionNote: "seen" });
const expected = dismissed ? "done" : "pending";
const before = queue.getById(row.qitemId)!;
const sent = sends;
db.prepare("UPDATE queue_items SET last_nudge_result = 'retained:typing_guard' WHERE qitem_id = ?").run(row.qitemId);
await sweep();
expect(queue.getById(finding!.qitemId)?.state).toBe(expected);
db.prepare("UPDATE queue_items SET last_nudge_result = NULL WHERE qitem_id = ?").run(row.qitemId);
await sweep();
await sweep();
expect(findingsTo("desk@ops").map((f) => ({ id: f.qitemId, state: f.state })))
.toEqual([{ id: finding!.qitemId, state: expected }]);
expect(sends).toBe(sent);
expect(queue.getById(row.qitemId)).toMatchObject({ state: "pending", claimedAt: null,
tsUpdated: before.tsUpdated, lastNudgeAttempt: before.lastNudgeAttempt });
});
it("with an escalates_to route, each unclaimed row's finding goes to that seat, never to the seat itself", async () => {
const door = seat("front", "door.root", "root@front");
seat("ops", "watch.desk", "desk@ops");
@@ -1,166 +0,0 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SettingsStore, SETTINGS_VALID_KEYS, isSettingsValidKey, parseFeedHostSubscriptionKey } from "../src/domain/user-settings/settings-store.js";
import { settingsBrowser } from "../src/domain/user-settings/settings-browser.js";
import { configRoutes } from "../src/routes/config.js";
let home: string;
let n = 0;
const freshStore = () => new SettingsStore(join(home, `config-${n++}.json`));
const browse = () => settingsBrowser(freshStore(), null, home);
/** The write path is the oracle: does the daemon's own set() accept this raw value for this key? */
const accepts = (key: string, raw: string): boolean => {
try { freshStore().set(key, raw); return true; } catch { return false; }
};
/** Each accepts 1 to 100 on its own, but set() also requires warning < critical: a cross-key rule these per-key fields do not describe. */
const PAIRED = new Set(["health.context_pressure.warning_percent", "health.context_pressure.critical_percent"]);
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "s06-browser-meta-"));
for (const key of Object.keys(process.env).filter((k) => /^(OPENRIG_|RIGGED_|SLACK_)/.test(k))) vi.stubEnv(key, "");
vi.stubEnv("OPENRIG_HOME", home);
vi.stubEnv("OPENRIG_WORKSPACE_ROOT", join(home, "workspace"));
});
afterEach(() => { vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); });
describe("browser entry metadata", () => {
it("adds type, choices, range, format, write route and timing without changing the existing fields", () => {
const entries = browse().entries;
expect(entries.find((e) => e.key === "terminal.history_limit")).toMatchObject({
type: "integer", range: { min: 2000, max: 1_000_000 }, writeRoute: true, appliesAt: "unknown",
value: expect.anything(), defaultKnown: true, application: expect.stringContaining("tmux"),
});
expect(entries.find((e) => e.key === "terminal.tmux_server")).toMatchObject({
type: "enum", choices: ["inherit", "isolated"], writeRoute: true, appliesAt: "daemon_restart",
});
expect(entries.find((e) => e.key === "daemon.port")).toMatchObject({ type: "integer", range: { min: 0, max: 65535 } });
expect(entries.find((e) => e.key === "retention.stream_days")).toMatchObject({ type: "integer", range: { min: 1 } });
expect(entries.find((e) => e.key === "retention.stream_days")!.range).not.toHaveProperty("max");
expect(entries.find((e) => e.key === "ui.timezone")).toMatchObject({ type: "string", format: "iana-timezone" });
expect(entries.find((e) => e.key === "retention.enabled")).toMatchObject({ type: "boolean", appliesAt: "daemon_restart" });
// A key with no validator reports a type and nothing it cannot back up.
const free = entries.find((e) => e.key === "host.name")!;
expect(free).toMatchObject({ type: "string", writeRoute: true });
expect(free).not.toHaveProperty("choices");
expect(free).not.toHaveProperty("range");
expect(free).not.toHaveProperty("format");
});
it("matches the daemon's own validator for every key it can be written under", () => {
const entries = browse().entries.filter((e) => e.group === "general" && isSettingsValidKey(e.key));
expect(entries.map((e) => e.key)).toEqual([...SETTINGS_VALID_KEYS]);
for (const e of entries) {
expect(e.writeRoute, e.key).toBe(true);
expect(e.type, e.key).toBeDefined();
if (e.type === "boolean") {
expect(accepts(e.key, "true"), e.key).toBe(true);
expect(accepts(e.key, "false"), e.key).toBe(true);
expect(accepts(e.key, "not-a-boolean"), e.key).toBe(false);
}
if (e.type === "integer" && !PAIRED.has(e.key)) {
expect(accepts(e.key, "not-a-number"), e.key).toBe(false);
if (e.range?.min !== undefined) {
expect(accepts(e.key, String(e.range.min)), `${e.key} min`).toBe(true);
expect(accepts(e.key, String(e.range.min - 1)), `${e.key} below min`).toBe(false);
}
if (e.range?.max !== undefined) {
expect(accepts(e.key, String(e.range.max)), `${e.key} max`).toBe(true);
expect(accepts(e.key, String(e.range.max + 1)), `${e.key} above max`).toBe(false);
}
// Where a side is left out, the validator must accept a value far on that side.
if (e.range?.max === undefined) expect(accepts(e.key, "1000000000"), `${e.key} no max`).toBe(true);
if (e.range?.min === undefined) expect(accepts(e.key, "0") || accepts(e.key, "1"), `${e.key} no min`).toBe(true);
}
if (e.choices) {
expect(e.type, e.key).toBe("enum");
for (const choice of e.choices) expect(accepts(e.key, choice), `${e.key}=${choice}`).toBe(true);
expect(accepts(e.key, "not-one-of-the-choices"), e.key).toBe(false);
}
}
});
it("states a range only where the validator is an interval, at sample points across the whole line", () => {
const samples = [-1_000_000_000, -100_001, -1000, -61, -60, -2, -1, 0, 1, 2, 59, 60, 100, 101, 599, 600, 601,
999, 1000, 1999, 2000, 2001, 3600, 3601, 65535, 65536, 999_999, 1_000_000, 1_000_001, 1_000_000_000];
const ranged = browse().entries.filter((x) => x.type === "integer" && x.range && !PAIRED.has(x.key));
expect(ranged.length).toBeGreaterThan(25);
for (const e of ranged) {
for (const sample of samples) {
const inside = (e.range!.min === undefined || sample >= e.range!.min) && (e.range!.max === undefined || sample <= e.range!.max);
expect(accepts(e.key, String(sample)), `${e.key} at ${sample}`).toBe(inside);
}
}
});
it("reports the per-key range of the paired percentages and leaves their cross-key rule out", () => {
const entries = browse().entries;
for (const key of PAIRED) expect(entries.find((e) => e.key === key)).toMatchObject({ type: "integer", range: { min: 1, max: 100 } });
// Per key the bounds are the validator's: critical takes 100, nothing takes 0 or 101 ...
expect(accepts("health.context_pressure.critical_percent", "100")).toBe(true);
for (const key of PAIRED) for (const bad of ["0", "101"]) expect(accepts(key, bad), `${key}=${bad}`).toBe(false);
expect(accepts("health.context_pressure.warning_percent", "1")).toBe(true);
// ... while the pair rule (warning below critical) still refuses a value inside that range, and is not a field here.
expect(accepts("health.context_pressure.warning_percent", "100")).toBe(false);
expect(entries.find((e) => e.key === "health.context_pressure.warning_percent")).not.toHaveProperty("pair");
});
it("names a format that agrees with what the validator accepts and refuses", () => {
const cases: Record<string, { format: string; ok: string[]; bad: string[] }> = {
"ui.timezone": { format: "iana-timezone", ok: ["Europe/London", "UTC"], bad: ["Not/AZone", "+01:00"] },
"policies.claude_compaction.preparation_root": { format: "absolute-path-or-empty", ok: ["/var/lib/example", ""], bad: ["relative/path"] },
"decisions.template_dir": { format: "absolute-path-or-empty", ok: ["/var/lib/example", ""], bad: ["relative/path"] },
"retention.erasure_ledger": { format: "absolute-path-or-empty", ok: ["/var/lib/example/ledger", ""], bad: ["relative/ledger"] },
"stream.intake": { format: "queue.worker, off, or member@rig", ok: ["off", "queue.worker", "seat@rig"], bad: ["no at sign"] },
"acp.to": { format: "member@rig-or-empty", ok: ["seat@rig", ""], bad: ["no-at-sign", "a@b@c"] },
};
const entries = browse().entries;
for (const [key, c] of Object.entries(cases)) {
expect(entries.find((e) => e.key === key)!.format, key).toBe(c.format);
for (const ok of c.ok) expect(accepts(key, ok), `${key}=${ok}`).toBe(true);
for (const bad of c.bad) expect(accepts(key, bad), `${key}=${bad}`).toBe(false);
}
// Every key that reports a format is in this table, so a new one cannot ship unchecked.
expect(entries.filter((e) => e.format).map((e) => e.key).sort()).toEqual(Object.keys(cases).sort());
});
it("reports the write route as the daemon's own key rules decide, for every entry", () => {
const store = freshStore();
store.set("feed.subscriptions.fixture-host.enabled", "true");
const entries = settingsBrowser(store, null, home).entries;
for (const e of entries) {
expect(e.writeRoute, e.key).toBe(isSettingsValidKey(e.key) || parseFeedHostSubscriptionKey(e.key) !== null);
}
expect(entries.find((e) => e.key === "feed.subscriptions.fixture-host.enabled")).toMatchObject({ type: "boolean", writeRoute: true });
expect(entries.find((e) => e.key === "slack.enabled")).toMatchObject({ writeRoute: false });
expect(entries.find((e) => e.key === "slack.enabled")).not.toHaveProperty("type");
expect(entries.filter((e) => e.group !== "general" && e.writeRoute)).toEqual([]);
});
it("agrees with the write route itself: the keys it reports writable are accepted, the others are unknown keys", async () => {
const store = freshStore();
const app = new Hono();
app.use("*", async (c, next) => { c.set("settingsStore" as never, store as never); await next(); });
app.route("/api/config", configRoutes({ home }));
const post = (key: string, value: string) => app.request(`/api/config/${key}`, {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ value }) });
expect((await post("terminal.history_limit", "5000")).status).toBe(200);
expect((await post("terminal.history_limit", "1999")).status).toBe(400);
const refused = await post("slack.enabled", "true");
expect(refused.status).toBe(400);
expect((await refused.json()).error).toContain("Unknown config key");
const browser = await (await app.request("/api/config?view=browser")).json();
expect(browser.entries.find((e: { key: string }) => e.key === "terminal.history_limit")).toMatchObject({ writeRoute: true, range: { min: 2000, max: 1_000_000 } });
expect(browser.entries.find((e: { key: string }) => e.key === "slack.enabled")).toMatchObject({ writeRoute: false });
expect(browser.readOnly).toBe(true);
});
it("marks timing only where the application guidance says a restart applies it, and says unknown otherwise", () => {
for (const e of browse().entries) {
const guidanceSaysRestart = /\bdaemon (?:restart|startup)\b/i.test(e.application);
expect(e.appliesAt, e.key).toBe(guidanceSaysRestart ? "daemon_restart" : "unknown");
}
});
});
@@ -18,13 +18,13 @@ const runFile = promisify(execFile);
const roots: string[] = [];
afterAll(() => { for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); });
/** The executable and argv node spawns for each child started while `fn` runs. */
async function spawnedCommands(fn: () => Promise<void>): Promise<{ file: string; args: string[] }[]> {
/** The file node spawns for each child started while `fn` runs. */
async function spawnedFiles(fn: () => Promise<void>): Promise<string[]> {
const children: ChildProcess[] = [];
const onChild = (message: unknown) => { children.push((message as { process: ChildProcess }).process); };
diagnostics.subscribe("child_process", onChild);
try { await fn(); } finally { diagnostics.unsubscribe("child_process", onChild); }
return children.map((child) => ({ file: child.spawnfile, args: child.spawnargs.slice(1) }));
return children.map((child) => child.spawnfile);
}
async function scenario(mode: "shell" | "argv") {
@@ -43,14 +43,12 @@ async function scenario(mode: "shell" | "argv") {
await runFile("tmux", ["-f", "/dev/null", "new-session", "-d", "-s", "anchor", "sleep 600"], { env: anchorEnv });
vi.stubEnv("TMUX", undefined);
vi.stubEnv("TMUX_TMPDIR", serverDir);
// Exercise the direct server-start path independently of an inherited systemd service environment.
vi.stubEnv("MANAGERPID", undefined);
let calls = 0;
const counted = <A,>(fn: (arg: A) => Promise<string>) => (arg: A) => { calls++; return fn(arg); };
const adapter = new TmuxAdapter(counted(execCommand), undefined, mode === "argv" ? counted(execArgvCommand) : undefined);
try {
let result: Record<string, unknown> = {};
const commands = await spawnedCommands(async () => {
const files = await spawnedFiles(async () => {
const created = await adapter.createSession("seat a", root);
const duplicate = await adapter.createSession("seat a", root);
// Wait for the fixture's /bin/sh login prompt. Input typed during system profile startup can be echoed before
@@ -95,7 +93,7 @@ async function scenario(mode: "shell" | "argv") {
result.noServerCause = probe.state === "transport_unavailable" ? probe.cause : null;
result.noServerSessions = await adapter.listSessions();
});
return { result, files: commands.map((command) => command.file), commands, calls, root: fs.realpathSync(root) };
return { result, files, calls, root: fs.realpathSync(root) };
} finally {
vi.unstubAllEnvs();
await runFile("tmux", ["kill-server"], { env: anchorEnv }).catch(() => {});
@@ -152,26 +150,11 @@ describe.skipIf(!nativeAvailable)("tmux shell and argv transports against a real
expect(comparable(argv.result)).toEqual(comparable(shell.result));
});
it("the argv transport uses a limit-setting shell for creation and a direct tmux path for ordinary calls", () => {
it("the shell transport starts /bin/sh for every call, the argv transport starts tmux by its path, or /bin/sh when PATH leaves it to the shell", () => {
const tmux = resolveOnPath("tmux", process.env.PATH);
if (tmux) expect(tmux).toMatch(/^\/.*\/tmux$/);
expect(shell.calls).toBeGreaterThan(15);
expect(shell.files).toEqual(Array(shell.calls).fill("/bin/sh"));
expect(argv.commands).toHaveLength(argv.calls);
// Both createSession attempts issue start-server ; source-file for their private config.
// Only those clients' child shells raise their soft limit;
// the executable and original arguments follow the fixed script as positional parameters.
const creating = argv.commands.filter((command) => command.args.includes("start-server"));
expect(creating).toHaveLength(2);
for (const command of argv.commands) {
if (creating.includes(command)) {
expect(command.file).toBe("/bin/sh");
expect(command.args[0]).toBe("-c");
expect(command.args[1]).toContain('exec "$0" "$@"');
expect(command.args.slice(2, 6)).toEqual(["tmux", "start-server", ";", "source-file"]);
} else {
expect(command.file).toBe(tmux ?? "/bin/sh");
}
}
expect(argv.files).toEqual(Array(argv.calls).fill(tmux ?? "/bin/sh"));
});
});
@@ -1,86 +0,0 @@
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { TMUX_SERVER_LIMIT_PREFIX, TMUX_SERVER_LIMIT_WARNING, tmuxServerStartCommand } from "../src/tmux-server-start.js";
const shell = (script: string, args: string[] = []) => spawnSync("/bin/sh", ["-c", script, "nofile-test", ...args], { encoding: "utf8", timeout: 10_000 });
let tmux = false;
try { execFileSync("tmux", ["-V"], { stdio: "ignore" }); tmux = true; } catch { /* Native prerequisite absent. */ }
describe.skipIf(process.platform === "win32")("tmux server creation limit", () => {
it("raises only the soft limit, capped by the hard limit and 8192", () => {
const result = shell('ulimit -S -n 256; ' + TMUX_SERVER_LIMIT_PREFIX + 'printf "%s %s" "$(ulimit -S -n)" "$(ulimit -H -n)"');
expect(result.status).toBe(0);
const [soft, hard] = result.stdout.split(" ");
expect(Number(soft)).toBe(hard === "unlimited" ? 8192 : Math.min(Number(hard), 8192));
expect(result.stderr).toBe("");
});
it("does not lower a soft limit above the target", ({ skip }) => {
const result = shell('ulimit -S -n 16384 2>/dev/null || exit 77; ' + TMUX_SERVER_LIMIT_PREFIX + 'ulimit -S -n');
if (result.status === 77) skip(); // This host's hard limit cannot supply the fixture.
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("16384");
});
it("honours a lower hard limit without changing it", ({ skip }) => {
const result = shell('ulimit -S -n 256; ulimit -H -n 1024 2>/dev/null || exit 77; ' + TMUX_SERVER_LIMIT_PREFIX + 'printf "%s %s" "$(ulimit -S -n)" "$(ulimit -H -n)"');
if (result.status === 77) skip();
expect(result.status).toBe(0);
expect(result.stdout).toBe("1024 1024");
expect(result.stderr).toBe("");
});
it("continues exactly once with unchanged argv and limit when raising fails", async () => {
const command = tmuxServerStartCommand("/bin/sh", ["-c", 'printf "ran:%s:%s" "$(ulimit -S -n)" "$1"', "payload", "quoted ' $HOME ; new-session", "new-session"]);
// Supply a real low inherited soft limit and a shell builtin override that refuses only a raise.
const prelude = 'ulimit -S -n 256; ulimit() { case "$*" in "-H -n") printf "8192\\n";; "-S -n 8192") return 1;; *) command ulimit "$@";; esac; }; ';
// Execute the production script in that same shell so the failed-set premise reaches it.
const result = spawnSync("/bin/sh", ["-c", prelude + command.args[1], ...command.args.slice(2)], { encoding: "utf8", timeout: 10_000 });
expect(result.status).toBe(0);
expect(result.stdout).toBe("ran:256:quoted ' $HOME ; new-session");
expect(result.stderr.trim()).toBe(TMUX_SERVER_LIMIT_WARNING);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
vi.resetModules();
const { reportTmuxServerLimit } = await import("../src/tmux-server-start.js");
reportTmuxServerLimit(result.stderr);
reportTmuxServerLimit(result.stderr);
expect(warn).toHaveBeenCalledExactlyOnceWith(TMUX_SERVER_LIMIT_WARNING);
} finally { warn.mockRestore(); }
});
it("keeps ordinary capture/send commands direct", () => {
for (const args of [["capture-pane", "-p"], ["send-keys", "-l", "text"], ["-V"]]) {
expect(tmuxServerStartCommand("tmux", args)).toEqual({ file: "tmux", args });
}
});
it.skipIf(!tmux)("a fresh private server inherits the raised limit; an existing one is preserved", async () => {
const dir = mkdtempSync(join(tmpdir(), "or-nofile-"));
const socket = join(dir, "socket");
const limit = join(dir, "limit");
const query = (...args: string[]) => execFileSync("tmux", ["-S", socket, ...args], { encoding: "utf8", timeout: 5000 });
try {
const start = shell('ulimit -S -n 256; ' + TMUX_SERVER_LIMIT_PREFIX + 'exec tmux -S "$1" -f /dev/null new-session -d -s owned /bin/sh -c \'ulimit -S -n > "$1"; sleep 30\' pane "$2"', [socket, limit]);
expect(start.status, start.stderr).toBe(0);
let actual = "";
for (let i = 0; i < 100; i++) {
try { actual = readFileSync(limit, "utf8").trim(); if (actual) break; } catch { /* Pane is starting. */ }
await new Promise(resolve => setTimeout(resolve, 10));
}
const hard = shell('ulimit -H -n').stdout.trim();
expect(Number(actual)).toBe(Math.min(hard === "unlimited" ? Infinity : Number(hard), 8192));
const pid = query("display-message", "-p", "#{pid}").trim();
const again = shell('ulimit -S -n 256; ' + TMUX_SERVER_LIMIT_PREFIX + 'exec tmux -S "$1" new-session -d -s another sleep 30', [socket]);
expect(again.status, again.stderr).toBe(0);
expect(query("display-message", "-p", "#{pid}").trim()).toBe(pid);
expect(query("list-sessions", "-F", "#{session_name}")).toContain("owned");
} finally {
try { query("kill-server"); } catch { /* Only this test's exact private socket. */ }
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -625,19 +625,6 @@ describe("SettingsStore (User Settings v0)", () => {
}
});
it("a non-string provider.selected in config.json falls back to the default with a warning", () => {
writeFileSync(configPath, JSON.stringify({ provider: { selected: ["claude"] } }));
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const store = new SettingsStore(configPath);
expect(store.resolveOne("provider.selected")).toMatchObject({ value: "", source: "default" });
const warns = stderrSpy.mock.calls.map((c) => String(c[0]));
expect(warns.some((w) => w.includes("file value for provider.selected rejected"))).toBe(true);
} finally {
stderrSpy.mockRestore();
}
});
it("a quoted in-range port in config.json keeps its port, and the CLI target and daemon bind agree", () => {
writeFileSync(configPath, JSON.stringify({ daemon: { port: "7434" } }));
const store = new SettingsStore(configPath);
-49
View File
@@ -1,49 +0,0 @@
import { CHECK_IN_DISPLAY, describeCheckIn, type CheckInStatus } from "@openrig/daemon/connection-contract";
import { wrapDetailLines, type ContentLine } from "./detail.js";
import { displayTime } from "./time.js";
import type { ConnectedAssistant } from "./types.js";
/** The hosted-members answer as connected assistants; null when it isn't one. A status this TUI doesn't know reads
* unknown, as in `rig ps`. */
export function toConnectedAssistants(body: unknown): ConnectedAssistant[] | null {
const members = (body as { members?: unknown } | null)?.members;
if (!Array.isArray(members)) return null;
return members.flatMap((member: Record<string, unknown>) => {
if (typeof member?.seat !== "string" || !member.seat) return [];
const known = typeof member.status === "string" && member.status in CHECK_IN_DISPLAY;
return [{ seat: member.seat, checkIn: {
status: known ? member.status as CheckInStatus : "unknown",
lastCheckInAt: typeof member.lastCheckInAt === "string" ? member.lastCheckInAt : null,
expectedMinutes: typeof member.expectedMinutes === "number" ? member.expectedMinutes : null,
onRequest: member.onRequest === true,
...(known && typeof member.reason === "string" ? { reason: member.reason } : {}),
} }];
});
}
/** The hosted-members read as the list, or why it couldn't be read, in `rig ps`'s words: an older daemon's 404, a failed
* read, or an answer that isn't one. A failed read isn't an empty list. */
export function connectedAssistantsOf(answer: { status: number; body: unknown }): { assistants: ConnectedAssistant[] } | { unavailable: string } {
if (answer.status === 404) return { unavailable: "this daemon doesn't serve it" };
if (answer.status >= 400) return { unavailable: `the read failed: HTTP ${answer.status}` };
const assistants = toConnectedAssistants(answer.body);
return assistants ? { assistants } : { unavailable: "the daemon's answer couldn't be read" };
}
/** The connected assistants under a seat table, each with its check-in line in the daemon's words and the time in the
* viewer's zone; only one rig's when `rig` is given. Nothing when there are none; one line saying check-in is
* unavailable, with the reason, when the list couldn't be read. */
export function connectedAssistantLines(assistants: ConnectedAssistant[] | undefined, unavailable: string | undefined, rig: string | null,
filter: string | null, width: number, timeZone?: string): ContentLine[] {
if (unavailable) return [{ text: "" }, ...wrapDetailLines([{ text: `Connected assistants: check-in unavailable (${unavailable})` }], width)];
const shown = (assistants ?? [])
.filter((a) => !rig || a.seat.endsWith(`@${rig}`))
.filter((a) => !filter || a.seat.includes(filter));
if (shown.length === 0) return [];
return [
{ text: "" },
{ text: "Connected assistants (outside the terminal; each checks its own queue):" },
...wrapDetailLines(shown.map((a) => ({ text: ` ${a.seat} ${describeCheckIn({ ...a.checkIn,
lastCheckInAt: a.checkIn.lastCheckInAt === null ? null : displayTime(a.checkIn.lastCheckInAt, timeZone) })}` })), width),
];
}
-8
View File
@@ -419,14 +419,6 @@ export class DaemonClient {
const query = [item ? `item=${encodeURIComponent(item)}` : null, view ? `view=${encodeURIComponent(view)}` : null].filter(Boolean).join("&");
return this.get(`/api/attention${query ? `?${query}` : ""}`);
}
/** Connected assistants (roster members outside any terminal) and their check-in. Optional: an older daemon has no
* such route. */
/** This daemon's connected assistants, as the status and body it answered; an older daemon answers 404. A failed read
* isn't an empty list, so the caller is given the status to tell them apart. */
async hostedMembers(): Promise<{ status: number; body: unknown }> {
const res = await this.optionalFetchImpl(`${this.baseUrl}/api/queue/hosted-members`, { headers: this.headers, signal: AbortSignal.timeout(5_000) });
return { status: res.status, body: res.ok ? await res.json().catch(() => null) : null };
}
queueItem(qitemId: string, options: { optional?: boolean } = {}) {
return this.get(`/api/queue/${encodeURIComponent(qitemId)}`, options.optional ? this.optionalFetchImpl : this.fetchImpl);
}
-12
View File
@@ -24,7 +24,6 @@ import { parse as parseYaml } from "yaml";
import type { AgentRow, FleetSnapshot, HealthCoverage, HealthRecord, HostNode, NeedsItem, PodNode, QueueRead, RecentTransitionSnap, SeatActivitySummary, SeatEffortRead, SliceDetailSnap, SpecEntry, ViewState } from "./types.js";
import { isHumanSeatSession } from "./pulse/pulse-model.js";
import { sanitizeHostDisplayName } from "./topology/host-label.js";
import { connectedAssistantsOf } from "./connected-assistants.js";
// Narrow read-shapes: just the served fields this module consumes (names match
// the daemon's serialized output — see the Phase-2 endpoint-shape survey).
@@ -573,15 +572,6 @@ export async function hydrateSnapshot(
// A drilled-into host that isn't this one: its pages read none of this host's seats or queue changes.
const drilledHost = viewContext?.section === "topology" ? viewContext.drill.find((segment) => segment.kind === "host")?.name : undefined;
const remoteDrill = !!drilledHost && drilledHost !== (instanceHealth?.selfHostId?.trim() || "local");
// Optional, like the Slack manifest: a failed read isn't a page failure. But it isn't an empty list either, so the seat
// tables say check-in is unavailable, as `rig ps` does. Read only for the instance and rig tables that list them, and
// only this daemon's, never a remote host's.
const listsAssistants = !viewContext || (viewContext.section === "topology" && viewContext.viewTab === "table"
&& (topologyLeaf?.kind === "host" || topologyLeaf?.kind === "rig"));
const connectedAssistants = listsAssistants && !remoteDrill
? await client.hostedMembers().then(connectedAssistantsOf,
(error: unknown) => ({ unavailable: `the read failed: ${error instanceof Error ? error.message : String(error)}` }))
: null;
const recentTransitionsRig = currentRigName ?? (!viewContext ? summaries?.[0]?.name : null) ?? null;
const recentTransitionsScope = topologyLeaf?.kind === "host"
? { kind: "instance" } as const
@@ -695,8 +685,6 @@ export async function hydrateSnapshot(
displayName: ownName && ownName !== "localhost" ? ownName : undefined,
reachable: true,
rigs: rigs.sort(byRigName),
...(connectedAssistants ? "unavailable" in connectedAssistants
? { connectedAssistantsUnavailable: connectedAssistants.unavailable } : { connectedAssistants: connectedAssistants.assistants } : {}),
};
// A remote host is any registered host, or one the attention aggregate answered for. Its reachability is the aggregate's
// answer where there is one, else the registry's probe. Only a drilled-into remote host's rigs are read, through this
-3
View File
@@ -31,7 +31,6 @@ import type { CrashCartModel } from "./crash-cart/crash-cart-model.js";
import type { DaemonState, DaemonUnverifiedEvidence } from "./crash-cart/contract.js";
import { runtimeMarkSegs } from "./topology/runtime-marks.js";
import { hostLabel, hostReachabilityLabel } from "./topology/host-label.js";
import { connectedAssistantLines } from "./connected-assistants.js";
import { barCells, flashActive, reducedMotion, spinnerFrame } from "./motion.js";
import { explorerWidth, MOTION_FRAME_MS } from "./visual-layout.js";
import type { ColorMode, Token } from "./theme.js";
@@ -868,7 +867,6 @@ function instanceContentLines(
});
}
}
lines.push(...connectedAssistantLines(host.connectedAssistants, host.connectedAssistantsUnavailable, null, state.filter, contentWidth, state.timeZone));
lines.push({ text: "" }, { text: host.rigs.some(r => r.inventoryUnavailable) ? `${host.rigs.length} rigs · inventory incomplete · ${seatCount} seats read` : `${host.rigs.length} rigs · ${seatCount} seats · ${workingCount} working · ${attentionCount} need attention${attentionUnknown ? ` · ${attentionUnknown} attention unknown` : ""} · ${openCount}${queueIncomplete(snap) ? "+ sampled" : ""} open rows` });
lines.push(...recentLines(snap, scope, contentWidth, false, state.timeZone));
return lines;
@@ -1109,7 +1107,6 @@ function contentLines(state: ViewState, snap: FleetSnapshot, contentWidth: numbe
zones,
});
}
if (!podFilter) lines.push(...connectedAssistantLines(host.connectedAssistants, host.connectedAssistantsUnavailable, rig.name, state.filter, contentWidth, state.timeZone));
lines.push({ text: "" });
const working = rows.filter((agent) => ["active", "working", "running"].includes(agent.status)).length;
const attention = rows.filter((agent) => /attention|needs|blocked|unknown|failed/.test(agent.status)).length;
-12
View File
@@ -97,18 +97,6 @@ export interface HostNode {
/** true/false only when a probe answered; null keeps missing or unknown distinct. */
reachable: boolean | null;
rigs: RigNode[];
/** Roster members outside any terminal (assistants reaching the team over MCP) and their check-in, read from this
* daemon only. Absent when not read, or when the read failed. */
connectedAssistants?: ConnectedAssistant[];
/** Why the connected assistants couldn't be read (an older daemon, a failed read, an unusable answer); absent when
* they were read or not asked for. */
connectedAssistantsUnavailable?: string;
}
/** A connected assistant's check-in as this daemon reports it. */
export interface ConnectedAssistant {
seat: string;
checkIn: Pick<import("@openrig/daemon/connection-contract").MemberCheckIn, "status" | "lastCheckInAt" | "expectedMinutes" | "onRequest" | "reason">;
}
export type SpecKind = "rig" | "agent" | "workflow";
-2
View File
@@ -37,7 +37,6 @@ const SPEC_4A_ROUTES = [
"/api/provider/status",
"/api/provider/usage/hosts",
"/api/telemetry/usage/cost",
"/api/queue/hosted-members",
];
describe("daemon client = the §4.A table, one module, nothing else (FR-8/FR-9)", () => {
@@ -76,7 +75,6 @@ describe("daemon client = the §4.A table, one module, nothing else (FR-8/FR-9)"
await c.providerStatus();
await c.usageAcrossHosts();
await c.usageCost();
await c.hostedMembers();
expect(seen.sort()).toEqual([...SPEC_4A_ROUTES].sort());
});
-63
View File
@@ -6,7 +6,6 @@ import { hydrateSnapshot } from "../src/hydrate.js";
import { renderScreen } from "../src/render.js";
import { createViewState } from "../src/state.js";
import { rowStatusGlyph, statusGlyph } from "../src/topology/glyphs.js";
import { displayTime } from "../src/time.js";
// Fixtures mirror the SERVED shapes traced firsthand at 5f3b5bd4 (Phase-2
// endpoint-shape survey): real field names, real enum values, real evidence
@@ -163,7 +162,6 @@ const FIXTURES: Record<string, unknown> = {
"/api/queue/list?state=pending&limit=50": [],
"/api/queue/list?state=done,handed-off&sort=updated&limit=20": [],
"/api/queue/recent-transitions?scope=rig&rig=myrig&limit=20": [],
"/api/queue/hosted-members": { hostId: "mm2-openrig1", members: [] },
};
function fixtureClient(
@@ -175,8 +173,6 @@ function fixtureClient(
const route = String(url).replace("http://x", "");
calls?.push(route);
const failure = overrides[route];
// Status 0 stands for a read that never got an answer, such as a dropped connection.
if (failure?.status === 0) throw new Error("fetch failed");
if (failure) return { ok: false, status: failure.status, json: async () => failure.body ?? null } as Response;
if (route in responses) return { ok: true, json: async () => responses[route] } as Response;
if (!(route in FIXTURES)) throw new Error(`unexpected route in test: ${route}`);
@@ -756,65 +752,6 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
});
});
it("lists connected assistants and their check-in under the seat tables, one rig's in the rig view", async () => {
const hosted = { hostId: "mm2-openrig1", members: [
{ seat: "chat@myrig", rung: false, status: "check-in-overdue", lastCheckInAt: "2026-11-12T13:05:00.000Z", expectedMinutes: 60, onRequest: false, reporter: "openrig-observed" },
{ seat: "desk@myrig", rung: false, status: "on-request", lastCheckInAt: "2026-11-12T12:00:00.000Z", expectedMinutes: null, onRequest: true, reporter: "openrig-observed" },
{ seat: "bot@downrig", rung: false, status: "a-newer-status", lastCheckInAt: null, expectedMinutes: null, onRequest: false, reporter: "openrig-observed", reason: "not shown" },
] };
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/queue/hosted-members": hosted }));
expect(snap.readErrors.some((error) => error.includes("hosted"))).toBe(false);
const rigView = createViewState({ instanceId: "t", getSnapshot: () => snap });
rigView.dispatch({ type: "drill", resource: "rig", name: "myrig" });
const rigLines = renderScreen(rigView.get(), snap, { cols: 220, rows: 60 }).lines.join("\n");
expect(rigLines).toContain("Connected assistants (outside the terminal; each checks its own queue):");
expect(rigLines).toContain(`chat@myrig overdue: last check-in ${displayTime("2026-11-12T13:05:00.000Z")}, expected every 60 min; its schedule may be off or waiting for an approval`);
expect(rigLines).toContain(`desk@myrig on request: it checks its queue when a person asks it; last check-in ${displayTime("2026-11-12T12:00:00.000Z")}`);
expect(rigLines).not.toContain("bot@downrig");
const instanceView = createViewState({ instanceId: "t", getSnapshot: () => snap });
instanceView.dispatch({ type: "drill", resource: "host", name: snap.hosts[0]!.name });
const instanceLines = renderScreen(instanceView.get(), snap, { cols: 160, rows: 60 }).lines.join("\n");
expect(instanceLines).toContain("chat@myrig overdue:");
// A status this TUI doesn't know reads unknown, without the newer daemon's reason.
expect(instanceLines).toContain("bot@downrig unknown");
expect(instanceLines).not.toContain("not shown");
});
it("a known-empty list of connected assistants stays quiet", async () => {
const snap = await hydrateSnapshot(fixtureClient());
expect(snap.hosts[0]!.connectedAssistants).toEqual([]);
expect(snap.hosts[0]!.connectedAssistantsUnavailable).toBeUndefined();
const view = createViewState({ instanceId: "t", getSnapshot: () => snap });
view.dispatch({ type: "drill", resource: "rig", name: "myrig" });
expect(renderScreen(view.get(), snap, { cols: 160, rows: 60 }).lines.join("\n")).not.toContain("Connected assistants");
});
it.each([
["an older daemon without the route", { overrides: { "/api/queue/hosted-members": { status: 404 } } }, "this daemon doesn't serve it"],
["a failed read", { overrides: { "/api/queue/hosted-members": { status: 500 } } }, "the read failed: HTTP 500"],
["a dropped connection", { overrides: { "/api/queue/hosted-members": { status: 0 } } }, "the read failed: fetch failed"],
["an answer without a members list", { responses: { "/api/queue/hosted-members": { hostId: "mm2-openrig1" } } }, "the daemon's answer couldn't be read"],
])("%s says check-in is unavailable, not that there are none", async (_case, setup, reason) => {
const snap = await hydrateSnapshot(fixtureClient("overrides" in setup ? setup.overrides : {}, "responses" in setup ? setup.responses : {}));
expect(snap.hosts[0]!.connectedAssistants).toBeUndefined();
expect(snap.hosts[0]!.connectedAssistantsUnavailable).toBe(reason);
// Optional, like the other enrichment: the page itself still reads as whole.
expect(snap.readErrors).toEqual([]);
const line = `Connected assistants: check-in unavailable (${reason})`;
const rigView = createViewState({ instanceId: "t", getSnapshot: () => snap });
rigView.dispatch({ type: "drill", resource: "rig", name: "myrig" });
const rigLines = renderScreen(rigView.get(), snap, { cols: 160, rows: 60 }).lines.join("\n");
expect(rigLines).toContain(line);
expect(rigLines).not.toContain("each checks its own queue");
const instanceView = createViewState({ instanceId: "t", getSnapshot: () => snap });
instanceView.dispatch({ type: "drill", resource: "host", name: snap.hosts[0]!.name });
expect(renderScreen(instanceView.get(), snap, { cols: 160, rows: 60 }).lines.join("\n")).toContain(line);
});
it("shows confirmed missing sessions as detached unless lifecycle still requires attention", async () => {
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
const nodes = [
@@ -66,9 +66,7 @@ it("a 24-rig landing fetches names only; a selected rig never reads unrelated in
]);
// The rig Table reads health once without fetching other rigs' node inventories.
expect(calls.filter(c => c.startsWith("/api/health"))).toEqual(["/api/health?limit=200"]);
// One read of this daemon's connected assistants, which the rig Table lists after its seats.
expect(calls.filter(c => c === "/api/queue/hosted-members")).toHaveLength(1);
expect(calls).toHaveLength(11);
expect(calls).toHaveLength(10);
expect(snap.health).toMatchObject({ availability: "loaded", total: 0, records: [] });
expect(renderScreen(view.get(), snap, { cols: 220, rows: 60 }).lines.join("\n")).toContain("no findings served; not a healthy verdict");
});