Compare commits

..
Author SHA1 Message Date
review50-r3andClaude Opus 5.5 1a52040b6b perf(daemon): name the queue's synchronous reads in the slow-operation log
An event-loop gap in the slow-operation log carries no site, so a stall during
one of the queue's synchronous reads couldn't be told from any other. Name
three of them:

- queue.handoff: a whole handoff, which runs synchronously (runSyncSite).
- queue.linked_successors.build: the stuck sweep's linked-successor read
  (runSyncSite).
- queue.decision_revision.replies: decisionRevision's read of a row's replies,
  which runs on every row read and on each row of the attention read. A begin
  and an end record per call would crowd the bounded log, so it goes through a
  new runSyncSiteWhenSlow, which records only a call at or above the existing
  slow threshold and writes no begin record. The recorder gains
  recordSlowSync for it; a failing record never replaces the call's own result.

Tests pin each site through a fake recorder, the real recorder's threshold and
outcome for recordSlowSync, and the new labels in the accepted-site list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 20:53:59 +00:00
16 changed files with 137 additions and 816 deletions

No files matched your search

-246
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, install 0.7.13, then restart the daemon:** `rig daemon stop`,
copy the database file (`rig config get db.path` prints its path), install
OpenRig 0.7.13 using your existing installation method, 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
@@ -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`
+1 -10
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
+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.
@@ -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);
@@ -136,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",
@@ -361,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
@@ -549,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. */
+11 -4
View File
@@ -105,6 +105,7 @@ import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, r
import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, formatHumanAnswers, humanAnswerText, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js";
import { slackMicros } from "./gateway/slack/state-store.js";
import { askBackRoute, RUNNER_CLOSE_WAITS, terminalAnswerMeaning } from "./receiver-context.js";
import { runSyncSite, runSyncSiteWhenSlow } from "./sync-site-wrap.js";
/** A direct ask's answer row ID: from the ask, or from the ask and the park episode its answer belongs to. */
function humanAnswerId(askId: string, episodeKey?: string): string {
@@ -821,14 +822,14 @@ export function createLinkedSuccessorLookup(db: Database.Database, sourceQitemId
const startedAt = performance.now();
const map = new Map<string, Array<{ qitemId: string; destination: string | null }>>();
const ids = JSON.stringify([...sources]);
const rows = db.prepare(
const rows = runSyncSite("queue.linked_successors.build", () => db.prepare(
`SELECT s.qitem_id, s.destination_session, s.handed_off_from AS source FROM queue_items s
WHERE s.handed_off_from IN (SELECT value FROM json_each(?))
UNION
SELECT s.qitem_id, s.destination_session, j.value AS source FROM queue_items s, json_each(s.chain_of_record) j
WHERE s.chain_of_record IS NOT NULL AND json_valid(s.chain_of_record)
AND j.value IN (SELECT value FROM json_each(?))`,
).all(ids, ids) as Array<{ qitem_id: string; destination_session: string | null; source: string }>;
).all(ids, ids)) as Array<{ qitem_id: string; destination_session: string | null; source: string }>;
for (const r of rows) {
const list = map.get(r.source);
const entry = { qitemId: r.qitem_id, destination: r.destination_session };
@@ -2634,8 +2635,13 @@ export class QueueRepository {
}
/** {@link handoff}'s whole act, synchronously: its transaction commits before this returns, and the wake is delivered
* after commit without being awaited, so a synchronous caller (an answer's close path) can hand a row on. */
* after commit without being awaited, so a synchronous caller (an answer's close path) can hand a row on. Named in
* the slow-operation log, so a stall during a handoff is attributed to it. */
private handoffNow(input: QueueHandoffInput): { closed: QueueItem; created: QueueItem } {
return runSyncSite("queue.handoff", () => this.handoffWithin(input));
}
private handoffWithin(input: QueueHandoffInput): { closed: QueueItem; created: QueueItem } {
const source = this.getById(input.qitemId);
if (!source) {
throw new QueueRepositoryError(
@@ -4563,8 +4569,9 @@ export class QueueRepository {
typeof opts?.excludeOperationReply === "string" && opts.excludeOperationReply.length > 0
? mcOperationTag(opts.excludeOperationReply)
: null;
// Named only when slow: it runs on every row read and on each row of the attention read.
const replies = this.hasReplyToColumn
? (excludeOp
? runSyncSiteWhenSlow("queue.decision_revision.replies", () => excludeOp
? (this.db
.prepare(
"SELECT qitem_id FROM queue_items WHERE reply_to = ? AND NOT (tags IS NOT NULL AND json_valid(tags) AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)) ORDER BY ts_created, rowid",
@@ -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).
@@ -48,6 +48,8 @@ export interface SlowOperationInstrumentation {
classify?: (value: T) => "ok" | "failed",
): Promise<T>;
recordRequest?(site: string, durationMs: number): void;
/** A synchronous site's duration, recorded only at or above the slow threshold (runSyncSiteWhenSlow). */
recordSlowSync?(site: string, durationMs: number, outcome: "ok" | "failed"): void;
/** Bounded nonblocking diagnostic append; false means observation was dropped. */
recordDiagnostic?(record: Record<string, unknown>): boolean;
snapshot?(): SlowOperationSnapshot;
@@ -262,6 +264,11 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
this.recordMeasurement(`request:${site}`, durationMs);
}
recordSlowSync(site: string, durationMs: number, outcome: "ok" | "failed" = "ok"): void {
if (!Number.isFinite(durationMs) || durationMs < this.slowThresholdMs) return;
this.recordMeasurement(site, durationMs, outcome);
}
recordDiagnostic(record: Record<string, unknown>): boolean {
this.diagnostic.offered++;
const coverage = { ...this.diagnostic, pending: this.diagnosticIds.size,
@@ -10,6 +10,26 @@ export function runSyncSite<T>(site: string, fn: () => T): T {
return recorder?.runSync ? recorder.runSync(site, fn) : fn();
}
/**
* runSyncSite for a site on a hot path (per request, per row), where a begin and an end record per call would crowd
* the bounded log: it records only a call that took at least the recorder's slow threshold, and writes no begin
* record, so a call that wedges the loop is named once it ends rather than while it runs.
*/
export function runSyncSiteWhenSlow<T>(site: string, fn: () => T): T {
const record = recorder?.recordSlowSync?.bind(recorder);
if (!record) return fn();
const startedAt = performance.now();
let outcome: "ok" | "failed" = "failed";
try {
const value = fn();
outcome = "ok";
return value;
} finally {
// Measurement only: a failing record never replaces fn's own result or error.
try { record(site, performance.now() - startedAt, outcome); } catch { /* the recorder reports its own health */ }
}
}
/**
* Async twin of runSyncSite: instruments a NON-blocking site through the
* recorder's async `runStage` seam (falls back to a bare call when no recorder
@@ -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);
});
});
@@ -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
@@ -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");
});
});
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository, createLinkedSuccessorLookup } from "../src/domain/queue-repository.js";
import { configureSyncSiteRecorder, runSyncSiteWhenSlow } from "../src/domain/sync-site-wrap.js";
// The queue's synchronous reads named in the slow-operation log, so an event-loop stall during one is attributed to it.
// A fake recorder takes every call; the real recorder's slow threshold is pinned in slow-op-recorder.test.ts.
describe("queue reads named in the slow-operation log", () => {
let db: Database.Database;
let repo: QueueRepository;
const sync: string[] = [];
const slow: Array<{ site: string; outcome: string }> = [];
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
repo = new QueueRepository(db, new EventBus(db));
configureSyncSiteRecorder({
runSync<T>(site: string, fn: () => T): T { sync.push(site); return fn(); },
recordSlowSync(site: string, _durationMs: number, outcome: "ok" | "failed"): void { slow.push({ site, outcome }); },
});
});
afterEach(() => { configureSyncSiteRecorder(undefined); sync.length = 0; slow.length = 0; db.close(); });
it("names a handoff", async () => {
const source = await repo.create({ sourceSession: "planner@r", destinationSession: "relay@r", body: "work", nudge: false });
sync.length = 0;
const { created } = await repo.handoff({ qitemId: source.qitemId, fromSession: "relay@r", toSession: "worker@r", nudge: false });
expect(created.destinationSession).toBe("worker@r");
expect(sync).toContain("queue.handoff");
});
it("names decisionRevision's reply read through the slow-only site, once per revision", async () => {
const ask = await repo.create({ sourceSession: "planner@r", destinationSession: "relay@r", body: "work", nudge: false });
slow.length = 0;
const revision = repo.decisionRevision(repo.getById(ask.qitemId)!);
expect(typeof revision).toBe("string");
expect(slow).toEqual([{ site: "queue.decision_revision.replies", outcome: "ok" }]);
});
it("names the stuck sweep's linked-successor build when it is built", () => {
const lookup = createLinkedSuccessorLookup(db, ["qitem-source"]);
expect(sync).toEqual([]);
expect(lookup.has("qitem-source", "worker@r")).toBe(false);
expect(lookup.has("qitem-source", "other@r")).toBe(false);
expect(sync).toEqual(["queue.linked_successors.build"]);
});
it("keeps the wrapped call's result and error, and records each outcome", () => {
expect(runSyncSiteWhenSlow("test.site", () => 7)).toBe(7);
expect(() => runSyncSiteWhenSlow("test.site", () => { throw new Error("read failed"); })).toThrow("read failed");
expect(slow).toEqual([{ site: "test.site", outcome: "ok" }, { site: "test.site", outcome: "failed" }]);
});
it("never lets a failing record replace the wrapped call's result", () => {
configureSyncSiteRecorder({ recordSlowSync(): void { throw new Error("recorder gone"); } });
expect(runSyncSiteWhenSlow("test.site", () => "value")).toBe("value");
expect(() => runSyncSiteWhenSlow("test.site", () => { throw new Error("read failed"); })).toThrow("read failed");
});
it("runs the call bare when no recorder is configured", () => {
configureSyncSiteRecorder(undefined);
expect(runSyncSiteWhenSlow("test.site", () => "bare")).toBe("bare");
expect(slow).toEqual([]);
});
});
@@ -21,6 +21,7 @@ const expectedSites = new Map<string, string[]>([
["domain/codex-thread-id.ts", ["codex_thread_id.resolve_home"]],
["domain/review/gather.ts", ["review.gather.git"]],
["domain/tmux-option-defaults.ts", ["tmux_options.command_v"]],
["domain/queue-repository.ts", ["queue.decision_revision.replies", "queue.linked_successors.build", "queue.handoff"]],
]);
const pluginVendorUrl = "https://github.com/mvschwarz/openrig-plugins/releases/latest/download/openrig-core.tar.gz";
@@ -290,6 +291,23 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
await recorder.close();
});
it("records a slow synchronous site only at or above the slow threshold, with its outcome", async () => {
const mod = await loadRecorderModule();
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
if (!mod) return;
const dir = tempDir();
const logPath = path.join(dir, "slow-operations.jsonl");
const recorder = new mod.SlowOpRecorder({ logPath, slowThresholdMs: 250 });
recorder.recordSlowSync("queue.decision_revision.replies", 249, "ok");
recorder.recordSlowSync("queue.decision_revision.replies", 250, "ok");
recorder.recordSlowSync("queue.decision_revision.replies", 400, "failed");
await recorder.flush();
const records = (await readRecords(logPath)).filter((record) => record.site === "queue.decision_revision.replies");
expect(records.map((record) => [record.phase, record.durationMs, record.outcome])).toEqual([["end", 250, "ok"], ["end", 400, "failed"]]);
await recorder.close();
});
// The request-timing MIDDLEWARE CONTRACT, hermetic + deterministic. The prior "real server" form
// ran the full daemon (event-loop monitor + a globalThis.fetch monkey-patch), which is structurally
// ambient-fragile under the concurrent-workspace gate: it intermittently returned 200 for an unmatched