import type { DriverEventEnvelope, RuntimeDriver, RuntimeDriverRunHandle, RuntimeDriverRunInput } from "../types.js"; /** * `claude-pty` driver — drives the REAL Claude Code interactive TUI. * * Unlike `claude-cli` / `claude-sdk` (which spawn a fresh one-shot process per * turn and fake continuity via `--resume`), this driver keeps ONE long-lived * interactive `claude` process alive per session, inside a PTY, and types each * turn's prompt into it. Context stays hot in the live process — no per-turn * cold start, no transcript reload. * * The hard problems of driving a TUI (no structured stdout, fragile * turn-completion detection) are NOT solved by scraping the rendered screen. * They are also NOT solved by the session transcript JSONL: the interactive * TUI keeps its transcript in memory and never flushes the file the way the * headless `-p` path does (verified against claude 2.1.x — the path the Stop * hook reports never materialises, even after `/quit`). * * Instead we observe the session entirely through Claude Code HOOKS. We inject * a per-invocation `--settings` file (so the user's `~/.claude/settings.json` * and other running sessions are untouched) registering command hooks that * append their JSON payload — one line each — to a per-session `hooks.jsonl`: * * - `PostToolUse` payloads carry `tool_name` / `tool_input` / `tool_response` * → emitted as normalized tool_use + tool_result messages. * - `Stop` payloads carry `last_assistant_message` AND mark the deterministic * end of a turn → emitted as an assistant message, then the turn resolves. * * The PTY's only jobs: keep the TUI alive, type input (bracketed paste), * answer the first-run trust dialog, send ESC to cancel a turn, and send * `/quit` to tear the session down. */ export interface PtyProcessLike { readonly pid: number; write(data: string): void; onData(listener: (data: string) => void): void; onExit(listener: (event: { exitCode: number; signal?: number; }) => void): void; kill(signal?: string): void; } export type PtySpawnFn = (file: string, args: string[], options: { cwd: string; env: Record; cols: number; rows: number; name: string; }) => PtyProcessLike; export interface ClaudePtyDriverOptions { /** Inject a PTY spawner (tests). Defaults to the Python pty allocator. */ ptySpawn?: PtySpawnFn; executable?: string; /** Python 3 interpreter used by the default PTY allocator. */ pythonExecutable?: string; /** Override where Claude writes transcripts/config. Defaults to env / ~/.claude. */ configDir?: string; /** Quiet window after spawn before the TUI is considered ready for input. */ readyQuietMs?: number; /** Hard cap waiting for the TUI to settle after spawn. */ readyTimeoutMs?: number; /** * Minimum time to keep watching for the first-run trust dialog before * concluding it won't appear (an early quiet gap during terminal setup can * precede the dialog by hundreds of ms — concluding "ready" then would type * the prompt into a not-yet-rendered TUI). Trusted dirs pay this once. */ startupMinMs?: number; /** Delay between bracketed-paste of the prompt and the submitting Enter. */ submitDelayMs?: number; /** * Quiet window (no PTY output) after submit that flags a non-starting turn. * The interactive TUI animates its status line for the whole turn, so a real * turn keeps `lastDataAt` advancing. Sustained PTY silence before the first * hook line means the submit was swallowed (Enter lost mid-render, or the * paste landed in a not-yet-focused input). On each quiet window we re-submit; * after `maxStartupResubmits` we fail fast with `CLAUDE_PTY_NO_FIRST_ACTIVITY`. */ startupQuietMs?: number; /** Max prompt re-submits while waiting for the first turn activity. */ maxStartupResubmits?: number; /** Poll cadence for the hook-payload file. */ pollIntervalMs?: number; /** Inactivity ceiling for a single turn before giving up. */ turnTimeoutMs?: number; /** How long dispose() waits for `/quit` to close the TUI before TERM. */ quitGraceMs?: number; /** How long dispose() waits after TERM before escalating to KILL. */ killGraceMs?: number; } export declare function buildPtyArgs(input: { claudeSessionId: string; settingsPath: string; /** * When true, launch with `--resume ` instead of `--session-id ` so * the TUI reloads the existing transcript. Used only when respawning a * session whose persistent process died mid-conversation — a fresh * `--session-id` launch would otherwise start with no prior context. */ resume?: boolean; model?: string; appendSystemPrompt?: string; additionalDirectories?: string[]; effort?: "low" | "medium" | "high" | "xhigh" | "max"; }): string[]; export declare class ClaudePtyDriver implements RuntimeDriver { readonly name: "claude-pty"; private readonly executable; private readonly pythonExecutable; private readonly injectedPtySpawn?; private readonly configDirOverride?; private readonly opts; private readonly sessions; private static readonly liveDrivers; private static guardsInstalled; private static installProcessGuards; constructor(options?: ClaudePtyDriverOptions); run(input: RuntimeDriverRunInput, onEventRaw: (event: DriverEventEnvelope) => Promise | void): RuntimeDriverRunHandle; /** * Graceful teardown of a session's interactive process. The runtime's * `stop()` calls this. We type `/quit` (a real interactive command) so the * TUI shuts down cleanly, flushing its transcript, then drop the session. */ dispose(sessionId: string): Promise; /** * Gracefully tear down every live session (used on host shutdown). Hosts that * trap their own signals should call this before exiting; the synchronous * `exit` guard is only a best-effort backstop for ungraceful exits. */ disposeAll(): Promise; /** * Synchronous best-effort teardown for the process `exit` handler (no awaits * allowed there). Sends SIGTERM to each relay: the relay's signal-forwarder * relays it into claude's separate session (a raw SIGKILL here could NOT be * forwarded and would re-orphan claude). The allocator's stdin-EOF path is * the guarantee; this just makes catchable exits tear down promptly. */ private disposeAllSync; private awaitExit; private ensureSession; /** * Drive the one-time startup to a ready-for-input state. * * The naive "first quiet window = ready" heuristic is wrong: terminal-setup * escapes are emitted, then there's a lull of a few hundred ms BEFORE the * first-run trust dialog renders. Concluding "ready" in that lull types the * prompt into a not-yet-rendered TUI and the dialog later swallows it. * * So we keep watching: if the trust dialog appears (it isn't suppressed by * `--dangerously-skip-permissions` in interactive mode), accept it with Enter * (preselected "Yes, I trust this folder"); otherwise only conclude "no * dialog, ready" once output is quiet AND we've watched at least * `startupMinMs` (so an early lull can't short-circuit). Either way we then * wait for the post-dialog render to settle before returning. */ private awaitStartup; /** * Read newly appended hook payload lines, emit their normalized messages, * and report whether a Stop hook (turn end) was among them. */ private pumpHooks; /** Advance past hook lines already written (without emitting them). */ private drainHooks; /** Pull complete newly-appended lines from the hooks file, advancing offset. */ private readHookLines; /** * Default PTY allocator — spawns Python 3 running {@link PTY_ALLOCATOR}, * which `pty.fork()`s a real pseudo-terminal, sets its window size, execs the * target, and relays bytes between our stdio pipes and the pty master. * * Why Python instead of a native pty addon (node-pty): a real pty needs * `openpty`/`forkpty` syscalls, so every JS pty library is either a native * addon (needs a matching prebuild or a from-source compile — brittle on new * Node ABIs) or a wrapper around a system binary. `python3` is present by * default on macOS and most Linux, needs no compile step, and works headless * (unlike the `script` binary, which requires its own controlling terminal). * Tests inject their own `ptySpawn`, so this is only the production default. */ private readonly pythonPtySpawn; } export declare const PTY_ALLOCATOR: string;