mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* fix(evals): a rejected API key is INFRA, never 131 assertion reds and reruns Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: register the credential-rejection fixture as free Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep the credential-rejection CLI replay off the Windows subset, like paid-rerun Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: name the Windows run in the curation reason Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.183.0 fix(evals): a rejected API key is INFRA, never 131 assertion reds and reruns (#3361) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
582 lines
26 KiB
TypeScript
582 lines
26 KiB
TypeScript
/**
|
|
* Spawn `claude` in a real PTY (launchClaudePty) and the session handle. Moved from claude-pty-runner.ts.
|
|
* Import through test/helpers/claude-pty-runner.ts from tests; pty/ modules import siblings directly.
|
|
*/
|
|
import * as path from 'node:path';
|
|
import { resolveEvalModel } from '../../../lib/eval-model';
|
|
import { hermeticChildEnv, hermeticSkillsConfigDir, isHermeticEnabled } from '../hermetic-env';
|
|
import { withHermeticSkillRuntime } from '../hermetic-skill-runtime';
|
|
import { ownedNativeReviewStateRoot, type NativeReviewState } from '../plan-count-fixture';
|
|
import { createPendingExitRecorder } from '../plan-count-pending-exit';
|
|
import { createPendingQuestionRecorder } from '../plan-count-pending-question';
|
|
import { createFilePermissionRecorder } from '../plan-count-file-permission';
|
|
import { createAutoplanArtifactRecorder } from '../autoplan-artifact-recorder';
|
|
import { trustDialogInput } from '../pty-trust-dialog';
|
|
import { resolveClaudeBinary } from './binary';
|
|
import { createPtyScreen, screenPanelEnd, stripAnsi } from './screen';
|
|
import { appendSessionLedger, sessionKey } from '../session-ledger';
|
|
|
|
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;
|
|
screenDeadlineAt?: number;
|
|
/** 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;
|
|
/** Restrict an opted-in artifact approval hook to the owned Eng QA test plan. */
|
|
engTestPlanArtifactOnly?: boolean;
|
|
/** Explicit disposable state from createNativeReviewState; ambient env grants no ownership. */
|
|
autoplanArtifactState?: NativeReviewState;
|
|
/** 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<string, string>;
|
|
/** Total run timeout (ms). Default 240000 (4 min). */
|
|
timeoutMs?: number;
|
|
/** Append this session's session-ledger row on close (default). runPtySession writes its own. */
|
|
sessionLedger?: boolean;
|
|
}
|
|
|
|
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(deadlineAt?: number): Promise<string>;
|
|
/**
|
|
* The last non-blank viewport a screen read returned, kept for failure dumps: the tail of visibleText()
|
|
* can be nothing but spinner redraws after a stall (autoplan-guard-pty, PR gate run 38976253774).
|
|
* '' before any non-blank read, or when screen observation is off. Never used for matching; optional
|
|
* because scripted sessions have no viewport history.
|
|
*/
|
|
lastScreen?(): string;
|
|
/** Same decoded viewport with styles and input epoch for acknowledged paste. */
|
|
currentScreenFrame(deadlineAt?: number): Promise<{ text: string; rawEnd: number;
|
|
styledText: Array<{ row: number; start: number; text: string; dim: boolean; inverse: boolean }> }>;
|
|
/**
|
|
* 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;
|
|
waitForOutput(since: number, timeoutMs: number): Promise<void>;
|
|
/** 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<RegExp | string>,
|
|
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<void>;
|
|
/** 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
|
|
* `<hermeticConfigDir>/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;
|
|
/** The same validated root bound into the artifact hook, distinct from legacy HOME/.gstack. */
|
|
autoplanArtifactStateRoot?: string;
|
|
/** Legacy QA namespace from this launcher; native artifacts retain their own root. */
|
|
autoplanEngTestPlanStateRoot?: 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<void>;
|
|
}
|
|
|
|
/** Let a numbered menu apply its selection before confirming it. */
|
|
export async function selectPtyNumberedOption(
|
|
session: Pick<ClaudePtySession, 'send'>,
|
|
index: number,
|
|
): Promise<void> {
|
|
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');
|
|
}
|
|
|
|
/**
|
|
* Spawn `claude --permission-mode plan` in a real PTY and return a session
|
|
* handle. Caller is responsible for `await session.close()` to release the
|
|
* subprocess and any timers.
|
|
*
|
|
* Auto-handles the workspace-trust dialog by selecting its explicit
|
|
* affirmative option. Tests should NOT have to handle it themselves.
|
|
*/
|
|
export async function launchClaudePty(
|
|
opts: ClaudePtyOptions = {},
|
|
): Promise<ClaudePtySession> {
|
|
const claudePath = resolveClaudeBinary();
|
|
if (!claudePath) {
|
|
throw new Error(
|
|
'claude binary not found on PATH. Install: https://docs.anthropic.com/en/docs/claude-code',
|
|
);
|
|
}
|
|
|
|
const cwd = opts.cwd ?? process.cwd();
|
|
const cols = opts.cols ?? 120;
|
|
const rows = opts.rows ?? 40;
|
|
const timeoutMs = opts.timeoutMs ?? 240_000;
|
|
const startedMono = performance.now();
|
|
const startedAt = new Date().toISOString();
|
|
const wallDeadline = startedMono + timeoutMs;
|
|
const screenAbort = new AbortController();
|
|
const launch = prepareLaunch(opts);
|
|
|
|
// Construction must succeed before any CLI can be spawned.
|
|
const screen = opts.observeScreen ? await createPtyScreen(cols, rows, {
|
|
deadlineAt: Math.min(opts.screenDeadlineAt ?? wallDeadline, wallDeadline), signal: screenAbort.signal,
|
|
}) : undefined;
|
|
const pty: PtyProcess = { buffer: '', exited: false, closing: false, exitCode: null, outputWaiters: new Set(),
|
|
screen, screenAbort, screenClosing: undefined, screenFailure: undefined, lastScreen: '', proc: undefined,
|
|
exitedPromise: Promise.resolve(), wallDeadline, recorders: { pendingFiles: [] } };
|
|
|
|
try {
|
|
createLaunchRecorders(opts, cwd, launch, pty.recorders);
|
|
pty.proc = (Bun as any).spawn([claudePath, ...launch.args], {
|
|
terminal: {
|
|
cols,
|
|
rows,
|
|
data(_t: unknown, chunk: Buffer) {
|
|
const text = chunk.toString('utf-8');
|
|
pty.buffer += text;
|
|
if (screen && !pty.screenClosing) screen.write(text);
|
|
notifyOutput(pty);
|
|
},
|
|
},
|
|
cwd,
|
|
env: launch.childEnv,
|
|
}); } catch (error) { screenAbort.abort(error); disposeRecorders(pty.recorders); await disposeScreen(pty); throw error; }
|
|
|
|
// Track exit so waitForAny can fail fast if claude crashes.
|
|
if (pty.proc.exited && typeof pty.proc.exited.then === 'function') {
|
|
pty.exitedPromise = pty.proc.exited
|
|
.then((code: number | null) => {
|
|
pty.exitCode = code;
|
|
pty.exited = true;
|
|
notifyOutput(pty);
|
|
void disposeScreen(pty);
|
|
})
|
|
.catch(() => {
|
|
pty.exited = true;
|
|
notifyOutput(pty);
|
|
void disposeScreen(pty);
|
|
});
|
|
}
|
|
|
|
// Top-level timeout. If a test forgets to close, this kills it eventually.
|
|
const wallTimer = setTimeout(() => {
|
|
screenAbort.abort(new Error('PTY work deadline exceeded.'));
|
|
try {
|
|
pty.proc.kill?.('SIGKILL');
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, Math.max(0, wallDeadline - performance.now()));
|
|
const trust = watchTrustDialog(pty);
|
|
const handle = sessionHandle(pty, launch, wallTimer, trust);
|
|
if (opts.sessionLedger === false) return handle;
|
|
const close = handle.close;
|
|
let recorded = false;
|
|
return Object.assign(handle, { close: () => {
|
|
if (!recorded) {
|
|
recorded = true;
|
|
const elapsed = performance.now() - startedMono;
|
|
appendSessionLedger({ key: sessionKey('pty', undefined), runner: 'pty', started_at: startedAt, budget_ms: timeoutMs,
|
|
elapsed_ms: elapsed, end: elapsed >= timeoutMs ? 'session_timeout' : 'completed', ...screenPanelEnd(handle.visibleText()), billed: false });
|
|
}
|
|
return close();
|
|
} });
|
|
}
|
|
|
|
/** Arguments, child environment and owned state roots, decided before any spawn. */
|
|
interface LaunchSetup {
|
|
args: string[];
|
|
childEnv: Record<string, string>;
|
|
hermetic: boolean;
|
|
hermeticSkillStateRoot: string | undefined;
|
|
autoplanArtifactStateRoot: string | undefined;
|
|
autoplanEngTestPlanStateRoot: string | undefined;
|
|
}
|
|
|
|
function prepareLaunch(opts: ClaudePtyOptions): LaunchSetup {
|
|
const args: string[] = [];
|
|
// Pin the model so smokes don't inherit the operator's settings.json model
|
|
// (see ClaudePtyOptions.model). Chain mirrors session-runner.ts so PTY and
|
|
// `claude -p` evals always agree. Pushed before extraArgs => a test-supplied
|
|
// --model wins (last flag wins).
|
|
const model = opts.model ?? process.env.EVALS_MODEL ?? resolveEvalModel('capture');
|
|
args.push('--model', model);
|
|
// Permission mode: 'plan' default, null => omit flag entirely.
|
|
const permissionMode = opts.permissionMode === undefined ? 'plan' : opts.permissionMode;
|
|
if (permissionMode !== null) {
|
|
args.push('--permission-mode', permissionMode);
|
|
}
|
|
// Hermetic children get zero MCP servers; gated on the same call-time
|
|
// check as the env scrub so EVALS_HERMETIC=0 restores operator MCP too.
|
|
// Before opts.extraArgs so a test could theoretically supply --mcp-config.
|
|
const hermetic = isHermeticEnabled();
|
|
if (hermetic) args.push('--strict-mcp-config');
|
|
if (opts.extraArgs) args.push(...opts.extraArgs);
|
|
|
|
// Hermetic by default (test/helpers/hermetic-env.ts): operator session
|
|
// context never reaches the child; per-test opts.env merges last.
|
|
let childEnv = hermeticChildEnv(opts.env);
|
|
// The opted-in viewport emulates xterm; placeholder styles are required to
|
|
// distinguish an empty suggestion from text the user has actually entered.
|
|
if (opts.observeScreen) {
|
|
childEnv.TERM = 'xterm-256color';
|
|
childEnv.FORCE_COLOR = '1';
|
|
}
|
|
let hermeticSkillStateRoot: string | undefined;
|
|
if (opts.seedSkills && hermetic && !opts.env?.CLAUDE_CONFIG_DIR) {
|
|
childEnv.CLAUDE_CONFIG_DIR = hermeticSkillsConfigDir();
|
|
if (opts.env?.HOME === undefined) {
|
|
const runtime = withHermeticSkillRuntime(childEnv);
|
|
childEnv = runtime.env;
|
|
hermeticSkillStateRoot = runtime.stateRoot;
|
|
// Runtime paths, registered skill assets, and ~/.gstack snapshots are
|
|
// owned inputs outside the fixture cwd. The registry has separate real
|
|
// directories, so its paths also need a grant beside the runtime symlink.
|
|
// Explicit HOME/config/state and operator permission settings stay separate.
|
|
args.push('--add-dir', runtime.root, '--add-dir', runtime.stateRoot,
|
|
'--add-dir', path.join(childEnv.CLAUDE_CONFIG_DIR, 'skills'));
|
|
}
|
|
}
|
|
|
|
let autoplanArtifactStateRoot = hermeticSkillStateRoot;
|
|
if (opts.autoplanArtifactState !== undefined) {
|
|
if (!opts.observeAutoplanArtifacts || !hermeticSkillStateRoot)
|
|
throw new Error('Explicit Autoplan artifact state requires the seeded hermetic launcher');
|
|
autoplanArtifactStateRoot = ownedNativeReviewStateRoot(opts.autoplanArtifactState, childEnv);
|
|
}
|
|
|
|
const autoplanEngTestPlanStateRoot = opts.approveAutoplanArtifactEdits && opts.autoplanArtifactState !== undefined
|
|
? hermeticSkillStateRoot : undefined;
|
|
return { args, childEnv, hermetic, hermeticSkillStateRoot, autoplanArtifactStateRoot, autoplanEngTestPlanStateRoot };
|
|
}
|
|
|
|
interface LaunchRecorders {
|
|
pendingExit?: ReturnType<typeof createPendingExitRecorder>;
|
|
pendingQuestion?: ReturnType<typeof createPendingQuestionRecorder>;
|
|
pendingArtifact?: ReturnType<typeof createAutoplanArtifactRecorder>;
|
|
pendingFiles: Array<{ expected: string; recorder: NonNullable<ReturnType<typeof createFilePermissionRecorder>> }>;
|
|
}
|
|
|
|
/** Opted-in owned hook recorders, merged into one --settings argument. Each is
|
|
* added to `recorders` as it is created, so a throw leaves the partial set for
|
|
* the caller to dispose. */
|
|
function createLaunchRecorders(opts: ClaudePtyOptions, cwd: string, launch: LaunchSetup, recorders: LaunchRecorders): void {
|
|
const { hermetic, childEnv, args, autoplanArtifactStateRoot } = launch;
|
|
if (opts.observePlanReady && hermetic && childEnv.CLAUDE_CONFIG_DIR) {
|
|
recorders.pendingExit = createPendingExitRecorder(cwd, childEnv.CLAUDE_CONFIG_DIR);
|
|
}
|
|
if (opts.observeSetupQuestions && hermetic && childEnv.CLAUDE_CONFIG_DIR) {
|
|
recorders.pendingQuestion = createPendingQuestionRecorder(cwd, childEnv.CLAUDE_CONFIG_DIR);
|
|
}
|
|
if (opts.observeAutoplanArtifacts && hermetic && childEnv.CLAUDE_CONFIG_DIR && autoplanArtifactStateRoot) {
|
|
recorders.pendingArtifact = createAutoplanArtifactRecorder(cwd, childEnv.CLAUDE_CONFIG_DIR, autoplanArtifactStateRoot,
|
|
opts.approveAutoplanArtifactEdits === true, opts.engTestPlanArtifactOnly === true, launch.autoplanEngTestPlanStateRoot);
|
|
}
|
|
if (opts.observeFilePermissions && hermetic && childEnv.CLAUDE_CONFIG_DIR) {
|
|
for (const expected of new Set(opts.observeFilePermissions)) {
|
|
const recorder = createFilePermissionRecorder(cwd, childEnv.CLAUDE_CONFIG_DIR, expected);
|
|
if (recorder) recorders.pendingFiles.push({ expected, recorder });
|
|
}
|
|
}
|
|
const { pendingExit, pendingQuestion, pendingArtifact, pendingFiles } = recorders;
|
|
if (pendingFiles.length || pendingQuestion || pendingArtifact) {
|
|
const hooks = pendingExit ? JSON.parse(pendingExit.settings).hooks : {};
|
|
for (const recorder of [...pendingFiles.map(p => p.recorder), ...(pendingQuestion ? [pendingQuestion] : []), ...(pendingArtifact ? [pendingArtifact] : [])]) for (const [event, entries] of Object.entries(recorder.hooks))
|
|
hooks[event] = [...(hooks[event] ?? []), ...entries];
|
|
args.push('--settings', JSON.stringify({hooks}));
|
|
} else if (pendingExit) args.push('--settings', pendingExit.settings);
|
|
}
|
|
|
|
function disposeRecorders(recorders: LaunchRecorders): void {
|
|
recorders.pendingFiles.forEach(({ recorder }) => recorder.dispose());
|
|
recorders.pendingExit?.dispose();
|
|
recorders.pendingQuestion?.dispose(); recorders.pendingArtifact?.dispose();
|
|
}
|
|
|
|
/** One spawned CLI: output buffer, exit state, viewport and owned recorders. */
|
|
interface PtyProcess {
|
|
buffer: string;
|
|
exited: boolean;
|
|
closing: boolean;
|
|
exitCode: number | null;
|
|
outputWaiters: Set<() => void>;
|
|
screen: Awaited<ReturnType<typeof createPtyScreen>> | undefined;
|
|
screenAbort: AbortController;
|
|
screenClosing: Promise<void> | undefined;
|
|
screenFailure: unknown;
|
|
lastScreen: string;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
proc: any;
|
|
exitedPromise: Promise<void>;
|
|
wallDeadline: number;
|
|
recorders: LaunchRecorders;
|
|
}
|
|
|
|
function notifyOutput(pty: PtyProcess): void { for (const done of pty.outputWaiters) done(); }
|
|
|
|
function keepScreen(pty: PtyProcess, text: string): string {
|
|
if (text.trim()) pty.lastScreen = text;
|
|
return text;
|
|
}
|
|
|
|
const lastScreenDump = (pty: PtyProcess) => pty.lastScreen ? `\nLast non-blank screen:\n${pty.lastScreen}` : '';
|
|
|
|
function disposeScreen(pty: PtyProcess): Promise<void> {
|
|
return pty.screenClosing ??= (pty.screen?.dispose() ?? Promise.resolve()).catch(error => { pty.screenFailure = error; });
|
|
}
|
|
|
|
function writeTerminal(pty: PtyProcess, data: string): void {
|
|
if (pty.exited) return;
|
|
try {
|
|
pty.proc.terminal?.write?.(data);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-handle the workspace-trust dialog. Runs once during the boot
|
|
* window, after both choices and the selected cursor are visible. Newer
|
|
* unnumbered menus default to "No, exit", so "1\r" would reject trust.
|
|
* The first paint can precede the input handler's readiness. Let startup
|
|
* settle, then deliver navigation and confirmation as separate events.
|
|
*/
|
|
function watchTrustDialog(pty: PtyProcess) {
|
|
let trustHandled = false;
|
|
let trustVisibleAt: number | undefined;
|
|
const inputTimers: ReturnType<typeof setTimeout>[] = [];
|
|
const watcher = setInterval(() => {
|
|
if (trustHandled || pty.exited) return;
|
|
const input = trustDialogInput(pty.buffer);
|
|
if (input !== null) {
|
|
trustVisibleAt ??= Date.now();
|
|
if (Date.now() - trustVisibleAt < 1_500) return;
|
|
trustHandled = true;
|
|
const keys = input.match(/\x1b\[[AB]|\r/g) ?? [];
|
|
for (const [i, key] of keys.entries()) {
|
|
inputTimers.push(setTimeout(() => {
|
|
if (pty.exited) return;
|
|
try { pty.proc.terminal?.write?.(key); } catch { /* ignore */ }
|
|
}, i * 500));
|
|
}
|
|
}
|
|
}, 200);
|
|
// Stop the watcher after 15s — by then the dialog has either fired or
|
|
// doesn't exist on this run.
|
|
const stop = setTimeout(() => clearInterval(watcher), 15_000);
|
|
return { watcher, stop, inputTimers };
|
|
}
|
|
|
|
async function waitForAnyOutput(
|
|
pty: PtyProcess,
|
|
patterns: Array<RegExp | string>,
|
|
waitOpts?: { timeoutMs?: number; pollMs?: number; since?: number },
|
|
): Promise<{ matched: RegExp | string; index: number }> {
|
|
const wTimeout = waitOpts?.timeoutMs ?? 60_000;
|
|
const poll = waitOpts?.pollMs ?? 250;
|
|
const since = waitOpts?.since;
|
|
const start = Date.now();
|
|
while (Date.now() - start < wTimeout) {
|
|
if (pty.exited) {
|
|
throw new Error(
|
|
`claude exited (code=${pty.exitCode}) before any pattern matched. ` +
|
|
`Last visible:\n${stripAnsi(pty.buffer).slice(-2000)}${lastScreenDump(pty)}`,
|
|
);
|
|
}
|
|
const visible = since !== undefined ? stripAnsi(pty.buffer.slice(since)) : stripAnsi(pty.buffer);
|
|
for (let i = 0; i < patterns.length; i++) {
|
|
const p = patterns[i]!;
|
|
const matchIdx = typeof p === 'string' ? visible.indexOf(p) : visible.search(p);
|
|
if (matchIdx >= 0) {
|
|
return { matched: p, index: matchIdx };
|
|
}
|
|
}
|
|
await Bun.sleep(poll);
|
|
}
|
|
throw new Error(
|
|
`Timed out after ${wTimeout}ms waiting for any of: ${patterns
|
|
.map((p) => (typeof p === 'string' ? JSON.stringify(p) : p.source))
|
|
.join(', ')}\nLast visible (since=${since ?? 'all'}):\n${
|
|
since !== undefined ? stripAnsi(pty.buffer.slice(since)).slice(-2000) : stripAnsi(pty.buffer).slice(-2000)
|
|
}${lastScreenDump(pty)}`,
|
|
);
|
|
}
|
|
|
|
/** SIGINT, then SIGKILL, each bounded by a shared 3s cleanup deadline; then recorders and viewport. */
|
|
async function closePty(pty: PtyProcess, wallTimer: ReturnType<typeof setTimeout>,
|
|
trust: ReturnType<typeof watchTrustDialog>): Promise<void> {
|
|
pty.closing = true;
|
|
notifyOutput(pty);
|
|
const cleanupDeadline = Math.min(pty.wallDeadline, performance.now() + 3_000);
|
|
const cleanupTimer = setTimeout(() => pty.screenAbort.abort(new Error('PTY cleanup deadline exceeded.')),
|
|
Math.max(0, cleanupDeadline - performance.now()));
|
|
clearTimeout(trust.stop);
|
|
clearInterval(trust.watcher);
|
|
for (const timer of trust.inputTimers) clearTimeout(timer);
|
|
try {
|
|
for (const [signal, timeout] of [['SIGINT', 2000], ['SIGKILL', 1000]] as const) {
|
|
if (pty.exited) break;
|
|
try {
|
|
pty.proc.kill?.(signal);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
let deadline!: ReturnType<typeof setTimeout>;
|
|
try {
|
|
await Promise.race([pty.exitedPromise, new Promise<void>((resolve) => {
|
|
deadline = setTimeout(resolve, Math.max(0, Math.min(timeout, cleanupDeadline - performance.now())));
|
|
})]);
|
|
} finally {
|
|
clearTimeout(deadline);
|
|
}
|
|
}
|
|
disposeRecorders(pty.recorders);
|
|
await disposeScreen(pty);
|
|
if (pty.screenFailure) throw pty.screenFailure;
|
|
} finally {
|
|
clearTimeout(cleanupTimer);
|
|
clearTimeout(wallTimer);
|
|
pty.proc.terminal?.close?.();
|
|
}
|
|
}
|
|
|
|
function sessionHandle(pty: PtyProcess, launch: LaunchSetup, wallTimer: ReturnType<typeof setTimeout>,
|
|
trust: ReturnType<typeof watchTrustDialog>): ClaudePtySession {
|
|
const { screen } = pty;
|
|
const { pendingExit, pendingQuestion, pendingArtifact, pendingFiles } = pty.recorders;
|
|
const send = (data: string) => writeTerminal(pty, data);
|
|
let lastMark = 0;
|
|
let closePromise: Promise<void> | undefined;
|
|
const readable = () => {
|
|
if (!screen) throw new Error('PTY screen observation was not enabled for this session.');
|
|
if (pty.screenFailure) throw new Error('PTY screen observation failed.', { cause: pty.screenFailure });
|
|
return screen;
|
|
};
|
|
const waitForAny = (patterns: Array<RegExp | string>, waitOpts?: { timeoutMs?: number; pollMs?: number; since?: number }) =>
|
|
waitForAnyOutput(pty, patterns, waitOpts);
|
|
return {
|
|
send,
|
|
sendKey: key => {
|
|
const map: Record<string, string> = {
|
|
Enter: '\r',
|
|
Up: '\x1b[A',
|
|
Down: '\x1b[B',
|
|
Esc: '\x1b',
|
|
Tab: '\t',
|
|
ShiftTab: '\x1b[Z',
|
|
CtrlC: '\x03',
|
|
};
|
|
send(map[key] ?? '');
|
|
},
|
|
rawOutput: () => pty.buffer,
|
|
visibleText: () => stripAnsi(pty.buffer),
|
|
currentScreen: async (deadlineAt?: number) => keepScreen(pty, await readable().read(deadlineAt)),
|
|
currentScreenFrame: async (deadlineAt?: number) => {
|
|
const frame = await readable().readFrame(deadlineAt);
|
|
return {text: keepScreen(pty, frame.text), rawEnd: frame.inputOffset, styledText: frame.styledText};
|
|
},
|
|
lastScreen: () => pty.lastScreen,
|
|
mark: () => (lastMark = pty.buffer.length),
|
|
waitForOutput: async (since: number, timeoutMs: number) => {
|
|
if (pty.buffer.length > since || pty.exited || pty.closing) return;
|
|
await new Promise<void>((resolve) => {
|
|
const done = () => {
|
|
clearTimeout(timer);
|
|
pty.outputWaiters.delete(done);
|
|
resolve();
|
|
};
|
|
const timer = setTimeout(done, timeoutMs);
|
|
pty.outputWaiters.add(done);
|
|
});
|
|
},
|
|
visibleSince: (marker?: number) => stripAnsi(pty.buffer.slice(marker ?? lastMark)),
|
|
waitForAny,
|
|
waitFor: async (pattern, waitOpts) => { await waitForAny([pattern], waitOpts); },
|
|
pid: () => pty.proc.pid as number | undefined,
|
|
exited: () => pty.exited,
|
|
exitCode: () => pty.exitCode,
|
|
hermeticConfigDir: launch.hermetic ? launch.childEnv.CLAUDE_CONFIG_DIR ?? null : null,
|
|
hermeticSkillStateRoot: launch.hermeticSkillStateRoot,
|
|
pendingPlanReadyFile: pendingExit?.file,
|
|
pendingQuestionFile: pendingQuestion?.file,
|
|
pendingAutoplanArtifactFile: pendingArtifact?.file,
|
|
autoplanArtifactStateRoot: pendingArtifact ? launch.autoplanArtifactStateRoot : undefined,
|
|
autoplanEngTestPlanStateRoot: pendingArtifact ? launch.autoplanEngTestPlanStateRoot : undefined,
|
|
startAutoplanArtifactEditApproval: pendingArtifact?.startEditApproval,
|
|
pendingFilePermissionFiles: pendingFiles.map(({ expected, recorder }) => ({ expected, file: recorder.file })),
|
|
close: () => closePromise ??= closePty(pty, wallTimer, trust),
|
|
};
|
|
}
|