Merge pull request #249 from Ark0N/fix/trust-dialog-auto-accept

fix: workspace trust dialog auto-accept has been dead (tmux sends cursor-forwards, not spaces)
This commit is contained in:
Ark0N
2026-08-09 16:58:30 +02:00
committed by GitHub
3 changed files with 307 additions and 9 deletions
+92
View File
@@ -0,0 +1,92 @@
/**
* @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;
+64 -9
View File
@@ -59,6 +59,13 @@ import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js';
import {
isTrustDialogScreen,
TRUST_DIALOG_WINDOW_MS,
TRUST_DIALOG_RETRY_MS,
TRUST_DIALOG_MAX_ATTEMPTS,
TRUST_DIALOG_SCAN_BYTES,
} from './session-trust-dialog.js';
import {
trackActivityStreak,
isSustainedActivity,
@@ -389,7 +396,10 @@ export class Session extends EventEmitter {
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
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; // Prevents repeated trust dialog auto-accept
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Enter presses sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan)
private _taskTracker: TaskTracker;
// Token tracking for auto-clear
@@ -1528,6 +1538,12 @@ export class Session extends EventEmitter {
throw new Error('Session already has a running process');
}
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
// rather than at PTY spawn so a slow mux attach still counts as startup.
this._interactiveStartedAt = Date.now();
this._trustDialogAttempts = 0;
this._lastTrustDialogScanAt = 0;
// 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
// automatic recovery/reconnect callers from re-creating a crash-looping PTY.
@@ -1765,14 +1781,7 @@ export class Session extends EventEmitter {
this._handleTerminalOutput(data);
// === Auto-accept workspace trust dialog ===
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
// Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept.
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
this._trustDialogAccepted = true;
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
// Send Enter to accept the default selection ("Yes, I trust this folder")
this.writeViaMux('\r');
}
this._maybeAcceptTrustDialog();
// === Idle/working detection runs on every chunk (latency-sensitive) ===
this._detectInteractiveActivity(data);
@@ -1880,6 +1889,52 @@ export class Session extends EventEmitter {
return this._respawnBlocked;
}
/**
* Answer Claude's workspace-trust dialog, which blocks a fresh case until
* someone presses Enter. Codeman sessions run permission-skipping or
* classifier-guarded modes, so the answer is always "yes, I trust this folder".
*
* Reads the RENDERED SCREEN rather than the chunk that just arrived. tmux
* repaints a row with cursor-forward escapes in place of spaces, so the wire
* carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder` and the old
* `data.includes('trust this folder')` could never match: the auto-accept had
* been dead for every session that hit the dialog. The screen is also what
* makes a retry safe, since the terminal buffer is append-only and keeps the
* dialog in its tail long after it has been answered.
*
* Three guards keep an Enter press off a live session: a startup-only window,
* a two-marker match (isTrustDialogScreen), and an attempt cap.
*/
private _maybeAcceptTrustDialog(): void {
if (this._trustDialogAccepted) return;
const now = Date.now();
if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) {
this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog
return;
}
if (now - this._lastTrustDialogScanAt < TRUST_DIALOG_RETRY_MS) return;
this._lastTrustDialogScanAt = now;
// Prefer the pane; fall back to the buffer tail on a direct-PTY session,
// where there is no screen to read.
const screen =
(this._mux && this._muxSession ? this._mux.capturePaneText?.(this._muxSession.muxName) : null) ??
this._terminalBuffer.value.slice(-TRUST_DIALOG_SCAN_BYTES);
if (!isTrustDialogScreen(screen)) 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;
}
console.log(
`[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts})`
);
// Enter confirms the highlighted default, "1. Yes, I trust this folder".
this.writeViaMux('\r');
}
/**
* Per-chunk working/idle detection for an interactive pane. Split out of the
* PTY `onData` handler so it can be unit tested without spawning one.