import * as pty from "node-pty"; import type { InterruptibleEngine, EngineRunOpts, EngineResult, EngineRateLimitInfo, StreamDelta, TurnProgress } from "../shared/types.js"; import { PtyLifecycleManager } from "./pty-lifecycle.js"; import type { PtyControlEvent, PtyViewEngine, PtyIdleSpawnOpts, PtySnapshotSubscription } from "./pty-view-engine.js"; import type { HookRegistry, HookPayload } from "../gateway/hook-registry.js"; import { type SseDataEvent, type UpstreamActivityInfo } from "./sse-pty-proxy.js"; export type { PtyControlEvent } from "./pty-view-engine.js"; interface InteractiveArgsOpts { prompt: string; settingsPath: string; resumeSessionId?: string; model?: string; effortLevel?: string; mcpConfigPath?: string; cliFlags?: string[]; attachments?: string[]; /** Gateway system prompt (persona/org context) + main-agent sentinel, passed via * the CLI `--append-system-prompt` flag. The settings-file `appendSystemPrompt` * KEY is ignored by claude CLI ≥2.1.x, so this flag is the only path that * actually lands it in the request `system` (and thus lets the SSE proxy tee). */ appendSystemPrompt?: string; } interface TranscriptUsage { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; assistantTurns: number; } /** * Sum assistant-message usage from a Claude transcript. * * `afterMs` scopes the sum to ONE turn. A Claude transcript is cumulative — it * holds every turn of the session — so an unscoped sum returns session-to-date * totals. Callers that ADD the result to a running total (accumulateSessionCost) * must pass the turn's start time, or an N-turn session is counted * quadratically. Codex reports a per-run delta already; this is what makes the * two engines agree. */ export declare function sumTranscriptUsage(content: string, afterMs?: number): TranscriptUsage; /** Claude Code stores per-project transcripts at * ~/.claude/projects//.jsonl, where the slug is the * cwd with every "/" and "." replaced by "-". Derive that path; fall back to a * scan across project dirs if the slug heuristic misses (defensive). Exported * for the transcript-recovery unit test. */ export declare function findTranscriptForSession(claudeSessionId: string, homeDir?: string, projectsDir?: string): string | undefined; export declare function lastAssistantTextFromTranscript(transcriptPath: string, afterMs?: number): string | undefined; export declare function stripReasoningBlocks(text: string): string; /** The Claude engine emits `…` — a model-generated *suggested * next user turn*. Jinn has no producer or consumer for the tag, so it lands in stored * transcripts as `role: "assistant"` content, where another agent reading the session * cannot tell it from an operator instruction. See issue #102. * * STRIP, never drop the whole message: the observed shape is most often a suggestion * fused to the FRONT of a genuine reply with no separator, so dropping would trade an * information leak for silent data loss. Callers drop only when nothing but whitespace * survives (the standalone shape). Kept separate from stripReasoningBlocks so private * reasoning and suggested user turns stay independently testable. */ export declare function stripSuggestionBlocks(text: string): string; /** Every path that stores or relays engine assistant text runs this. The two strippers * stay separate above; composing them in one place means no call site can accidentally * apply only one of them. */ export declare function sanitizeAssistantText(text: string): string; /** Cost for ONE turn. `afterMs` (the turn's start) scopes the cumulative * transcript to this turn — see sumTranscriptUsage. */ export declare function computeInteractiveCost(transcriptPath: string, model?: string, afterMs?: number): { cost: number; turns: number; } | null; /** * Map a StopFailure payload to an EngineRateLimitInfo in the shape ClaudeEngine * produces from `rate_limit_event` JSON, so detectRateLimit() and manager.ts's * wait-retry machinery work unchanged. The payload never names the reset, so a * rate-limit failure — and only that one — asks the account's usage source. */ export declare function rateLimitFromStopFailure(payload: HookPayload | undefined): Promise; /** * The prompt is user text, and the claude CLI's parser reads a leading dash as a * flag (`error: unknown option '- '`), killing the PTY before the turn starts. So * the prompt trails everything behind `--`. That is also the only placement that * works: put it earlier and the variadic `--mcp-config` swallows it; put `--` * earlier and the flags after it become positionals. */ export declare function buildInteractiveArgs(o: InteractiveArgsOpts): string[]; export declare function claudeHookToDeltas(h: Record): StreamDelta[]; /** * Translate one parsed Anthropic SSE `data:` event into StreamDeltas. This is the * live streaming source (replacing the old transcript tailer): word-by-word text * in true order, tool markers positioned correctly relative to text, and live * context tokens from message_start.usage. * - message_start.usage → `context` (input + cache_read + cache_creation) * - content_block_start tool_use → `tool_use` marker (in-order with text) * - content_block_delta text_delta → incremental `text` (word-by-word) * tool_result is NOT in the assistant SSE stream (tools run between messages); the * PostToolUse hook supplies that completion marker. input_json_delta / thinking * deltas are intentionally not surfaced to the chat pane. */ export declare function sseEventToDeltas(e: SseDataEvent): StreamDelta[]; /** Per-message gate: buffers the opening text of an assistant message just long * enough to tell a real reply from a compaction summary (dropped whole) or a leading * suggested-user-turn block (stripped, remainder kept). Exported for tests. */ export declare class CompactionStreamGate { private held; private opening; private verdict; /** Text seen since a `` opener, awaiting its close tag. */ private stripBuf; /** A new assistant message started — decide again from scratch. */ reset(): void; /** Deltas safe to forward now. Text is briefly held while the opener is undecided. */ accept(deltas: StreamDelta[]): StreamDelta[]; /** Message finished — release anything still held (messages shorter than the opener). * An unterminated `` block releases nothing: fail closed. */ end(): StreamDelta[]; /** Emit the remainder of a suggestion block once its close tag has arrived. */ private consumeStripping; private flush; } export interface TurnResolverOpts { fallbackSessionId: string | undefined; /** When true (warm-PTY reuse / post-idle-spawn), the resolver skips waiting for * SessionStart (it already fired once at process start) and pre-fills the * Claude session id from fallbackSessionId. */ assumeStarted?: boolean; /** Test override for the StopFailure grace window (default 20s). */ stopFailureGraceMs?: number; /** While true, a graced StopFailure keeps waiting instead of settling. */ shouldDeferStopFailure?: () => boolean; /** This turn is a Claude-native local command (see isNativeClaudeCommand). Such * commands produce no new assistant message, so a Stop hook's * last_assistant_message is the PREVIOUS turn's stale text — maybeComplete must * settle empty rather than re-persist it as a duplicate. */ native?: boolean; } /** State machine for one interactive turn: resolves after BOTH SessionStart + Stop, or on StopFailure/interrupt. */ export declare class TurnResolver { private opts; readonly promise: Promise; private resolve; private settled; private claudeSessionId; private gotSessionStart; private stopPayload; private stopFailurePayload; private graceTimer; constructor(opts: TurnResolverOpts); onHook(h: HookPayload): void; /** Claude session id learned so far (for engineSessionId persistence on warm-PTY turns). */ get sessionId(): string | undefined; get isSettled(): boolean; /** The StopFailure payload, if the turn ended in an API error (Task 5.3 maps it to rateLimit). */ get stopFailure(): HookPayload | undefined; /** transcript_path from whichever hook carried it. */ get transcriptPath(): string | undefined; private maybeComplete; interrupt(reason: string): void; completeNativeCommand(): void; completeRecovered(text: string, sessionId?: string): void; /** Proof of life (SSE delta / tool hook) while a StopFailure is pending — * re-arms the grace window. No-op when no failure is pending. */ noteActivity(): void; private armGrace; private clearGrace; private settleWithFailure; private settle; } /** Stall predicate, split out so it is testable without a live PTY. Both bounds * must hold: a long turn that is still streaming is healthy, and a brief quiet * gap early in a turn is normal. Exported for tests. */ export declare function shouldSettleStalledTurn(elapsedMs: number, quietMs: number): boolean; /** * Whether real work is in flight, and so missing-Stop recovery must hold off. * * A turn blocked on a safety prompt is the one case where a non-zero tool count * does NOT mean work is happening: PreToolUse fires, THEN the CLI sits on a * dialog nobody is there to answer. Counting that as busy suppressed the stall * backstop forever — sessions pinned at status:"running" for hours (one observed * at 9h26m) instead of failing after 15 minutes. Exported for tests. */ export declare function recoveryBlockedByWork(activeTools: number, blockedOnPermission: boolean, upstreamActive: boolean): boolean; /** Hooks that prove the pasted prompt is running. SessionStart is excluded on * purpose: the idle spawn that warmed this PTY fires it before the paste. */ export declare const SUBMIT_ACK_HOOKS: Set; /** True for the Notification hook that means "the CLI is blocked on a permission * dialog". Verified against claude 2.1.220: notification_type is * "permission_prompt" and it fires ~6s after the PreToolUse for the gated tool. * Other Notification types (idle nudges) must not trip this. */ export declare function isPermissionPromptNotification(h: HookPayload | Record): boolean; export declare function isNativeClaudeCommand(prompt: string): boolean; /** Optional submit acknowledgement probe for pasteAndSubmit. Supplied by the * gateway-driven warm-PTY path, where an unsubmitted prompt is an invisible hang; * omitted for raw WS input, where a human is at the keyboard and can press Enter. */ export interface SubmitConfirmation { /** True once the CLI acknowledged the prompt (UserPromptSubmit or any in-turn hook). */ submitted: () => boolean; /** True while the CLI is demonstrably doing real work — an upstream request in * flight or a local tool running. * * Both the retry and the give-up must pause on this. Claude Code QUEUES a * pasted prompt behind a turn already running (a human typing in the CLI/xterm * view, say) and fires no UserPromptSubmit until it dequeues. Without this * gate, a queued-but-perfectly-alive prompt looks identical to a swallowed CR: * we would spray CRs at a busy TUI and then report a healthy turn as lost. */ busy?: () => boolean; /** Called before each re-sent CR. */ onRetry?: (attempt: number) => void; /** Called when the retries are exhausted and the prompt is still unacknowledged. * Reporting only — settling the turn is shouldSettleStalledTurn's job. */ onUnconfirmed?: (attempts: number) => void; /** Test overrides. */ intervalMs?: number; attempts?: number; } /** Bracketed-paste `text` into a PTY then submit with CR after a 150ms beat. * Phase 0 finding: bracketed-paste does NOT neutralize a leading /, @, or ! — * they still trigger the slash-command / mention / bash-mode handlers and the * turn is never submitted. neutralizeForPaste() prepends a space for mentions, * bash-mode, and jinn-skill slash commands, while letting engine-native commands * (/compact, /clear, /model, …) pass through raw so the TUI actually runs them. * Shared by injectPrompt() (warm-PTY first turn) and writeStdin() (raw WS input). * * Pass `confirm` to make the submit verified rather than assumed: the CR is * re-sent until the CLI acknowledges the prompt, and `onUnconfirmed` fires if it * never does. Backticking attachment paths (below) removes the known trigger for * a swallowed CR; this covers the ones we have not characterised — a large paste * into a TUI mid-redraw, or one near auto-compact. Returns a cancel function — * the caller MUST call it when the turn settles (see the cancellation note). */ /** * Compose the "Attached files:" suffix, with every path wrapped in backticks. * * The backticks are load-bearing, not cosmetic. Claude Code's TUI scans * bracketed-paste text for tokens resolving to an existing IMAGE file and, on a * hit, enters an async "Pasting…" state while it reads and base64-encodes the * file into an `[Image #N]` chip. Keypresses are discarded while that state is * active — including pasteAndSubmit's submit CR, which fires on a fixed 150ms * timer. Any real screenshot takes longer than 150ms to encode, so the CR is * swallowed and the turn hangs forever: text sits in the input box, no spinner, * no error, no Stop hook. * * Verified against a live PTY (claude 2.1.220): a 5.8MB PNG hangs at 150ms and * submits at 400ms; the same path in backticks submits at 150ms every time. * Newlines are irrelevant — a zero-newline prompt with a real image path hangs, * and a three-newline prompt with a non-existent path submits. * * Backticks stop the path from being auto-attached, so there is no async state * to race. The model resolves the path with Read (which renders images), which * is already how the cold/argv path behaves — argv prompts never traverse the * TUI paste handler, so warm and cold now agree. */ export declare function buildAttachmentSuffix(attachments: readonly string[]): string; /** Keep bare image paths out of Claude Code's async paste-to-attachment path. */ export declare function neutralizeImagePathsForPaste(text: string): string; export declare function pasteAndSubmit(proc: Pick, text: string, confirm?: SubmitConfirmation): () => void; export declare class InteractiveClaudeEngine implements InterruptibleEngine, PtyViewEngine { private lifecycle; private hookRegistry; name: "claude"; /** Active turn resolvers keyed by Jinn session id. `boundProc` is the specific * PTY serving this turn (captured at spawn / warm-reuse). A PTY's onExit only * interrupts the active resolver when it IS that bound proc — so a stale PTY * released by a kill->respawn race can't poison the freshly-started turn. * `onStream` is the current turn's delta callback; the per-PTY SSE proxy routes * parsed events here (a PTY outlives its turn, so the proxy looks this up live). */ private active; /** Sessions with an in-flight async idle-spawn (proxy.start awaited) — prevents * a second ensureIdleSpawn from racing in a duplicate PTY during that gap. */ private idleSpawning; /** Per-session PTY output streams (scrollback ring buffer + live subscribers). * Survives PTY respawn. */ private streams; /** Last terminal geometry reported by the client per session. Used to spawn * follow-up PTYs at the correct dimensions when a turn comes in after the * warm PTY was reaped — otherwise spawn() falls back to 120×40 and the TUI * text body is locked in at the wrong width. Intentionally survives PTY * release (its job is to size the NEXT spawn); growth is bounded by setCapped. */ private lastGeom; private lastOutputAt; /** Model/effort the live PTY was spawned with, per session. `--model`/`--effort` * apply only at spawn, so a mid-chat switch must cold-respawn rather than reuse * the warm PTY (which would keep running the old model). */ private spawnParams; /** Sessions with a post-failure recovery listener armed (turn settled as an * API error, but the CLI may still finish — a late Stop supersedes). */ private lateRecovery; /** Post-settle background work per session: the CLI's SSE proxy still has * upstream requests in flight or a background Bash monitor after the Stop * hook settled the turn. `emitted` tracks whether the gateway was told, so a * cleared (null) notification is only sent when there's something to clear. */ private bgActivity; private backgroundMonitors; private backgroundActivityCb?; /** Test override for the post-settle clear quiet window (default 10s). */ backgroundClearQuietMs: number; /** Answer Claude Code's hardcoded safety prompts automatically. On by default: * a gateway PTY has no keyboard, so the alternative is a wedged session. Set * `engines.claude.autoApproveSafetyPrompts: false` to leave them for a human * in the CLI/xterm view instead — the turn then fails via the stall backstop * rather than hanging, which is the other half of this fix. */ private autoApproveSafetyPrompts; constructor(lifecycle: PtyLifecycleManager, hookRegistry: HookRegistry, opts?: { autoApproveSafetyPrompts?: boolean; }); /** Single-registration callback for post-settle background activity. `info` is * the live in-flight snapshot; `null` means cleared (quiet for * backgroundClearQuietMs, or the session's PTY was released). Never fires * while a run() is in flight for the session — the turn is already "running"; * only post-settle activity matters. */ onBackgroundActivity(cb: (jinnSessionId: string, info: UpstreamActivityInfo | null) => void): void; /** * Read the pending safety dialog off the terminal and answer it. * * Reading the screen (rather than trusting the hook) is the point: the * Notification payload says only "Claude needs your permission" — not which * dialog, nor what its options are. The parser refuses anything it does not * fully recognise, so an unfamiliar dialog stalls the turn instead of being * answered blind. `blockedOnPermissionAt` stays set on every failure path, so * whatever we decline to answer still reaches the stall backstop. */ private answerPermissionPrompt; onRuntimeActivity(cb: (jinnSessionId: string, info: UpstreamActivityInfo | null) => void): void; /** Per-PTY SSE proxy reported an in-flight change. Always record it (counts * must stay truthful across the run boundary); emission is gated downstream. */ private handleUpstreamActivity; /** Track the installed Claude CLI's observed monitor lifecycle. A top-level * PostToolUse Bash returns backgroundTaskId when launch succeeds; TaskStop * PostToolUse carries the stopped id in tool_input when termination succeeds. * Background Bash calls made inside Task subagents carry agent_id and are * not session monitors. */ private handleBackgroundMonitorHook; /** Emit the session's background state if it's post-settle and changed: * active streams/monitors emit immediately (cancelling any pending clear); * zero activity arms a quiet-window timer that emits `null` once, only if * activity was previously reported. Suppressed while a run() is in flight. */ private maybeEmitBackground; /** A new run() is taking the session: retract any reported background state * (the session is about to be "running") but KEEP the live counts — the proxy * persists across turns, and run()'s finally re-checks them post-settle. */ private suppressBackground; /** Drop all background state for a session (PTY released / killed), emitting * the cleared notification if activity had been reported. */ private clearBackground; private hasActiveUpstream; run(opts: EngineRunOpts): Promise; /** Build the env passed to the claude PTY: inherits process.env but strips * CLAUDECODE / CLAUDE_CODE_* so the child doesn't think it's nested, then * enables fullscreen rendering. Shared by spawn() and ensureIdleSpawn(). * When `proxyPort` is given, points ANTHROPIC_BASE_URL at the per-PTY SSE * forward proxy on 127.0.0.1 — subscription OAuth token is passed separately * by claude, so this stays cc_entrypoint=cli / subsidy-safe (verified Item A). */ private buildPtyEnv; /** Translate parsed SSE events from a PTY's proxy into StreamDeltas and route * them to the active turn's onStream. A PTY outlives its turn, so we look up * the live active entry here rather than capturing onStream at spawn. * Any SSE event is also proof of life for a pending StopFailure grace window. */ private handleSseEvent; /** Allocate + start a per-PTY SSE forward proxy. Returns the proxy and its port, * or {port:0} if it failed to bind — in which case the PTY is spawned WITHOUT * ANTHROPIC_BASE_URL (direct to Anthropic): the turn still works, only live * word-by-word streaming degrades. */ private startProxy; /** Wrap a freshly-spawned pty.IPty in a PtyHandle and wire its output into * the session's scrollback ring buffer + live subscribers. On PTY exit, if this * proc is the one bound to the active turn, the resolver is interrupted (a crash * with no Stop hook); a stale proc replaced by a respawn is treated as benign. * `proxy` (the per-PTY SSE forward proxy) is torn down when this PTY exits. */ private wireProcToStream; /** node-pty spawn of the genuine claude binary (no -p → cc_entrypoint=cli). * Allocates a per-PTY SSE forward proxy first and points the child at it. */ private spawn; /** Spawn an idle PTY for the CLI/xterm view. If an engineSessionId is provided, * resumes that session; otherwise spawns a fresh `claude` so a brand-new CLI-mode * session shows the TUI before the user types anything. * Does NOTHING if a warm PTY already exists or a turn is starting. * Fire-and-forget (void): allocating the per-PTY SSE proxy is async, so the * actual spawn happens after a microtask; `idleSpawning` guards re-entrancy. */ ensureIdleSpawn(jinnSessionId: string, opts: PtyIdleSpawnOpts): void; /** Inject a follow-up prompt into a warm PTY via bracketed-paste + CR. */ private injectPrompt; subscribeWithSnapshot(sessionId: string, cb: (data: Buffer) => void, onControl?: (event: PtyControlEvent) => void): PtySnapshotSubscription; restartPty(sessionId: string, opts: PtyIdleSpawnOpts): void; /** Write raw text to the warm PTY as a bracketed-paste + CR (same /@!-guard as injectPrompt). No-op if no warm PTY. */ writeStdin(sessionId: string, text: string): void; writeRaw(sessionId: string, data: string): void; /** Resize the warm PTY + remember the geometry for the next cold spawn. */ resizePty(sessionId: string, cols: number, rows: number): void; kill(sessionId: string, reason?: string): void; killAll(): void; /** Recycle idle warm PTYs only (org-reload). Never interrupts an in-flight * turn: sessions in `this.active` are skipped, so the turn that wrote the org * file runs to completion on its current persona and the next turn picks up * the new one via cold respawn. */ killIdle(): void; /** True only while a turn is in flight (distinct from "PTY is warm"). */ isTurnRunning(sessionId: string): boolean; /** Observable progress for the in-flight turn, or undefined if none is running. * * isTurnRunning() answers "does the gateway think a turn exists" — it is a * bookkeeping lookup, so it stays true for a wedged turn forever. This answers * the question that actually matters: is that turn *getting anywhere*. The * reconciler uses it to catch hangs the heartbeat cannot (the heartbeat runs for * as long as run() is pending, so a fresh heartbeat proves only that the gateway * is still waiting), and serializeSession uses it to show stall in the UI. * * PTY output alone is a weak signal — the TUI redraws its footer while idle at * the prompt — so hooks and tool state are reported alongside it and callers * weigh them together. */ turnProgress(sessionId: string): TurnProgress | undefined; /** True iff a warm PTY exists for this session (in the lifecycle manager). */ hasWarmPty(sessionId: string): boolean; /** Track viewing state from the frontend. Called by pty-ws on `viewing` messages * from CliTerminal (mount/unmount + Page Visibility). Ref-counted so multiple tabs * viewing the same session keep it warm until the last one leaves. */ setViewing(sessionId: string, viewing: boolean): void; /** InterruptibleEngine.isAlive — true if a turn OR a warm PTY exists. */ isAlive(sessionId: string): boolean; /** Keep listening for a late Stop after an API-error settle. Public visibility * is for tests; used by run() and kill(). No-op when the caller didn't provide * onLateRecovery. */ armLateRecovery(jinnSessionId: string, opts: EngineRunOpts): void; /** Tear down a pending late-recovery listener (new turn starting / kill / expiry). */ cancelLateRecovery(jinnSessionId: string): void; } //# sourceMappingURL=claude-interactive.d.ts.map