/** * Real-PTY runner for Claude Code plan-mode E2E tests. * * Spawns the actual `claude` binary via `Bun.spawn({terminal:})`, drives * it through stdin/stdout, parses the rendered terminal frames, and exposes * primitives the 5 plan-mode tests need. Replaces the SDK-based * `runPlanModeSkillTest` from plan-mode-helpers.ts which never worked * because plan mode doesn't use the AskUserQuestion tool — it uses its * own TTY-rendered native confirmation UI. * * Why this exists: the SDK harness intercepts `canUseTool` for * `AskUserQuestion`. Claude in plan mode renders its "Ready to execute" * confirmation as a native option list (1-4 numbered options) without * invoking the AskUserQuestion tool. The SDK never sees it. Real PTY * does — it shows up as text on screen with `❯` cursor markers. * * Architecture: pure Bun.spawn — no node-pty, no native modules, no chmod * fixes. Bun 1.3.10+ has built-in PTY support via the `terminal:` spawn * option. Pattern borrowed from cc-pty-import branch's terminal-agent.ts * (the WS/cookie/Origin scaffolding there is for the browser sidebar; * tests don't need it). */ import { resolveEvalModel } from '../../lib/eval-model'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { stripVTControlCharacters } from 'node:util'; import { hermeticChildEnv, hermeticSkillsConfigDir, isHermeticEnabled } from './hermetic-env'; import { withHermeticSkillRuntime } from './hermetic-skill-runtime'; import { createPlanCountFixture } from './plan-count-fixture'; import { createPlanCountSnapshotWriter } from './plan-count-artifacts'; import { nativeSeededPlanSelection } from './plan-scope-selection'; import { readPlanFloorTarget, type PlanFloorTargetDelivery } from './plan-floor-target'; import { findNativeAutoDecision, type NativeAutoDecision } from './native-auto-decide'; import { readPlanCountTranscript, unresolvedPlanQuestionCalls, type NativePlanQuestionCall, type PlanCountTranscript, type NativePublicToolEvent } from './plan-count-transcript'; import { createPendingExitRecorder, withPendingExit, isCurrentPlanApprovalScreen } from './plan-count-pending-exit'; import { createPendingQuestionRecorder } from './plan-count-pending-question'; import { createFilePermissionRecorder, currentFilePermissionBinding, type FilePermissionEpoch } from './plan-count-file-permission'; import { createAutoplanArtifactRecorder } from './autoplan-artifact-recorder'; import { trustDialogInput } from './pty-trust-dialog'; import { createPtyScreen } from './pty-screen'; import { isRecordedDxManualNavigation } from './dx-selected-navigation'; import { engCacheWriterDecision } from './eng-cache-writer-decision'; /** Strip ANSI escapes for pattern-matching against visible text. */ export function stripAnsi(s: string): string { return s .replace(/\x1b\[[\d;]*[a-zA-Z]/g, '') .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') .replace(/\x1b[()][AB012]/g, '') .replace(/\x1b[78=>]/g, ''); } /** Find claude on PATH, with fallback locations. Mirrors terminal-agent.ts. */ export function resolveClaudeBinary(): string | null { const override = process.env.BROWSE_TERMINAL_BINARY; if (override && fs.existsSync(override)) return override; // eslint-disable-next-line @typescript-eslint/no-explicit-any const which = (Bun as any).which?.('claude'); if (which) return which; const candidates = [ '/opt/homebrew/bin/claude', '/usr/local/bin/claude', `${process.env.HOME}/.local/bin/claude`, `${process.env.HOME}/.bun/bin/claude`, `${process.env.HOME}/.npm-global/bin/claude`, ]; for (const c of candidates) { try { fs.accessSync(c, fs.constants.X_OK); return c; } catch { /* keep searching */ } } return null; } export interface ClaudePtyOptions { /** Register the repo's shipped skills in the child's user scope via * hermeticSkillsConfigDir(). Required by any test that types a /skill * slash command; without it hermetic claude rejects the command as * Unknown before any model turn. No effect when EVALS_HERMETIC=0. */ seedSkills?: boolean; /** * Permission mode for the session. * - 'plan' (default) — launches with --permission-mode plan * - undefined — no --permission-mode flag at all (regular interactive) * Other valid SDK modes ('default', 'acceptEdits', 'bypassPermissions', * 'auto', 'dontAsk') are passed through verbatim. */ permissionMode?: 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions' | 'auto' | 'dontAsk' | null; /** Extra args after the permission-mode flag. */ extraArgs?: string[]; /** * Model for the spawned interactive `claude`. Without an explicit --model the * child inherits the operator's ~/.claude/settings.json model (e.g. * the operator's own settings. Resolution mirrors session-runner.ts exactly: * opts.model ?? EVALS_MODEL ?? resolveEvalModel('capture'). * Pushed BEFORE extraArgs so a test-supplied --model still wins (last flag wins). */ model?: string; /** Terminal size. Default 120x40. Plan-mode UI lays out cleanly at this size. */ cols?: number; rows?: number; /** Opt in when input targeting or completion needs the actual VT viewport. */ observeScreen?: boolean; /** Count-only pending identity; the hook never approves or changes native tools. */ observePlanReady?: boolean; /** Pending AUQ identity for explicit navigation; never supplies answered coverage. */ observeSetupQuestions?: boolean; /** Count-only native permission epochs for these exact disposable fixture/report paths. */ observeFilePermissions?: readonly string[]; /** Opt-in metadata only, limited to launcher-owned Autoplan review artifacts. */ observeAutoplanArtifacts?: boolean; /** AP-only exact artifact Edit approvals; inactive until the owner starts its command. */ approveAutoplanArtifactEdits?: boolean; /** Working directory. Default: process.cwd(). The repo cwd has the gstack * skill registry and trusted-folder cookie, so most tests want this. */ cwd?: string; /** Extra env on top of process.env. */ env?: Record; /** Total run timeout (ms). Default 240000 (4 min). */ timeoutMs?: number; } export interface ClaudePtySession { /** Send raw bytes to PTY stdin. Newlines = "\r" in TTY world. */ send(data: string): void; /** Send a key by name. Limited set used by these tests. */ sendKey(key: 'Enter' | 'Up' | 'Down' | 'Esc' | 'Tab' | 'ShiftTab' | 'CtrlC'): void; /** Raw accumulated stdout (with ANSI). For forensics. */ rawOutput(): string; /** Visible (ANSI-stripped) output for the entire session. For pattern matching. */ visibleText(): string; /** Flush the opted-in terminal parser and return only its current viewport. */ currentScreen(): Promise; /** * Mark the current buffer position. Subsequent waitForAny / visibleSince * calls only look at output AFTER this mark. Use to scope assertions to * "after I sent the skill command" — avoids matching against the trust * dialog or boot banner residue. Returns a marker handle. */ mark(): number; /** Visible text since the most recent (or specific) mark. */ visibleSince(marker?: number): string; /** * Wait for any of the supplied patterns to appear in visibleText. Resolves * with the first match. Throws on timeout (with last 2KB of visible text). * If `since` is supplied, only matches text after that mark. */ waitForAny( patterns: Array, opts?: { timeoutMs?: number; pollMs?: number; since?: number }, ): Promise<{ matched: RegExp | string; index: number }>; /** Convenience: single-pattern wait. */ waitFor( pattern: RegExp | string, opts?: { timeoutMs?: number; pollMs?: number; since?: number }, ): Promise; /** Process pid (for debug). */ pid(): number | undefined; /** Whether the underlying process has exited. */ exited(): boolean; /** Exit code, if known. */ exitCode(): number | null; /** * The hermetic CLAUDE_CONFIG_DIR this session's claude was pointed at, or * null when EVALS_HERMETIC=0. Forensics: hermetic plan files live under * `/plans/` (extractPlanFilePath still matches them — * the dir name ends in `/.claude` by contract). */ hermeticConfigDir: string | null; /** Owned HOME/.gstack created by the seeded launcher; absent for caller overrides. */ hermeticSkillStateRoot?: string; /** Owned pre-tool identity record, removed by close(). */ pendingPlanReadyFile?: string; pendingQuestionFile?: string; pendingAutoplanArtifactFile?: string; startAutoplanArtifactEditApproval?: (commandStartedAt: number) => void; pendingFilePermissionFiles?: Array<{ expected: string; file: string }>; /** * Send SIGINT, then SIGKILL after 1s. Always safe to call multiple times. * Awaits process exit before resolving. */ close(): Promise; } /** Let a numbered menu apply its selection before confirming it. */ export async function selectPtyNumberedOption( session: Pick, index: number, ): Promise { if (!Number.isInteger(index) || index < 1 || index > 9) { throw new RangeError(`Invalid numbered option: ${index}`); } session.send(String(index)); await Bun.sleep(500); session.send('\r'); } /** Detect a complete, recognized workspace-trust menu. */ export function isTrustDialogVisible(visible: string): boolean { return trustDialogInput(visible) !== null; } /** * Detect plan-mode's native "ready to execute" confirmation. Tests both the * spaced and whitespace-collapsed forms because stripAnsi removes cursor- * positioning escapes (e.g. `\x1b[40C`) that render visually as spaces but * leave no character behind — so "ready to execute" can come through as * "readytoexecute" depending on the rendering path. */ export function isPlanReadyVisible(visible: string): boolean { if (/ready to execute|Would you like to proceed/i.test(visible)) return true; const collapsed = visible.replace(/\s+/g, ''); if (/readytoexecute|Wouldyouliketoproceed/i.test(collapsed)) return true; // Claude also renders a compact ExitPlanMode approval, without a plan // preview. Recognize its complete active menu, not prose mentioning exit. // This identifies an input gate only; native/report evidence is separate. return /(?:^|\n)\s*Exit plan mode\?\s*\n\s*Claude wants to exit plan mode\s*\n\s*❯\s*1\.\s*Yes, and switch to default \(ask each time\) for this session\s*\n\s*2\.\s*No\s*$/i.test(visible); } /** * Detect the AUTO_DECIDE preamble template firing. The model prints * "Auto-decided