import type { IdentityJsonValue } from '../../domain/agent-run/identity.js'; import { Capability } from '../capabilities.js'; import type { AgentAdapter, AgentCompactResult, AgentProcess, AgentProcessSpawner, AgentProcessSupervision, AgentSpawnConfig, Backend, ContinuationSink, InjectionAckSink, McpComposition, UserMessage } from '../types.js'; import type { ContextUsage } from '../../core/types/agent-types.js'; import { CancelledError } from './defaults.js'; import { CortexAgentContext } from './spawn-args.js'; import { type TmuxExec } from './tmux-control.js'; import { extractAskUserQuestions, getCurrentPlanFilePath, mergeSubstantialOutput } from './event-parser.js'; type TurnTokenUsage = { input: number | null; output: number | null; cacheCreation: number | null; cacheRead: number | null; }; interface ClaudeSessionOptions { needsResume: boolean; model?: string | null; isUserInitiated?: boolean; callbackSource?: string | null; scheduleTaskId?: string | null; sessionKey?: string | null; claudeAgent?: string | null; systemPrompt?: string | null; appendSystemPrompt?: string | null; outputStyle?: string | null; tools?: string | null; pluginDirs?: string[] | null; anthropicBaseUrl?: string; extraEnv?: Record; cwd?: string; mcpComposition?: McpComposition; mcpConfigPaths?: string[]; disableHooks?: boolean; streamDeltas?: boolean; captureTranscriptLogs?: boolean; preserveUnreportedAccounting?: boolean; processSpawner?: AgentProcessSpawner; /** Absolute CLI path frozen by a trial policy; absent resolves `claude` from PATH. */ cliPath?: string; /** Compiled benchmark policy guard; present replaces the ambient hook surface entirely. */ benchmarkPolicyGuard?: IdentityJsonValue; /** Exact allowlisted child environment for a pinned trial. */ pinnedEnv?: NodeJS.ProcessEnv; /** Absolute trial deadline the child's MCP call budget is derived from at each spawn. */ benchmarkDeadlineEpochMs?: number; /** Extra CLI options from profile (e.g. {"--thinking": "xhigh"}). */ extraOption?: Record; /** Thinking level from the profile's `thinking` field → `--effort `. Absent → no flag. */ thinking?: string | null; /** Cortex execution context surfaced to the MCP server child as CORTEX_THREAD_ID/PROFILE/PROJECT/SESSION_NAME env vars. * Captured at spawn time; later turns on the same session reuse the original snapshot. */ context?: CortexAgentContext; } /** * Extract the prompt text from a `--replay-user-messages` echo. `message.content` arrives either * as a bare string or as text blocks. Returns null for anything else — notably the tool_result * carriers print mode already emits as `user` lines, which must never be read as a prompt echo. */ export declare function extractReplayText(data: any): string | null; declare class ClaudeSession { private proc; private rl; sessionId: string; private channel; private sessionKey; /** Model name requested via --model CLI arg (used as fallback for cost_record). */ modelName: string | null; private isUserInitiated; private callbackSource; private scheduleTaskId; private claudeAgent; private systemPrompt; private appendSystemPrompt; private outputStyle; private tools; private pluginDirs; private anthropicBaseUrl; private extraEnv; private cwd; private mcpComposition; private mcpConfigPaths; private disableHooks; private streamDeltas; private captureTranscriptLogs; private preserveUnreportedAccounting; private processSpawner; private cliPath; private benchmarkPolicyGuard; private pinnedEnv; /** The trial deadline as an instant. The MCP budget derived from it is not stored: every spawn * recomputes it, so a resumed or restarted process never inherits a stale budget. */ private benchmarkDeadlineEpochMs; private supervision; private extraOption; private thinking; private context; private currentTurn; /** Cursor over the `stream_event` sequence (--include-partial-messages). Session-scoped rather * than turn-scoped because the stream is a property of the process, and every `message_start` * resets it anyway. */ private streamDeltaState; /** Current provider-call usage plus configured/result-reconciled context window. */ private contextUsageTracker; /** Tracks in-flight background tasks (run_in_background) for this session. */ private bgTracker; /** Set by orchestration to receive spontaneous background-task continuation turns. */ private continuationSink; /** One-shot events that arrived before completion-only waiting installed its sink. */ private pendingContinuationDeliveries; /** Messages injected into an in-flight turn that the CLI has not echoed back yet, in write * order. Each is popped by its `--replay-user-messages` echo (the delivery ack). */ private pendingInjections; /** Set by orchestration to receive injection delivery acks. */ private injectionAck; /** Set when an injected message was consumed with NO turn in flight — the CLI is about to start * a turn of its own for it. Consumed by the next assistant line, which opens the * synthetic turn that captures the reply. */ private injectionContinuationArmed; private alive; private needsResume; private idleTimer; private turnIdleTimer; private maxTimer; private stderr; private cumulativeCostUsd; /** Captured from result event's modelUsage key for cost_record. */ lastModelName: string | null; /** Captured from result event's usage for legacy cost_record and compact accounting. */ lastTokenUsage: TurnTokenUsage | null; constructor(channel: string, sessionId: string, options: ClaudeSessionOptions); private toSpawnOptions; matchesSpawn(cwd: string, composition: McpComposition): boolean; private handleProcessClose; /** Deliver a synthetic interrupted result to the continuation sink (single-fire: the sink * reference is cleared before invoking). Fires only when background work may still produce * a continuation (or `force`, for a dying spontaneous turn); otherwise just clears the sink. */ private notifyBgInterrupted; private spawnProcess; private createTurnStreams; private registerTurn; private deliverContinuation; /** Register/replace the continuation sink. Persists across normal turns; lives as long * as the pooled session, until close()/kill(). */ setContinuationSink(sink: ContinuationSink): void; clearContinuationSink(): void; /** Register/replace the injection delivery-ack sink. Lifetime mirrors continuationSink. */ setInjectionAckSink(sink: InjectionAckSink): void; clearInjectionAckSink(): void; /** * Deliver a user message into the turn already in flight. * * Writes the SAME NDJSON user line a normal turn writes, but registers NO turn: the message is * absorbed by the run already in progress, so the already-awaited turn promise covers it and no * second result is fabricated. Cost/turn accounting stays with the running turn. * * Where it lands is a race the caller cannot control, so both outcomes are wired here: * - tool-result boundary → folds into the running turn, ONE result. Nothing extra * to do; the turn's own callbacks carry the reply. * - mid-text-generation → the CLI drains its queue only after this turn's result and then * starts a turn of its own. The echo handler arms the existing spontaneous-turn * path so that reply is captured by continuationSink instead of dropped. * * Returns false when there is no live process or no active turn — the caller then falls back to * the normal queue. */ injectUserMessage(message: UserMessage): boolean; /** * Handle a `--replay-user-messages` echo. The CLI echoes EVERY user message, so most echoes are * the turn's own opening prompt and must be ignored; only an echo matching the head of the * pending-injection queue is a delivery ack. Nothing else in the system reads these events. */ private handleReplayEcho; private continuationCallbacks; /** Open a synthetic turn to capture the spontaneous continuation the CLI emits after a * background task finishes. Its output is delivered or buffered for continuationSink. */ private openContinuationTurn; private writeTurnStdin; private startTurnIdleTimer; sendMessage(userMessage: string, options: { files?: any[]; callbackSource?: string | null; scheduleTaskId?: string | null; isUserInitiated?: boolean; onProgress?: ((progress: any) => void) | null; onAssistantMessage?: ((text: string, blockId?: string, model?: string | null) => void) | null; onAssistantDelta?: ((text: string, blockId: string) => void) | null; onToolUse?: ((name: string, input: any, toolUseId: string) => void) | null; onToolResult?: ((toolUseId: string, content: string, isError: boolean) => void) | null; onCompact?: ((info: { trigger: string; preTokens?: number; }) => void) | null; onContextUsage?: ((usage: ContextUsage) => void) | null; }): Promise; /** Invoke Claude Code's local slash handler without recording a Cortex user turn. */ compact(): Promise; private compactUsage; private bumpTurnIdleTimer; private turnCost; private captureTurnAccounting; private reportedAccounting; private settleResultTurn; private handleResultEvent; /** Preserve the complete result carrier that print mode emits as a `user` content block. */ private handleToolResultEvent; private handleAssistantToolBlock; private handleAssistantTextBlock; private handleAssistantEvent; private emitContextUsage; private handleLine; private closeTurnLogs; private resetIdleTimer; close(): void; kill(): boolean; getSupervision(): AgentProcessSupervision | undefined; isAlive(): boolean; } export declare function closeSession(channel: string, sessionKey?: string): void; /** Hard-stop the pooled session for a channel (SIGTERM, same path the foreground Stop takes via * handle.kill()). Unlike closeSession's graceful stdin-end + 30s grace, this ends the process now * — used by the Stop path to actually kill background tasks still running inside it after the * foreground turn ended. Returns false when no live session exists for the key. */ export declare function killSession(channel: string, sessionKey?: string): boolean; /** Close all sessions whose key starts with the given prefix (used by Thread cleanup). */ export declare function closeSessionsByPrefix(prefix: string): void; export declare function closeAllSessions(): void; export interface RunClaudeOptions { channel: string; sessionId?: string | null; files?: any[]; callbackSource?: string | null; scheduleTaskId?: string | null; model?: string | null; isUserInitiated?: boolean; onProgress?: any; onAssistantMessage?: any; onToolUse?: ((name: string, input: any, toolUseId: string) => void) | null; onToolResult?: ((toolUseId: string, content: string, isError: boolean) => void) | null; sessionKey?: string | null; claudeAgent?: string | null; systemPrompt?: string | null; outputStyle?: string | null; tools?: string | null; pluginDirs?: string[] | null; anthropicBaseUrl?: string; } /** * Decide whether a print-mode ClaudeSession should spawn with `--resume `. * * Print sessions default to `DATA_DIR` and may receive an explicit cwd, so transcript lookup uses * the same resolved cwd as the process spawn. A *fresh* session (notably the `cortex tui` * frontend) pre-registers its sessionId BEFORE the first Claude turn, so callers ask to * resume an id that has no transcript yet — Claude then exits with * "No conversation found with session ID: ". Gating the resume request on the * transcript actually existing keeps the first turn on `--session-id` (create) and lets * only later turns / reconnects use `--resume`. Self-healing: a deleted transcript also * correctly falls back to create. Mirrors {@link resolveTuiResume} for the tmux path. */ export declare function resolveResumeForPrint(requestedResume: boolean, sessionId: string, exists?: (p: string) => boolean, cwd?: string): boolean; export declare function runClaude(userMessage: string, opts: RunClaudeOptions): { promise: Promise; kill(): boolean; sessionId: string; }; /** Pure dispatch: select claude adapter mode from an AgentSpawnConfig. * Defaults to 'print' for missing or unrecognized values (conservative — never silently * flips a session into the experimental TUI path). */ export declare function selectClaudeMode(config: AgentSpawnConfig): 'print' | 'tui'; /** Test hook: mirror of ClaudeSession.toSpawnOptions() for the AgentSpawnConfig entry point. * Must stay in sync with ClaudeSession constructor + toSpawnOptions — both paths derive * ClaudeSpawnOptions through deriveClaudeSpawnOptions(), so any field added to that helper * is covered here without divergence. */ declare function computeSpawnArgsForConfig(config: AgentSpawnConfig): string[]; export declare class ClaudeAdapter implements AgentAdapter { readonly backend: Backend; readonly capabilities: Set; spawn(config: AgentSpawnConfig): AgentProcess; /** * DR-0012 TUI-mode dispatch. Returns an AgentProcess whose send() pushes ALL NormalizedEvents * (including derived ones — ask_user_question, plan_*, cost_record, turn_complete) via * ClaudeTuiSession's onEvent stream, then resolves with the TuiAgentResult cast to AgentResult. * * Sessions are pooled in `tuiSessions` by sessionKey; multi-turn reuses the same tmux session. * kill() forwards to ClaudeTuiSession.kill() which tears down the tmux session. */ private spawnTui; close(sessionKey: string): Promise; kill(sessionKey: string): boolean; listSessions(): string[]; } /** * DR-0012 §3.6 startup hook — sweep orphan tmux sessions matching the cortex-claude- prefix. * * Rationale: tmux sessions are independent of agent-server's process lifetime, but the in-memory * `tuiSessions` Map is not. After an agent-server restart we have no record of channel/sessionKey * → tmux mapping (it was never persisted), so we cannot re-adopt existing tmux sessions into the * pool. The honest choice is to kill them at startup; otherwise they accumulate forever and a * later session reusing the same sessionId would conflict with `tmux new-session -s ` * (which fails on duplicate). Logs the killed names so operators can investigate if needed. * * Full re-adoption (preserving an in-flight TUI session across restart) requires persisting * sessionKey + cwd + needsResume metadata to disk — deferred as a follow-up. * * Override `exec` in tests so we don't touch the real tmux server. */ export declare function recoverTuiOrphans(exec?: TmuxExec): { found: string[]; killed: string[]; }; /** Construct a ClaudeSession WITHOUT spawning the `claude` child process, for unit * testing handleLine / continuation routing. Initializes only the fields the line * handlers touch. Callers should stub createTurnStreams to avoid log file I/O and * register cleanup via t.after(() => session.close()) to clear the idle timer. */ declare function makeSessionForTest(modelName?: string | null, autoCompactWindow?: number | null): ClaudeSession; export declare const _test: { extractAskUserQuestions: typeof extractAskUserQuestions; mergeSubstantialOutput: typeof mergeSubstantialOutput; computeSpawnArgs: typeof computeSpawnArgsForConfig; makeSessionForTest: typeof makeSessionForTest; }; export { getCurrentPlanFilePath }; export { CancelledError };