import * as claudeAgentSdk from "@anthropic-ai/claude-agent-sdk";
import { query, } from "@anthropic-ai/claude-agent-sdk";
import { loadSessionId, saveSessionId, loadUsageSnapshot, saveUsageSnapshot, getConfigDir } from "../../../config/index.js";
import { seedClaudeAutoMemorySetting } from "../../../config/claude-settings.js";
import { logger } from "../../../logger.js";
import { createToolServer } from "../../../tools/index.js";
import { sanitizeJsonString, sanitizeJsonValueInPlace } from "../../../utils/json-sanitize.js";
import { buildAgentEnv, getModelId } from "../../model-provider.js";
import { logSystemPrompt } from "../system-prompt-log.js";
import { getAgentState } from "../../state.js";
import { DEFAULT_COMPACTION_TIMEOUT_MS } from "../../compaction-policy.js";
import { resolveClaudeCliExecutable } from "./cli-resolver.js";
import { ensureBrowserForTool } from "../browser-tool-policy.js";
import path from "path";
import { getClaudeTranscriptDirForProfileDir } from "../../../utils/claude-transcripts.js";
import * as imagePrunerUnTyped from "../../image-pruner.js";
import * as sessionTrimmerUnTyped from "../../session-trimmer.js";
const imagePruner = imagePrunerUnTyped;
const sessionTrimmer = sessionTrimmerUnTyped;
/**
 * Used-token threshold (fraction of context window) at which we trigger
 * compaction for **large** context windows (>= {@link COMPACT_MAX_CONTEXT_WINDOW}).
 *
 * Smaller windows scale up to {@link COMPACT_SMALL_WINDOW_USED_PCT_THRESHOLD}
 * via {@link getCompactUsedPctThreshold}.
 */
export const COMPACT_LARGE_WINDOW_USED_PCT_THRESHOLD = 0.7;
const COMPACT_MIN_CONTEXT_WINDOW = 200_000;
const COMPACT_MAX_CONTEXT_WINDOW = 1_000_000;
/**
 * Used-token threshold (fraction of context window) at which we trigger
 * compaction for **small** context windows (<= {@link COMPACT_MIN_CONTEXT_WINDOW}).
 * Set above 1.0 to effectively disable token-based compaction for tiny
 * windows where the SDK already manages context internally.
 */
const COMPACT_SMALL_WINDOW_USED_PCT_THRESHOLD = 5.0;
export const MAX_BASE64_IMAGE_BLOCKS = 10;
/**
 * Safety-net threshold: trigger compaction when remaining type:"image" blocks
 * exceed this count, to stay under Claude API's 100-image hard limit.
 *
 * Since #328, pruned images become text blocks ("[Screenshot: archived=...]")
 * and no longer count toward the API image limit. This threshold now primarily
 * guards against edge cases: legacy placeholder PNGs from older transcripts,
 * sessions with many unpruned screenshots accumulating before the pruner runs,
 * or rapid screenshot bursts between prune cycles.
 */
export const IMAGE_COMPACT_THRESHOLD = 80;
//const CONTEXT_1M_BETAS = ["context-1m-2025-08-07"] as const;
/**
 * Cap the SDK's `startup()` initialization wait. The SDK's default is 60s,
 * which can effectively block an entire wake cycle if the CLI hangs on
 * startup. We prefer to fall back to a normal cold `query()` quickly and
 * let the live path surface the real error.
 */
const WARM_INITIALIZE_TIMEOUT_MS = 15_000;
/**
 * How long to keep a pre-warmed subprocess alive before proactively
 * refreshing it. Bounded staleness protects us from silent degradation:
 *  - Anthropic API sessions have server-side idle limits; a long-idle
 *    warmed handle may get GC'd on the server, causing the first real
 *    turn to fail and forcing a cold recovery.
 *  - Long-lived Node subprocesses can accumulate state (fds, memory,
 *    half-closed sockets). Periodic replacement keeps each consumed
 *    handle young.
 *
 * Ten minutes is a comfortable trade-off: frequent enough to avoid
 * most server-side reaping thresholds, infrequent enough that the
 * extra cold-starts during refresh are negligible (~6/hour).
 */
const WARM_REFRESH_TTL_MS = 10 * 60_000;
/**
 * When a proactive refresh fails (startup errored), retry at this
 * shorter cadence instead of waiting a full TTL. This keeps us from
 * sitting cold for 10 minutes if the failure was transient.
 */
const WARM_REFRESH_RETRY_MS = 30_000;
const sdkStartup = claudeAgentSdk.startup;
/**
 * Compute the used-pct compaction threshold for a given context window.
 *
 * Behavior:
 *  - `contextWindow <= 0` (unknown): return `fallbackThreshold` (caller's choice).
 *  - `contextWindow <= 200K`: {@link COMPACT_SMALL_WINDOW_USED_PCT_THRESHOLD} (effectively disabled).
 *  - `contextWindow >= 1M`:  {@link COMPACT_LARGE_WINDOW_USED_PCT_THRESHOLD} (default 50%).
 *  - In between: linear interpolation between the two.
 *
 * Tiny windows accumulate tokens slowly and the SDK manages context internally,
 * so we want a much higher trigger; very large windows benefit from compacting
 * earlier to keep latency / cache hit-rate reasonable.
 */
function getCompactUsedPctThreshold(contextWindow, fallbackThreshold) {
    if (contextWindow <= 0)
        return fallbackThreshold;
    if (contextWindow <= COMPACT_MIN_CONTEXT_WINDOW)
        return COMPACT_SMALL_WINDOW_USED_PCT_THRESHOLD;
    if (contextWindow >= COMPACT_MAX_CONTEXT_WINDOW)
        return COMPACT_LARGE_WINDOW_USED_PCT_THRESHOLD;
    const progress = (contextWindow - COMPACT_MIN_CONTEXT_WINDOW) / (COMPACT_MAX_CONTEXT_WINDOW - COMPACT_MIN_CONTEXT_WINDOW);
    return COMPACT_SMALL_WINDOW_USED_PCT_THRESHOLD + progress * (COMPACT_LARGE_WINDOW_USED_PCT_THRESHOLD - COMPACT_SMALL_WINDOW_USED_PCT_THRESHOLD);
}
/**
 * Wraps the V1 query() API with a long-lived async generator for streaming
 * input. This allows injecting additional user messages while the agent is
 * processing (interrupt messages), instead of waiting for the query to
 * finish before sending the next message.
 */
export class ClaudeAgentSession {
    config;
    buildSystemPrompt;
    runtimeSurface;
    getDualSessionEnabled;
    getAndroidUseEnabled;
    getCardAvailable;
    /** Working directory for the SDK subprocess. Defaults to profile config dir. */
    sessionCwd;
    nativeTools;
    /** Resolved external MCP servers (playwright, serpapi, etc.) for this runtime. */
    externalMcpServers;
    /** Dynamic MCP servers added at runtime via manage_mcp_servers tool. */
    dynamicMcpServers;
    currentQuery = null;
    warmQuery = null;
    warmUpPromise = null;
    warmReadyAtMs = null;
    warmGeneration = 0;
    sessionId;
    transcriptPath = null;
    mode;
    /**
     * Pending message queue for the long-lived generator.
     * When a message is injected, it's pushed here and the waiting
     * generator is woken up via the resolver.
     */
    pendingMessages = [];
    messageResolver = null;
    generatorClosed = true;
    _orphanedInjections = 0;
    lastCompactRequestAtMs = 0;
    compactInFlight = false;
    /**
     * Number of consecutive compaction attempts that hit the timeout without a
     * successful `compact_boundary`. Incremented when the compaction timer
     * fires; reset to 0 on any successful compaction. The wake loop reads this
     * to decide when to force an auto-refresh — see the auto-refresh constants in
     * `agent/compaction-policy.ts`.
     */
    consecutiveCompactionTimeouts = 0;
    stopRequested = false;
    /**
     * Tracks whether we've seeded `<profile>/.claude/settings.local.json`
     * with our auto-memory directory override this process-lifetime. The seed
     * helper is itself idempotent, but this flag keeps hot paths (every
     * `consume()` / `warmUp()`) from hitting the filesystem repeatedly.
     */
    autoMemorySeedDone = false;
    /** Optional: auto-injects screenshots after Playwright tool calls. */
    _screenshotInjector = null;
    /** AbortController passed to the SDK; aborting it cleanly stops the running query. */
    abortController = null;
    warmAbortController = null;
    /**
     * Timer that proactively replaces the pre-warmed subprocess at
     * `WARM_REFRESH_TTL_MS` intervals. Cleared when the warm is consumed,
     * on stop, or before arming a new one.
     */
    warmRefreshTimer = null;
    lastUsageSnapshot = null;
    playwriterConnectionsResetThisTurn = false;
    constructor({ config, buildSystemPrompt, runtimeSurface, sessionContext, }) {
        this.config = config;
        this.buildSystemPrompt = buildSystemPrompt;
        this.runtimeSurface = runtimeSurface;
        this.getDualSessionEnabled = sessionContext.getDualSessionEnabled;
        this.getAndroidUseEnabled = sessionContext.getAndroidUseEnabled;
        this.getCardAvailable = sessionContext.getCardAvailable;
        // Always use the config dir as cwd so the SDK stores transcripts in a
        // consistent directory regardless of which project the coding session
        // is working on.
        this.sessionCwd = getConfigDir();
        this.nativeTools = runtimeSurface.nativeTools;
        this.externalMcpServers = runtimeSurface.externalMcpServers;
        this.dynamicMcpServers = {};
        this.mode = sessionContext.mode;
        this.sessionId = loadSessionId(this.mode);
        const persisted = loadUsageSnapshot(this.mode);
        if (persisted && this.sessionId) {
            this.lastUsageSnapshot = { ...persisted, capturedAtMs: Date.now() };
        }
    }
    set screenshotInjector(injector) {
        this._screenshotInjector = injector;
    }
    /**
     * Seed `<profile>/.claude/settings.local.json` once per process so the
     * CLI's auto-memory lands inside the active profile instead of the
     * default `~/.claude/projects/...` tree. Called from every CLI-spawn
     * path (warm + cold); the underlying helper is itself idempotent.
     *
     * Gated on the `autoMemorySeedDone` flag so the happy path doesn't
     * re-read/re-write the file every turn. The flag is set **only on
     * success** — if the first attempt fails (permissions, disk full, etc.)
     * the next warm/cold spawn will retry until it lands, instead of
     * silently leaving the CLI on its default memory path for the rest of
     * the session.
     */
    ensureAutoMemorySeeded() {
        if (this.autoMemorySeedDone)
            return;
        try {
            this.autoMemorySeedDone = seedClaudeAutoMemorySetting();
        }
        catch (err) {
            // Never let a settings-seeding failure block the agent. Worst case
            // the CLI uses its default memory path and /obs reports that path
            // correctly via the system/init event — degraded but functional.
            // We deliberately do NOT set autoMemorySeedDone here so the next
            // spawn retries.
            logger.warn(`[auto-memory] Seeding failed; proceeding with CLI defaults: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /**
     * Build a fresh mcpServers record with a new visionclaw tool server instance.
     * This avoids the "Already connected to a transport" error when the same
     * McpServer instance is reused across concurrent/sequential query() calls.
     */
    buildMcpServers() {
        if (this.runtimeSurface.visionClawToolTransport.transport !== "claude-mcp") {
            throw new Error("Claude session requires claude-mcp VisionClaw tool transport");
        }
        return {
            ...this.externalMcpServers,
            ...this.dynamicMcpServers,
            visionclaw: createToolServer({
                dualSession: this.getDualSessionEnabled()
                    && this.runtimeSurface.visionClawToolTransport.supportsSwitchSessionTool,
                androidUse: this.getAndroidUseEnabled(),
                cardAvailable: this.getCardAvailable(),
            }),
        };
    }
    buildNativeToolOptions() {
        return {
            tools: { type: "preset", preset: "claude_code" },
        };
    }
    buildHooks() {
        return {
            UserPromptSubmit: [
                {
                    hooks: [
                        (async (input, _toolUseID, { signal }) => {
                            const up = input;
                            try {
                                this.captureTranscriptPath(up.transcript_path);
                                if (signal.aborted)
                                    return {};
                                const res = await imagePruner.pruneSessionImages({
                                    transcriptPath: up.transcript_path,
                                    keepLastNBase64Images: MAX_BASE64_IMAGE_BLOCKS,
                                });
                                if (this.lastUsageSnapshot) {
                                    this.captureUsageSnapshot({
                                        ...this.lastUsageSnapshot,
                                        allImageBlocks: res.allImageBlocks,
                                    });
                                }
                            }
                            catch (err) {
                                logger.warn(`UserPromptSubmit image pruning failed: ${err instanceof Error ? err.message : String(err)}`);
                            }
                            return {};
                        }),
                    ],
                    timeout: 120,
                },
            ],
            PreCompact: [
                {
                    hooks: [
                        (async (input, _toolUseID, { signal }) => {
                            const pre = input;
                            try {
                                this.captureTranscriptPath(pre.transcript_path);
                                if (signal.aborted)
                                    return {};
                                const res = await imagePruner.pruneSessionImages({
                                    transcriptPath: pre.transcript_path,
                                    keepLastNBase64Images: MAX_BASE64_IMAGE_BLOCKS,
                                });
                                if (res.fileChanged) {
                                    logger.system(`Pruned transcript images before compaction: pruned=${res.prunedImageBlocks} resized=${res.resizedImageBlocks} kept=${res.keptImageBlocks} total=${res.totalImageBlocks}`);
                                }
                            }
                            catch (err) {
                                logger.warn(`PreCompact image pruning failed: ${err instanceof Error ? err.message : String(err)}`);
                            }
                            return {};
                        }),
                    ],
                    timeout: 120,
                },
            ],
            PreToolUse: [
                {
                    matcher: "mcp__playwright__.*",
                    hooks: [
                        (async (_input) => {
                            const pre = _input;
                            const result = await ensureBrowserForTool({
                                toolName: pre.tool_name,
                                browserBackend: this.config.browserBackend,
                                playwriterConnectionsResetThisTurn: this.playwriterConnectionsResetThisTurn,
                                playwriterRelayHost: this.config.playwriterRelayHost,
                                playwriterRelayPort: this.config.playwriterRelayPort,
                            });
                            this.playwriterConnectionsResetThisTurn = result.playwriterConnectionsResetThisTurn;
                            return {};
                        }),
                    ],
                    timeout: 30,
                },
            ],
            PostToolUse: [
                {
                    hooks: [
                        (async (input, _toolUseID) => {
                            const post = input;
                            // Track the (possibly mutated) tool response separately from
                            // post.tool_response so the top-level-string case can swap in
                            // a sanitized replacement without aliasing the original.
                            let toolResponse = post.tool_response;
                            let mutated = false;
                            try {
                                const { resizedCount } = await imagePruner.resizeOversizedImagesInToolOutput(toolResponse);
                                if (resizedCount > 0) {
                                    logger.debug(`[PostToolUse] resized ${resizedCount} oversized image(s) in ${post.tool_name} output`);
                                    mutated = true;
                                }
                            }
                            catch (err) {
                                logger.warn(`PostToolUse image resize failed: ${err instanceof Error ? err.message : String(err)}`);
                            }
                            // Strip lone UTF-16 surrogates from the tool response so they
                            // never reach the model API's JSON validator (which rejects
                            // them, unlike Node's permissive JSON.stringify).
                            //
                            // MCP tools return objects (CallToolResult), but
                            // tool_response is typed as `unknown` and non-MCP tools may
                            // surface raw strings. Handle both shapes:
                            //  - string: sanitize and replace `toolResponse`
                            //  - object/array: sanitize in place
                            try {
                                if (typeof toolResponse === "string") {
                                    const sanitized = sanitizeJsonString(toolResponse);
                                    if (sanitized !== toolResponse) {
                                        toolResponse = sanitized;
                                        logger.debug(`[PostToolUse] sanitized lone surrogates in ${post.tool_name} string output`);
                                        mutated = true;
                                    }
                                }
                                else if (sanitizeJsonValueInPlace(toolResponse)) {
                                    logger.debug(`[PostToolUse] sanitized lone surrogates in ${post.tool_name} output`);
                                    mutated = true;
                                }
                            }
                            catch (err) {
                                logger.warn(`PostToolUse surrogate sanitize failed: ${err instanceof Error ? err.message : String(err)}`);
                            }
                            if (this._screenshotInjector) {
                                try {
                                    await this._screenshotInjector.onToolComplete(post.tool_name, this);
                                }
                                catch (err) {
                                    logger.warn(`PostToolUse screenshot injection failed: ${err instanceof Error ? err.message : String(err)}`);
                                }
                            }
                            if (mutated) {
                                return {
                                    hookSpecificOutput: {
                                        hookEventName: "PostToolUse",
                                        updatedMCPToolOutput: toolResponse,
                                    },
                                };
                            }
                            return {};
                        }),
                    ],
                    timeout: 120,
                },
            ],
        };
    }
    /**
     * Build the shared Claude query options used for both pre-warm and live runs.
     */
    buildQueryOptions(systemPrompt, savedSessionId, abortController, effort) {
        const cliResolution = resolveClaudeCliExecutable();
        const options = {
            model: getModelId(this.config, { useCase: this.mode === "coding" ? "main_coding" : "main_general" }),
            ...(cliResolution.kind !== "sdk-default"
                ? { pathToClaudeCodeExecutable: cliResolution.path }
                : {}),
            systemPrompt,
            ...this.buildNativeToolOptions(),
            disallowedTools: this.runtimeSurface.disallowedTools,
            permissionMode: "bypassPermissions",
            allowDangerouslySkipPermissions: true,
            cwd: this.sessionCwd,
            settingSources: ["project"],
            mcpServers: this.buildMcpServers(),
            hooks: this.buildHooks(),
            env: buildAgentEnv(this.config),
            ...(savedSessionId ? { resume: savedSessionId } : {}),
            ...(abortController ? { abortController } : {}),
            includePartialMessages: true,
            //betas: [...CONTEXT_1M_BETAS],
            thinking: { type: "adaptive" },
        };
        options.effort = effort ?? (this.mode === "coding" ? "high" : "medium");
        return options;
    }
    /**
     * Cancel any pending proactive-refresh timer. Idempotent.
     */
    clearWarmRefreshTimer() {
        if (this.warmRefreshTimer) {
            clearTimeout(this.warmRefreshTimer);
            this.warmRefreshTimer = null;
        }
    }
    /**
     * Arm the proactive-refresh timer. Caller must ensure any previous timer
     * is cleared. Uses `.unref()` so the process can exit while a warm
     * subprocess is idle — the refresh is purely a background optimization
     * and should never hold the event loop alive on its own.
     */
    scheduleWarmRefresh(delayMs) {
        this.clearWarmRefreshTimer();
        const timer = setTimeout(() => {
            this.warmRefreshTimer = null;
            void this.performWarmRefresh();
        }, delayMs);
        if (typeof timer.unref === "function") {
            timer.unref();
        }
        this.warmRefreshTimer = timer;
    }
    /**
     * Handler for the refresh timer. Covers two cases:
     *  1. Normal TTL refresh: `warmQuery` is non-null → close the stale
     *     subprocess and start a fresh one.
     *  2. Retry after a previous failure: `warmQuery` is null → just
     *     attempt warmUp again (no teardown needed).
     *
     * If the warm was already consumed between timer arming and firing,
     * the consume path would have cleared the timer, so we shouldn't
     * reach here in that case.
     */
    async performWarmRefresh() {
        if (this.stopRequested) {
            // stopRequested is briefly true between requestStop() and the next
            // loop iteration's clearStop(). If we just return here without
            // rescheduling, the refresh loop silently dies until someone
            // explicitly re-arms it. Defer to a shorter retry so the refresh
            // resumes once the stop window passes.
            logger.debug(`Claude warm refresh deferred: stop requested; retrying in ${WARM_REFRESH_RETRY_MS}ms`);
            this.scheduleWarmRefresh(WARM_REFRESH_RETRY_MS);
            return;
        }
        if (this.warmQuery) {
            const oldWarm = this.warmQuery;
            const oldAbort = this.warmAbortController;
            const oldAgeMs = this.warmReadyAtMs === null ? null : Date.now() - this.warmReadyAtMs;
            this.warmQuery = null;
            this.warmAbortController = null;
            this.warmReadyAtMs = null;
            // Tear down the stale warm subprocess before starting a fresh one.
            // We abort first so any in-flight SDK I/O short-circuits, then close
            // to release the child process and open transports.
            try {
                oldAbort?.abort();
            }
            catch { /* best-effort */ }
            try {
                oldWarm.close();
            }
            catch { /* best-effort */ }
            logger.debug(`Claude warm refresh: replacing ${oldAgeMs === null ? "warm" : `${oldAgeMs}ms-old warm`} subprocess`);
        }
        else {
            // Retry path after a prior failure — nothing to tear down.
            logger.debug("Claude warm refresh: retrying after previous failure");
        }
        // `warmUp()` swallows its own errors (to keep its contract simple for
        // fire-and-forget callers), so we detect failure by inspecting warm
        // state after it resolves rather than relying on a thrown error. We
        // route the post-warmUp checks through a helper so TypeScript doesn't
        // narrow `this.stopRequested`/`this.warmQuery` based on the synchronous
        // assignments above.
        await this.warmUp();
        this.handleWarmRefreshOutcome();
    }
    /**
     * Post-warmUp follow-up for performWarmRefresh. Kept in its own method so
     * TS's control-flow narrowing in performWarmRefresh doesn't treat these
     * fields as having their pre-await values.
     */
    handleWarmRefreshOutcome() {
        if (this.stopRequested) {
            // Stop arrived while we were refreshing — requestStop already cleaned
            // up; don't re-arm a refresh timer.
            return;
        }
        if (!this.warmQuery) {
            // warmUp() failed (or was a no-op because startup is unavailable).
            // Schedule a shorter retry so we don't sit cold for a full TTL after
            // a transient failure. If startup is permanently unavailable, these
            // retries are cheap no-ops.
            logger.debug(`Claude warm refresh did not produce a new warm; retrying in ${WARM_REFRESH_RETRY_MS}ms`);
            this.scheduleWarmRefresh(WARM_REFRESH_RETRY_MS);
        }
        // On success, warmUp() already scheduled the next refresh at TTL.
    }
    /**
     * Send the initial user message and return the query generator to stream
     * responses. The underlying async generator stays open so that additional
     * messages can be injected via injectMessage().
     */
    async warmUp() {
        if (!sdkStartup) {
            return;
        }
        // Seed `<profile>/.claude/settings.local.json` once per process. The
        // CLI reads `autoMemoryDirectory` from this file at subprocess
        // startup, so we must write it before spawning the warm child.
        this.ensureAutoMemorySeeded();
        // Already warm — nothing to do, but defensively re-arm the refresh
        // timer if it happens to be missing. This can occur if a TTL refresh
        // fired during a brief stopRequested window and chose to defer rather
        // than close the warm.
        if (this.warmQuery) {
            if (!this.warmRefreshTimer && !this.stopRequested) {
                this.scheduleWarmRefresh(WARM_REFRESH_TTL_MS);
            }
            return;
        }
        // A warm-up is already in flight — await that one instead of racing.
        if (this.warmUpPromise) {
            await this.warmUpPromise;
            return;
        }
        // Honor an in-progress stop request — don't spin up a subprocess we're
        // just going to abort on the next line.
        if (this.stopRequested) {
            return;
        }
        const savedSessionId = this.sessionId;
        const systemPrompt = this.buildSystemPrompt();
        const warmAbortController = new AbortController();
        const warmStartMs = Date.now();
        const generation = this.warmGeneration;
        const promise = (async () => {
            try {
                const warmed = await sdkStartup({
                    options: this.buildQueryOptions(systemPrompt, savedSessionId, warmAbortController),
                    initializeTimeoutMs: WARM_INITIALIZE_TIMEOUT_MS,
                });
                if (generation !== this.warmGeneration) {
                    try {
                        warmed.close();
                    }
                    catch { /* best-effort */ }
                    logger.debug("Claude query pre-warm discarded after session reset");
                    return;
                }
                // Note: we intentionally do NOT discard the freshly-ready warm when
                // `this.stopRequested` is true. requestStop() is typically a "stop
                // the current task" signal, and the user usually starts a new task
                // shortly after. Throwing the warm away here would force the next
                // task to pay the cold-start cost we just paid. On actual process
                // shutdown, the warm child process is reaped with the parent.
                this.warmQuery = warmed;
                this.warmAbortController = warmAbortController;
                this.warmReadyAtMs = Date.now();
                logger.debug(`Claude query pre-warm ready in ${this.warmReadyAtMs - warmStartMs}ms (next refresh in ${WARM_REFRESH_TTL_MS}ms)`);
                // Arm the proactive-refresh timer. When it fires we'll close this
                // subprocess and spin up a fresh one, keeping staleness bounded.
                this.scheduleWarmRefresh(WARM_REFRESH_TTL_MS);
            }
            catch (err) {
                logger.debug(`Claude query pre-warm skipped after ${Date.now() - warmStartMs}ms: ${err instanceof Error ? err.message : String(err)}`);
                if (generation === this.warmGeneration) {
                    this.warmQuery = null;
                    this.warmAbortController = null;
                    this.warmReadyAtMs = null;
                }
            }
            finally {
                if (generation === this.warmGeneration) {
                    this.warmUpPromise = null;
                }
            }
        })();
        this.warmUpPromise = promise;
        await promise;
    }
    /**
     * Tear down any idle pre-warmed subprocess and cancel its refresh timer.
     *
     * Called when something in `config` that was baked into the warm options
     * changed (provider, env, model). The warm subprocess inlines provider
     * env via `buildAgentEnv(config)` at warm time and `sendAndStream()`
     * deliberately reuses those baked options on the warm path — so without
     * this, a provider switch can be silently undone by consuming a stale
     * warm child for one more turn.
     *
     * Idempotent and best-effort: safe to call when no warm exists, and
     * does not throw on close failures (the child will be reaped on
     * process exit if abort/close misfires).
     */
    discardWarmQuery() {
        this.clearWarmRefreshTimer();
        const oldWarm = this.warmQuery;
        const oldAbort = this.warmAbortController;
        if (!oldWarm && !oldAbort)
            return;
        this.warmQuery = null;
        this.warmAbortController = null;
        this.warmReadyAtMs = null;
        try {
            oldAbort?.abort();
        }
        catch { /* best-effort */ }
        try {
            oldWarm?.close();
        }
        catch { /* best-effort */ }
        logger.debug("Claude warm subprocess discarded (provider/env changed)");
    }
    sendAndStream(content, options) {
        this.playwriterConnectionsResetThisTurn = false;
        const savedSessionId = this.sessionId;
        const effort = options?.effort;
        // Resolve the Claude Code executable — prefers the native binary shipped
        // by @anthropic-ai/claude-agent-sdk >= 0.2.113, falls back to the legacy
        // cli.js, and finally lets the SDK resolve it internally.
        const cliResolution = resolveClaudeCliExecutable();
        logger.debug(`Claude CLI resolution: kind=${cliResolution.kind}` +
            (cliResolution.kind === "sdk-default" ? "" : ` path=${cliResolution.path}`));
        const systemPrompt = this.buildSystemPrompt();
        // Log the resolved system prompt for training data capture. The console
        // gets a compact head/tail preview; the full prompt goes into `data.prompt`
        // of the JSONL entry so the log file remains complete.
        logSystemPrompt(systemPrompt);
        // If a reset summary is pending, prepend it to the content as priority context.
        const resetSummary = this.consumeResetSummary();
        let finalContent = content;
        if (resetSummary && !savedSessionId) {
            const summaryBlock = {
                type: "text",
                text: `This is a fresh session created after a context reset. Here is a summary of your previous session state:\n\n${resetSummary}\n\nReview this summary and continue where you left off. Check your memory and todo list for any additional context.`,
            };
            if (Array.isArray(finalContent)) {
                finalContent = [summaryBlock, ...finalContent];
            }
            else if (typeof finalContent === "string") {
                finalContent = [summaryBlock, { type: "text", text: finalContent }];
            }
        }
        // Seed the generator with the initial message
        const initialMessage = {
            type: "user",
            session_id: savedSessionId ?? "",
            message: { role: "user", content: finalContent },
            parent_tool_use_id: null,
        };
        this.pendingMessages = [initialMessage];
        this.generatorClosed = false;
        this._orphanedInjections = 0;
        // Create a long-lived async generator that yields the initial message
        // and then waits for additional messages injected via injectMessage().
        // We capture references to the session's pending queue and resolver
        // so the generator can read from them without aliasing `this`.
        const pending = this.pendingMessages;
        const setResolver = (r) => {
            this.messageResolver = r;
        };
        const isClosed = () => this.generatorClosed;
        const shiftPending = () => pending.shift();
        async function* messageStream() {
            while (!isClosed()) {
                // Yield all pending messages
                let next = shiftPending();
                while (next) {
                    yield next;
                    next = shiftPending();
                }
                // If generator is still open, wait for the next message
                if (!isClosed()) {
                    await new Promise((resolve) => {
                        setResolver(resolve);
                    });
                }
            }
        }
        if (savedSessionId) {
            logger.system(`Resuming session: ${savedSessionId}`);
        }
        else {
            logger.system("Creating new session...");
        }
        const prompt = messageStream();
        if (this.warmQuery) {
            // Warm path: the subprocess was already initialized with a full set of
            // query options (system prompt, MCP servers, hooks, CLI path, betas,
            // env, resume, etc.) by warmUp(). We deliberately do NOT call
            // buildQueryOptions() here — doing so would spin up a throwaway
            // McpServer instance and allocate a fresh hook closure set that get
            // immediately discarded, defeating the whole point of pre-warming.
            //
            // Cancel the proactive-refresh timer first: we're about to consume
            // this warm, and we don't want the timer to fire mid-turn and try
            // to close a subprocess that's now actively serving a query.
            this.clearWarmRefreshTimer();
            const warmQuery = this.warmQuery;
            this.warmQuery = null;
            // Adopt the warm abort controller so requestStop() cleanly cancels
            // the running query via the same AbortSignal the SDK is watching.
            this.abortController = this.warmAbortController;
            this.warmAbortController = null;
            const warmAgeMs = this.warmReadyAtMs === null ? null : Date.now() - this.warmReadyAtMs;
            if (effort) {
                logger.debug(`Claude query using pre-warmed subprocess${warmAgeMs === null ? "" : ` (ready ${warmAgeMs}ms ago)`} (effort=${effort} ignored — warm subprocess uses pre-built options)`);
            }
            else {
                logger.debug(`Claude query using pre-warmed subprocess${warmAgeMs === null ? "" : ` (ready ${warmAgeMs}ms ago)`}`);
            }
            this.warmReadyAtMs = null;
            this.currentQuery = warmQuery.query(prompt);
        }
        else {
            // Cold path: build full options and let query() spawn a fresh CLI.
            // Same rationale as warmUp() — seed before the subprocess starts so
            // it picks up the overridden `autoMemoryDirectory`.
            this.ensureAutoMemorySeeded();
            const abortController = new AbortController();
            this.abortController = abortController;
            const options = this.buildQueryOptions(systemPrompt, savedSessionId, abortController, effort);
            if (effort) {
                logger.debug(`Claude query using cold start (effort=${effort})`);
            }
            else {
                logger.debug("Claude query using cold start");
            }
            this.currentQuery = query({ prompt, options });
        }
        return this.currentQuery;
    }
    /**
     * Inject a new user message into the running query stream.
     * The agent will see this as a follow-up user message in the conversation.
     * Only works while a query is active (between sendAndStream and closeInput).
     *
     * @returns true if the message was injected, false if the generator is closed.
     */
    injectMessage(content, options) {
        const msg = {
            type: "user",
            session_id: this.sessionId ?? "",
            message: { role: "user", content },
            parent_tool_use_id: null,
            ...(options?.shouldQuery === false ? { shouldQuery: false } : {}),
        };
        if (this.generatorClosed) {
            logger.warn("Cannot inject message: generator is closed");
            return false;
        }
        this.pendingMessages.push(msg);
        logger.info("Injected interrupt message into active session");
        // Wake the generator if it's waiting
        if (this.messageResolver) {
            this.messageResolver();
            this.messageResolver = null;
        }
        return true;
    }
    /**
     * Signal the input generator to close.
     * Any injected messages still in the pending queue are counted as
     * orphaned — they were accepted by injectMessage but never delivered
     * to the SDK.
     */
    closeInput() {
        this._orphanedInjections = this.pendingMessages.length;
        if (this._orphanedInjections > 0) {
            logger.warn(`closeInput: ${this._orphanedInjections} injected message(s) orphaned`);
        }
        this.pendingMessages = [];
        this.generatorClosed = true;
        if (this.messageResolver) {
            this.messageResolver();
            this.messageResolver = null;
        }
    }
    /**
     * Returns true if injected messages were lost when the generator closed.
     */
    get hasOrphanedInjections() {
        return this._orphanedInjections > 0;
    }
    get isInputClosed() {
        return this.generatorClosed;
    }
    /**
     * Request that the current agent turn be stopped.
     * Uses the SDK's AbortController to cleanly signal the running query to
     * stop, then closes the input stream so the loop returns to idle.
     */
    requestStop() {
        this.stopRequested = true;
        if (this.abortController) {
            this.abortController.abort();
            logger.system("Stop requested — aborting query via AbortController");
        }
        // IMPORTANT: Do NOT tear down the idle pre-warmed subprocess here.
        // requestStop() is typically called to end the *current* turn (user
        // /stop, watchdog timeout, stream silence, obs UI button); the user
        // will usually start a new task shortly after. Preserving the idle
        // warm lets that next task skip cold-start (~2-3s).
        //
        // The warm is a fully separate subprocess with its own AbortController
        // — aborting `this.abortController` above does not touch it.
        //
        // For graceful shutdown: the warm is a child process of ours and will
        // be reaped when we exit; the refresh timer uses `.unref()` so it
        // doesn't block process exit.
        this.closeInput();
    }
    isStopRequested() {
        return this.stopRequested;
    }
    clearStop() {
        this.stopRequested = false;
        // Only null the *live query's* abort controller. The warm subprocess
        // has its own abort controller that must survive the stop→clearStop
        // cycle — sendAndStream() adopts it when consuming the warm.
        this.abortController = null;
    }
    /**
     * Update the persisted session ID.
     *
     * Only saves on first capture (null → id). Once set, a different ID from
     * the SDK is ignored — it means the resume failed and the SDK created a
     * throwaway conversation. Persisting it would corrupt session.json and
     * orphan the real transcript.
     */
    captureSessionId(id) {
        if (!id || this.sessionId)
            return;
        this.sessionId = id;
        saveSessionId(id, this.mode);
    }
    captureTranscriptPath(p) {
        if (p && typeof p === "string") {
            this.transcriptPath = p;
        }
    }
    getSessionId() {
        return this.sessionId;
    }
    pendingResetSummary = null;
    resetForNewSession(summary) {
        this.warmGeneration++;
        this.warmUpPromise = null;
        this.sessionId = null;
        this.transcriptPath = null;
        this.lastUsageSnapshot = null;
        // A fresh session starts with a clean compaction history.
        this.consecutiveCompactionTimeouts = 0;
        this.pendingResetSummary = summary ?? null;
        // Discard warm query since it's bound to the old session
        this.discardWarmQuery();
        logger.system(`[session] reset for new session (mode=${this.mode})`);
    }
    injectSummary(summary) {
        this.pendingResetSummary = summary;
        this.discardWarmQuery();
        logger.system(`[session] summary injected for next turn (mode=${this.mode})`);
    }
    /**
     * If a reset summary is pending, consume and return it (clears the pending state).
     */
    consumeResetSummary() {
        const s = this.pendingResetSummary;
        this.pendingResetSummary = null;
        return s;
    }
    /**
     * Proactively request compaction by running the /compact slash command as a
     * separate one-turn query resumed from the current session.
     *
     * This is more reliable than injecting "/compact" into the streaming prompt
     * because the running query may treat it as ordinary text.
     */
    async requestCompaction() {
        if (this.compactInFlight)
            return;
        this.compactInFlight = true;
        try {
            getAgentState().compacting = true;
        }
        catch {
            // Agent state not yet initialized
        }
        // TODO: maybe should use reply map.
        const sendCompactNotice = (text) => {
            try {
                const state = getAgentState();
                const { ownerConfig, channelManager } = state;
                const channel = ownerConfig.telegramChatId ? "telegram" : ownerConfig.ownerEmail ? "gmail" : undefined;
                const recipient = ownerConfig.telegramChatId ? String(ownerConfig.telegramChatId) : ownerConfig.ownerEmail;
                if (channel && recipient) {
                    channelManager
                        .sendMessage(channel, recipient, text)
                        .catch((err) => {
                        logger.warn(`Failed to send compaction notification: ${err instanceof Error ? err.message : String(err)}`);
                    });
                }
            }
            catch {
                // Agent state not yet initialized — skip notification
            }
        };
        try {
            const savedSessionId = this.sessionId;
            if (!savedSessionId) {
                logger.warn("Cannot request /compact: session_id not available yet");
                return;
            }
            // Resolve the Claude Code executable — same lookup as sendAndStream().
            const cliResolution = resolveClaudeCliExecutable();
            const options = {
                model: getModelId(this.config, { useCase: this.mode === "coding" ? "main_coding" : "main_general" }),
                ...(cliResolution.kind !== "sdk-default"
                    ? { pathToClaudeCodeExecutable: cliResolution.path }
                    : {}),
                systemPrompt: this.buildSystemPrompt(),
                permissionMode: "bypassPermissions",
                allowDangerouslySkipPermissions: true,
                cwd: this.sessionCwd,
                settingSources: ["project"],
                mcpServers: this.buildMcpServers(),
                env: buildAgentEnv(this.config),
                resume: savedSessionId,
                maxTurns: 1,
            };
            logger.system("Requesting /compact...");
            const isSilent = getAgentState().ownerConfig.silentCompaction;
            if (!isSilent) {
                sendCompactNotice("My brain is getting full... Let me tidy up my thoughts. This may take a minute — I might forget some older details.");
            }
            const cliLabel = cliResolution.kind === "sdk-default"
                ? "sdk-default"
                : `${cliResolution.kind}:${cliResolution.path}`;
            logger.debug(`Compaction query options: cli=${cliLabel} cwd=${this.sessionCwd} resume=${savedSessionId}`);
            // Sanitize the transcript before compaction — the compact subprocess
            // loads the file on startup, so any corrupt entries must be fixed first.
            const tp = this.transcriptPath ?? (savedSessionId ? this.deriveTranscriptPath(savedSessionId) : null);
            if (tp) {
                try {
                    await imagePruner.sanitizeTranscript(tp);
                }
                catch (sanitizeErr) {
                    logger.warn(`Pre-compact sanitization failed: ${sanitizeErr instanceof Error ? sanitizeErr.message : String(sanitizeErr)}`);
                }
            }
            // Guard against compaction hanging forever — abort after the runtime
            // timeout (10 minutes). Bloated sessions routinely exceed the old
            // 5-minute cap.
            const compactionTimeoutMs = DEFAULT_COMPACTION_TIMEOUT_MS;
            const compactAbort = new AbortController();
            const compactTimer = setTimeout(() => {
                logger.warn(`[compact] Compaction timed out after ${compactionTimeoutMs / 1000}s — aborting`);
                // A timeout means this session never shrank; track the streak so the
                // wake loop can force a full /refresh once it crosses the threshold.
                this.consecutiveCompactionTimeouts++;
                compactAbort.abort();
            }, compactionTimeoutMs);
            try {
                for await (const msg of query({
                    prompt: "/compact",
                    options: { ...options, abortController: compactAbort },
                })) {
                    if (msg.type === "system" && msg.subtype === "compact_boundary") {
                        // Capture session id (should remain the same) and log compaction metadata.
                        if ("session_id" in msg && msg.session_id) {
                            this.captureSessionId(msg.session_id);
                        }
                        const meta = msg.compact_metadata;
                        logger.system(`Compaction completed (trigger=${meta?.trigger ?? "unknown"}, pre_tokens=${meta?.pre_tokens ?? "?"})`);
                        // Compaction reached a boundary — the session shrank, so clear any
                        // accumulated timeout streak.
                        this.consecutiveCompactionTimeouts = 0;
                        if (typeof meta?.pre_tokens === "number") {
                            this.capturePostCompactionSnapshot(meta.pre_tokens);
                        }
                        else if (this.lastUsageSnapshot) {
                            // No token data available, but still reset the image count
                            // so maybeCompact() doesn't re-trigger on the stale value.
                            this.captureUsageSnapshot({
                                ...this.lastUsageSnapshot,
                                allImageBlocks: 0,
                            });
                        }
                        if (!isSilent) {
                            sendCompactNotice("Brain cleanup done! Ready to continue.");
                        }
                        // Trim the JSONL transcript to remove pre-compaction content.
                        // This keeps the live file small for faster resumes and pruning.
                        const tp = this.transcriptPath;
                        if (tp) {
                            try {
                                const archivePath = sessionTrimmer.deriveArchivePath(tp);
                                await sessionTrimmer.trimTranscriptAfterCompaction({
                                    transcriptPath: tp,
                                    archivePath,
                                });
                            }
                            catch (trimErr) {
                                logger.warn(`Post-compaction JSONL trim failed: ${trimErr instanceof Error ? trimErr.message : String(trimErr)}`);
                            }
                        }
                    }
                }
            }
            finally {
                clearTimeout(compactTimer);
            }
        }
        catch (err) {
            logger.warn(`Request /compact failed: ${err instanceof Error ? err.message : String(err)}`);
        }
        finally {
            this.compactInFlight = false;
            try {
                getAgentState().compacting = false;
            }
            catch {
                // Agent state not yet initialized
            }
        }
    }
    captureUsageSnapshot(snapshot) {
        // Merge with previous snapshot so callers that only update token data
        // don't clobber allImageBlocks, and vice-versa.
        this.lastUsageSnapshot = {
            ...this.lastUsageSnapshot,
            ...snapshot,
            capturedAtMs: snapshot.capturedAtMs ?? Date.now(),
        };
        saveUsageSnapshot(this.lastUsageSnapshot, this.mode);
    }
    /**
     * Update the usage snapshot after the SDK performs mid-query auto-compaction.
     * Uses `pre_tokens` (the post-compaction token count) and the last known
     * context window to compute an accurate `usedPct`, preventing the post-query
     * `maybeCompact` from triggering a redundant compaction.
     */
    capturePostCompactionSnapshot(postCompactionTokens) {
        // A post-compaction snapshot means a compaction succeeded (including the
        // SDK's mid-query auto-compaction reported via stream-handler), so the
        // timeout streak is broken.
        this.consecutiveCompactionTimeouts = 0;
        const contextWindow = this.lastUsageSnapshot?.contextWindow ?? 0;
        if (contextWindow <= 0 || postCompactionTokens < 0) {
            this.lastUsageSnapshot = null;
            return;
        }
        this.captureUsageSnapshot({
            usedInputTokens: postCompactionTokens,
            contextWindow,
            usedPct: postCompactionTokens / contextWindow,
            allImageBlocks: 0,
        });
    }
    /**
     * Unified between-turn compaction gate.  Triggers compaction when **either**:
     *  1. Token usage exceeds the context-window threshold, OR
     *  2. Total image blocks (real + placeholder) in the transcript reach
     *     `imageThreshold`, approaching Claude API's 100-image limit.
     *
     * Both triggers share the same cooldown and in-flight guard so we never
     * run two back-to-back compactions in the same between-turn pass.
     */
    async maybeCompact(options) {
        const explicitUsedPctThreshold = options?.usedPctThreshold;
        const cooldownMs = options?.cooldownMs ?? 10 * 60 * 1000;
        const imageThreshold = options?.imageThreshold ?? IMAGE_COMPACT_THRESHOLD;
        // --- guard: in-flight ---
        if (this.compactInFlight) {
            logger.debug("[compact] skip: compaction already in flight");
            return;
        }
        // --- guard: cooldown ---
        const nowMs = Date.now();
        const sinceLastCompactMs = nowMs - this.lastCompactRequestAtMs;
        if (sinceLastCompactMs <= cooldownMs) {
            logger.debug(`[compact] skip: cooldown active sinceLast=${sinceLastCompactMs}ms cooldown=${cooldownMs}ms`);
            return;
        }
        // --- trigger 1: token usage ---
        const snap = this.lastUsageSnapshot;
        // Caller-supplied override always wins. Otherwise auto-scale by
        // context window, falling back to the large-window default when no
        // snapshot is available yet.
        const effectiveUsedPctThreshold = explicitUsedPctThreshold ??
            (snap
                ? getCompactUsedPctThreshold(snap.contextWindow, COMPACT_LARGE_WINDOW_USED_PCT_THRESHOLD)
                : COMPACT_LARGE_WINDOW_USED_PCT_THRESHOLD);
        let tokenTrigger = false;
        if (snap) {
            const snapshotAgeMs = nowMs - (snap.capturedAtMs ?? 0);
            logger.debug(`[compact] check: usedPct=${(snap.usedPct * 100).toFixed(1)}% threshold=${(effectiveUsedPctThreshold * 100).toFixed(1)}% used=${snap.usedInputTokens} window=${snap.contextWindow} snapshotAge=${snapshotAgeMs}ms`);
            tokenTrigger = snap.contextWindow > 0 && snap.usedPct >= effectiveUsedPctThreshold;
        }
        // --- trigger 2: image count ---
        const allImages = snap?.allImageBlocks ?? 0;
        const imageTrigger = allImages >= imageThreshold;
        if (!tokenTrigger && !imageTrigger) {
            if (snap) {
                logger.debug(`[compact] skip: under threshold (usedPct=${(snap.usedPct * 100).toFixed(1)}% threshold=${(effectiveUsedPctThreshold * 100).toFixed(1)}%, images=${allImages}/${imageThreshold})`);
            }
            else {
                logger.debug(`[compact] skip: no usage snapshot yet (images=${allImages}/${imageThreshold})`);
            }
            return;
        }
        const reason = [tokenTrigger && "tokens", imageTrigger && `images(${allImages}>=${imageThreshold})`]
            .filter(Boolean)
            .join("+");
        logger.system(`[compact] requesting compaction (reason=${reason})`);
        this.lastCompactRequestAtMs = nowMs;
        await this.requestCompaction();
    }
    getTranscriptPath() {
        return this.transcriptPath;
    }
    getTranscriptInfo() {
        return {
            kind: "claude",
            ...(this.transcriptPath ? { sessionFilePath: this.transcriptPath } : {}),
            sessionDir: getClaudeTranscriptDirForProfileDir(getConfigDir()),
        };
    }
    /**
     * Derive the transcript path from the session ID and config dir, even before
     * the SDK subprocess has started and reported it via hooks.
     */
    deriveTranscriptPath(sessionId) {
        const dir = getClaudeTranscriptDirForProfileDir(getConfigDir());
        return path.join(dir, `${sessionId}.jsonl`);
    }
    getUsageSnapshot() {
        return this.lastUsageSnapshot ? { ...this.lastUsageSnapshot } : null;
    }
    getConsecutiveCompactionTimeouts() {
        return this.consecutiveCompactionTimeouts;
    }
    /** Returns the live Query object, or null if no query is active. */
    getCurrentQuery() {
        return this.currentQuery;
    }
    /**
     * Validation adapter around `Query.getContextUsage()` (SDK 0.2.114+).
     *
     * The SDK's `Query` interface exposes `getContextUsage()` as a control
     * request over the streaming bridge. It returns a rich breakdown:
     *
     * ```ts
     * {
     *   categories: { name: string; tokens: number; color: string; isDeferred?: boolean }[];
     *   totalTokens: number;
     *   maxTokens: number;       // context window for the *active* model
     *   rawMaxTokens: number;    // nominal max before any reductions
     *   percentage: number;      // UI percentage (0..100)
     *   gridRows: ...[];         // TUI-only
     *   model: string;
     *   memoryFiles: { path: string; type: string; tokens: number }[];
     *   mcpTools: { name: string; serverName: string; tokens: number }[];
     * }
     * ```
     *
     * We map it onto our existing {@link UsageSnapshot} shape:
     *   - `usedInputTokens` ← `totalTokens`
     *   - `contextWindow`   ← `maxTokens` (post-reduction; matches what the
     *                         model actually has to work with right now)
     *   - `usedPct`         ← computed from the two above, NOT from the
     *                         SDK's `percentage` field. The SDK's value is
     *                         UI-scale (0..100); our consumers expect 0..1.
     *                         Computing locally keeps both fields internally
     *                         consistent.
     *   - `allImageBlocks`  — intentionally preserved from any existing
     *                         snapshot; `getContextUsage` doesn't know about
     *                         raw image-block counts in the transcript, so
     *                         we don't clobber the value that
     *                         {@link captureUsageSnapshot} maintains.
     *
     * This is purely a read adapter — no callers in compaction or the
     * wake loop should depend on it yet. It exists to let us sanity-check
     * the live SDK response before wiring any behavior changes around it.
     * Returns null when no live query is active, when the method is absent
     * on the active Query instance (e.g. an older CLI), or when the call
     * errors transiently.
     */
    async getLiveContextUsage() {
        const q = this.currentQuery;
        if (!q) {
            logger.debug("[getLiveContextUsage] no active query");
            return null;
        }
        // The typed interface declares the method, but runtime code may
        // predate it if the user pins an older CLI via
        // `pathToClaudeCodeExecutable`. Probe defensively.
        const candidate = q.getContextUsage;
        if (typeof candidate !== "function") {
            logger.debug("[getLiveContextUsage] query.getContextUsage unavailable");
            return null;
        }
        try {
            // `getContextUsage()` is a control request answered by the CLI
            // subprocess. When that subprocess is wedged (e.g. its model round-trip
            // is stalled during a TLS/DNS flap) the control reply never arrives and
            // this await would hang indefinitely, leaking a pending promise on every
            // turn. Bound it: race against a timeout that resolves to a sentinel so
            // we fall through to the existing null-return path instead of hanging.
            const GET_CONTEXT_USAGE_TIMEOUT_MS = 5_000;
            const TIMED_OUT = Symbol("getContextUsage-timeout");
            let timeoutHandle;
            const rawOrTimeout = await Promise.race([
                candidate.call(q),
                new Promise((resolve) => {
                    timeoutHandle = setTimeout(() => resolve(TIMED_OUT), GET_CONTEXT_USAGE_TIMEOUT_MS);
                }),
            ]);
            clearTimeout(timeoutHandle);
            if (rawOrTimeout === TIMED_OUT) {
                logger.debug(`[getLiveContextUsage] timed out after ${GET_CONTEXT_USAGE_TIMEOUT_MS / 1000}s (subprocess control reply stalled)`);
                return null;
            }
            const raw = rawOrTimeout;
            // logger.debug(
            //   `[getContextUsage] raw: totalTokens=${String(raw.totalTokens)} maxTokens=${String(raw.maxTokens)}`,
            // );
            const totalTokens = typeof raw.totalTokens === "number" ? raw.totalTokens : NaN;
            const maxTokens = typeof raw.maxTokens === "number" ? raw.maxTokens : NaN;
            if (!Number.isFinite(totalTokens) || !Number.isFinite(maxTokens) || maxTokens <= 0) {
                logger.debug(`[getLiveContextUsage] unexpected shape: totalTokens=${String(raw.totalTokens)} maxTokens=${String(raw.maxTokens)}`);
                return null;
            }
            const usage = {
                usedInputTokens: totalTokens,
                contextWindow: maxTokens,
                usedPct: totalTokens / maxTokens,
                allImageBlocks: this.lastUsageSnapshot?.allImageBlocks,
                capturedAtMs: Date.now(),
            };
            // logger.debug(
            //   `[getLiveContextUsage] snapshot: usedPct=${(usage.usedPct * 100).toFixed(1)}% used=${usage.usedInputTokens} window=${usage.contextWindow}`,
            // );
            return usage;
        }
        catch (err) {
            logger.debug(`[getLiveContextUsage] call failed: ${err instanceof Error ? err.message : String(err)}`);
            return null;
        }
    }
    /** Replace the full set of dynamic MCP servers (used when loading from disk). */
    setDynamicMcpServers(servers) {
        this.dynamicMcpServers = { ...servers };
    }
    /** Get the current dynamic MCP servers record. */
    getDynamicMcpServers() {
        return { ...this.dynamicMcpServers };
    }
}
//# sourceMappingURL=session.js.map