From aaa93d42524ca8a5a170471013cc427feb327c97 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 1 Sep 2026 02:31:26 +0200 Subject: [PATCH] fix(session): answer Claude Code 2.1.252's reversed folder-trust dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every claude session in a directory claude had not seen before died about six seconds after it started (`Pane is dead (status 1)`), before the agent drew a composer. Reproduced on a fresh case and measured. Claude Code 2.1.252 rewrote the dialog. It used to be ❯ 1. Yes, I trust this folder 2. No, exit and is now unnumbered, reversed, and highlights the option that quits: ❯ No, exit Yes, I trust this folder Detection still worked (the confirm affordance carries the match once the numbered option text is gone), so the failure was entirely in the answer: the auto-accept pressed Enter on the highlighted default, which is now exit. trustDialogNextKey() reads the ❯ marker off the rendered pane and returns ONE keystroke at a time: an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option, and null for a frame that does not say. Both layouts are handled, and which way the trust option lies is read from the frame rather than assumed, so a further reordering costs a repaint instead of a session. The last marked option wins, because the direct-PTY fallback reads an append-only buffer where an older frame must not out-vote the freshest one. Two things only a live pane showed: - The scan ran solely from the PTY onData handler. The arrow that moves the cursor is the last output the pane produces, so the first fix parked every session with the cursor sitting on the right option and no Enter ever sent. It now schedules its own follow-up read (_trustDialogTimer, cleared in _clearAllTimers()), offset past the scan throttle so the chain cannot break on a boundary. - The keystroke cap goes 3 -> 6, since answering is no longer one press. The bundled codeman skill had the same blind \r as its bounded fallback, so preamble 1.21.0 replaces it with _trust_key/_accept_trust: read terminal?full=1, steer onto the trust option, re-read, then confirm. Those keystrokes go out under their own clientId, because input sequence numbers are monotonic per client and spending prompt numbers on dialog keys would make the next send-and-wait look like a stale duplicate and vanish while reporting success. The readiness recipes in docs/extending-codeman.md, docs/api-reference.md and the skill's own reference carry the corrected answer, plus a symptom-table entry for a worker whose pane is dead seconds after spawn. Verified live on an isolated instance (own data dir and tmux socket): fresh case -> arrow at 5 s -> Enter at 7 s -> composer, with hasTrustDialogAccepted recorded. With the server-side auto-accept disabled in a throwaway copy, the skill's fallback cleared a genuinely parked dialog in 1.1 s and spawn_worker took a brand-new case to a live composer in 7.2 s; spawn_workers + sendwait + last_text then ran end to end. --- docs/api-reference.md | 15 ++-- docs/extending-codeman.md | 42 ++++++++--- skills/codeman/SKILL.md | 66 +++++++++++++---- skills/codeman/preamble.sh | 58 ++++++++++++--- skills/codeman/reference/endpoints.md | 3 +- skills/codeman/reference/recipes.md | 31 ++++---- skills/codeman/reference/verbs.md | 44 +++++++---- src/session-trust-dialog.ts | 102 +++++++++++++++++++++++--- src/session.ts | 52 ++++++++++++- test/session-trust-dialog.test.ts | 102 +++++++++++++++++++++++++- 10 files changed, 427 insertions(+), 88 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index cd709c7a..dcd0d631 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -204,11 +204,16 @@ turn. The reliable sequence is: poll `GET /api/v1/sessions/:id` until `.data.pid` is non-null, then `wait-output` for the composer's own marker (`bypass`, the status bar of a CLI spawned in bypass mode) with a short timeout, handling the trust -dialog only as the bounded fallback (`trust` matched → send `\r` → wait for -`bypass` again). Do not probe `trust` first and Enter blindly: the dialog text -stays in the terminal buffer for the life of the session, so a `trust` probe with -`from=buffer` keeps matching on every later run and the Enter lands in a ready -composer. A worked version is in +dialog only as the bounded fallback. + +⚠️ **The fallback is not a bare `\r`.** Claude Code 2.1.252 unnumbered the dialog's +options, reversed them and highlights `No, exit`, so an Enter sent blind quits the +CLI and the pane dies seconds after the spawn. Read the `❯` marker off the current +frame (`GET /api/v1/sessions/:id/terminal?full=1`), send `ESC [ B` while it is on +`No, exit`, re-read, and confirm only once it is on `Yes, I trust this folder`. +Reading the current frame is also what keeps this correct on later runs: the dialog +text stays in the terminal buffer for the life of the session, so a `trust` probe +with `from=buffer` keeps matching long after the dialog is gone. A worked version is in [`extending-codeman.md`](extending-codeman.md#seam-3-http-api-and-cli). ### `GET /api/v1/sessions/:id/wait` diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index e2924499..2d440485 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -228,6 +228,14 @@ window: the wait resolves on `idle` in a couple of seconds with `timedOut: false indistinguishable from a finished turn. Wait for the pid, then wait for the composer, answering the dialog only as the bounded fallback. +⚠️ **Answering it is not "press Enter".** Claude Code 2.1.252 dropped the options' +numbers, reversed them, and highlights `No, exit` by default, so a blind `\r` quits +the CLI and the pane is dead seconds after the spawn. Read the `❯` marker off the +rendered pane (`GET .../terminal?full=1`), send `ESC [ B` while it sits on `No, exit`, +re-read, and confirm only once the marker is on `Yes, I trust this folder`. Codeman's +own auto-accept (`trustDialogNextKey()` in `src/session-trust-dialog.ts`) does exactly +this, inside a 90 s startup window and a 6-keystroke cap. + A worked orchestration: start a worker, get it ready, prompt it, wait, clean up. ```bash @@ -244,24 +252,36 @@ SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" \ [ -n "$SID" ] && [ "$SID" != null ] || { echo "quick-start failed"; exit 1; } # 2. READINESS: composer marker first, trust dialog only as the bounded fallback. -# Skip this and step 3 reports a turn that never ran. Do NOT probe trust first -# and Enter blindly: the dialog text stays in the buffer for the life of the -# session, so on every later run that probe matches stale text and the Enter -# lands in a ready composer. Match single tokens only: TUI text can arrive -# without its spaces. Stage 1 is short on purpose (an already-trusted case -# matches in <1 s; a first-run case can never pass it and pays it in full). +# Skip this and step 3 reports a turn that never ran. Match single tokens only: +# TUI text can arrive without its spaces. Stage 1 is short on purpose (an +# already-trusted case matches in <1 s; a first-run case can never pass it and +# pays it in full). +# ⚠️ NEVER answer the dialog with a bare \r. Its highlighted option is `No, exit` +# (claude-cli 2.1.252), so a blind Enter quits the CLI; and the dialog text stays +# in the buffer for the life of the session, so a `from=buffer` probe for `trust` +# keeps matching long after it is gone. Read the CURRENT pane instead and steer. until [ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] do sleep 1; done R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \ --data-urlencode 'timeout=5000') # composer's status bar = ready if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then - T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ - --data-urlencode 'match=trust' --data-urlencode 'from=buffer' \ - --data-urlencode 'timeout=2000') - jq -e '.data.wait.matched' <<<"$T" >/dev/null && \ + ESC=$(printf '\033') # \x1b is GNU-sed only; this form also works on macOS + for _ in 1 2 3 4 5 6; do + # Which option the ❯ marker sits on, read off the CURRENT frame. + K=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/terminal" --data-urlencode 'full=1' \ + | jq -r '.data.terminalBuffer // empty' \ + | sed -e "s/$ESC\[[0-9;?]*[a-zA-Z]//g" -e "s/$ESC[()][AB0]//g" | tr -d ' \t' \ + | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \ + | sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/') + [ -n "$K" ] || break # no dialog on screen: nothing to answer + [ "$K" = confirm ] && IN="\r" || IN="$ESC[B" "${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ - -H 'Content-Type: application/json' -d '{"input":"\r","useMux":true}' >/dev/null + -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg i "$IN" '{input:$i,useMux:true}')" >/dev/null + [ "$K" = confirm ] && break + sleep 1 # re-read: confirm the arrow landed + done "${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \ --data-urlencode 'timeout=45000' >/dev/null diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 3e0c336d..231cbdb8 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.20.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.20.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.21.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -127,6 +127,43 @@ _dsh_up() { # -> "true"/"false". The DeepSeek Harness T --data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \ --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' } +# ---- the workspace-trust dialog: READ the screen, never press Enter blind ---- +# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights +# "No, exit" by default: +# Security guide +# ❯ No, exit +# Yes, I trust this folder +# Enter to confirm . Esc to cancel +# so the bare \r that answered the old layout now answers *exit* and the pane is +# dead (`status 1`) seconds after the spawn -- measured on a live 2.1.252 case. +# These two read the rendered pane and steer onto the trust option instead. +_trust_key() { # -> "confirm" | "move" | "" (nothing safe to press) + # full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that + # is the current frame rather than every repaint since launch. tail -1 anyway, + # because the freshest marked row is the only one still true. + "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + | jq -r '.data.terminalBuffer // empty' \ + | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ + | tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \ + | sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/' +} +_accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not + local sid="$1" k i=1 + while [ "$i" -le 6 ]; do + k=$(_trust_key "$sid") + [ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read + # A SEPARATE clientId for these keys. seq is monotonic per clientId, so + # spending prompt numbers here would make the next sendwait -- whose default + # seq is the epoch second -- look like a stale duplicate and vanish silently. + "${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg k "$([ "$k" = confirm ] && printf '\r' || printf '\033[B')" \ + --arg c "$CID-trust-$sid" --argjson s "$i" \ + '{input:$k,useMux:true,clientId:$c,seq:$s}')" >/dev/null + [ "$k" = confirm ] && return 0 + sleep 1; i=$((i+1)) # re-read: the arrow is CONFIRMED before Enter goes out + done + return 1 +} # spawn_worker [mode] -> session id on stdout, diagnostics on stderr. # quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means # a READY worker whose end-of-turn signal can be trusted -- a claude worker in a @@ -177,19 +214,16 @@ spawn_worker() { grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || { echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2 delete_session "$sid" >/dev/null; return 1; } - # Short composer wait FIRST, then the trust-dialog probe: a case still showing the - # dialog can never pass the composer wait, so probing early keeps a cold case from + # Short composer wait FIRST, then the trust dialog: a case still showing the + # dialog can never pass the composer wait, so acting early keeps a cold case from # paying the whole long wait before the fallback even runs (§5.2). A warm case - # matches in under a second and never reaches the probe. + # matches in under a second and never reaches it, and _accept_trust returns in a + # blink when there is no dialog, so this costs nothing in the ordinary slow case. r=$(_composer_up "$sid" 5000) if [ "$r" != true ]; then - if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \ - --data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \ - | jq -e '.data.wait.matched' >/dev/null; then - # Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback. - "${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \ - -d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null - fi + # Codeman answers this dialog itself and normally wins the race; this is the + # bounded fallback for when its 90 s window / 6-keystroke cap has run out. + _accept_trust "$sid" r=$(_composer_up "$sid" 45000) fi [ "$r" = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >&2 @@ -288,10 +322,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.20.0 +CODEMAN_PREAMBLE=1.21.0 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -342,7 +376,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index 0cee70bd..3bd024e5 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.20.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -49,6 +49,43 @@ _dsh_up() { # -> "true"/"false". The DeepSeek Harness T --data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \ --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' } +# ---- the workspace-trust dialog: READ the screen, never press Enter blind ---- +# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights +# "No, exit" by default: +# Security guide +# ❯ No, exit +# Yes, I trust this folder +# Enter to confirm . Esc to cancel +# so the bare \r that answered the old layout now answers *exit* and the pane is +# dead (`status 1`) seconds after the spawn -- measured on a live 2.1.252 case. +# These two read the rendered pane and steer onto the trust option instead. +_trust_key() { # -> "confirm" | "move" | "" (nothing safe to press) + # full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that + # is the current frame rather than every repaint since launch. tail -1 anyway, + # because the freshest marked row is the only one still true. + "${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \ + | jq -r '.data.terminalBuffer // empty' \ + | sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \ + | tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \ + | sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/' +} +_accept_trust() { # -> 0 once it has answered the dialog, 1 if it could not + local sid="$1" k i=1 + while [ "$i" -le 6 ]; do + k=$(_trust_key "$sid") + [ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read + # A SEPARATE clientId for these keys. seq is monotonic per clientId, so + # spending prompt numbers here would make the next sendwait -- whose default + # seq is the epoch second -- look like a stale duplicate and vanish silently. + "${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg k "$([ "$k" = confirm ] && printf '\r' || printf '\033[B')" \ + --arg c "$CID-trust-$sid" --argjson s "$i" \ + '{input:$k,useMux:true,clientId:$c,seq:$s}')" >/dev/null + [ "$k" = confirm ] && return 0 + sleep 1; i=$((i+1)) # re-read: the arrow is CONFIRMED before Enter goes out + done + return 1 +} # spawn_worker [mode] -> session id on stdout, diagnostics on stderr. # quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means # a READY worker whose end-of-turn signal can be trusted -- a claude worker in a @@ -99,19 +136,16 @@ spawn_worker() { grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || { echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2 delete_session "$sid" >/dev/null; return 1; } - # Short composer wait FIRST, then the trust-dialog probe: a case still showing the - # dialog can never pass the composer wait, so probing early keeps a cold case from + # Short composer wait FIRST, then the trust dialog: a case still showing the + # dialog can never pass the composer wait, so acting early keeps a cold case from # paying the whole long wait before the fallback even runs (§5.2). A warm case - # matches in under a second and never reaches the probe. + # matches in under a second and never reaches it, and _accept_trust returns in a + # blink when there is no dialog, so this costs nothing in the ordinary slow case. r=$(_composer_up "$sid" 5000) if [ "$r" != true ]; then - if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \ - --data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \ - | jq -e '.data.wait.matched' >/dev/null; then - # Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback. - "${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \ - -d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null - fi + # Codeman answers this dialog itself and normally wins the race; this is the + # bounded fallback for when its 90 s window / 6-keystroke cap has run out. + _accept_trust "$sid" r=$(_composer_up "$sid" 45000) fi [ "$r" = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >&2 @@ -210,4 +244,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.20.0 +CODEMAN_PREAMBLE=1.21.0 diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index c9ab7d43..a2ae7945 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -800,7 +800,8 @@ for environment and setup problems. | wait routes 404 on a valid session id | read the `.error` text: a `Route ...` prefix means the server predates the wait endpoints (< 1.13.0; a dev build can serve them while reporting an older version, so probe, never version-compare), poll `terminal?tail=` and say so. `Session ... not found` means your id is wrong, not the server | | wait on `stop` never resolves | a mode with no hook signals, or hooks not reaching the server (Docker/remote), or a case created by Codeman < 1.13.0 against an `--https` install (its hook curls lacked `-k` and TLS-failed silently; a 1.13.0+ server rewrites them the next time a session starts in that case). Use markers or `idle,exit` | | wait on `stop` never resolves, on a **dsh** worker whose pane clearly finished | that profile does not implement the harness's supervisor contract, which Codeman cannot detect at request time (an unrecognized profile is treated as launchable on purpose). The wait is accepted and then times out. Drive that worker with markers, or switch to a profile that reports — `@deepseek-harness-tui/dsh-tui` does | -| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept did not fire (it is bounded by a 90 s window and an attempt cap); use the readiness recipe in SKILL.md, wait for `shift+tab` first, accept the dialog only as the bounded fallback | +| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept did not fire (it is bounded by a 90 s window and a keystroke cap); use the readiness recipe in SKILL.md, wait for `shift+tab` first, answer the dialog only as the bounded fallback | +| a brand-new claude worker's pane is DEAD (`status 1`) seconds after the spawn | something pressed Enter at the first-run trust dialog. Since claude-cli 2.1.252 its options are unnumbered, reversed, and the highlighted default is `No, exit`, so a blind `\r` — an up-front Enter, or a task prompt typed into the dialog — quits the CLI. Answer it by reading the `❯` marker off `terminal?full=1` and arrowing onto `Yes, I trust this folder` first: `_accept_trust` in the §0 preamble | | readiness burns its whole budget, then the worker answers fine anyway | you matched `bypass`, which is the statusline of ONE permission mode. Codeman spawns `--dangerously-skip-permissions` by default, but the server's `claudeMode` setting also has `auto` (`auto mode on`), `allowedTools` and `normal` (both `don't ask on`), and the effective per-session value is not exposed on `GET /api/v1/sessions/:id`. Match **`shift+tab`** instead: every mode's status bar ends `(shift+tab to cycle)` (measured per mode against claude-cli 2.1.226). Expect `blocked` signals mid-turn on the non-default modes | | ANSI escapes survive the strip pipeline | `sed -e 's/\x1b…'` on macOS: `\x1b` is GNU-only, BSD sed matches nothing and strips nothing. Use the `ESC=$(printf '\033')` form above | | `wait-output` times out although the pane shows the text | multi-word match against a TUI screen; the stream has no spaces there, match one token | diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index 58971ead..c444163c 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -69,9 +69,14 @@ SEQ=1 # $CID is the fixed literal from the preamble; never rebuild # plus a two-marker screen match in session-trust-dialog.ts), not the output stream. # It still misses two ways, and both leave the dialog up until someone answers it: # it only scans in the first 90 s after the pane started (TRUST_DIALOG_WINDOW_MS), -# and it gives up after 3 Enter presses (TRUST_DIALOG_MAX_ATTEMPTS). So: composer -# marker first, dialog only as the bounded fallback (a blind Enter up front would -# land in an already-ready composer). +# and it gives up after 6 keystrokes (TRUST_DIALOG_MAX_ATTEMPTS). So: composer +# marker first, dialog only as the bounded fallback. +# ⚠️ The dialog is NOT answered with Enter. Since claude-cli 2.1.252 the options +# lost their numbers, swapped places, and the highlighted one is `No, exit`, so a +# blind \r quits the CLI and the pane is dead seconds after the spawn (measured). +# _accept_trust (§0 preamble) reads the ❯ marker off the rendered pane, arrows onto +# `Yes, I trust this folder`, re-reads to confirm the move landed, and only then +# presses Enter. # Stage 1 is SHORT on purpose: an already-trusted case matches in <1 s, while a # virgin case can never pass it (the dialog is up) and always pays it in full, # the long budget belongs to stage 3, after the dialog is answered. @@ -92,13 +97,8 @@ done R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000') if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then - T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ - --data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000') - if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then - "${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \ - -d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null - SEQ=$((SEQ+1)) - fi + _accept_trust "$SID" # reads the marker and steers; never a blind \r. Own clientId, + # so it spends none of $SEQ's numbers. R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000') fi @@ -106,8 +106,8 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then # stage 4, mode-agnostic and bounded: answering a trivial prompt IS readiness. # COSTS THE WORKER ONE BILLED TURN, so it only runs when the fast marker missed. # Split token (the typed line echoes into the stream) and unique per call. Must stay - # AFTER the dialog fallback: free text plus \r into a trust dialog still up answers - # it blind, the same footgun as an up-front Enter. + # AFTER the dialog fallback: the select widget swallows the text and the \r answers + # whatever is highlighted, which on a live dialog is `No, exit`. TOK="${RANDOM}_$$" "${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \ -d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null @@ -527,9 +527,10 @@ done circuit breaker, which exists to stop a worker that crashes on every start from being restarted in a loop; clearing it unasked re-arms that loop. - Then run **Flow 1's readiness stages 1-3** on each SID. A path claude has never been - run in shows the trust dialog, and typing your task into a dialog answers it blind and - loses the task. Stages 1-3 cost no turn; stage 4, if it fires, costs that worker one - billed turn. + run in shows the trust dialog, and typing your task into it does not just lose the + task: the select widget swallows the text and the trailing `\r` answers the + highlighted option, which since claude-cli 2.1.252 is `No, exit`. Stages 1-3 cost no + turn; stage 4, if it fires, costs that worker one billed turn. ### 4. Hand out the tasks: markers, not send-and-wait diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index fa09d565..41b3b27c 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -171,10 +171,27 @@ itself, reliably enough that stage 1 usually just works: `_maybeAcceptTrustDialo reads the **rendered pane** via `capturePaneText()` rather than the arriving chunk (the per-chunk `includes()` version could never match, because tmux repaints the row with cursor-forward escapes in place of spaces, and it is documented in-source as the -historical bug). The remaining miss modes are structural: the auto-accept only runs -inside a 90 s window after interactive start and gives up after 3 attempts. So keep -the dialog handling as a bounded fallback, and never send a blind Enter up front (if -auto-accept already fired, it lands in the composer). +historical bug). + +⚠️ **The answer is no longer "press Enter".** Claude Code 2.1.252 dropped the option +numbers, reversed the two options, and highlights the one that quits: + +``` + ❯ No, exit + Yes, I trust this folder + Enter to confirm · Esc to cancel +``` + +so a blind `\r` answers *exit*: the pane is dead (`Pane is dead (status 1)`) about six +seconds after the spawn, measured on a fresh case. Read the marker off the rendered +pane (`GET .../terminal?full=1`), send `ESC [ B` while it sits on `No, exit`, re-read, +and press Enter only once the marker is on the trust option. `_accept_trust` in the +§0 preamble is exactly that, and `trustDialogNextKey()` is the server-side twin. + +The remaining miss modes are structural: the auto-accept only runs inside a 90 s window +after interactive start and gives up after 6 keystrokes. So keep the dialog handling as +a bounded fallback, and never send a blind Enter up front — landing in an already-ready +composer only wastes a turn, landing in this dialog ends the worker. Stage 1 is short on purpose: an already-trusted case matches `shift+tab` in under a second, while a case still showing the dialog cannot pass stage 1 at all and always @@ -232,14 +249,11 @@ SEQ=1 # $CID came from the §0 preamble; do NOT rebuild it from $$ R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000') if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then - # composer never appeared, so the trust dialog is probably still up; accept it once - T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ - --data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000') - if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then - "${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \ - -d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null - SEQ=$((SEQ+1)) - fi + # Composer never appeared, so the trust dialog is probably still up. NEVER a blind + # Enter here: the highlighted option is "No, exit". _accept_trust (§0 preamble) reads + # the marker off the pane, arrows onto the trust option, re-reads, then confirms. It + # carries its OWN clientId, so it spends none of $SEQ's numbers. + _accept_trust "$SID" R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000') fi @@ -248,8 +262,10 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then # of a broken worker, and answering is proof that it works. Split the token (your # keystrokes echo into the stream) and keep it unique per call. This costs the worker # one billed turn, so it runs only after the fast path missed. It must stay AFTER - # stage 2, which is the only thing that clears the trust dialog: free text plus \r - # into a dialog still up answers it blind, the same footgun as the up-front Enter. + # stage 2, which is the only thing that clears the trust dialog: the typed text is + # swallowed by the select widget and the \r then answers whatever is highlighted, + # which since 2.1.252 is "No, exit" -- the same footgun as the up-front Enter, except + # that it kills the worker rather than wasting a turn. TOK="${RANDOM}_$$" "${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \ -d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null diff --git a/src/session-trust-dialog.ts b/src/session-trust-dialog.ts index ff7e2db0..e6402f34 100644 --- a/src/session-trust-dialog.ts +++ b/src/session-trust-dialog.ts @@ -1,16 +1,32 @@ /** - * @fileoverview Recognizing Claude Code's workspace-trust dialog on screen. + * @fileoverview Recognizing Claude Code's workspace-trust dialog on screen, and + * working out which keystroke answers it. * - * Claude asks once per directory before it will read or edit anything: + * Claude asks once per directory before it will read or edit anything. The + * layout has changed under us at least twice; both of these are live shapes: * - * Quick safety check: Is this a project you created or one you trust? ... + * Quick safety check: Is this a project you created or one you trust? ... (<= 2.1.220) * ❯ 1. Yes, I trust this folder * 2. No, exit * Enter to confirm · Esc to cancel * + * Quick safety check: Is this a project you created or one you trust? ... (2.1.252) + * Security guide + * ❯ No, exit + * Yes, I trust this folder + * Enter to confirm · Esc to cancel + * * Codeman sessions run permission-skipping or classifier-guarded modes, so the * answer is always yes, and a session parked on this dialog is simply stuck. * + * ⚠️ **Never press Enter without reading the selection.** The options are now + * unnumbered, REVERSED, and the highlighted default is "No, exit" — so the blind + * `\r` that answered the old layout picks *exit* on the new one and the pane + * dies (`Pane is dead (status 1)`) seconds after the session starts, which is + * exactly what a fresh case did on Claude Code 2.1.252. `trustDialogNextKey()` + * reads the `❯` marker instead and moves the cursor onto the trust option before + * it confirms anything. + * * **Why the text has to be compacted.** tmux repaints a row by writing each word * and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each * word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`. @@ -39,6 +55,25 @@ const TRUST_PHRASES = [ /** The dialog's own affordances. Prose that quotes the question will not have these. */ const CONFIRM_PHRASES = ['entertoconfirm', 'esctocancel', '2.no,exit']; +/** The option that answers yes, compacted. Identical text in both layouts. */ +const YES_OPTION = 'yes,itrustthisfolder'; + +/** The option that quits Claude. It is the highlighted DEFAULT since 2.1.252. */ +const NO_OPTION = 'no,exit'; + +/** Ink's selection marker. The only marked row while the dialog is up. */ +const SELECTION_MARK = '❯'; + +/** A numbered option's `1.` / `2.` prefix, which the 2.1.220 layout put after the marker. */ +const OPTION_NUMBER_PREFIX = /^\d+\./; + +/** Move the selection one row down / up. Literal, so `send-keys -l` carries them. */ +export const TRUST_KEY_DOWN = '\x1b[B'; +export const TRUST_KEY_UP = '\x1b[A'; + +/** Confirm the highlighted option. */ +export const TRUST_KEY_CONFIRM = '\r'; + /** * Charset-select sequences (`ESC ( B`), which tmux emits around styled runs and * `stripAnsi` does not cover. Left in, they would land inside a phrase as a @@ -65,6 +100,50 @@ export function isTrustDialogScreen(text: string): boolean { return TRUST_PHRASES.some((p) => compact.includes(p)) && CONFIRM_PHRASES.some((p) => compact.includes(p)); } +/** + * Which option the `❯` marker sits on, or null when this text does not say. + * + * The LAST marked option wins. A pane capture holds exactly one frame and so + * exactly one marker, but the direct-PTY fallback reads an append-only buffer + * where every repaint since launch is still present — there the freshest frame + * is the one at the end, and an older one must not out-vote it. + */ +function selectedTrustOption(compact: string): { at: number; option: 'yes' | 'no' } | null { + let selected: { at: number; option: 'yes' | 'no' } | null = null; + for (let at = compact.indexOf(SELECTION_MARK); at >= 0; at = compact.indexOf(SELECTION_MARK, at + 1)) { + const after = compact.slice(at + SELECTION_MARK.length).replace(OPTION_NUMBER_PREFIX, ''); + if (after.startsWith(YES_OPTION)) selected = { at, option: 'yes' }; + else if (after.startsWith(NO_OPTION)) selected = { at, option: 'no' }; + } + return selected; +} + +/** + * The single keystroke that moves this dialog one step closer to "yes", or null + * when the screen does not show clearly enough to touch. + * + * One step per call on purpose: the caller re-reads the screen between + * keystrokes, so a moved cursor is CONFIRMED before Enter is pressed rather than + * assumed. Firing arrow+Enter together would re-create the failure this exists + * to prevent whenever the arrow is dropped (Ink drops keystrokes while it is + * still mounting a widget) — the Enter would then land on "No, exit". + * + * Returning null is the safe answer, not a failure: an unreadable frame means + * wait for the next repaint, and a layout whose options this cannot name means + * leave the dialog to the human. The caller's startup window bounds the waiting. + */ +export function trustDialogNextKey(text: string): string | null { + const compact = compactScreenText(text); + if (!compact.includes(YES_OPTION)) return null; // no trust option to steer onto + const selected = selectedTrustOption(compact); + if (!selected) return null; // marker missing, or not on an option we recognize + if (selected.option === 'yes') return TRUST_KEY_CONFIRM; + // On "No, exit". Which way the trust option lies is read from THIS frame — it + // sits below in 2.1.252 and above in the numbered layout before it — so the + // order flipping again costs a repaint, not a killed session. + return compact.includes(YES_OPTION, selected.at) ? TRUST_KEY_DOWN : TRUST_KEY_UP; +} + /** * How long after the pane starts the dialog is still plausible. It renders * before the main UI, so this only has to cover a slow first launch; leaving it @@ -72,16 +151,21 @@ export function isTrustDialogScreen(text: string): boolean { */ export const TRUST_DIALOG_WINDOW_MS = 90_000; -/** Minimum gap between two Enter presses, and between two screen reads. */ +/** Minimum gap between two keystrokes, and between two screen reads. */ export const TRUST_DIALOG_RETRY_MS = 1500; /** - * Attempts before giving up and leaving the dialog to the user. A keystroke can - * land while Ink is still mounting the widget and be dropped, which is the other - * half of why sessions got stuck here; retrying costs nothing, but retrying - * forever would hammer Enter into whatever came next. + * Keystrokes before giving up and leaving the dialog to the user. A keystroke + * can land while Ink is still mounting the widget and be dropped, which is the + * other half of why sessions got stuck here; retrying costs nothing, but + * retrying forever would hammer Enter into whatever came next. + * + * Six rather than three because answering is no longer one press: the 2.1.252 + * layout needs an arrow onto the trust option and then Enter, each confirmed + * against a re-read of the screen, so a cap of three left only one dropped + * keystroke of slack. */ -export const TRUST_DIALOG_MAX_ATTEMPTS = 3; +export const TRUST_DIALOG_MAX_ATTEMPTS = 6; /** * How much of the append-only terminal buffer to read on a direct-PTY session, diff --git a/src/session.ts b/src/session.ts index 8a0608aa..750690ea 100644 --- a/src/session.ts +++ b/src/session.ts @@ -66,6 +66,8 @@ import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; import { isTrustDialogScreen, + trustDialogNextKey, + TRUST_KEY_CONFIRM, TRUST_DIALOG_WINDOW_MS, TRUST_DIALOG_RETRY_MS, TRUST_DIALOG_MAX_ATTEMPTS, @@ -455,8 +457,9 @@ export class Session extends EventEmitter { private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read) private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up) - private _trustDialogAttempts = 0; // Enter presses sent at the trust dialog + private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read + private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below) private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan) private _taskTracker: TaskTracker; @@ -1841,6 +1844,10 @@ export class Session extends EventEmitter { this._interactiveStartedAt = Date.now(); this._trustDialogAttempts = 0; this._lastTrustDialogScanAt = 0; + if (this._trustDialogTimer) { + clearTimeout(this._trustDialogTimer); + this._trustDialogTimer = null; + } // COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a // short window), refuse to respawn. This is the uniform choke point that stops @@ -2229,6 +2236,15 @@ export class Session extends EventEmitter { * makes a retry safe, since the terminal buffer is append-only and keeps the * dialog in its tail long after it has been answered. * + * ⚠️ **The keystroke is read off the screen, never assumed.** Claude Code + * 2.1.252 dropped the option numbers, put "No, exit" first, and highlights IT + * by default, so the bare `\r` this used to send now answers *exit*: a fresh + * case died (`Pane is dead (status 1)`) about six seconds after spawning. + * `trustDialogNextKey()` returns one step at a time — an arrow while the + * cursor is on the wrong option, Enter only once the screen shows it on the + * trust option — and this method re-reads the pane between the two, so a + * dropped arrow costs a repaint instead of the session. + * * Three guards keep an Enter press off a live session: a startup-only window, * a two-marker match (isTrustDialogScreen), and an attempt cap. */ @@ -2249,17 +2265,39 @@ export class Session extends EventEmitter { this._terminalBuffer.value.slice(-TRUST_DIALOG_SCAN_BYTES); if (!isTrustDialogScreen(screen)) return; + // Null means the frame does not say which option is highlighted. Waiting for + // the next repaint is the safe move; pressing Enter blind is the bug. + const key = trustDialogNextKey(screen); + if (key === null) return; + this._trustDialogAttempts++; if (this._trustDialogAttempts > TRUST_DIALOG_MAX_ATTEMPTS) { this._trustDialogAccepted = true; // leave it to the user rather than keep typing console.warn(`[Session] Workspace trust dialog did not clear after retries: ${this.id}`); return; } + const step = key === TRUST_KEY_CONFIRM ? 'confirming' : 'moving to the trust option'; console.log( - `[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts})` + `[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts}, ${step})` ); - // Enter confirms the highlighted default, "1. Yes, I trust this folder". - this.writeViaMux('\r'); + this.writeViaMux(key); + + // ⚠️ Schedule the next read; do NOT wait for more PTY output. This scan only + // ever ran from `onData`, which was enough while one Enter answered the + // dialog. It is not enough now: the arrow that moves the cursor is the LAST + // output the pane produces, so a dialog left sitting on the trust option + // never gets its Enter and the worker stays parked on it forever (measured + // on a live 2.1.252 spawn: cursor moved at 6 s, then nothing). The timer is + // one-shot and self-rearming through this same path, and every exit route + // goes through _clearAllTimers(). + // The +100ms puts the re-entry OUTSIDE the scan throttle above; firing at + // exactly the throttle boundary would let the scan return early and break + // the chain with the dialog still on screen. + if (this._trustDialogTimer) clearTimeout(this._trustDialogTimer); + this._trustDialogTimer = setTimeout(() => { + this._trustDialogTimer = null; + this._maybeAcceptTrustDialog(); + }, TRUST_DIALOG_RETRY_MS + 100); } /** @@ -2784,6 +2822,12 @@ export class Session extends EventEmitter { } private _clearAllTimers(): void { + // Clear the workspace-trust follow-up read + if (this._trustDialogTimer) { + clearTimeout(this._trustDialogTimer); + this._trustDialogTimer = null; + } + // Clear activity timeout to prevent memory leak if (this.activityTimeout) { clearTimeout(this.activityTimeout); diff --git a/test/session-trust-dialog.test.ts b/test/session-trust-dialog.test.ts index 5b45a4e2..7aaa50a3 100644 --- a/test/session-trust-dialog.test.ts +++ b/test/session-trust-dialog.test.ts @@ -8,10 +8,23 @@ * * RAW_DIALOG_CHUNK below is a verbatim slice of the PTY stream from a live * session parked on that dialog (Claude Code 2.1.220). + * + * The second bug this pins: Claude Code 2.1.252 dropped the option numbers, put + * "No, exit" first and highlights IT, so the blind Enter that answered the old + * layout selects *exit* and the pane dies seconds after the session starts. + * RENDERED_DIALOG_2_1_252 is a verbatim `capture-pane -p` of that screen. */ import { describe, expect, it, vi, afterEach } from 'vitest'; import { Session } from '../src/session.js'; -import { isTrustDialogScreen, compactScreenText, TRUST_DIALOG_MAX_ATTEMPTS } from '../src/session-trust-dialog.js'; +import { + isTrustDialogScreen, + compactScreenText, + trustDialogNextKey, + TRUST_KEY_CONFIRM, + TRUST_KEY_DOWN, + TRUST_KEY_UP, + TRUST_DIALOG_MAX_ATTEMPTS, +} from '../src/session-trust-dialog.js'; /** Verbatim from the wire: note the `\x1b[C` where every space should be. */ const RAW_DIALOG_CHUNK = @@ -29,6 +42,25 @@ const RENDERED_DIALOG = [ ' Enter to confirm · Esc to cancel', ].join('\n'); +/** + * Verbatim `capture-pane -p` from Claude Code 2.1.252 on a fresh case: no + * numbers, the options reversed, and the cursor parked on the one that quits. + */ +const RENDERED_DIALOG_2_1_252 = [ + ' Accessing workspace:', + ' /home/arkon/codeman-cases/trustprobe1', + ' 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', +].join('\n'); + +/** The same screen after one arrow press: the cursor has moved onto "yes". */ +const RENDERED_DIALOG_2_1_252_ON_YES = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit\n Yes,', ' No, exit\n ❯ Yes,'); + /** An ordinary working session: no dialog anywhere. */ const RENDERED_MAIN_UI = [ '✻ Actualizing… (13m 23s · ↓ 47.5k tokens)', @@ -61,12 +93,54 @@ describe('isTrustDialogScreen', () => { expect(isTrustDialogScreen('press Enter to confirm the release')).toBe(false); }); + it('sees the 2.1.252 dialog, whose options lost their numbers', () => { + // '2.no,exit' is gone from this layout, so the confirm affordance is now the + // only thing carrying the match. + expect(isTrustDialogScreen(RENDERED_DIALOG_2_1_252)).toBe(true); + }); + it('compacts away both real spaces and the escapes tmux sends instead', () => { expect(compactScreenText('I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder')).toBe('itrustthisfolder'); expect(compactScreenText('I trust this folder')).toBe('itrustthisfolder'); }); }); +describe('trustDialogNextKey', () => { + it('confirms straight away when the trust option is already highlighted', () => { + expect(trustDialogNextKey(RENDERED_DIALOG)).toBe(TRUST_KEY_CONFIRM); + expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252_ON_YES)).toBe(TRUST_KEY_CONFIRM); + }); + + it('moves DOWN instead of confirming when 2.1.252 parks the cursor on "No, exit"', () => { + // The regression in one line: Enter here answers *exit* and kills the pane. + expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252)).toBe(TRUST_KEY_DOWN); + }); + + it('moves UP when the trust option is the one above, as in the numbered layout', () => { + const numberedOnNo = RENDERED_DIALOG.replace(' ❯ 1. Yes,', ' 1. Yes,').replace( + ' 2. No, exit', + ' ❯ 2. No, exit' + ); + expect(trustDialogNextKey(numberedOnNo)).toBe(TRUST_KEY_UP); + }); + + it('reads the LAST frame in an append-only buffer, not the first', () => { + // The direct-PTY fallback has no pane to capture, so it reads a buffer that + // still holds every repaint since launch. The freshest frame is the truth. + const buffer = `${RENDERED_DIALOG_2_1_252}\n${RENDERED_DIALOG_2_1_252_ON_YES}`; + expect(trustDialogNextKey(buffer)).toBe(TRUST_KEY_CONFIRM); + }); + + it('presses nothing when the screen does not say which option is selected', () => { + // A layout this cannot read is a dialog for the human, not a coin flip: the + // wrong guess exits Claude. + const noMarker = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit', ' No, exit'); + expect(trustDialogNextKey(noMarker)).toBe(null); + expect(trustDialogNextKey(RENDERED_MAIN_UI)).toBe(null); + expect(trustDialogNextKey('')).toBe(null); + }); +}); + describe('Session trust-dialog auto-accept', () => { afterEach(() => vi.useRealTimers()); @@ -102,6 +176,32 @@ describe('Session trust-dialog auto-accept', () => { expect(writes).toEqual(['\r']); }); + it('walks the 2.1.252 dialog onto the trust option before it confirms', () => { + vi.useFakeTimers(); + // The whole point: no Enter goes out while "No, exit" is highlighted. + let screen = RENDERED_DIALOG_2_1_252; + const { writes, tick } = sessionShowing(() => screen); + tick(); + expect(writes).toEqual([TRUST_KEY_DOWN]); + + screen = RENDERED_DIALOG_2_1_252_ON_YES; + vi.advanceTimersByTime(2000); + tick(); + expect(writes).toEqual([TRUST_KEY_DOWN, TRUST_KEY_CONFIRM]); + }); + + it('never presses Enter while the cursor sits on "No, exit"', () => { + vi.useFakeTimers(); + // A dialog that never moves (a dropped arrow, a wedged pane) must run out of + // attempts pressing arrows, not answer *exit* on the way. + const { writes, tick } = sessionShowing(() => RENDERED_DIALOG_2_1_252); + for (let i = 0; i < 20; i++) { + tick(); + vi.advanceTimersByTime(2000); + } + expect(writes).toEqual(Array(TRUST_DIALOG_MAX_ATTEMPTS).fill(TRUST_KEY_DOWN)); + }); + it('retries a dropped keystroke, then gives up rather than typing forever', () => { vi.useFakeTimers(); // Ink can drop a keystroke while it is still mounting the widget, so one