/**
 * Session-scoped streaming layer for `spectral serve`.
 *
 * Background: prior to this module each WebSocket owned its own `AgentBridge`
 * instance and the routes layer enforced single-writer-wins (4001 eviction)
 * to keep that bridge unique per session. That model lost data on browser
 * refresh — the WS close torn down the spectral process mid-stream, and a re-open
 * couldn't recover what hadn't yet hit `agent_end` (and thus SQLite).
 *
 * New model:
 *   - Pi lifecycle is per **Spectral session**, not per WS.
 *   - 0..N WebSockets may attach to the same session simultaneously. Each
 *     gets the same broadcast stream of events.
 *   - When a WS detaches (close, error, refresh), the spectral process keeps
 *     running. Closing every tab does NOT cancel the in-flight turn —
 *     it runs to completion and persists on `agent_end` as before.
 *   - On `attach`, the manager hands back a replay payload: full DB history
 *     plus a snapshot of the currently in-flight turn (if any). The client
 *     replays the snapshot through the same reducer it uses for live events
 *     and continues streaming naturally.
 *   - Persistence shape is unchanged: only the final assistant message is
 *     written to SQLite on `agent_end`. In-flight events live in memory only.
 *     A server crash mid-turn discards the in-flight state — acceptable
 *     for MVP.
 *
 * Failure modes:
 *   - spectral throws synchronously in `prompt()` → bridge surfaces as `error`
 *     event; manager broadcasts and clears `currentTurn`.
 *   - One subscriber's `ws.send` throws → caught, logged, removed from the
 *     subscriber set; broadcast continues to the rest.
 *   - `agent_end` arrives without a current turn (defensive) → broadcast
 *     anyway so any late attachers don't get stuck.
 *
 * TODO (future): idle GC. A `SessionStream` with `subscribers.size === 0`
 *   and no current turn could be disposed after some grace window (e.g. 5
 *   minutes) to release spectral resources for chronically-idle sessions. Skipped
 *   for now — streams accumulate for the lifetime of the server process.
 */
import { randomUUID } from "node:crypto";
import { AgentBridge } from "./agent-bridge.js";
import { getMemoryState, isSourceEntry, rawTokensSinceLastBound, rawTokensSinceLastCompaction, } from "../memory/branch.js";
import { observationPoolTokens, renderSummary } from "../memory/compaction.js";
import { loadConfig } from "../memory/config.js";
import { setProjectObsStore } from "../memory/project-observations-store.js";
import { estimateStringTokens } from "../memory/tokens.js";
import { reflectionContent, reflectionId } from "../memory/types.js";
const DEFAULT_BRIDGE_FACTORY = (args) => new AgentBridge(args);
/** Safety limit for autonomous loop iterations per session. */
const MAX_LOOP_ITERATIONS = 100;
/**
 * Token threshold above which the loop triggers compaction between iterations.
 * Aligned with the observational memory extension default (memory/config.ts).
 */
const LOOP_COMPACTION_THRESHOLD_TOKENS = 50_000;
/**
 * Defensive cap for fresh-bridge history replay when we already have a
 * persisted observational-memory snapshot. spectral's own compaction keeps a
 * recent live tail verbatim and summarizes the older prefix; replaying the
 * entire SQLite transcript after restart defeats that and can explode the
 * next prompt. We therefore rehydrate only an approximate recent tail.
 *
 * Keep this conservative — the tail is rehydrated as raw user/assistant
 * messages PLUS spectral reconstructs tool-result messages from events_jsonl,
 * so the actual context inflation is 3-8× the stored token estimate.
 */
const REHYDRATION_TAIL_TOKEN_BUDGET = 20_000;
/**
 * Number of accumulated wire events before flushing the in-flight turn
 * to SQLite. Batch-persisting means a server crash mid-turn only loses
 * at most the last `BATCH_FLUSH_INTERVAL` events, not the entire turn.
 */
const BATCH_FLUSH_INTERVAL = 10;
/** Marker the agent emits in its response to signal the task is complete. */
const LOOP_DONE_MARKER = "<LOOP_DONE>";
function persistObservationalMemorySnapshot(store, sessionId, bridge) {
    if (!bridge.getSessionBranch)
        return;
    try {
        const entries = bridge.getSessionBranch();
        const memoryState = getMemoryState(entries);
        const allObservations = [...memoryState.committedObs, ...memoryState.pendingObs];
        const hasMemory = memoryState.reflections.length > 0 || allObservations.length > 0;
        if (!hasMemory) {
            store.deleteSessionMemorySnapshot(sessionId);
            return;
        }
        const summary = renderSummary(memoryState.reflections, allObservations);
        const details = {
            type: "observational-memory",
            version: 4,
            observations: allObservations,
            reflections: memoryState.reflections,
        };
        store.upsertSessionMemorySnapshot(sessionId, {
            summary,
            details,
            coveredSourceCount: entries.filter((entry) => isSourceEntry(entry)).length,
        });
    }
    catch (err) {
        console.warn(`[spectral] warn: failed to persist observational memory snapshot for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
    }
}
function estimateStoredMessageReplayTokens(message) {
    return (estimateStringTokens(message.content) +
        estimateStringTokens(message.events) +
        (message.images?.length ?? 0) * 2_000);
}
function selectHistoryForBridge(history, hasMemorySnapshot) {
    if (!history || history.length === 0)
        return history;
    if (!hasMemorySnapshot)
        return history;
    let total = 0;
    let start = history.length - 1;
    for (; start >= 0; start--) {
        const nextTotal = total + estimateStoredMessageReplayTokens(history[start]);
        if (total > 0 && nextTotal > REHYDRATION_TAIL_TOKEN_BUDGET)
            break;
        total = nextTotal;
    }
    const selected = history.slice(Math.max(0, start + 1));
    return selected.length > 0 ? selected : [history[history.length - 1]];
}
function prunePersistedHistoryAfterCompaction(store, sessionId, bridge) {
    if (!bridge.getSessionBranch)
        return;
    try {
        const entries = bridge.getSessionBranch();
        // Pi appends a CompactionEntry (type "compaction") at the end whose
        // firstKeptEntryId marks the first branch entry that survives in LLM context.
        // We scan backwards so we find the most recent compaction (there should be
        // exactly one per compaction pass).
        const lastCompaction = [...entries].reverse().find((entry) => entry.type === "compaction");
        const firstKeptEntryId = lastCompaction?.firstKeptEntryId;
        if (!firstKeptEntryId) {
            console.log(`[spectral] prune: skipped – no compaction entry (or no firstKeptEntryId) ` +
                `in branch for ${sessionId} (${entries.length} entries)`);
            return;
        }
        const keptEntryIndex = entries.findIndex((entry) => entry.id === firstKeptEntryId);
        if (keptEntryIndex === -1) {
            console.log(`[spectral] prune: skipped – firstKeptEntryId "${firstKeptEntryId}" ` +
                `not found in ${entries.length} branch entries for ${sessionId}`);
            return;
        }
        // Count user + assistant messages in the kept portion of the branch.
        // Tool-result entries are separate branch entries but are NOT separate
        // SQLite rows – they live inside the assistant message's events_jsonl.
        // Counting them would inflate the count and make the prune a silent no-op.
        let keptPersistedMessageCount = 0;
        for (let i = keptEntryIndex; i < entries.length; i++) {
            const entry = entries[i];
            if (entry.type !== "message")
                continue;
            const role = entry.message && typeof entry.message === "object" && "role" in entry.message
                ? entry.message.role
                : undefined;
            if (role === "user" || role === "assistant" || role === "system") {
                keptPersistedMessageCount += 1;
            }
        }
        const existing = store.getMessages(sessionId);
        if (existing.length === 0)
            return;
        console.log(`[spectral] prune: ${sessionId} – branch has ${entries.length} entries, ` +
            `kept portion starts at index ${keptEntryIndex} (id=${firstKeptEntryId}), ` +
            `${keptPersistedMessageCount} user+assistant entries kept, ` +
            `${existing.length} SQLite messages`);
        if (keptPersistedMessageCount <= 0) {
            console.log(`[spectral] prune: skipped – no kept messages for ${sessionId}`);
            return;
        }
        if (keptPersistedMessageCount >= existing.length) {
            console.log(`[spectral] prune: skipped – kept message count (${keptPersistedMessageCount}) ` +
                `>= SQLite message count (${existing.length}) for ${sessionId}`);
            return;
        }
        const keptMessages = existing.slice(-keptPersistedMessageCount);
        const pruned = existing.length - keptMessages.length;
        store.deleteMessagesBySession(sessionId);
        for (const msg of keptMessages) {
            store.appendMessage(sessionId, {
                id: msg.id,
                role: msg.role,
                content: msg.content,
                eventsJsonl: msg.events,
                createdAt: msg.createdAt,
                images: msg.images,
            });
        }
        console.log(`[spectral] prune: finished – dropped ${pruned} pre-compaction message(s) ` +
            `for ${sessionId}, ${keptMessages.length} kept`);
    }
    catch (err) {
        console.warn(`[spectral] warn: failed to prune persisted history after compaction for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
    }
}
function defaultMemoryStatus(cwd) {
    const config = loadConfig(cwd);
    return {
        mode: config.passive ? "passive" : "active",
        phase: "idle",
        inFlight: {
            observer: false,
            compaction: false,
            reflection: false,
            pruner: false,
        },
        reflections: { count: 0, tokens: 0 },
        observations: {
            committedCount: 0,
            committedTokens: 0,
            pendingCount: 0,
            pendingTokens: 0,
        },
        thresholds: {
            observation: config.observationThresholdTokens,
            compaction: config.compactionThresholdTokens,
            reflection: config.reflectionThresholdTokens,
        },
        progress: {
            sinceObservationBoundTokens: 0,
            sinceLastCompactionTokens: 0,
            observationPoolTokens: 0,
        },
    };
}
export class SessionStreamManager {
    store;
    cwd;
    backendUrl;
    machineJwt;
    bridgeFactory;
    agentDir;
    streams = new Map();
    disposed = false;
    constructor(opts) {
        this.store = opts.store;
        this.cwd = opts.cwd;
        this.backendUrl = opts.backendUrl;
        this.machineJwt = opts.machineJwt;
        this.bridgeFactory = opts.bridgeFactory ?? DEFAULT_BRIDGE_FACTORY;
        this.agentDir = opts.agentDir;
        // Wire the project observations store singleton so the memory extension's
        // read_project_observations tool can query cross-session observations.
        setProjectObsStore({
            insertProjectObservations: (pid, sid, obs, ts) => this.store.insertProjectObservations(pid, sid, obs, ts),
            searchProjectObservations: (pid, q, limit) => this.store.searchProjectObservations(pid, q, limit),
            getProjectByCwd: (cwd) => this.store.getProjectByCwd(cwd),
        });
    }
    /**
     * Attach a subscriber to a session. Lazily creates the underlying spectral
     * session on first attach. The caller is responsible for sending the
     * initial `session_ready` frame using the returned replay payload (this
     * keeps wire-protocol concerns in the routes layer).
     *
     * Throws if the session id is unknown in SQLite (caller should turn this
     * into a wire-level error frame + close).
     */
    attach(sessionId, subscriber) {
        if (this.disposed)
            throw new Error("SessionStreamManager disposed");
        const detail = this.store.getSession(sessionId);
        if (!detail)
            throw new Error(`Unknown sessionId: ${sessionId}`);
        let stream = this.streams.get(sessionId);
        if (!stream) {
            stream = this.createStream(sessionId, detail.messages);
            this.streams.set(sessionId, stream);
        }
        stream.subscribers.add(subscriber);
        return {
            history: detail.messages,
            currentTurn: stream.currentTurn ? snapshotTurn(stream.currentTurn) : null,
            ready: stream.ready,
            forkCompactPending: stream.forkCompactSourceId != null,
            contextWindowUsed: stream.contextWindowUsed,
            contextWindowMax: stream.contextWindowMax,
        };
    }
    /**
     * Detach a subscriber. Idempotent. Does NOT dispose the underlying spectral
     * session — even when subscribers reach zero, the in-flight turn must
     * complete and persist.
     */
    detach(sessionId, subscriber) {
        const stream = this.streams.get(sessionId);
        if (!stream)
            return;
        stream.subscribers.delete(subscriber);
        // Intentional: do NOT dispose the bridge here. See file-level docs.
    }
    /** True if the session has an in-flight turn (manager-side; not WS-side). */
    hasActiveTurn(sessionId) {
        return this.streams.get(sessionId)?.currentTurn != null;
    }
    /**
     * Return the set of session IDs that currently have an in-flight turn.
     * Cheap O(streams) scan used by the list-project / list-sessions endpoints
     * to enrich responses with `hasActiveTurn` markers so the frontend can
     * render a yellow pulsating dot next to active sessions/projects.
     */
    getActiveTurnSessionIds() {
        const ids = new Set();
        for (const [sid, stream] of this.streams) {
            if (stream.currentTurn !== null)
                ids.add(sid);
        }
        return ids;
    }
    getSessionMemoryStatus(sessionId) {
        const detail = this.store.getSession(sessionId);
        if (!detail)
            throw new Error(`Unknown sessionId: ${sessionId}`);
        const stream = this.streams.get(sessionId);
        const config = loadConfig(stream?.cwd ?? this.cwd);
        const status = defaultMemoryStatus(stream?.cwd ?? this.cwd);
        status.mode = config.passive ? "passive" : "active";
        const entries = stream?.bridge.getSessionBranch?.();
        if (entries) {
            const memoryState = getMemoryState(entries);
            status.reflections = {
                count: memoryState.reflections.length,
                tokens: memoryState.reflections.reduce((sum, reflection) => sum + estimateStringTokens(reflectionContent(reflection)), 0),
            };
            status.observations = {
                committedCount: memoryState.committedObs.length,
                committedTokens: observationPoolTokens(memoryState.committedObs),
                pendingCount: memoryState.pendingObs.length,
                pendingTokens: observationPoolTokens(memoryState.pendingObs),
            };
            status.progress = {
                sinceObservationBoundTokens: rawTokensSinceLastBound(entries),
                sinceLastCompactionTokens: rawTokensSinceLastCompaction(entries),
                observationPoolTokens: observationPoolTokens([
                    ...memoryState.committedObs,
                    ...memoryState.pendingObs,
                ]),
            };
        }
        else {
            const snapshot = this.store.getSessionMemorySnapshot(sessionId);
            if (snapshot) {
                status.reflections = {
                    count: snapshot.details.reflections.length,
                    tokens: snapshot.details.reflections.reduce((sum, reflection) => sum + estimateStringTokens(reflectionContent(reflection)), 0),
                };
                status.observations = {
                    committedCount: snapshot.details.observations.length,
                    committedTokens: observationPoolTokens(snapshot.details.observations),
                    pendingCount: 0,
                    pendingTokens: 0,
                };
                status.progress.observationPoolTokens = observationPoolTokens(snapshot.details.observations);
            }
        }
        const activity = stream?.bridge.getMemoryActivity?.();
        if (activity) {
            status.phase = activity.phase;
            status.inFlight = activity.inFlight;
        }
        else if (stream?.compacting) {
            status.phase = "compacting";
            status.inFlight.compaction = true;
        }
        return status;
    }
    getSessionMemoryDetails(sessionId) {
        const detail = this.store.getSession(sessionId);
        if (!detail)
            throw new Error(`Unknown sessionId: ${sessionId}`);
        const stream = this.streams.get(sessionId);
        const entries = stream?.bridge.getSessionBranch?.();
        let observations = [];
        let pendingObs = [];
        let reflections = [];
        let summary = null;
        if (entries) {
            const memoryState = getMemoryState(entries);
            observations = memoryState.committedObs;
            pendingObs = memoryState.pendingObs;
            reflections = memoryState.reflections;
            summary = renderSummary(reflections, [...observations, ...pendingObs]);
        }
        else {
            const snapshot = this.store.getSessionMemorySnapshot(sessionId);
            if (snapshot) {
                observations = snapshot.details.observations;
                reflections = snapshot.details.reflections;
                summary = snapshot.summary;
            }
        }
        const pendingIds = new Set(pendingObs.map((o) => o.id));
        const wireObservations = [
            ...observations.map((o) => ({
                id: o.id,
                content: o.content,
                timestamp: o.timestamp,
                relevance: o.relevance,
                pending: false,
            })),
            ...pendingObs.map((o) => ({
                id: o.id,
                content: o.content,
                timestamp: o.timestamp,
                relevance: o.relevance,
                pending: true,
            })),
        ];
        const wireReflections = reflections.map((r) => ({
            id: reflectionId(r) ?? "legacy",
            content: reflectionContent(r),
            supportingObservationIds: typeof r === "object" && "supportingObservationIds" in r
                ? r.supportingObservationIds
                : [],
        }));
        return {
            summary,
            observations: wireObservations,
            reflections: wireReflections,
        };
    }
    async compactSession(sessionId) {
        if (this.disposed)
            throw new Error("SessionStreamManager disposed");
        const detail = this.store.getSession(sessionId);
        if (!detail)
            throw new Error(`Unknown sessionId: ${sessionId}`);
        let stream = this.streams.get(sessionId);
        if (!stream) {
            stream = this.createStream(sessionId, detail.messages);
            this.streams.set(sessionId, stream);
        }
        if (stream.currentTurn) {
            throw new Error("Session is busy with an active turn");
        }
        if (stream.compacting) {
            throw new Error("Session is already being compacted");
        }
        if (!stream.bridge.compact) {
            throw new Error("Manual compaction is not supported for this session");
        }
        await stream.ready;
        stream.compacting = true;
        try {
            await stream.bridge.compact();
            // Prune persisted history synchronously after compaction, so even if the
            // compaction_end event handler races or misses, SQLite reflects the
            // compacted state before the next user prompt or reconnect.
            prunePersistedHistoryAfterCompaction(this.store, sessionId, stream.bridge);
        }
        finally {
            if (stream.compacting)
                stream.compacting = false;
            this.maybeAutoDequeue(stream);
        }
    }
    /**
     * Persist a user message and forward it to spectral. Resolves after the user
     * message is persisted + spectral is invoked (NOT after the turn completes —
     * the turn lifetime is observed via the broadcast stream).
     *
     * Broadcast ordering:
     *   1. user message persisted to SQLite
     *   2. `user_message_appended` broadcast to all subscribers (including
     *      the originating tab)
     *   3. new `currentTurn` opened
     *   4. `bridge.prompt()` invoked (events arrive asynchronously and are
     *      buffered + broadcast as they come)
     *
     * Sticky model selection (Phase 3 — Available Models whitelist):
     *   - When `modelId` is provided, we apply it via `bridge.setModel()` and
     *     persist to SQLite for cross-restart recovery, BEFORE invoking
     *     `bridge.prompt()`. If `setModel` fails (unknown model, registry
     *     unavailable, agent-side error) the bridge has already emitted an
     *     `error` wire event and we drop the prompt to avoid running it
     *     against the wrong model.
     *   - When `modelId` is omitted, we look up SQLite. If a previous turn
     *     persisted a value, we reapply it on this turn (this is the
     *     cross-restart recovery path: a fresh server process has lost spectral's
     *     in-memory model state, so we re-pin from durable storage).
     *   - When neither envelope nor SQLite have a value, we leave model
     *     selection to spectral's own settings file (pre-Phase-3 behaviour).
     */
    async prompt(sessionId, content, modelId, images, reasoningEffort, opts) {
        if (this.disposed)
            throw new Error("SessionStreamManager disposed");
        let stream = this.streams.get(sessionId);
        if (!stream)
            throw new Error(`No active stream for session: ${sessionId}`);
        // Guard: block new prompts while compaction is running. Compaction
        // takes ~1 LLM call (<5 s) and the bridge emits a clear error message
        // so the user knows to wait a moment.
        if (stream.compacting) {
            this.broadcast(stream, {
                type: "error",
                message: "Session is being compacted. Please wait a moment and try again.",
            });
            return;
        }
        // If the bridge was disposed (e.g. by cancelTurn), recreate it before
        // proceeding. We rebuild only the bridge inside the existing stream so
        // subscribers, cwd, and other metadata are preserved.
        if (stream.startError) {
            const history = this.store.getSession(sessionId)?.messages;
            const memorySnapshot = this.store.getSessionMemorySnapshot(sessionId);
            const bridgeHistory = selectHistoryForBridge(history, memorySnapshot != null);
            const bridgeOpts = {
                cwd: stream.cwd,
                agentDir: this.agentDir,
                backendUrl: this.backendUrl,
                machineJwt: this.machineJwt,
                history: bridgeHistory,
                memorySnapshot,
                emit: (event) => this.handleBridgeEvent(stream, event),
                onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
                    try {
                        this.store.appendMessage(sessionId, {
                            id: messageId,
                            role: "assistant",
                            content,
                            eventsJsonl,
                        });
                    }
                    catch (err) {
                        console.error(`[spectral] error: failed to persist assistant message: ${err instanceof Error ? err.message : String(err)}`);
                    }
                },
                onAssistantMessageSkipped: (messageId) => {
                    this.store.deleteMessage(messageId);
                },
                onError: (err) => {
                    console.error(`[spectral] error: agent bridge error: ${err.message}`);
                },
            };
            stream.bridge = this.bridgeFactory(bridgeOpts);
            stream.startError = null;
            stream.ready = stream.bridge
                .start()
                .catch((err) => {
                const e = err instanceof Error ? err : new Error(String(err));
                stream.startError = e;
                this.broadcast(stream, {
                    type: "error",
                    message: `Failed to start agent: ${e.message}`,
                });
                throw e;
            });
        }
        // Wait for spectral to be ready before we persist + invoke. If start failed,
        // surface to all subscribers instead of throwing into the route handler.
        try {
            await stream.ready;
        }
        catch (err) {
            const e = err instanceof Error ? err : new Error(String(err));
            this.broadcast(stream, { type: "error", message: `Agent not ready: ${e.message}` });
            return;
        }
        // Sticky-model resolution & application. Phase 3 (Available Models
        // whitelist). Order:
        //   a) If envelope carried a `modelId`, use it.
        //   b) Else, look up the per-session persisted modelId in SQLite
        //      (cross-restart recovery — server restart wipes spectral's in-memory
        //      session model state, but our durable store has the last value).
        //   c) Else, ask the bridge for the first available model from the
        //      backend whitelist (same sortOrder the frontend uses for its
        //      default picker display).
        //
        // We apply BEFORE persisting the user message: if the bridge can't
        // resolve the model (unknown id, registry unavailable), it has already
        // emitted an `error` wire event and we drop the prompt rather than
        // recording a user message we know we won't be able to respond to.
        //
        // Persistence: only the envelope-supplied value is written back. A
        // recovery-only application (case b) doesn't update the row — the
        // value is already there.
        const effectiveModelId = modelId ??
            this.store.getSessionModel(sessionId) ??
            stream.bridge.getFirstAvailableModelId?.();
        if (effectiveModelId && stream.bridge.setModel) {
            const ok = await stream.bridge.setModel(effectiveModelId);
            if (!ok) {
                // Bridge already emitted an error; nothing else to do. We
                // intentionally do not persist the user message — the turn never
                // ran.
                return;
            }
        }
        if (effectiveModelId) {
            try {
                this.store.setSessionModel(sessionId, effectiveModelId);
            }
            catch (err) {
                // Persisting the sticky model is best-effort: the live turn will
                // still run with the model already applied above. A failure here
                // only affects cross-restart recovery, which is non-critical.
                console.warn(`[spectral] warn: failed to persist sticky model for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
        // Sticky reasoning-effort resolution. Same order as modelId:
        //   a) Envelope-supplied value → apply + persist.
        //   b) Otherwise, look up the persisted value from SQLite.
        //   c) When neither is present, leave spectral's current thinking level unchanged.
        // We apply BEFORE persisting the user message so the turn runs with the
        // correct reasoning level.
        const effectiveReasoningEffort = reasoningEffort ?? this.store.getSessionReasoningEffort(sessionId) ?? undefined;
        if (effectiveReasoningEffort && stream.bridge.setReasoningEffort) {
            stream.bridge.setReasoningEffort(effectiveReasoningEffort);
        }
        if (reasoningEffort !== undefined) {
            try {
                this.store.setSessionReasoningEffort(sessionId, reasoningEffort || null);
            }
            catch (err) {
                console.warn(`[spectral] warn: failed to persist reasoning effort for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
        // 1. Persist user message first (survives mid-prompt failures).
        // When skipPersistence is true (e.g. auto-research), the prompt is
        // an internal system instruction — do NOT write it to SQLite or
        // broadcast it as a user message.
        if (!opts?.skipPersistence) {
            let stored;
            try {
                stored = this.store.appendMessage(sessionId, {
                    role: "user",
                    content,
                    images,
                });
            }
            catch (err) {
                const e = err instanceof Error ? err : new Error(String(err));
                this.broadcast(stream, {
                    type: "error",
                    message: `Failed to persist user message: ${e.message}`,
                });
                return;
            }
            // 2. Broadcast the persisted message so every tab — including the one
            //    that sent the prompt — appends an authoritative user turn.
            this.broadcast(stream, { type: "user_message_appended", message: stored });
        }
        // 3. Open a new in-flight turn. Allocated even before spectral emits anything
        //    so a re-attach immediately after `prompt` sees the turn.
        stream.currentTurn = {
            turnId: randomUUID(),
            startedAt: Date.now(),
            events: [],
            assistantText: "",
        };
        // 4. Fire spectral. `prompt` resolves on agent_end; errors inside
        //    AgentBridge are caught and emitted as wire errors. Synchronous throws
        //    (e.g. disposed bridge, missing session) arrive as a rejected promise
        //    we handle here so the frontend sees the error rather than a silent
        //    dead turn.
        stream.bridge.prompt(content, images).catch((err) => {
            const msg = err instanceof Error ? err.message : String(err);
            console.error(`[spectral] error: bridge.prompt failed: ${msg}`);
            this.broadcast(stream, {
                type: "error",
                message: `Agent prompt failed: ${msg}`,
            });
        });
    }
    /**
     * Tear down everything. Best-effort: disposes every bridge, drops all
     * subscribers. After this the manager is unusable.
     */
    dispose() {
        if (this.disposed)
            return;
        this.disposed = true;
        for (const stream of this.streams.values()) {
            stream.loopActive = false;
            try {
                stream.bridge.dispose();
            }
            catch {
                // ignore
            }
            stream.subscribers.clear();
        }
        this.streams.clear();
    }
    /** Test/inspection helper: how many streams are currently tracked. */
    streamCount() {
        return this.streams.size;
    }
    /**
     * Count of sessions with an in-flight turn (i.e. a `currentTurn` set).
     * Used by `gracefulShutdown` to decide whether to keep waiting before
     * tearing down — a non-zero count means at least one assistant response
     * is mid-stream and we'd rather let it finish (within the grace window)
     * than orphan a half-streamed message in the UI.
     *
     * Cheap O(streams) scan; we only call it ~50× during a 5 s graceful
     * shutdown so the linear walk is fine.
     */
    activeTurnCount() {
        let n = 0;
        for (const s of this.streams.values()) {
            if (s.currentTurn != null)
                n++;
        }
        return n;
    }
    /**
     * Cancel the in-flight turn for a session (user pressed Stop in the UI).
     * Disposes the agent bridge and broadcasts `agent_end` so all subscribers
     * see the turn close. The stream itself is kept alive — the next user
     * message (via `prompt()`) will lazily create a fresh bridge.
     *
     * Idempotent: if no stream exists for the session, or no turn is in
     * flight, this is a no-op.
     */
    cancelTurn(sessionId) {
        const stream = this.streams.get(sessionId);
        if (!stream)
            return;
        // Stop any active loop before disposing — if the loop was mid-iteration
        // the next agent_end would otherwise trigger another prompt.
        stream.loopActive = false;
        stream.loopOriginalPrompt = null;
        stream.loopGoal = null;
        stream.loopIterationCount = 0;
        // Capture whether a turn is in-flight BEFORE we dispose the bridge.
        // dispose() tears down spectral, which can cause the in-flight prompt()
        // promise to reject synchronously/microtask and emit an error event
        // through handleBridgeEvent — which clears currentTurn. We must
        // broadcast agent_end regardless of what dispose() does to currentTurn.
        const hadTurn = stream.currentTurn != null;
        // Dispose the agent bridge immediately — this tears down spectral's session and
        // unsubscribe. The bridge's own event handler is detached; no further
        // events will flow. We broadcast agent_end ourselves below.
        try {
            stream.bridge.dispose();
        }
        catch {
            // ignore
        }
        // Set startError so any late `await stream.ready` in the loop path
        // (if the loop was between iterations when we disposed) rejects cleanly
        // instead of hanging. Also prevents the next prompt() from waiting on
        // a dead bridge's ready promise.
        stream.startError = new Error("Turn cancelled");
        // Clear batch-persist tracking so the next turn doesn't accidentally
        // flush events against a stale messageId.
        stream.currentMessageId = null;
        stream.lastFlushedEventCount = 0;
        // Broadcast agent_end so all subscribers close their open turn and
        // re-enable their composers. Use the pre-disposal flag — dispose()
        // may have cleared currentTurn via an error event from the torn-down
        // spectral session.
        if (hadTurn) {
            this.broadcast(stream, { type: "agent_end" });
            stream.currentTurn = null;
            this.maybeAutoDequeue(stream);
        }
        // Don't delete the stream — the subscribers are still attached and the
        // next `prompt()` call will create a fresh bridge via `createStream()`.
        // We just need to signal that the bridge is gone so `prompt()` knows to
        // recreate one.
    }
    /**
     * Tear down a single session's stream — disposes the agent bridge and clears
     * subscribers. Idempotent. Called by the routes layer right before
     * `DELETE /api/sessions/:id` so the SQL cascade doesn't leave a zombie
     * spectral process driving events at a session that no longer exists.
     *
     * Does NOT remove the session from the store — that's the caller's job.
     */
    disposeSessionStream(sessionId) {
        const stream = this.streams.get(sessionId);
        if (!stream)
            return;
        stream.loopActive = false;
        // Flush the last batch of in-flight events before tearing down, so a
        // shutdown / GC doesn't lose events that haven't hit the interval yet.
        this.flushInFlightTurn(stream);
        try {
            stream.bridge.dispose();
        }
        catch {
            // ignore
        }
        // Best-effort: notify any still-open subscribers so they close cleanly
        // rather than hanging on a dead spectral process. We don't broadcast through
        // `broadcast()` because that would re-enter the dead-subscriber pruning
        // loop on a stream we're about to drop anyway.
        for (const sub of stream.subscribers) {
            if (!sub.isOpen())
                continue;
            try {
                sub.send({ type: "error", message: "Session deleted" });
            }
            catch {
                // ignore
            }
        }
        stream.subscribers.clear();
        this.streams.delete(sessionId);
    }
    /**
     * Tear down every stream whose session belongs to the given list of ids.
     * Used by the project-delete path: the route layer reads the project's
     * session ids from `deleteProject()` and passes them here BEFORE the SQL
     * cascade fires, so no spectral process ever observes the FK cascade.
     */
    disposeProjectStreams(sessionIds) {
        for (const sid of sessionIds) {
            this.disposeSessionStream(sid);
        }
    }
    /**
     * Set the autonomous iterative loop state for a session.
     *
     * When `active` is true, the manager replays `originalPrompt` after each
     * `agent_end` event — the agent sees its own file changes from prior
     * iterations and iteratively improves its solution (Ralph Wiggum pattern).
     * The loop stops when the agent emits `<LOOP_DONE>` in its response or the
     * safety iteration limit is reached.
     */
    setLoopActive(sessionId, active, originalPrompt, maxIterations, goal) {
        const stream = this.streams.get(sessionId);
        if (stream) {
            stream.loopActive = active;
            stream.loopOriginalPrompt = active ? (originalPrompt ?? null) : null;
            if (active) {
                if (maxIterations !== undefined && maxIterations > 0) {
                    stream.loopMaxIterations = Math.min(maxIterations, MAX_LOOP_ITERATIONS);
                }
                stream.loopGoal = goal?.trim() || null;
            }
            else {
                stream.loopIterationCount = 0;
                stream.loopMaxIterations = MAX_LOOP_ITERATIONS;
                stream.loopGoal = null;
            }
        }
    }
    /**
     * Fork & Compact: trigger compaction after the first assistant turn of a
     * forked session. Uses spectral's built-in `compact()` which generates a summary
     * of older context, retaining the most recent ~20K tokens (including the
     * user's new message + the assistant's response).
     *
     * Custom instructions reference the most recent user message so the LLM
     * summary prioritizes information relevant to the current task.
     */
    triggerForkCompact(stream) {
        if (!stream.bridge.compact) {
            console.warn(`[spectral] warn: bridge does not support compact ("Fork & Compact" skipped for ${stream.sessionId})`);
            return;
        }
        stream.compacting = true;
        // Read the most recent user message to use as guidance for the
        // compaction summary.
        const detail = this.store.getSession(stream.sessionId);
        const lastUserMsg = detail?.messages
            .filter((m) => m.role === "user")
            .pop();
        const guidanceMessage = lastUserMsg?.content?.trim() || "";
        const customInstructions = guidanceMessage
            ? `Focus the context summary on information relevant to: "${guidanceMessage}"`
            : undefined;
        void stream.bridge
            .compact(customInstructions)
            .then(() => {
            stream.compacting = false;
            try {
                this.store.clearForkCompactSource(stream.sessionId);
            }
            catch {
                // best-effort
            }
            console.log(`[spectral] fork-compact completed for ${stream.sessionId}`);
            this.maybeAutoDequeue(stream);
        })
            .catch((err) => {
            stream.compacting = false;
            const msg = err instanceof Error ? err.message : String(err);
            console.error(`[spectral] fork-compact failed for ${stream.sessionId}: ${msg}`);
            this.broadcast(stream, {
                type: "error",
                message: `Context compaction failed: ${msg}`,
            });
            // Clear the flag even on failure so it doesn't block forever.
            try {
                this.store.clearForkCompactSource(stream.sessionId);
            }
            catch {
                // best-effort
            }
            this.maybeAutoDequeue(stream);
        });
    }
    /**
     * Send the next prompt for an autonomous loop iteration, waiting for any
     * in-flight observational-memory compaction to finish first so the LLM
     * sees the compacted context.
     *
     * The compaction-trigger extension fires on agent_end (via setTimeout),
     * so we poll stream.compacting briefly. If compaction hasn't started after
     * a short grace period, we check whether the context window exceeds the
     * threshold and proactively trigger compaction via the bridge (which
     * invokes spectral's full pipeline, including the session_before_compact hook
     * where the observational memory extension provides its summary).
     *
     * A duplicate call from the extension's delayed compaction trigger is
     * harmless — spectral throws "Already compacted" which the extension catches.
     */
    /**
     * Send the next prompt for an autonomous loop iteration, compacting first
     * if the context window exceeds the threshold.
     *
     * Instead of polling for the extension's delayed compaction-trigger (which
     * fires via setTimeout), we proactively call bridge.compact() directly.
     * bridge.compact() → session.compact() → fires session_before_compact
     * (where the extension's compaction-hook provides the observational-memory
     * summary), then appends the compaction entry, reloads the compacted
     * context into agent.state.messages, and emits compaction_start/end.
     *
     * The extension's trigger still fires (via setTimeout), but by the time
     * its callback runs, bridge.compact() has already appended the compaction
     * entry → prepareCompaction() returns undefined → "Already compacted"
     * → the trigger's onError handler catches it harmlessly.
     */
    buildLoopPrompt(stream) {
        const parts = [];
        if (stream.loopGoal) {
            parts.push(`[GOAL]: ${stream.loopGoal}`);
        }
        parts.push(stream.loopOriginalPrompt);
        parts.push("");
        parts.push("--- LOOP INSTRUCTIONS ---\n" +
            "You are running in an iterative loop. Each iteration you see your previous changes\n" +
            "and can improve them further.\n" +
            (stream.loopGoal
                ? "The [GOAL] above defines what you need to achieve.\n"
                : "\n") +
            "CRITICAL: After EVERY response you MUST self-evaluate:\n" +
            "- If the task IS FULLY COMPLETE (nothing meaningful left to do), " +
            "respond with <LOOP_DONE> as the VERY FIRST text in your reply,\n" +
            "  followed by a brief summary of what was accomplished.\n" +
            "- If more work remains, continue working WITHOUT <LOOP_DONE>.\n" +
            "- <LOOP_DONE> is the ONLY way to stop the loop. If you do not emit it,\n" +
            "  you WILL be prompted again to keep working.\n" +
            "Be decisive: if the task is done, stop. Do not keep polishing.");
        return parts.join("\n");
    }
    async sendNextLoopIteration(stream) {
        const shouldCompact = stream.bridge.compact &&
            typeof stream.contextWindowUsed === "number" &&
            stream.contextWindowUsed > LOOP_COMPACTION_THRESHOLD_TOKENS;
        if (shouldCompact) {
            try {
                console.log(`[loop] compacting context (~${stream.contextWindowUsed.toLocaleString()} tokens > ${LOOP_COMPACTION_THRESHOLD_TOKENS.toLocaleString()} threshold)`);
                await stream.bridge.compact();
                console.log("[loop] compaction complete, sending next iteration");
            }
            catch (err) {
                const msg = err instanceof Error ? err.message : String(err);
                if (msg === "Already compacted" ||
                    msg === "Nothing to compact (session too small)") {
                    console.log("[loop] compaction already done or not needed, proceeding");
                }
                else {
                    console.error(`[loop] compaction failed: ${msg}`);
                }
            }
        }
        await this.prompt(stream.sessionId, this.buildLoopPrompt(stream), undefined);
    }
    // --- internals ----------------------------------------------------------
    createStream(sessionId, history) {
        // Resolve cwd from the owning project. Sessions without a project
        // shouldn't exist (FK enforces it), but we fall back to the manager's
        // default cwd if the lookup somehow fails — better than crashing the
        // attach.
        const projectId = this.store.getSessionProjectId(sessionId);
        let cwd = this.cwd;
        if (projectId) {
            const project = this.store.getProject(projectId);
            if (project)
                cwd = project.path;
        }
        // Forward declaration so the bridge factory's emit callback can refer to
        // the stream object that's still being assembled.
        const forkSourceId = this.store.getForkCompactSource(sessionId);
        const stream = {
            sessionId,
            cwd,
            bridge: undefined,
            ready: Promise.resolve(),
            startError: null,
            subscribers: new Set(),
            currentTurn: null,
            currentMessageId: null,
            lastFlushedEventCount: 0,
            loopActive: false,
            loopIterationCount: 0,
            loopOriginalPrompt: null,
            loopMaxIterations: MAX_LOOP_ITERATIONS,
            loopGoal: null,
            forkCompactSourceId: forkSourceId ?? null,
            compacting: false,
            contextWindowUsed: null,
            contextWindowMax: null,
        };
        const memorySnapshot = this.store.getSessionMemorySnapshot(sessionId);
        const bridgeHistory = selectHistoryForBridge(history, memorySnapshot != null);
        const bridgeOpts = {
            cwd,
            agentDir: this.agentDir,
            backendUrl: this.backendUrl,
            machineJwt: this.machineJwt,
            history: bridgeHistory,
            memorySnapshot,
            emit: (event) => this.handleBridgeEvent(stream, event),
            onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
                try {
                    this.store.appendMessage(sessionId, {
                        id: messageId,
                        role: "assistant",
                        content,
                        eventsJsonl,
                    });
                }
                catch (err) {
                    console.error(`[spectral] error: failed to persist assistant message: ${err instanceof Error ? err.message : String(err)}`);
                }
            },
            onAssistantMessageSkipped: (messageId) => {
                this.store.deleteMessage(messageId);
            },
            onError: (err) => {
                console.error(`[spectral] error: agent bridge error: ${err.message}`);
            },
        };
        stream.bridge = this.bridgeFactory(bridgeOpts);
        stream.ready = stream.bridge
            .start()
            .then(() => {
            // After spectral has replayed history into its session manager, populate
            // context window state from its built-in getContextUsage() estimator.
            // Historical sessions need this — without it, the initial
            // session_ready carries null fields and the context-window bar
            // stays invisible until the user sends a new prompt.
            const ctx = stream.bridge.getContextUsage?.();
            if (ctx) {
                stream.contextWindowUsed = ctx.tokens;
                stream.contextWindowMax = ctx.contextWindow;
                // Emit directly to subscribers (not via handleBridgeEvent, which
                // would buffer the synthetic event into currentTurn). The frontend
                // reducer's token_usage fallthrough updates session-level context
                // window fields when no open turn exists.
                for (const subscriber of stream.subscribers) {
                    if (!subscriber.isOpen())
                        continue;
                    try {
                        subscriber.send({
                            type: "token_usage",
                            messageId: "",
                            usage: {
                                inputTokens: 0,
                                outputTokens: 0,
                                cacheReadTokens: 0,
                                cacheWriteTokens: 0,
                                totalTokens: 0,
                                cost: null,
                                creditsUsed: 0,
                            },
                            contextWindowUsed: ctx.tokens,
                            contextWindowMax: ctx.contextWindow,
                        });
                    }
                    catch {
                        // best-effort
                    }
                }
            }
            this.maybeAutoDequeue(stream);
        })
            .catch((err) => {
            const e = err instanceof Error ? err : new Error(String(err));
            stream.startError = e;
            // Notify any already-attached subscribers so they don't sit on a
            // dead connection. Late attachers see startError via attach->ready.
            this.broadcast(stream, {
                type: "error",
                message: `Failed to start agent: ${e.message}`,
            });
            throw e;
        });
        return stream;
    }
    handleBridgeEvent(stream, event) {
        // Buffer replayable events into the in-flight turn. We intentionally
        // accept events even if currentTurn is null (rare race: spectral emits before
        // prompt() opened the turn), in which case we open one defensively so
        // late attachers see the events. The first event in such a case is
        // typically `message_start`.
        if (isReplayable(event)) {
            if (!stream.currentTurn) {
                stream.currentTurn = {
                    turnId: randomUUID(),
                    startedAt: Date.now(),
                    events: [],
                    assistantText: "",
                };
            }
            stream.currentTurn.events.push(event);
            if (event.type === "text_delta") {
                stream.currentTurn.assistantText += event.delta;
            }
            // Track message lifecycle for batch persistence.
            if (event.type === "message_start") {
                stream.currentMessageId = event.messageId;
                stream.lastFlushedEventCount = 0;
                // Stub-insert so the server can recover this message on restart even
                // if it crashes before the first batch flush. `INSERT OR REPLACE`
                // ensures the final `onAssistantMessageComplete` write wins.
                try {
                    this.store.appendMessage(stream.sessionId, {
                        id: event.messageId,
                        role: "assistant",
                        content: "",
                        eventsJsonl: "",
                        createdAt: Date.now(),
                    });
                }
                catch (err) {
                    console.error(`[spectral] error: batch-persist stub insert failed: ${err instanceof Error ? err.message : String(err)}`);
                }
            }
            // Batch-persist: flush accumulated events to SQLite every N events so a
            // server crash mid-turn only loses the last batch, not the entire turn.
            if (stream.currentMessageId &&
                stream.currentTurn &&
                stream.currentTurn.events.length - stream.lastFlushedEventCount >= BATCH_FLUSH_INTERVAL) {
                this.flushInFlightTurn(stream);
            }
        }
        // Broadcast first, then maybe close out the turn. agent_end clears the
        // buffer because by that point the assistant message is already in
        // SQLite (AgentBridge calls onAssistantMessageComplete on message_end,
        // which fires before agent_end).
        //
        // Track context window state from token_usage events — the bridge emits
        // cumulative session-wide values from spectral's getContextUsage(). This lets
        // late-attaching subsribers (reconnects / multi-tab) get the latest
        // context window state via session_ready.
        if (event.type === "token_usage") {
            if (event.contextWindowUsed != null)
                stream.contextWindowUsed = event.contextWindowUsed;
            if (event.contextWindowMax != null)
                stream.contextWindowMax = event.contextWindowMax;
        }
        // Track compaction lifecycle so prompt() can block new messages while
        // compaction is running.
        //
        // Persist synthetic system messages so historic session replay (reconnect,
        // page refresh) shows compaction events in the message timeline. These are
        // broadcast live to connected subscribers AND persisted, so both live and
        // historic sessions see them.
        if (event.type === "compaction_start") {
            stream.compacting = true;
            const reasonLabel = event.reason === "manual"
                ? "Manual compaction"
                : event.reason === "overflow"
                    ? "Context overflow"
                    : "Context threshold";
            const sysMsg = `${reasonLabel} — compacting conversation context…`;
            try {
                this.store.appendMessage(stream.sessionId, {
                    role: "system",
                    content: sysMsg,
                    eventsJsonl: "",
                    createdAt: Date.now(),
                });
            }
            catch {
                // best-effort; never block the stream on persistence
            }
        }
        if (event.type === "compaction_end") {
            stream.compacting = false;
            if (!event.aborted) {
                prunePersistedHistoryAfterCompaction(this.store, stream.sessionId, stream.bridge);
            }
            persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
            // Drain the prompt queue after compaction finishes. During
            // compaction, auto-dequeue in agent_end is skipped to prevent the
            // lossy prompt() guard from dropping queued items.
            this.maybeAutoDequeue(stream);
            // After compaction the session context has been reduced; push updated
            // context-window stats to all subscribers so the frontend's context
            // bar refreshes immediately instead of waiting for the next turn.
            const ctx = stream.bridge.getContextUsage?.();
            if (ctx) {
                stream.contextWindowUsed = ctx.tokens;
                stream.contextWindowMax = ctx.contextWindow;
                this.broadcast(stream, {
                    type: "token_usage",
                    messageId: "",
                    usage: {
                        inputTokens: 0,
                        outputTokens: 0,
                        cacheReadTokens: 0,
                        cacheWriteTokens: 0,
                        totalTokens: 0,
                        cost: null,
                        creditsUsed: 0,
                    },
                    contextWindowUsed: ctx.tokens,
                    contextWindowMax: ctx.contextWindow,
                });
            }
            const tokensMsg = typeof event.tokensBefore === "number"
                ? ` (from ~${event.tokensBefore.toLocaleString()} tokens)`
                : "";
            const sysMsg = event.aborted
                ? "Compaction aborted."
                : event.summary
                    ? `Compaction complete${tokensMsg}. Summary: ${event.summary}`
                    : `Compaction complete${tokensMsg}.`;
            try {
                this.store.appendMessage(stream.sessionId, {
                    role: "system",
                    content: sysMsg,
                    eventsJsonl: "",
                    createdAt: Date.now(),
                });
            }
            catch {
                // best-effort
            }
        }
        this.broadcast(stream, event);
        if (event.type === "agent_end") {
            persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
            // Final flush + clear batch-persist tracking. `onAssistantMessageComplete`
            // has already written the authoritative final row to SQLite (it fires on
            // `message_end`, which precedes `agent_end`), so the staged row is already
            // replaced with complete data. We just zero out the in-memory trackers.
            stream.currentMessageId = null;
            stream.lastFlushedEventCount = 0;
            const finishedTurn = stream.currentTurn;
            stream.currentTurn = null;
            // Autonomous iterative loop (Ralph Wiggum pattern).
            // When loopActive is set, check for completion marker, then re-send
            // the ORIGINAL prompt so the agent sees its prior changes and
            // iteratively improves the solution.
            if (stream.loopActive && stream.loopOriginalPrompt) {
                const finishedAssistantText = finishedTurn?.assistantText ?? "";
                // Check for the loop-done marker in the first portion of the
                // response (the prompt instructs the agent to emit it first).
                const head = finishedAssistantText.slice(0, 500);
                if (head.includes(LOOP_DONE_MARKER)) {
                    console.log(`[loop] completion marker detected after ${stream.loopIterationCount} iteration(s), stopping`);
                    const completedIterations = stream.loopIterationCount;
                    stream.loopActive = false;
                    stream.loopIterationCount = 0;
                    stream.loopOriginalPrompt = null;
                    stream.loopGoal = null;
                    this.broadcast(stream, {
                        type: "loop_complete",
                        iterations: completedIterations,
                    });
                }
                else if (stream.loopIterationCount >= stream.loopMaxIterations) {
                    console.log(`[loop] max iterations (${stream.loopMaxIterations}) reached, stopping`);
                    stream.loopActive = false;
                    stream.loopOriginalPrompt = null;
                    stream.loopGoal = null;
                    this.broadcast(stream, {
                        type: "loop_max_iterations",
                        iterations: stream.loopIterationCount,
                    });
                }
                else {
                    stream.loopIterationCount++;
                    console.log(`[loop] iteration ${stream.loopIterationCount}/${stream.loopMaxIterations}`);
                    this.broadcast(stream, {
                        type: "loop_iteration",
                        iteration: stream.loopIterationCount,
                        maxIterations: stream.loopMaxIterations,
                        prompt: stream.loopOriginalPrompt,
                    });
                    void this.sendNextLoopIteration(stream).catch((err) => {
                        console.error(`[loop] iteration failed: ${err instanceof Error ? err.message : String(err)}`);
                        stream.loopActive = false;
                        stream.loopOriginalPrompt = null;
                        stream.loopGoal = null;
                    });
                }
            }
            // Fork & Compact: after the first assistant turn of a forked session,
            // compact the context. The custom instructions reference the user's
            // new message so the summary preserves relevant context.
            if (stream.forkCompactSourceId && !stream.compacting) {
                const sourceId = stream.forkCompactSourceId;
                stream.forkCompactSourceId = null; // one-shot
                this.triggerForkCompact(stream);
            }
            // Auto-dequeue: after the turn finishes and no loop iteration is
            // pending, check the persistent prompt queue. If there's a queued
            // prompt, start it immediately without broadcasting agent_end —
            // the frontend transitions seamlessly to the next turn.
            // Skip if compaction is in-flight (e.g. fork-compact just started
            // above) — compaction_end will drain the queue when it finishes.
            if (!stream.loopActive || !stream.loopOriginalPrompt) {
                if (!stream.compacting && this.maybeAutoDequeue(stream))
                    return;
            }
        }
        else if (event.type === "error") {
            // An error event arriving outside a turn (or bubbling out of one) —
            // discard partial buffer to avoid replaying half a turn that the
            // client has already shown an error for. The error event itself is
            // still broadcast above. Also clear batch-persist tracking so the
            // next `message_start` starts a fresh sequence.
            stream.currentMessageId = null;
            stream.lastFlushedEventCount = 0;
            stream.currentTurn = null;
        }
    }
    /**
     * Flush the current in-flight turn's events to SQLite for crash recovery.
     * Only the events accumulated since the last flush are written — we append
     * them to the already-stored JSONL via INSERT OR REPLACE. Called every
     * `BATCH_FLUSH_INTERVAL` events from `handleBridgeEvent`.
     *
     * Errors are caught, logged, and swallowed: batch persistence is a
     * best-effort hardening, never a failure path that should block the stream.
     */
    flushInFlightTurn(stream) {
        const turn = stream.currentTurn;
        const messageId = stream.currentMessageId;
        if (!turn || !messageId)
            return;
        const newEvents = turn.events.slice(stream.lastFlushedEventCount);
        if (newEvents.length === 0)
            return;
        try {
            // Build JSONL from all events (already-flushed + new) so the row is
            // always a complete, self-consistent snapshot. Older batches are
            // included so history rehydration doesn't need to stitch fragments.
            const eventsJsonl = turn.events.map((e) => JSON.stringify(e)).join("\n");
            this.store.appendMessage(stream.sessionId, {
                id: messageId,
                role: "assistant",
                content: turn.assistantText,
                eventsJsonl,
                createdAt: turn.startedAt,
            });
            stream.lastFlushedEventCount = turn.events.length;
        }
        catch (err) {
            console.error(`[spectral] error: batch-persist flush failed: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    broadcast(stream, event) {
        const dead = [];
        for (const sub of stream.subscribers) {
            if (!sub.isOpen()) {
                dead.push(sub);
                continue;
            }
            try {
                sub.send(event);
            }
            catch (err) {
                console.error(`[spectral] error: failed to send WS frame to subscriber: ${err instanceof Error ? err.message : String(err)}`);
                dead.push(sub);
            }
        }
        for (const sub of dead)
            stream.subscribers.delete(sub);
    }
    /**
     * Push the current prompt queue state to all subscribers of a session.
     * Called after every queue mutation (enqueue, remove, clear, dequeue).
     * Safe to call when no stream exists or no subscribers — silently no-ops.
     */
    pushQueueState(sessionId) {
        const stream = this.streams.get(sessionId);
        if (!stream || stream.subscribers.size === 0)
            return;
        const rows = this.store.getPromptQueue(sessionId);
        this.broadcast(stream, {
            type: "queue_changed",
            queue: rows.map((r) => ({
                id: r.id,
                content: r.content,
                position: r.position,
                createdAt: r.created_at,
            })),
        });
    }
    /**
     * Auto-dequeue the next prompt from the persistent queue and start a
     * new turn. Called from the `agent_end` handler when no loop is active.
     * Returns true if a prompt was dequeued and a turn started.
     */
    maybeAutoDequeue(stream) {
        if (stream.currentTurn || stream.compacting)
            return false;
        if (stream.loopActive && stream.loopOriginalPrompt)
            return false;
        const next = this.store.dequeuePrompt(stream.sessionId);
        if (!next)
            return false;
        // Push updated queue state so the frontend sees the item was removed.
        this.pushQueueState(stream.sessionId);
        // Fire the next prompt. Reuses the sticky model from the just-completed
        // turn (persisted by `prompt()` into the sessions.model_id column).
        const lastModelId = this.store.getSessionModel(stream.sessionId);
        this.prompt(stream.sessionId, next.content, lastModelId ?? undefined).catch((err) => {
            // Surface any errors from the auto-dequeue prompt (synchronous throws
            // before the first await, e.g. disposed manager, missing stream) so
            // subscribers see what went wrong instead of a silent dead turn.
            const msg = err instanceof Error ? err.message : String(err);
            console.error(`[spectral] error: auto-dequeue failed: ${msg}`);
            this.broadcast(stream, {
                type: "error",
                message: `Failed to start auto-dequeued prompt: ${msg}`,
            });
        });
        return true;
    }
}
function isReplayable(event) {
    return (event.type === "message_start" ||
        event.type === "text_delta" ||
        event.type === "thinking_delta" ||
        event.type === "tool_call" ||
        event.type === "tool_result" ||
        event.type === "message_end" ||
        event.type === "token_usage" ||
        event.type === "error" ||
        event.type === "subagent_start" ||
        event.type === "subagent_progress" ||
        event.type === "subagent_end");
}
function snapshotTurn(turn) {
    // Defensive copy of the events array so the snapshot can't be mutated by
    // subsequent buffer pushes. Individual event objects are safe to share —
    // they're never mutated after creation.
    return {
        turnId: turn.turnId,
        startedAt: turn.startedAt,
        events: turn.events.slice(),
    };
}
