mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 16:03:17 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a52040b6b |
No files matched your search
-246
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
-32
@@ -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
|
||||
|
||||
Reference in new issue
Block a user