mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
A session on a fresh directory sat on Claude's "Quick safety check: Is
this a project you created or one you trust?" dialog until a human
pressed Enter. Reproduced on a new case, then read off the wire:
1.\x1b[C Yes,\x1b[C I\x1b[C trust\x1b[C this\x1b[C folder
tmux repaints a row by writing each word followed by a cursor-forward
escape instead of a space, and Ink colours each word separately, so
`data.includes('trust this folder')` could never match a chunk. The
spaces are not there to strip: they were never sent. The auto-accept has
been dead for every session that hit the dialog.
Match on whitespace-free, ANSI-free, lowercased text instead
(`compactScreenText`), which survives both that repaint style and the
spaced full-screen redraw.
Answering means pressing Enter into a session, so three guards bound it:
- Read the RENDERED SCREEN (capturePaneText), not the chunk. The terminal
buffer is append-only and keeps the dialog in its tail long after it
has been answered, so a retry driven off the buffer would type into a
live session. Direct-PTY sessions, which have no pane, fall back to a
short buffer tail.
- Require a trust phrase AND the dialog's own confirm affordance. One
phrase is not enough, since an agent's transcript can quote it.
- Only look during the first 90s of the pane's life, and cap it at three
attempts. Ink can drop a keystroke while it is still mounting the
widget, which is the other half of why sessions got stuck, but a
dialog that will not clear must not become an Enter loop.
Verified end to end on a fresh case: dialog answered on attempt 1, one
Enter sent in total, session went straight to the composer and answered a
prompt. Before the fix the same flow parked on the dialog indefinitely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
93 lines
4.1 KiB
TypeScript
93 lines
4.1 KiB
TypeScript
/**
|
||
* @fileoverview Recognizing Claude Code's workspace-trust dialog on screen.
|
||
*
|
||
* Claude asks once per directory before it will read or edit anything:
|
||
*
|
||
* Quick safety check: Is this a project you created or one you trust? ...
|
||
* ❯ 1. Yes, I trust this folder
|
||
* 2. No, exit
|
||
* 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.
|
||
*
|
||
* **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`.
|
||
* Stripping the escapes leaves `Itrustthisfolder`: the spaces are not there to
|
||
* strip, they were never sent. A plain `includes('trust this folder')` therefore
|
||
* never matched a single chunk, which is why the auto-accept had been silently
|
||
* dead. Removing ALL whitespace instead is what survives both that repaint style
|
||
* and the spaced full-screen redraw.
|
||
*
|
||
* **Why two markers are required.** Answering means pressing Enter, so a false
|
||
* positive types into a live session. One phrase is not enough: an agent's own
|
||
* transcript can quote it (this file does). Matching a trust phrase AND the
|
||
* dialog's confirm affordance is the cheap way to require the actual widget, and
|
||
* the caller adds the real guard by only looking during session startup.
|
||
*/
|
||
|
||
import { stripAnsi } from './utils/index.js';
|
||
|
||
/** Phrases from the question or the "yes" option, whitespace removed, lowercased. */
|
||
const TRUST_PHRASES = [
|
||
'trustthisfolder', // 2.x: "1. Yes, I trust this folder"
|
||
'trustthefiles', // older: "Do you trust the files in this folder?"
|
||
'oneyoutrust', // 2.x question: "a project you created or one you trust?"
|
||
];
|
||
|
||
/** The dialog's own affordances. Prose that quotes the question will not have these. */
|
||
const CONFIRM_PHRASES = ['entertoconfirm', 'esctocancel', '2.no,exit'];
|
||
|
||
/**
|
||
* 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
|
||
* literal `(B` and break the match.
|
||
*/
|
||
// eslint-disable-next-line no-control-regex
|
||
const CHARSET_SELECT = /\x1b[()][AB0]/g;
|
||
|
||
/**
|
||
* Normalize a screen or PTY chunk for phrase matching: escapes dropped, every
|
||
* whitespace run removed, lowercased.
|
||
*/
|
||
export function compactScreenText(text: string): string {
|
||
return stripAnsi(text).replace(CHARSET_SELECT, '').replace(/\s+/g, '').toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* True when this text is the trust dialog rather than something merely talking
|
||
* about it. Feed the RENDERED SCREEN where possible: the session's terminal
|
||
* buffer is append-only, so the dialog stays in its tail long after it is gone.
|
||
*/
|
||
export function isTrustDialogScreen(text: string): boolean {
|
||
const compact = compactScreenText(text);
|
||
return TRUST_PHRASES.some((p) => compact.includes(p)) && CONFIRM_PHRASES.some((p) => compact.includes(p));
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* open forever would let a transcript that quotes the dialog trigger an Enter.
|
||
*/
|
||
export const TRUST_DIALOG_WINDOW_MS = 90_000;
|
||
|
||
/** Minimum gap between two Enter presses, 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.
|
||
*/
|
||
export const TRUST_DIALOG_MAX_ATTEMPTS = 3;
|
||
|
||
/**
|
||
* How much of the append-only terminal buffer to read on a direct-PTY session,
|
||
* which has no pane to capture. Small on purpose: the dialog scrolls out of a
|
||
* short tail as soon as Claude repaints its main UI, which is what keeps a
|
||
* fallback retry from firing at an already-answered dialog.
|
||
*/
|
||
export const TRUST_DIALOG_SCAN_BYTES = 4000;
|