mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
fix(session): answer Claude Code 2.1.252's reversed folder-trust dialog
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.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user