/**
 * Per-connection spectral SDK lifecycle.
 *
 * One `AgentBridge` instance per active WebSocket connection. Wraps:
 *  - `createAgentSession` (in-memory session manager — we own persistence in
 *    SQLite; spectral doesn't need to write its own JSONL files).
 *  - `subscribe` listener that translates spectral `AgentSessionEvent`s into our
 *    own `ServerEvent` wire format and pushes them through a caller-supplied
 *    sink.
 *  - `prompt(text)` to send user input.
 *  - `dispose()` for clean teardown on WS close.
 *
 * Event mapping (agent → wire):
 *  - `message_start` (assistant)        → emit our own `message_start` with a
 *    fresh UUID `messageId`. spectral's AssistantMessage has no stable id field, so
 *    we mint one per turn and use it for all subsequent deltas/tool events
 *    until `message_end`.
 *  - `message_update` w/ inner text_delta / thinking_delta → wire `text_delta`
 *    or `thinking_delta` carrying the `delta` and our `messageId`.
 *  - `tool_execution_start`             → wire `tool_call`.
 *  - `tool_execution_end`               → wire `tool_result`.
 *  - `message_end` (assistant)          → wire `message_end` and persist the
 *    final assembled text + JSONL of every WIRE event we emitted for this
 *    message into SQLite. Content is taken from the final
 *    `AssistantMessage.content` (concatenating `text` blocks); we fall back
 *    to the `text_delta` accumulator when the provider didn't populate
 *    final blocks (e.g. deepseek via openai-compatible).
 *  - `agent_end`                        → wire `agent_end`.
 *
 * Persistence shape:
 *  `events_jsonl` is the newline-delimited JSON of the wire-format
 *  `ServerEvent`s we emitted for this message — NOT raw spectral
 *  `AgentSessionEvent`s. This guarantees the client's `parseWireEvents`
 *  reducer can rehydrate the turn after a refresh using the exact same
 *  reducer it uses for the live broadcast.
 *
 * Errors thrown synchronously from `session.prompt()` (e.g. no model
 * configured) are caught by the caller in `routes.ts` and re-emitted as
 * `{type:"error"}`.
 *
 * History rehydration:
 *  On first attach to a previously-created session (e.g. after a server
 *  restart), the SessionStreamManager passes the full SQLite transcript
 *  to the AgentBridge via `AgentBridgeOptions.history`. Before
 *  `createAgentSession` is called, each message is appended to the
 *  in-memory SessionManager so the LLM sees the full conversation
 *  context from the very first prompt. Multi-turn conversations within
 *  a single spectral session also work normally (the same AgentSession
 *  instance is reused across `prompt()` calls).
 */
import { createJiti } from "@mariozechner/jiti";
import { AuthStorage, createAgentSession, DefaultResourceLoader, ModelRegistry, SessionManager, SettingsManager, } from "../sdk/coding-agent/index.js";
import { randomUUID } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import aexolMcpExtension from "../extensions/aexol-mcp.js";
import kanbanBridgeExtension from "../extensions/kanban-bridge.js";
import spectralVisionExtension from "../extensions/spectral-vision-fallback.js";
import subagentExt from "../agent/index.js";
import designerExtension from "../designer/index.js";
import observationalMemory from "../memory/index.js";
import { Runtime } from "../memory/runtime.js";
import { OBSERVATIONAL_MEMORY_CONTEXT_CUSTOM_TYPE, OBSERVATIONAL_MEMORY_SNAPSHOT_CUSTOM_TYPE, } from "../memory/types.js";
import { fetchAllowedModels as defaultFetchAllowedModels, } from "../relay/models-fetch.js";
/**
 * Synthetic provider names registered with spectral's `ModelRegistry`. They route
 * 100% of inference traffic through the backend (`${backendUrl}/v1`), which
 * authenticates with the machine JWT and forwards to the upstream provider
 * with centrally-managed API keys.
 *
 * Two providers (rather than one) because spectral's `ProviderConfigInput.api`
 * picks the request shape per registration. Backend supports both, but a
 * single bag would force all models onto one shape; instead we group by
 * upstream provider type.
 */
const SPECTRAL_PROXY_ANTHROPIC = "spectral-proxy-anthropic";
const SPECTRAL_PROXY_OPENAI = "spectral-proxy-openai";
const SPECTRAL_PROXY_USER_MODEL = "spectral-proxy-user-model";
/**
 * Walk ancestor directories from `cwd` up to the filesystem root, collecting
 * any existing skill directories at the given relative paths (e.g.
 * `.opencode/skills` or `.aexol/skills`).
 *
 * Directories are returned in order from the deepest ancestor to `cwd` so
 * project-local skills override ancestor-level ones when there are name
 * collisions (spectral's skill loader keeps the first found).
 */
function collectAncestorSkillDirs(cwd, relativePaths) {
    const found = [];
    let dir = resolve(cwd);
    while (true) {
        for (const rel of relativePaths) {
            const candidate = join(dir, rel);
            try {
                if (existsSync(candidate) && statSync(candidate).isDirectory()) {
                    found.push(candidate);
                }
            }
            catch {
                // skip permission errors etc.
            }
        }
        const parent = dirname(dir);
        if (parent === dir)
            break;
        dir = parent;
    }
    return found;
}
/**
 * Concatenate text from an `AssistantMessage.content` array. Returns the
 * empty string when no text blocks are present (tool-only turns) or when
 * the input is missing/non-array (defensive — spectral's `message_end` always
 * carries an array, but we don't want to crash on a future sdk change).
 */
function extractTextFromContent(content) {
    if (!Array.isArray(content))
        return "";
    let out = "";
    for (const block of content) {
        if (block &&
            typeof block === "object" &&
            block.type === "text" &&
            typeof block.text === "string") {
            out += block.text;
        }
    }
    return out;
}
/**
 * Resolve the entry point of the spectral-mcp-adapter extension.
 * Uses the bundled copy in dist/mcp/index.js (same directory as this file).
 * Returns the absolute path, or null if the bundled file is missing.
 */
function resolveMcpAdapterEntry() {
    const __dirname = dirname(fileURLToPath(import.meta.url));
    // agent-bridge.ts compiles to dist/server/agent-bridge.js;
    // the bundled MCP adapter sits next door at dist/mcp/index.js.
    const bundledIndex = resolve(__dirname, "..", "mcp", "index.js");
    if (existsSync(bundledIndex))
        return bundledIndex;
    return null;
}
/**
 * Token pricing per model (USD per 1M tokens). Matches provider list
 * prices as of May 2026. Used to compute token cost server-side when
 * spectral's own cost field is unavailable (synthetic proxy models are
 * registered with zero cost to avoid agent-side billing).
 *
 * Keys are matched as prefix substrings against modelId, so
 * `"claude-sonnet-4"` covers both `claude-sonnet-4-20250514` and any
 * future point-release.
 */
const MODEL_PRICING = [
    // Anthropic Claude models
    { prefix: "claude-opus-4", input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
    { prefix: "claude-sonnet-4", input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
    { prefix: "claude-3-5-sonnet", input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
    { prefix: "claude-3-5-haiku", input: 0.80, output: 4, cacheWrite: 1, cacheRead: 0.08 },
    { prefix: "claude-3-opus", input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 },
    { prefix: "claude-3-sonnet", input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 },
    { prefix: "claude-3-haiku", input: 0.25, output: 1.25, cacheWrite: 1.25, cacheRead: 0.025 },
    // OpenAI models
    { prefix: "gpt-5.5", input: 1.75, output: 14, cacheWrite: 14, cacheRead: 0.35 },
    { prefix: "gpt-5.4", input: 1.75, output: 14, cacheWrite: 14, cacheRead: 0.35 },
    { prefix: "gpt-5.3", input: 1.75, output: 14, cacheWrite: 14, cacheRead: 0.35 },
    { prefix: "gpt-5.2", input: 1.75, output: 14, cacheWrite: 14, cacheRead: 0.35 },
    { prefix: "gpt-5.1", input: 2, output: 8, cacheWrite: 8, cacheRead: 0.50 },
    { prefix: "gpt-5", input: 1.25, output: 10, cacheWrite: 10, cacheRead: 0.25 },
    { prefix: "gpt-4.1", input: 2, output: 8, cacheWrite: 8, cacheRead: 0.50 },
    { prefix: "gpt-4o", input: 2.50, output: 10, cacheWrite: 10, cacheRead: 1.25 },
    { prefix: "gpt-4-turbo", input: 10, output: 30, cacheWrite: 0, cacheRead: 0 },
    { prefix: "gpt-4", input: 30, output: 60, cacheWrite: 0, cacheRead: 0 },
    { prefix: "gpt-3.5-turbo", input: 0.50, output: 1.50, cacheWrite: 0, cacheRead: 0 },
    { prefix: "o1", input: 15, output: 60, cacheWrite: 0, cacheRead: 0 },
    { prefix: "o3-mini", input: 1.10, output: 4.40, cacheWrite: 0, cacheRead: 0 },
    { prefix: "o4-mini", input: 1.10, output: 4.40, cacheWrite: 0, cacheRead: 0 },
    // Google Gemini models
    { prefix: "gemini-2.5-pro", input: 1.25, output: 10, cacheWrite: 0, cacheRead: 0 },
    { prefix: "gemini-2.5-flash", input: 0.15, output: 0.60, cacheWrite: 0, cacheRead: 0 },
    // DeepSeek models
    { prefix: "deepseek-v3", input: 0.27, output: 1.10, cacheWrite: 0, cacheRead: 0 },
    { prefix: "deepseek-r1", input: 0.55, output: 2.19, cacheWrite: 0, cacheRead: 0 },
    { prefix: "deepseek/deepseek-v3", input: 0.27, output: 1.10, cacheWrite: 0, cacheRead: 0 },
    { prefix: "deepseek/deepseek-r1", input: 0.55, output: 2.19, cacheWrite: 0, cacheRead: 0 },
    // Meta Llama models (common via OpenRouter-compatible endpoints)
    { prefix: "meta-llama/llama-4", input: 0.20, output: 0.80, cacheWrite: 0, cacheRead: 0 },
    { prefix: "meta-llama/llama-3.3", input: 0.20, output: 0.50, cacheWrite: 0, cacheRead: 0 },
];
/**
 * Strip vendor prefix to extract the bare model name.
 * e.g. "openai/gpt-5.3-codex" → "gpt-5.3-codex".
 * Returns the original string when no separator is present.
 */
function bareModelId(modelId) {
    const idx = modelId.lastIndexOf("/");
    return idx === -1 ? modelId : modelId.slice(idx + 1);
}
/** Get model cost from backend credit rates, falling back to hardcoded pricing table. */
function getModelCost(m) {
    // Prefer backend credit rates when configured
    const hasCredits = m.creditInputPer1M != null ||
        m.creditOutputPer1M != null ||
        m.creditCacheReadPer1M != null ||
        m.creditCacheWritePer1M != null;
    if (hasCredits) {
        return {
            input: m.creditInputPer1M ?? 0,
            output: m.creditOutputPer1M ?? 0,
            cacheRead: m.creditCacheReadPer1M ?? 0,
            cacheWrite: m.creditCacheWritePer1M ?? 0,
        };
    }
    // Fall back to hardcoded pricing table when credits aren't configured
    const pricing = lookupPricing(m.modelId);
    return pricing
        ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
        : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
}
/** Look up pricing for a modelId. Returns null when unknown. */
function lookupPricing(modelId) {
    const bare = bareModelId(modelId);
    for (const entry of MODEL_PRICING) {
        if (modelId.startsWith(entry.prefix) || bare.startsWith(entry.prefix)) {
            return {
                input: entry.input,
                output: entry.output,
                cacheWrite: entry.cacheWrite,
                cacheRead: entry.cacheRead,
            };
        }
    }
    return null;
}
function inferSyntheticOpenAICompat(model) {
    const bare = bareModelId(model.modelId);
    const isDeepSeek = model.provider === "deepseek" ||
        model.modelId.startsWith("deepseek/") ||
        bare.startsWith("deepseek");
    if (isDeepSeek) {
        return {
            thinkingFormat: "deepseek",
            requiresReasoningContentOnAssistantMessages: true,
            supportsDeveloperRole: false,
        };
    }
    if (model.provider === "openrouter") {
        const compat = {
            thinkingFormat: "openrouter",
        };
        if (model.modelId.startsWith("anthropic/")) {
            compat.cacheControlFormat = "anthropic";
        }
        return compat;
    }
    return undefined;
}
/**
 * Calculate credits from token usage using per-model credit rates.
 *
 * NOTE: We intentionally keep higher precision here (no 2-decimal rounding)
 * so small turns don't collapse to 0.00 in live UI aggregation. Rendering
 * layers decide final display precision.
 */
function calculateCredits(inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, creditInputPer1M, creditOutputPer1M, creditCachedInputPer1M, creditCacheReadPer1M, creditCacheWritePer1M) {
    const inputRate = creditInputPer1M ?? 0;
    const outputRate = creditOutputPer1M ?? 0;
    const cachedInputRate = creditCachedInputPer1M ?? 0;
    const cacheReadRate = creditCacheReadPer1M ?? 0;
    const cacheWriteRate = creditCacheWritePer1M ?? 0;
    const credits = (inputTokens / 1_000_000) * inputRate +
        (outputTokens / 1_000_000) * outputRate +
        (cacheReadTokens / 1_000_000) * cacheReadRate +
        (cacheWriteTokens / 1_000_000) * cacheWriteRate;
    if (inputRate === 0 && outputRate === 0 && cacheReadRate === 0 && cacheWriteRate === 0) {
        if (cachedInputRate > 0) {
            return ((inputTokens + cacheReadTokens + cacheWriteTokens) / 1_000_000) * cachedInputRate;
        }
        return 0;
    }
    return credits;
}
/**
 * Parse the newline-delimited JSON of wire events persisted alongside an
 * assistant message and extract all tool_call / tool_result events.
 *
 * These are used during session rehydration to reconstruct the full tool
 * interaction history (call → result) so the LLM context is complete —
 * without this, reconnected sessions would see only the assistant text
 * and never the tool results, causing the model to get confused about
 * the conversation state.
 */
function parseWireToolEvents(eventsJsonl) {
    const toolCalls = [];
    const toolResults = [];
    if (!eventsJsonl)
        return { toolCalls, toolResults };
    for (const line of eventsJsonl.split("\n")) {
        const trimmed = line.trim();
        if (!trimmed)
            continue;
        try {
            const ev = JSON.parse(trimmed);
            if (ev.type === "tool_call") {
                toolCalls.push({
                    id: ev.id,
                    name: ev.name,
                    arguments: ev.args ?? {},
                });
            }
            else if (ev.type === "tool_result") {
                toolResults.push({
                    id: ev.id,
                    result: ev.result,
                    isError: ev.isError ?? false,
                });
            }
        }
        catch {
            // Skip malformed JSON lines — they won't be meaningful for
            // rehydration and we'd rather keep the session alive than fail.
        }
    }
    return { toolCalls, toolResults };
}
/**
 * Safety clean-up for content blocks rehydrated from persisted wire events.
 *
 * Sessions created before the fixes in tool-registrar.ts may carry raw
 * ImageContent (base64 blobs) and untruncated large text blocks in their
 * events_jsonl. When replayed into a fresh session manager without cleanup
 * they can trigger 400 API errors (unsupported image content in tool results)
 * or context-window overflow — both surface as "skipping empty intermediate
 * message" / hung sessions after reconnect.
 */
const MAX_REHYDRATED_TEXT = 100_000;
function sanitizeRehydratedBlock(block) {
    // Convert images to text placeholders — most LLM APIs reject
    // ImageContent inside tool-result messages (even multimodal
    // models often only accept images in user-turn content).
    if (block.type === "image") {
        const sizeBytes = typeof block.data === "string" ? block.data.length : 0;
        return {
            type: "text",
            text: `[Image: ${block.mimeType ?? "image/png"}, ${sizeBytes.toLocaleString()} bytes base64]` +
                `\n(Image data is available in the raw MCP result.)`,
        };
    }
    // Truncate oversized text blocks to prevent context overflow.
    if (block.type !== "text")
        return block;
    if (block.text.length <= MAX_REHYDRATED_TEXT)
        return block;
    const omitted = block.text.length - MAX_REHYDRATED_TEXT;
    return {
        ...block,
        text: block.text.slice(0, MAX_REHYDRATED_TEXT) +
            `\n\n[truncated — ${omitted.toLocaleString()} bytes omitted]`,
    };
}
function normalizeSnapshotDetails(details) {
    if (details.version === 4)
        return details;
    return {
        type: "observational-memory",
        version: 4,
        observations: details.observations,
        reflections: details.reflections,
    };
}
function toRestoredMemorySnapshot(snapshot) {
    return {
        type: "observational-memory-snapshot",
        version: 1,
        coveredSourceCount: snapshot.coveredSourceCount,
        summary: snapshot.summary,
        details: normalizeSnapshotDetails(snapshot.details),
    };
}
export class AgentBridge {
    session;
    sessionManager;
    unsubscribe;
    pending;
    disposed = false;
    opts;
    /**
     * spectral's model registry. Built lazily in `start()` so we can resolve a
     * `modelId` (envelope-supplied or SQLite-persisted) to a concrete `Model`
     * via `registry.getAll().find(m => m.id === modelId)` before invoking
     * `session.setModel()`. Phase 3 (Available Models whitelist).
     */
    modelRegistry;
    /**
     * Raw allowed models list from the backend, preserved in sortOrder.
     * Used by `getFirstAvailableModelId()` to return the backend-curated
     * top pick when no explicit model selection is made.
     */
    allowedModels;
    /**
     * Last `modelId` we successfully applied via `session.setModel()`, or
     * `undefined` if we never applied one (spectral falls back to its own settings
     * file in that case, matching pre-Phase-3 behaviour). Tracked so repeated
     * envelopes carrying the same modelId don't churn spectral's internal state.
     */
    lastAppliedModelId;
    /** Current model's credit rates (from availableAgentModels), used for token_usage. */
    activeCreditRates = null;
    memoryRuntime = new Runtime();
    memoryPhase = "idle";
    constructor(opts) {
        this.opts = opts;
    }
    /**
     * Create the spectral session, wire up subscription, and return.
     * Throws on creation failure (caller should surface to client).
     */
    async start() {
        if (this.disposed)
            throw new Error("AgentBridge already disposed");
        const extensionFactories = [aexolMcpExtension, kanbanBridgeExtension, async (ext) => { spectralVisionExtension(ext); }, async (ext) => { subagentExt(ext); }, async (ext) => { designerExtension(ext); }, async (ext) => { observationalMemory(ext); }];
        // Load spectral-mcp-adapter via jiti so tsc never crawls its .ts files in
        // node_modules. The static `import` was causing tsc to type-check
        // spectral-mcp-adapter's source and fail the build on its type errors.
        // jiti is the same loader spectral uses internally for all extensions.
        const mcpAdapterPath = resolveMcpAdapterEntry();
        if (mcpAdapterPath) {
            try {
                const jiti = createJiti(import.meta.url, { moduleCache: false });
                const mcpAdapterFactory = await jiti.import(mcpAdapterPath, { default: true });
                if (typeof mcpAdapterFactory === "function") {
                    extensionFactories.push(async (ext) => mcpAdapterFactory(ext));
                }
            }
            catch {
                console.info("[AgentBridge] spectral-mcp-adapter not found; standard MCP servers disabled.");
            }
        }
        else {
            console.info("[AgentBridge] spectral-mcp-adapter not found; standard MCP servers disabled.");
        }
        // ResourceLoader with extensions wired in via factories.
        // Each factory's signature `(ext: ExtensionAPI) => Promise<void>` matches
        // the ExtensionFactory type exactly, so we can pass them directly.
        //
        // Skill discovery: spectral's defaults scan ~/.spectral/agent/skills/ (user),
        // .spectral/skills/ (project via CONFIG_DIR_NAME), and .agents/skills/
        // (ancestor-walked). We additionally walk ancestors for
        // .opencode/skills and .aexol/skills so OpenCode/Codex/Aexol skills
        // work out of the box.
        const extraSkillPaths = collectAncestorSkillDirs(this.opts.cwd, [".opencode/skills", ".aexol/skills"]);
        const resourceLoader = new DefaultResourceLoader({
            cwd: this.opts.cwd,
            agentDir: this.opts.agentDir ?? `${process.env.HOME ?? ""}/.spectral/agent`,
            extensionFactories,
            noExtensions: false,
            noSkills: false,
            noPromptTemplates: true,
            noThemes: true,
            additionalSkillPaths: extraSkillPaths,
        });
        await resourceLoader.reload();
        // In-memory session: SQLite is our source of truth.
        const sessionManager = SessionManager.inMemory(this.opts.cwd);
        this.sessionManager = sessionManager;
        // Rehydrate session history so the LLM sees the full conversation
        // transcript from the beginning (not just the current prompt).
        // Tool calls and their results are reconstructed from the persisted
        // wire-event JSONL so the LLM context is complete — without them
        // reconnected sessions would see assistant messages that reference
        // tool invocations whose results never appear, causing hung/confused
        // responses.
        if (this.opts.history && this.opts.history.length > 0) {
            for (const msg of this.opts.history) {
                if (msg.role === "user") {
                    const content = msg.images && msg.images.length > 0
                        ? [
                            ...msg.images.map((img) => ({
                                type: "image",
                                data: img.data,
                                mimeType: img.mimeType,
                            })),
                            { type: "text", text: msg.content },
                        ]
                        : msg.content;
                    sessionManager.appendMessage({
                        role: "user",
                        content,
                        timestamp: msg.createdAt,
                    });
                }
                else if (msg.role === "assistant") {
                    // Parse wire events to reconstruct tool calls & results from the
                    // original turn. Without this, reconnected sessions lose all tool
                    // interaction context.
                    const { toolCalls, toolResults } = parseWireToolEvents(msg.events);
                    // Build a toolCallId → toolName lookup for tool result messages.
                    const toolNameById = new Map();
                    for (const tc of toolCalls) {
                        toolNameById.set(tc.id, tc.name);
                    }
                    // Build assistant message content: text + reconstructed ToolCall blocks.
                    const content = [];
                    if (msg.content) {
                        content.push({ type: "text", text: msg.content });
                    }
                    for (const tc of toolCalls) {
                        content.push({
                            type: "toolCall",
                            id: tc.id,
                            name: tc.name,
                            arguments: tc.arguments,
                        });
                    }
                    sessionManager.appendMessage({
                        role: "assistant",
                        content,
                        api: "anthropic-messages",
                        provider: "spectral-proxy-anthropic",
                        model: "unknown",
                        usage: {
                            input: 0,
                            output: 0,
                            cacheRead: 0,
                            cacheWrite: 0,
                            totalTokens: 0,
                            cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
                        },
                        stopReason: toolCalls.length > 0
                            ? "toolUse"
                            : "stop",
                        timestamp: msg.createdAt,
                    });
                    // Append tool result messages so the LLM sees the full tool
                    // interaction (call → result → next assistant response).
                    //
                    // Safety: truncate text blocks here too — sessions created before
                    // the truncation fix (in tool-registrar.ts) may have untruncated
                    // large MCP results in their wire events, which would overflow the
                    // context window when replayed into the fresh session manager.
                    for (const tr of toolResults) {
                        const toolName = toolNameById.get(tr.id) ?? "unknown";
                        const rawResult = tr.result;
                        const rawContent = Array.isArray(rawResult?.content)
                            ? rawResult.content
                            : [];
                        sessionManager.appendMessage({
                            role: "toolResult",
                            toolCallId: tr.id,
                            toolName,
                            content: rawContent.map(sanitizeRehydratedBlock),
                            details: rawResult?.details,
                            isError: tr.isError,
                            timestamp: msg.createdAt,
                        });
                    }
                }
                // system messages are informational only; skip for LLM context
            }
            console.info(`[AgentBridge] Rehydrated ${this.opts.history.length} history message(s) into session context`);
        }
        if (this.opts.memorySnapshot) {
            const restoredSnapshot = toRestoredMemorySnapshot(this.opts.memorySnapshot);
            sessionManager.appendCustomEntry(OBSERVATIONAL_MEMORY_SNAPSHOT_CUSTOM_TYPE, restoredSnapshot);
            // Always inject the snapshot summary into LLM context. When raw
            // history is also rehydrated, selectHistoryForBridge limits it to
            // a small recent tail so duplication with the summary is minimal.
            // The summary carries the compacted older context that would
            // otherwise be lost after reconnect/restart.
            if (restoredSnapshot.summary.trim().length > 0) {
                sessionManager.appendCustomMessageEntry(OBSERVATIONAL_MEMORY_CONTEXT_CUSTOM_TYPE, restoredSnapshot.summary, false, {
                    restoredFromSnapshot: true,
                    coveredSourceCount: restoredSnapshot.coveredSourceCount,
                });
            }
            console.info(`[AgentBridge] Restored observational memory snapshot covering ${restoredSnapshot.coveredSourceCount} source entr${restoredSnapshot.coveredSourceCount === 1 ? "y" : "ies"}`);
        }
        // Build a model registry that does NOT touch ~/.spectral/agent/auth.json or
        // ~/.spectral/agent/models.json — the backend is now the only source of
        // provider credentials and the only allowed inference target. We then
        // register synthetic providers (`spectral-proxy-anthropic` /
        // `spectral-proxy-openai`) whose `baseUrl` points at the backend's
        // `/v1` proxy and whose `apiKey` is the machine JWT. spectral will then
        // POST `${baseUrl}/messages` (or `/chat/completions`) with
        // `Authorization: Bearer <machineJwt>` for every turn — the backend
        // verifies the JWT, looks up the requested `model` in the BaseModel
        // whitelist, and forwards to the upstream provider with its own
        // centrally-managed API keys. There is intentionally NO fallback to
        // disk-based auth: this is the single path for `spectral serve`.
        const authStorage = AuthStorage.inMemory();
        this.modelRegistry = ModelRegistry.inMemory(authStorage);
        let allowedModels;
        try {
            allowedModels = await this.refreshAllowedModels();
        }
        catch (err) {
            const e = err instanceof Error ? err : new Error(String(err));
            throw new Error(`Failed to fetch allowed models from backend; check SPECTRAL_BACKEND_URL ` +
                `and machine JWT. Underlying error: ${e.message}`);
        }
        // Build an in-memory SettingsManager seeded with admin-configured
        // defaults from the backend. findInitialModel() will pick up the
        // isDefault model; the vision extension can query isVisionDefault.
        const settingsOverrides = {};
        const defaultModel = allowedModels.find((m) => m.isDefault);
        if (defaultModel) {
            const proxyProvider = this.proxyProviderForAllowedModel(defaultModel);
            settingsOverrides.defaultProvider = proxyProvider;
            settingsOverrides.defaultModel = defaultModel.modelId;
            console.info(`✓ Default model from backend: ${proxyProvider}/${defaultModel.modelId}`);
        }
        const defaultVisionModel = allowedModels.find((m) => m.isVisionDefault);
        if (defaultVisionModel) {
            const proxyProvider = this.proxyProviderForAllowedModel(defaultVisionModel);
            settingsOverrides.defaultVisionProvider = proxyProvider;
            settingsOverrides.defaultVisionModel = defaultVisionModel.modelId;
            console.info(`✓ Default vision model from backend: ${proxyProvider}/${defaultVisionModel.modelId}`);
        }
        const settingsManager = SettingsManager.inMemory(settingsOverrides);
        console.info(`✓ Inference routed via backend proxy (${allowedModels.length} model(s) available)`);
        const result = await createAgentSession({
            cwd: this.opts.cwd,
            resourceLoader,
            sessionManager,
            authStorage,
            modelRegistry: this.modelRegistry,
            settingsManager,
        });
        this.session = result.session;
        // Headless UI context: forwards extension notify() calls as wire events
        // so the browser can surface memory activity, MCP status, etc.
        const uiContext = createHeadlessUIContext((event) => {
            if (event.type === "agent_notification") {
                this.updateMemoryPhase(event.message, event.system);
            }
            try {
                this.opts.emit(event);
            }
            catch { /* best-effort */ }
        });
        // Emit session_start so extensions can initialize (e.g. spectral-mcp-adapter
        // connects to MCP servers, loads configs from ~/.config/mcp/mcp.json etc.).
        // bindExtensions also fires resources_discover for dynamic skill/prompt
        // registration.
        try {
            await this.session.bindExtensions({ uiContext });
            console.info("[AgentBridge] session_start emitted; extensions initialized.");
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            console.warn(`[AgentBridge] session_start failed (extension init error): ${msg}`);
        }
        // Subscribe BEFORE any prompt fires.
        this.unsubscribe = this.session.subscribe((ev) => this.handleEvent(ev));
    }
    proxyProviderForAllowedModel(model) {
        if (model.provider === "anthropic") {
            return SPECTRAL_PROXY_ANTHROPIC;
        }
        if (model.provider === "built-in") {
            return SPECTRAL_PROXY_USER_MODEL;
        }
        return SPECTRAL_PROXY_OPENAI;
    }
    async refreshAllowedModels(opts) {
        if (!this.modelRegistry) {
            throw new Error("AgentBridge model registry unavailable");
        }
        const fetchModels = this.opts.fetchAllowedModels ?? defaultFetchAllowedModels;
        const allowedModels = await fetchModels({
            backendUrl: this.opts.backendUrl,
            machineJwt: this.opts.machineJwt,
            bypassCache: opts?.bypassCache,
        });
        this.allowedModels = allowedModels;
        this.registerSyntheticProviders(allowedModels);
        return allowedModels;
    }
    /**
     * Register one synthetic provider per upstream API shape. Anthropic models
     * go to `${backendUrl}/v1/messages` (Messages API); everything else (OpenAI,
     * Google, Cerebras, etc.) goes to `${backendUrl}/v1/chat/completions`
     * (OpenAI-compatible API). The backend supports both endpoints natively
     * (verified in F1).
     *
     * spectral will send `Authorization: Bearer ${apiKey}` (because `authHeader: true`)
     * which carries the machine JWT — the only credential the backend trusts.
     *
     * The `id` we register is the raw `modelId` (e.g. `claude-3-5-haiku-latest`),
     * which is exactly what the backend expects in `body.model`.
     */
    registerSyntheticProviders(allowedModels) {
        if (!this.modelRegistry)
            return;
        const baseUrl = `${this.opts.backendUrl.replace(/\/$/, "")}/v1`;
        const anthropicModels = allowedModels.filter((m) => m.provider === "anthropic");
        const openaiCompatModels = allowedModels.filter((m) => m.provider !== "anthropic" && m.provider !== "built-in");
        if (anthropicModels.length > 0) {
            this.modelRegistry.registerProvider(SPECTRAL_PROXY_ANTHROPIC, {
                baseUrl,
                apiKey: this.opts.machineJwt,
                authHeader: true,
                api: "anthropic-messages",
                models: anthropicModels.map((m) => {
                    return {
                        id: m.modelId,
                        name: m.displayName,
                        api: "anthropic-messages",
                        // Pin provider/baseUrl explicitly so spectral's ModelRegistry doesn't
                        // auto-derive `provider` from a slash-prefixed id (e.g. treating
                        // `deepseek/deepseek-v4-pro` as provider `"deepseek"`), which would
                        // make `hasConfiguredAuth(model)` look up the wrong provider key
                        // and surface "No API key for deepseek/...". Both must point back
                        // at our synthetic proxy provider so auth resolves to the machine JWT.
                        provider: SPECTRAL_PROXY_ANTHROPIC,
                        baseUrl,
                        reasoning: m.supportsReasoning ?? false,
                        input: m.supportsImages !== false ? ["text", "image"] : ["text"],
                        cost: getModelCost(m),
                        contextWindow: m.contextWindow ?? 0,
                        maxTokens: 0,
                    };
                }),
            });
        }
        if (openaiCompatModels.length > 0) {
            this.modelRegistry.registerProvider(SPECTRAL_PROXY_OPENAI, {
                baseUrl,
                apiKey: this.opts.machineJwt,
                authHeader: true,
                api: "openai-completions",
                models: openaiCompatModels.map((m) => {
                    return {
                        id: m.modelId,
                        name: m.displayName,
                        api: "openai-completions",
                        // See anthropic batch above for rationale — without these, spectral
                        // auto-derives `provider` from slash-prefixed ids like
                        // `deepseek/deepseek-v4-pro` or `meta-llama/llama-3.3-70b-instruct`,
                        // breaking auth lookup against our synthetic proxy provider.
                        provider: SPECTRAL_PROXY_OPENAI,
                        baseUrl,
                        reasoning: m.supportsReasoning ?? false,
                        input: m.supportsImages !== false ? ["text", "image"] : ["text"],
                        cost: getModelCost(m),
                        contextWindow: m.contextWindow ?? 0,
                        maxTokens: 0,
                        compat: inferSyntheticOpenAICompat(m),
                    };
                }),
            });
        }
        // Built-in UserModel entries — custom models registered by the team.
        // These route through `${backendUrl}/v1` (OpenAI-compatible) and carry
        // the `name` as the model id, which the backend resolves to the actual
        // provider/configuration via the UserModel record.
        const userModelEntries = allowedModels.filter((m) => m.provider === "built-in");
        if (userModelEntries.length > 0) {
            this.modelRegistry.registerProvider(SPECTRAL_PROXY_USER_MODEL, {
                baseUrl,
                apiKey: this.opts.machineJwt,
                authHeader: true,
                api: "openai-completions",
                models: userModelEntries.map((m) => {
                    return {
                        id: m.modelId,
                        name: m.displayName,
                        api: "openai-completions",
                        provider: SPECTRAL_PROXY_USER_MODEL,
                        baseUrl,
                        reasoning: m.supportsReasoning ?? false,
                        input: m.supportsImages !== false ? ["text", "image"] : ["text"],
                        cost: getModelCost(m),
                        contextWindow: m.contextWindow ?? 0,
                        maxTokens: 0,
                        compat: inferSyntheticOpenAICompat(m),
                    };
                }),
            });
        }
    }
    /**
     * Apply a sticky model selection to the underlying spectral session, if it
     * differs from what was last applied. No-ops when:
     *  - `modelId` is null/undefined (caller passed nothing to apply)
     *  - the same modelId was already applied to this session
     *  - the registry can't resolve the modelId (emits an `error` wire event
     *    so the client surfaces the failure, but does NOT throw — the caller
     *    is expected to drop the prompt)
     *
     * Returns true when the requested model is now in effect (either because
     * we just applied it or because it was already applied). Returns false
     * on resolution failure so the caller can skip `prompt()` and avoid
     * driving spectral against the wrong model.
     *
     * Phase 3 (Available Models whitelist).
     */
    /**
     * Return the modelId of the first available model from the backend
     * whitelist (preserving the backend's sortOrder, which is the same
     * ordering the frontend uses in its model picker). Returns `undefined`
     * when no models are available (e.g. backend unreachable at startup).
     *
     * Used by `SessionStreamManager.prompt()` as a defense-in-depth
     * default when neither the envelope nor SQLite supply a modelId.
     */
    getFirstAvailableModelId() {
        return this.allowedModels?.[0]?.modelId;
    }
    /**
     * Return current session context usage from spectral's built-in estimator.
     * Used after compaction and session start to push updated context-window
     * stats to the frontend without waiting for the next assistant turn.
     */
    getContextUsage() {
        return this.session?.getContextUsage();
    }
    getSessionBranch() {
        return (this.sessionManager?.getBranch() ?? []);
    }
    getMemoryActivity() {
        return {
            phase: this.memoryPhase,
            inFlight: {
                observer: this.memoryRuntime.observerInFlight,
                compaction: this.memoryRuntime.compactInFlight || this.memoryRuntime.compactHookInFlight,
                reflection: this.memoryPhase === "reflecting",
                pruner: this.memoryPhase === "pruning",
            },
        };
    }
    updateMemoryPhase(message, system) {
        const lower = message.toLowerCase();
        if (system === "memory_reflection" || lower.includes("reflector") || lower.includes("reflecting")) {
            this.memoryPhase = "reflecting";
            return;
        }
        if (system === "memory_pruner" || lower.includes("pruner") || lower.includes("pruning") || lower.includes("prune")) {
            this.memoryPhase = "pruning";
            return;
        }
        if (system === "memory_compaction" || lower.includes("compaction") || lower.includes("compact")) {
            this.memoryPhase = "compacting";
            return;
        }
        if (system === "memory_observer" || lower.includes("observer")) {
            this.memoryPhase = "observing";
        }
    }
    async setModel(modelId) {
        if (!modelId)
            return true; // nothing to apply — spectral keeps its current model
        if (!this.session)
            throw new Error("AgentBridge.start() not called");
        if (this.lastAppliedModelId === modelId)
            return true; // idempotent: same model already in effect
        if (!this.modelRegistry) {
            // Defensive: start() always populates this; if it didn't we can't
            // resolve and must surface to the client rather than silently using
            // spectral's default.
            this.opts.emit({
                type: "error",
                message: `Cannot apply modelId "${modelId}": model registry unavailable`,
            });
            return false;
        }
        let model = this.modelRegistry
            .getAvailable()
            .find((m) => m.id === modelId);
        let refreshError;
        if (!model) {
            try {
                await this.refreshAllowedModels({ bypassCache: true });
                model = this.modelRegistry
                    .getAvailable()
                    .find((m) => m.id === modelId);
            }
            catch (err) {
                refreshError = err instanceof Error ? err : new Error(String(err));
                this.opts.onError?.(refreshError);
            }
        }
        if (!model) {
            this.opts.emit({
                type: "error",
                message: `Unknown modelId "${modelId}" — not found in spectral model registry${refreshError ? ` after refresh: ${refreshError.message}` : ""}`,
            });
            return false;
        }
        try {
            await this.session.setModel(model);
            this.lastAppliedModelId = modelId;
            const selected = this.allowedModels?.find((m) => m.modelId === modelId);
            this.activeCreditRates = selected
                ? {
                    creditInputPer1M: selected.creditInputPer1M ?? null,
                    creditOutputPer1M: selected.creditOutputPer1M ?? null,
                    creditCachedInputPer1M: selected.creditCachedInputPer1M ?? null,
                    creditCacheReadPer1M: selected.creditCacheReadPer1M ?? null,
                    creditCacheWritePer1M: selected.creditCacheWritePer1M ?? null,
                }
                : null;
            return true;
        }
        catch (err) {
            const e = err instanceof Error ? err : new Error(String(err));
            this.opts.onError?.(e);
            this.opts.emit({
                type: "error",
                message: `Failed to switch to modelId "${modelId}": ${e.message}`,
            });
            return false;
        }
    }
    /**
     * Map a frontend reasoning-effort string to spectral's ThinkingLevel.
     * Frontend sends: xhigh | high | medium | low | minimal | none | undefined
     * spectral expects:   "high" | "medium" | "low" | "minimal" | "off"
     *
     * Mapping:
     *   xhigh   → high    (spectral doesn't have xhigh, default to max)
     *   high    → high
     *   medium  → medium
     *   low     → low
     *   minimal → minimal
     *   none    → off
     *   undefined → no-op (spectral keeps whatever it has currently)
     */
    mapReasoningEffortToThinkingLevel(effort) {
        if (effort === undefined)
            return undefined;
        switch (effort) {
            case "xhigh":
                return "high";
            case "high":
            case "medium":
            case "low":
            case "minimal":
                return effort;
            case "none":
                return "off";
            default:
                return undefined;
        }
    }
    /**
     * Set the reasoning/thinking effort level for the next prompt.
     * Pass `undefined` to leave spectral's current level unchanged.
     *
     * The caller (SessionStreamManager) is responsible for persisting the
     * value to SQLite; this method only applies it to spectral's in-memory session.
     */
    setReasoningEffort(effort) {
        const level = this.mapReasoningEffortToThinkingLevel(effort);
        if (level === undefined)
            return;
        if (!this.session) {
            // Bridge hasn't started yet — the reasoning effort will be picked
            // up from storage on the next prompt via session-stream's persistence.
            return;
        }
        try {
            this.session.setThinkingLevel(level);
        }
        catch (err) {
            const e = err instanceof Error ? err : new Error(String(err));
            this.opts.onError?.(e);
            this.opts.emit({
                type: "error",
                message: `Failed to set reasoning effort: ${e.message}`,
            });
        }
    }
    /**
     * Forward a user message to ext. Resolves when the full turn ends.
     * The caller is responsible for persisting the user message to SQLite
     * BEFORE invoking this — we don't do it here because spectral's `prompt` may
     * fail and we still want the user message recorded.
     *
     * When `images` is non-empty, each base64-encoded attachment is converted
     * to a spectral `ImageContent` block and passed as `options.images` to
     * `session.prompt()`. If the current model does not support image inputs,
     * images are instead converted to text placeholders so the conversation
     * can continue without errors.
     */
    async prompt(text, images) {
        if (!this.session)
            throw new Error("AgentBridge.start() not called");
        // Always pass images through to the session.
        // The spectral-vision-fallback extension intercepts images via the
        // `context` event and replaces them with text descriptions using a
        // vision-capable model BEFORE they reach the LLM. This means images
        // work regardless of whether the main model supports them.
        try {
            if (images && images.length > 0) {
                const imageContents = images.map((img) => ({
                    type: "image",
                    data: img.data,
                    mimeType: img.mimeType,
                }));
                await this.session.prompt(text, { images: imageContents });
            }
            else {
                await this.session.prompt(text);
            }
        }
        catch (err) {
            const e = err instanceof Error ? err : new Error(String(err));
            this.opts.onError?.(e);
            this.opts.emit({ type: "error", message: e.message });
        }
    }
    /**
     * Manually compact the session context via spectral's built-in compaction.
     * Pi generates a summary of older conversation history, preserving the
     * most recent ~20K tokens verbatim. Compaction events are forwarded to
     * the wire through `handleEvent()`.
     *
     * `customInstructions` can inject guidance into the compaction prompt
     * so the summary captures information relevant to the current task.
     */
    async compact(customInstructions) {
        if (!this.session)
            throw new Error("AgentBridge.start() not called");
        this.memoryPhase = "compacting";
        try {
            await this.session.compact(customInstructions);
        }
        finally {
            if (this.memoryPhase === "compacting" &&
                !this.memoryRuntime.observerInFlight &&
                !this.memoryRuntime.compactInFlight &&
                !this.memoryRuntime.compactHookInFlight) {
                this.memoryPhase = "idle";
            }
        }
    }
    dispose() {
        if (this.disposed)
            return;
        this.disposed = true;
        // Persist the current in-flight message (if any) before tearing down.
        // Without this, cancelling mid-turn loses the message along with its
        // token_usage event, which breaks context-window tracking on reconnect.
        this.finalizePendingMessage();
        try {
            this.unsubscribe?.();
        }
        catch {
            // ignore
        }
        try {
            this.session?.dispose();
        }
        catch {
            // ignore
        }
        this.session = undefined;
        this.unsubscribe = undefined;
        this.pending = undefined;
    }
    // --- internals -----------------------------------------------------------
    /**
     * Emit a wire event AND record it in the pending message's audit log so
     * the persisted JSONL exactly matches the live broadcast. Use this in
     * place of `this.opts.emit` for any event that should appear in
     * `events_jsonl` for the current assistant message.
     */
    emitAndBuffer(event) {
        if (event.type === "compaction_start")
            this.memoryPhase = "compacting";
        if (event.type === "compaction_end")
            this.memoryPhase = "idle";
        if (this.pending)
            this.pending.wireEvents.push(event);
        this.opts.emit(event);
    }
    /**
     * Finalize the current `pending` assistant message: persist its assembled
     * content + wire events (including any tool_call / tool_result events that
     * arrived after `message_end`) to SQLite via `onAssistantMessageComplete`.
     *
     * Idempotent: marks the pending as `finalized` on first call; subsequent
     * calls are no-ops (guards against double-persisting when both
     * `message_start` and `agent_end` would otherwise finalize the same pending).
     *
     * Skips empty framing-only messages (messages with no text, thinking, or
     * tool events that contribute nothing the client can render).
     */
    finalizePendingMessage() {
        if (!this.pending || this.pending.finalized)
            return;
        this.pending.finalized = true;
        const { messageId, text, wireEvents, content } = this.pending;
        // Skip persistence for pure-framing intermediate "messages". Pi
        // emits a `message_end` for each internal step (e.g. between two
        // tool-calling rounds) and many of those carry no visible content
        // at all — only `message_start` + `message_end` framing. Persisting
        // them creates orphan rows that are dropped on hydration anyway,
        // and they pollute the sidebar message counts. The decision is:
        //   - persist if `content` is non-empty (text the user saw), OR
        //   - persist if any "meaningful" wire event was buffered
        //     (text_delta / thinking_delta / tool_call / tool_result).
        // Otherwise the message contributes nothing the client can render
        // — drop it. Live broadcast is unchanged; subscribers already saw
        // the framing on the wire.
        const finalContent = content ?? text;
        const hasMeaningfulEvent = wireEvents.some((e) => e.type === "text_delta" ||
            e.type === "thinking_delta" ||
            e.type === "tool_call" ||
            e.type === "tool_result");
        if (!finalContent && !hasMeaningfulEvent) {
            console.debug("[agent-bridge] skipping empty intermediate message");
            try {
                this.opts.onAssistantMessageSkipped?.(messageId);
            }
            catch (err) {
                const e = err instanceof Error ? err : new Error(String(err));
                console.error(`[agent-bridge] onAssistantMessageSkipped failed: ${e.message}`);
            }
            return;
        }
        const eventsJsonl = wireEvents.map((e) => JSON.stringify(e)).join("\n");
        try {
            this.opts.onAssistantMessageComplete({
                messageId,
                content: finalContent,
                eventsJsonl,
            });
        }
        catch (err) {
            const e = err instanceof Error ? err : new Error(String(err));
            this.opts.onError?.(e);
        }
    }
    /**
     * Subscriber callback. Public so tests can drive event flow without
     * spinning up a real spectral session — production code never calls this
     * directly; spectral's `subscribe()` does, via the closure registered in
     * `start()`.
     */
    handleEvent(ev) {
        if (this.disposed)
            return;
        switch (ev.type) {
            case "message_start": {
                // Only assistant messages get our message_start frame on the wire.
                // User messages are persisted separately by the routes layer.
                if (ev.message.role !== "assistant")
                    return;
                // Finalize the previous pending message (if any) before starting a
                // new one. This captures tool events that arrived after the previous
                // `message_end` but before this `message_start`. Deferred persistence
                // is necessary because spectral fires tool_execution_* events BETWEEN
                // messages — after the previous `message_end` nulled the pending in
                // the old code, those tool events were lost from history.
                this.finalizePendingMessage();
                const messageId = randomUUID();
                const wireEvent = {
                    type: "message_start",
                    messageId,
                    role: "assistant",
                };
                this.pending = { messageId, text: "", wireEvents: [wireEvent] };
                this.opts.emit(wireEvent);
                return;
            }
            case "message_update": {
                if (!this.pending)
                    return;
                const inner = ev.assistantMessageEvent;
                if (inner.type === "text_delta") {
                    this.pending.text += inner.delta;
                    this.emitAndBuffer({
                        type: "text_delta",
                        messageId: this.pending.messageId,
                        delta: inner.delta,
                    });
                }
                else if (inner.type === "thinking_delta") {
                    this.emitAndBuffer({
                        type: "thinking_delta",
                        messageId: this.pending.messageId,
                        delta: inner.delta,
                    });
                }
                // Other inner types (text_start/end, toolcall_*, done, error) we
                // don't surface as their own wire frames — tool calls come through
                // the top-level tool_execution_* events instead.
                return;
            }
            case "tool_execution_start": {
                // Emit subagent_start for subagent tool invocations (Option B frontend panels).
                if (ev.toolName === "subagent") {
                    const args = ev.args;
                    const mode = args?.chain?.length > 0 ? "chain"
                        : args?.tasks?.length > 0 ? "parallel"
                            : "single";
                    const se = {
                        type: "subagent_start",
                        toolCallId: ev.toolCallId,
                        mode,
                        agent: typeof args?.agent === "string" ? args.agent : undefined,
                        task: typeof args?.task === "string" ? args.task : undefined,
                        taskCount: mode === "chain"
                            ? args?.chain?.length
                            : mode === "parallel"
                                ? args?.tasks?.length
                                : undefined,
                        totalSteps: mode === "chain" ? args?.chain?.length : undefined,
                    };
                    this.emitAndBuffer(se);
                }
                this.emitAndBuffer({
                    type: "tool_call",
                    messageId: this.pending?.messageId ?? "",
                    id: ev.toolCallId,
                    name: ev.toolName,
                    args: ev.args,
                });
                return;
            }
            case "tool_execution_update": {
                // Emit subagent_progress for subagent tool updates.
                if (ev.toolName === "subagent") {
                    const partialResult = ev.partialResult;
                    const details = partialResult?.details;
                    if (details?.results) {
                        const mode = (details.mode ?? "single");
                        const total = details.results.length;
                        const done = details.results.filter(r => r.exitCode !== undefined && r.exitCode !== -1).length;
                        const running = total - done;
                        const currentAgent = details.results.find(r => r.exitCode === -1 || r.exitCode === undefined)?.agent;
                        // Extract streaming text from partialResult content (set by text_delta forwarding).
                        const streamingText = extractTextFromContent(partialResult?.content);
                        const se = {
                            type: "subagent_progress",
                            toolCallId: ev.toolCallId,
                            mode,
                            completedCount: done,
                            totalCount: total,
                            currentStep: mode === "chain" ? details.results[details.results.length - 1]?.step : undefined,
                            currentAgent,
                            message: mode === "parallel"
                                ? `Parallel: ${done}/${total} done, ${running} running`
                                : mode === "chain"
                                    ? `Chain: step ${details.results.length}/${total}`
                                    : `Running ${currentAgent ?? "subagent"}...`,
                            streamingText: mode === "single" ? streamingText : undefined,
                        };
                        try {
                            this.opts.emit(se);
                        }
                        catch { /* best-effort */ }
                    }
                }
                return;
            }
            case "tool_execution_end": {
                // Emit subagent_end for subagent tool completions.
                if (ev.toolName === "subagent") {
                    const result = ev.result;
                    const details = result?.details;
                    if (details?.results && details.results.length > 0) {
                        const mode = (details.mode ?? "single");
                        const getOutput = (msgs) => {
                            if (!msgs)
                                return "(no output)";
                            for (let i = msgs.length - 1; i >= 0; i--) {
                                if (msgs[i].role === "assistant") {
                                    for (const part of msgs[i].content) {
                                        if (part.type === "text" && part.text)
                                            return part.text;
                                    }
                                }
                            }
                            return "(no output)";
                        };
                        const results = details.results.map(r => ({
                            agent: r.agent,
                            agentSource: r.agentSource ?? "unknown",
                            task: r.task,
                            exitCode: r.exitCode,
                            output: getOutput(r.messages),
                            model: r.model,
                            errorMessage: r.errorMessage,
                            stopReason: r.stopReason,
                            step: r.step,
                            usage: {
                                inputTokens: r.usage?.input ?? 0,
                                outputTokens: r.usage?.output ?? 0,
                                cacheReadTokens: r.usage?.cacheRead ?? 0,
                                cacheWriteTokens: r.usage?.cacheWrite ?? 0,
                                cost: r.usage?.cost ?? 0,
                                turns: r.usage?.turns ?? 0,
                            },
                        }));
                        const totalUsage = results.reduce((acc, r) => ({
                            inputTokens: acc.inputTokens + r.usage.inputTokens,
                            outputTokens: acc.outputTokens + r.usage.outputTokens,
                            cacheReadTokens: acc.cacheReadTokens + r.usage.cacheReadTokens,
                            cacheWriteTokens: acc.cacheWriteTokens + r.usage.cacheWriteTokens,
                            cost: acc.cost + r.usage.cost,
                            turns: acc.turns + r.usage.turns,
                        }), { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0, turns: 0 });
                        const se = {
                            type: "subagent_end",
                            toolCallId: ev.toolCallId,
                            mode,
                            results,
                            totalUsage,
                        };
                        this.emitAndBuffer(se);
                    }
                }
                this.emitAndBuffer({
                    type: "tool_result",
                    messageId: this.pending?.messageId ?? "",
                    id: ev.toolCallId,
                    result: ev.result,
                    isError: ev.isError,
                });
                return;
            }
            case "message_end": {
                if (ev.message.role !== "assistant" || !this.pending)
                    return;
                const { messageId, text } = this.pending;
                // Prefer the assembled text from spectral's final AssistantMessage
                // (authoritative — providers like deepseek only populate this and
                // skip per-token `text_delta`s entirely). Fall back to the
                // accumulator for providers that stream deltas without re-asserting
                // the final content, and finally to the empty string for tool-only
                // turns that legitimately have no text.
                const assembled = extractTextFromContent(ev.message.content);
                const content = assembled || text;
                const endEvent = { type: "message_end", messageId };
                this.pending.wireEvents.push(endEvent);
                this.opts.emit(endEvent);
                // Emit token usage for this assistant message. spectral provides token
                // counts via ev.message.usage; credits are computed from the active
                // model's configured credit rates.
                //
                // NOTE: `usage.cost` is still forwarded as a legacy wire field for
                // backward compatibility, but the UI is credits-first.
                //
                // Skip zero-total-usage events — they happen when a turn is
                // cancelled before the provider starts streaming, and emitting
                // inputTokens:0 would mislead the UI into showing 0% context window
                // usage after reconnect.
                const usage = ev.message.usage;
                const totalTokens = (usage?.input ?? 0) +
                    (usage?.output ?? 0) +
                    (usage?.cacheRead ?? 0) +
                    (usage?.cacheWrite ?? 0);
                if (usage && totalTokens > 0) {
                    // Resolve current context window usage from spectral's built-in
                    // `getContextUsage()`, which handles post-compaction ambiguity
                    // and estimates total tokens from the full session tree.
                    const ctxUsage = this.session?.getContextUsage();
                    const usageEvent = {
                        type: "token_usage",
                        messageId,
                        usage: {
                            inputTokens: usage.input ?? 0,
                            outputTokens: usage.output ?? 0,
                            cacheReadTokens: usage.cacheRead ?? 0,
                            cacheWriteTokens: usage.cacheWrite ?? 0,
                            totalTokens: usage.totalTokens ?? totalTokens,
                            cost: usage.cost?.total ?? null,
                            creditsUsed: calculateCredits(usage.input ?? 0, usage.output ?? 0, usage.cacheRead ?? 0, usage.cacheWrite ?? 0, this.activeCreditRates?.creditInputPer1M, this.activeCreditRates?.creditOutputPer1M, this.activeCreditRates?.creditCachedInputPer1M, this.activeCreditRates?.creditCacheReadPer1M, this.activeCreditRates?.creditCacheWritePer1M),
                        },
                        contextWindowUsed: ctxUsage?.tokens ?? null,
                        contextWindowMax: ctxUsage?.contextWindow ?? null,
                    };
                    this.pending.wireEvents.push(usageEvent);
                    this.opts.emit(usageEvent);
                }
                // Defer persistence: keep `this.pending` alive so tool events that
                // arrive after `message_end` (spectral fires tool_execution_* events
                // BETWEEN messages) are buffered into `pending.wireEvents`. We store
                // the final content and persist later — when the next `message_start`
                // signals a new step, or when `agent_end` closes the turn.
                this.pending.content = content;
                return;
            }
            case "agent_end": {
                // Finalize the last pending message before closing the turn. After
                // `agent_end`, no more tool events will arrive, so this is the final
                // persistence opportunity for the current message.
                this.finalizePendingMessage();
                this.pending = undefined;
                this.opts.emit({ type: "agent_end" });
                return;
            }
            case "compaction_start": {
                // Forward compaction lifecycle events to the wire so the browser
                // can surface a "Compacting…" indicator and track compaction
                // activity.
                this.opts.emit({
                    type: "compaction_start",
                    reason: ev.reason,
                });
                return;
            }
            case "compaction_end": {
                this.opts.emit({
                    type: "compaction_end",
                    summary: ev.result?.summary ?? "",
                    tokensBefore: ev.result?.tokensBefore ?? 0,
                    firstKeptEntryId: ev.result?.firstKeptEntryId,
                    aborted: ev.aborted,
                });
                return;
            }
            default:
                // Other spectral-internal events (turn_start, queue_update,
                // auto_retry_*, tool_execution_update) are intentionally not on
                // the wire surface for MVP and are NOT persisted — the wire format
                // is the source of truth for replay.
                return;
        }
    }
}
// ---------------------------------------------------------------------------
// Headless UI context
// ---------------------------------------------------------------------------
/**
 * Detect the memory subsystem from a notification message text.
 * The memory extension prefixes all messages with "Observational memory:".
 */
function detectMemorySystem(message) {
    if (!message.startsWith("Observational memory:"))
        return undefined;
    const lower = message.toLowerCase();
    if (lower.includes("observer"))
        return "memory_observer";
    if (lower.includes("compaction") || lower.includes("compact"))
        return "memory_compaction";
    if (lower.includes("reflection") || lower.includes("reflect"))
        return "memory_reflection";
    if (lower.includes("pruner") || lower.includes("prune"))
        return "memory_pruner";
    // Keep generic observational-memory messages visible in the landing badge.
    return "memory_observer";
}
/**
 * Detect subsystem from notification messages by known prefix.
 * Returns undefined for generic extension messages without a matching prefix.
 */
function detectNotifSystem(message) {
    if (message.startsWith("[spectral-vision]"))
        return "vision";
    return detectMemorySystem(message);
}
/**
 * Create a minimal ExtensionUIContext that forwards `notify()` calls as
 * `agent_notification` wire events. All other UI methods are no-ops —
 * only extensions (not spectral's TUI) call into the UI context in headless mode,
 * and extensions that call methods other than `notify()` are expected to
 * guard with `ctx.hasUI` first.
 */
function createHeadlessUIContext(emit) {
    // Defer to a Proxy so we don't need to stub every method.
    // `notify` is the only method called by extensions in serve mode
    // (observational memory, MCP status bar updates, spectral-vision).
    const handler = {
        get(_target, prop) {
            if (prop === "notify") {
                return (message, type) => {
                    const level = type ?? "info";
                    const system = detectNotifSystem(message);
                    if (system?.startsWith("memory_")) {
                        console.info(`[AgentBridge][memory][${level}] ${message}`);
                    }
                    else if (system === "vision") {
                        console.info(`[AgentBridge][vision][${level}] ${message}`);
                    }
                    emit({
                        type: "agent_notification",
                        message,
                        level,
                        system,
                    });
                };
            }
            // All other methods: return a no-op function or undefined.
            // Methods that return Promises (select, confirm, input, custom, editor)
            // resolve to `undefined` — extensions are expected to guard with `ctx.hasUI`
            // before calling them, and none do in serve mode.
            // Methods that set state (setStatus, setWorkingMessage, setWidget, etc.)
            // are safe to no-op.
            return () => undefined;
        },
    };
    return new Proxy({}, handler);
}
