/**
 * Relay envelope dispatcher (CLI side).
 *
 * Translates inbound `RelayFrame`s from the backend into calls against the
 * pure REST handlers (`handlers/projects.ts`, `handlers/sessions.ts`) and
 * the live `SessionStreamManager`. Pure module — no socket, no timers,
 * no logging side-channels except what the caller passes in. The caller
 * (`commands/serve.ts`) owns lifecycle and the `RelayClient`.
 *
 * Two entry points, one per inbound frame kind we route:
 *
 *  - `handleRestRequest(frame, deps)` — synchronous-style request/response.
 *    Resolves to a `RestResponseFrame` the caller sends back via
 *    `relay.send(...)`. Errors thrown by handlers are mapped to status
 *    codes (400/404/500) and returned in the body, never re-thrown — the
 *    backend has a 30 s pending-request timeout and we want every request
 *    to terminate with a wire response so the browser doesn't sit on a
 *    stale spinner.
 *
 *  - `handleClientMessage(frame, deps)` — fire-and-forget. Attaches a
 *    `Subscriber` (the relay socket, wrapped) to the session if not
 *    already attached, then calls `manager.prompt()`. Outbound
 *    `ServerEvent`s flow back through the subscriber as `WsEventFrame`s
 *    via `deps.relay.send()`.
 *
 * Subscriber bookkeeping lives in `serve.ts`, NOT here. The dispatcher
 * is given the subscriber by the caller so it stays pure-ish (depends
 * only on `manager` + `relay.send`). This also keeps the lifecycle for
 * "detach all on relay disconnect" in one place.
 *
 * Path matching is a 30-line inline switch; we deliberately do not pull
 * in `path-to-regexp` for five literal patterns. The matcher returns
 * the parsed `id` param when applicable.
 *
 * Close-code policy (mirroring backend `router.ts`):
 *  - The dispatcher itself never closes the relay; that's serve.ts.
 *  - Backend cancels pending REST requests with `code:"timeout"` (30 s)
 *    or `code:"machine_offline"` (CLI socket dropped). Both are
 *    transparent to this layer — we just respond when we can.
 */
import { homedir } from "node:os";
import { BadRequestError, NotFoundError } from "../server/handlers/errors.js";
import { handleListMcpStatus } from "../server/handlers/mcp-status.js";
import { handlePathAutocomplete } from "../server/handlers/paths-autocomplete.js";
import { handleBindStudioProject, handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
import { handleCompactSession, handleCreateSession, handleDeleteSession, handleForkSession, handleRememberAndDeleteSession, handleGetSessionDetail, handleGetSessionMemoryDetails, handleGetSessionMemoryStatus, handleUpdateSession, } from "../server/handlers/sessions.js";
import { handleClearPromptQueue, handleEnqueuePrompt, handleGetPromptQueue, handleRemovePrompt, } from "../server/handlers/queue.js";
import { handleGetSettings, handlePutSettings, } from "../server/handlers/settings.js";
import { shutdownState } from "../server/shutdown.js";
import { handleAutoResearch } from "./auto-research.js";
/**
 * Inline path matcher. Returns `null` for any path/method combination we
 * don't recognise; the caller turns that into a `404 Unknown route`.
 *
 * Intentionally literal — a regex table would be marginally fancier but
 * also marginally slower and harder to read for ~11 routes.
 */
export function matchRoute(method, path) {
    // Strip query string for path matching but keep it for the handler.
    const qIdx = path.indexOf("?");
    const cleanPath = qIdx === -1 ? path : path.slice(0, qIdx);
    const query = qIdx >= 0 ? new URLSearchParams(path.slice(qIdx + 1)) : undefined;
    // /api/mcp-status
    if (cleanPath === "/api/mcp-status") {
        if (method === "GET")
            return { route: "list_mcp_status" };
        return null;
    }
    // /api/settings
    if (cleanPath === "/api/settings") {
        if (method === "GET")
            return { route: "get_settings" };
        if (method === "PUT")
            return { route: "put_settings" };
        return null;
    }
    // /api/paths/autocomplete
    if (cleanPath === "/api/paths/autocomplete") {
        if (method === "GET")
            return { route: "list_path_autocomplete", query };
        return null;
    }
    // /api/projects
    if (cleanPath === "/api/projects") {
        if (method === "GET")
            return { route: "list_projects" };
        if (method === "POST")
            return { route: "create_project" };
        return null;
    }
    // /api/sessions
    if (cleanPath === "/api/sessions") {
        if (method === "POST")
            return { route: "create_session" };
        return null;
    }
    // /api/projects/:id/bind-studio
    const bindStudioMatch = /^\/api\/projects\/([^/]+)\/bind-studio$/.exec(cleanPath);
    if (bindStudioMatch) {
        const id = decodeURIComponent(bindStudioMatch[1]);
        if (method === "POST")
            return { route: "bind_studio", id };
        return null;
    }
    // /api/projects/:id  and  /api/projects/:id/sessions
    const projectMatch = /^\/api\/projects\/([^/]+)(\/sessions)?$/.exec(cleanPath);
    if (projectMatch) {
        const id = decodeURIComponent(projectMatch[1]);
        const isSessions = projectMatch[2] === "/sessions";
        if (isSessions) {
            if (method === "GET")
                return { route: "list_project_sessions", id };
            return null;
        }
        if (method === "PATCH")
            return { route: "update_project", id };
        if (method === "DELETE")
            return { route: "delete_project", id };
        return null;
    }
    // /api/sessions/:id
    const sessionMatch = /^\/api\/sessions\/([^/]+)$/.exec(cleanPath);
    if (sessionMatch) {
        const id = decodeURIComponent(sessionMatch[1]);
        if (method === "GET")
            return { route: "get_session", id };
        if (method === "PATCH")
            return { route: "update_session", id };
        if (method === "DELETE")
            return { route: "delete_session", id };
        return null;
    }
    // /api/sessions/:id/memory/details and /api/sessions/:id/memory and /api/sessions/:id/compact
    const sessionActionMatch = /^\/api\/sessions\/([^/]+)\/(memory(?:\/details)?|compact)$/.exec(cleanPath);
    if (sessionActionMatch) {
        const id = decodeURIComponent(sessionActionMatch[1]);
        const action = sessionActionMatch[2];
        if (action === "memory" && method === "GET")
            return { route: "get_session_memory", id };
        if (action === "memory/details" && method === "GET")
            return { route: "get_session_memory_details", id };
        if (action === "compact" && method === "POST")
            return { route: "compact_session", id };
        return null;
    }
    // /api/sessions/:id/remember-and-delete
    const rememberDeleteMatch = /^\/api\/sessions\/([^/]+)\/remember-and-delete$/.exec(cleanPath);
    if (rememberDeleteMatch) {
        const id = decodeURIComponent(rememberDeleteMatch[1]);
        if (method === "POST")
            return { route: "remember_and_delete_session", id };
        return null;
    }
    // /api/sessions/:id/fork
    const forkMatch = /^\/api\/sessions\/([^/]+)\/fork$/.exec(cleanPath);
    if (forkMatch) {
        const id = decodeURIComponent(forkMatch[1]);
        if (method === "POST")
            return { route: "fork_session", id };
        return null;
    }
    // /api/sessions/:id/queue  and  /api/sessions/:id/queue/:itemId
    const queueMatch = /^\/api\/sessions\/([^/]+)\/queue(\/([^/]+))?$/.exec(cleanPath);
    if (queueMatch) {
        const id = decodeURIComponent(queueMatch[1]);
        const itemId = queueMatch[3] ? decodeURIComponent(queueMatch[3]) : undefined;
        if (!itemId) {
            if (method === "GET")
                return { route: "get_prompt_queue", id };
            if (method === "POST")
                return { route: "enqueue_prompt", id };
            if (method === "DELETE")
                return { route: "clear_prompt_queue", id };
        }
        else {
            if (method === "DELETE")
                return { route: "remove_prompt", id, itemId };
        }
        return null;
    }
    return null;
}
/**
 * Dispatch a `rest_request` frame. Always resolves with a
 * `RestResponseFrame` — handler exceptions are caught and translated.
 *
 * Status mapping:
 *  - 200: handler returned successfully (body is the handler's return value
 *         or `{ ok: true }` for void returns)
 *  - 400: `BadRequestError` thrown by handler, OR malformed body for a
 *         route that requires one, OR unknown route
 *  - 404: `NotFoundError` thrown by handler
 *  - 405: route matches but method doesn't (returned as 404 since we don't
 *         distinguish — the matcher treats it as "no route", consistent
 *         with the original Hono router which also 404'd)
 *  - 500: anything else; `error` field carries a sanitized message
 */
export async function handleRestRequest(frame, deps) {
    const { reqId, method, path, body } = frame;
    const logger = deps.logger ?? console;
    const match = matchRoute(method, path);
    if (!match) {
        return {
            kind: "rest_response",
            reqId,
            status: 404,
            body: { error: `Unknown route: ${method} ${path}` },
        };
    }
    try {
        const result = await dispatchRoute(match, body, deps);
        return {
            kind: "rest_response",
            reqId,
            status: 200,
            body: result,
        };
    }
    catch (err) {
        if (err instanceof BadRequestError) {
            return {
                kind: "rest_response",
                reqId,
                status: 400,
                body: { error: err.message, code: err.code },
            };
        }
        if (err instanceof NotFoundError) {
            return {
                kind: "rest_response",
                reqId,
                status: 404,
                body: { error: err.message, code: err.code },
            };
        }
        // Unexpected — log locally so operators can correlate, but only
        // return a sanitized message to the caller.
        logger.error?.(`[dispatcher] unexpected error in ${method} ${path}:`, err);
        return {
            kind: "rest_response",
            reqId,
            status: 500,
            body: { error: "Internal error" },
        };
    }
}
/**
 * Inner switch: route → handler call. Separated so `handleRestRequest`
 * owns ONE try/catch boundary for all error mapping.
 *
 * Meta publish is fired AFTER the handler returns successfully (i.e.
 * after SQLite has committed) so subscribers never see a "create" event
 * for a row the next read won't find. Publish failures are swallowed
 * inside `safePublish` — the wire response goes back regardless.
 *
 * The body is `unknown` from the wire; each handler validates its own
 * shape via the `BadRequestError`-throwing checks they already do.
 */
async function dispatchRoute(match, body, deps) {
    const { store, manager, publishMetaEvent, logger } = deps;
    const id = match.id ?? "";
    switch (match.route) {
        case "list_projects": {
            const activeIds = manager.getActiveTurnSessionIds();
            const projects = handleListProjects(store);
            return projects.map((p) => {
                const sessions = store.listSessionsByProject(p.id);
                const hasActiveSession = sessions.some((s) => activeIds.has(s.id));
                return { ...p, hasActiveSession };
            });
        }
        case "create_project": {
            const project = handleCreateProject(store, asObject(body));
            safePublish(publishMetaEvent, logger, {
                type: "project_created",
                projectId: project.id,
            });
            return project;
        }
        case "update_project": {
            const project = handleUpdateProject(store, id, asObject(body));
            safePublish(publishMetaEvent, logger, {
                type: "project_renamed",
                projectId: project.id,
            });
            return project;
        }
        case "delete_project": {
            // Order matters: tear down live streams BEFORE the row is gone so
            // the bridges don't try to write to a deleted FK target. Mirrors
            // the ordering the deleted Hono route used.
            const project = store.getProject(id);
            if (project) {
                const sessions = store.listSessionsByProject(id);
                manager.disposeProjectStreams(sessions.map((s) => s.id));
            }
            const result = handleDeleteProject(store, id);
            // Publish AFTER the cascade has committed. We use `id` (the URL
            // param) as the projectId — `handleDeleteProject` would have
            // thrown 404 above if it didn't exist.
            safePublish(publishMetaEvent, logger, {
                type: "project_deleted",
                projectId: id,
            });
            return result;
        }
        case "bind_studio": {
            const result = await handleBindStudioProject(store, id, asObject(body));
            safePublish(publishMetaEvent, logger, {
                type: "project_studio_bound",
                projectId: id,
                studioProjectId: result.studioProjectId,
                studioProjectName: result.studioProjectName,
            });
            return result;
        }
        case "list_project_sessions": {
            const activeIds = manager.getActiveTurnSessionIds();
            const sessions = handleListSessionsByProject(store, id);
            return sessions.map((s) => ({
                ...s,
                hasActiveTurn: activeIds.has(s.id),
            }));
        }
        case "create_session": {
            const session = handleCreateSession(store, asObject(body));
            safePublish(publishMetaEvent, logger, {
                type: "session_created",
                projectId: session.projectId,
                sessionId: session.id,
            });
            return session;
        }
        case "get_session":
            return handleGetSessionDetail(store, id);
        case "get_session_memory":
            return handleGetSessionMemoryStatus(store, manager, id);
        case "get_session_memory_details":
            return handleGetSessionMemoryDetails(store, manager, id);
        case "update_session": {
            const session = handleUpdateSession(store, id, asObject(body));
            safePublish(publishMetaEvent, logger, {
                type: "session_renamed",
                projectId: session.projectId,
                sessionId: session.id,
            });
            return session;
        }
        case "delete_session": {
            // Capture projectId BEFORE the row is gone — otherwise the
            // post-delete publish has no project to attribute to. Treat a
            // missing session here the same way `handleDeleteSession` does
            // (throws NotFoundError → 404), but capture first.
            const detail = store.getSession(id);
            // Same ordering rationale as delete_project.
            manager.disposeSessionStream(id);
            handleDeleteSession(store, id);
            if (detail) {
                safePublish(publishMetaEvent, logger, {
                    type: "session_deleted",
                    projectId: detail.projectId,
                    sessionId: id,
                });
            }
            return { ok: true };
        }
        case "compact_session":
            return await handleCompactSession(store, manager, id);
        case "remember_and_delete_session": {
            // Compact first — the compaction hook's persistProjectObservations
            // runs inside compactSession, writing reflections to cross-session
            // durable memory. On failure (e.g. no API key), we let the error
            // propagate so the session is NOT deleted.
            await handleRememberAndDeleteSession(store, manager, id);
            const detail = store.getSession(id);
            manager.disposeSessionStream(id);
            handleDeleteSession(store, id);
            if (detail) {
                safePublish(publishMetaEvent, logger, {
                    type: "session_deleted",
                    projectId: detail.projectId,
                    sessionId: id,
                });
            }
            return { ok: true };
        }
        case "fork_session": {
            const session = handleForkSession(store, id, asObject(body));
            safePublish(publishMetaEvent, logger, {
                type: "session_created",
                projectId: session.projectId,
                sessionId: session.id,
            });
            return session;
        }
        case "list_path_autocomplete": {
            const prefix = match.query?.get("prefix") ?? "";
            return handlePathAutocomplete(prefix);
        }
        case "enqueue_prompt":
            return handleEnqueuePrompt(store, manager, id, asObject(body));
        case "get_prompt_queue":
            return handleGetPromptQueue(store, id);
        case "remove_prompt":
            handleRemovePrompt(store, manager, id, match.itemId ?? "");
            return { ok: true };
        case "clear_prompt_queue":
            handleClearPromptQueue(store, manager, id);
            return { ok: true };
        case "list_mcp_status": {
            if (!deps.mcpState) {
                return [];
            }
            return handleListMcpStatus(deps.mcpState);
        }
        case "get_settings":
            return handleGetSettings(deps.cwd ?? homedir());
        case "put_settings":
            return handlePutSettings(deps.cwd ?? homedir(), asObject(body));
    }
}
/**
 * Invoke `publishMetaEvent` if provided, swallowing any throw. The
 * caller has already returned a 200 to the browser; a publish failure
 * (e.g. relay socket just dropped) must NEVER turn a successful
 * mutation into a wire-level error.
 */
function safePublish(publish, logger, event) {
    if (!publish)
        return;
    try {
        publish(event);
    }
    catch (err) {
        (logger ?? console).error?.(`[dispatcher] publishMetaEvent failed for ${event.type}:`, err);
    }
}
/**
 * Coerce an `unknown` body to a plain object for the handler. Returns an
 * empty object when the body is missing/non-object so the handler's own
 * field checks produce the canonical error message ("name (string) is
 * required") instead of a confusing "body must be object" from us.
 */
function asObject(body) {
    if (body !== null && typeof body === "object" && !Array.isArray(body)) {
        return body;
    }
    return {};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Check whether a raw `images` array from the wire has at least one entry. */
function hasImages(raw) {
    if (!Array.isArray(raw) || raw.length === 0)
        return false;
    return raw.some((i) => i !== null &&
        typeof i === "object" &&
        typeof i.data === "string" &&
        typeof i.mimeType === "string");
}
/**
 * Coerce a raw wire `images` payload into `ImageAttachment[]` or
 * `undefined`. Filters out malformed entries (missing data/mimeType).
 */
function coerceImages(raw) {
    if (!Array.isArray(raw))
        return undefined;
    const out = [];
    for (const i of raw) {
        if (i !== null &&
            typeof i === "object" &&
            typeof i.data === "string" &&
            typeof i.mimeType === "string") {
            out.push({
                data: i.data,
                mimeType: i.mimeType,
                width: typeof i.width === "number"
                    ? i.width
                    : undefined,
                height: typeof i.height === "number"
                    ? i.height
                    : undefined,
            });
        }
    }
    return out.length > 0 ? out : undefined;
}
/**
 * Build a `Subscriber` that wraps each `ServerEvent` in a `WsEventFrame`
 * and pushes it through the relay. `isOpen()` defers to the relay's
 * connection state — we treat queue-while-disconnected as "still open"
 * since `RelayClient.send()` will buffer and flush on reconnect.
 */
function makeRelaySubscriber(sessionId, relay) {
    return {
        send(event) {
            relay.send({
                kind: "ws_event",
                sessionId,
                event,
            });
        },
        isOpen() {
            // The relay client buffers while reconnecting; from the manager's
            // POV the subscriber is always reachable until serve.ts detaches
            // it. Returning `true` here means the manager won't garbage-collect
            // the subscriber on transient socket blips.
            return true;
        },
    };
}
/**
 * Dispatch a `client_message` frame. Idempotent w.r.t. attach: the same
 * `Subscriber` is reused across messages for a given session.
 *
 * Errors:
 *  - Unknown sessionId → emits a `ws_event` carrying an `error`-typed
 *    `ServerEvent` so the browser sees it on the established stream
 *    (mirrors how the old WS route surfaced `manager.attach` failures).
 *  - Unknown message shape → silently ignored with a logger warning;
 *    the wire contract is `{type:"user_message", content:string}` and
 *    anything else is a protocol violation we don't escalate.
 *  - `manager.prompt()` rejection → logged; the manager itself broadcasts
 *    an `error` event to subscribers, so we don't double-report.
 */
export function handleClientMessage(frame, deps) {
    const { sessionId, message, modelId, reasoningEffort } = frame;
    const { manager, relay, subscribers } = deps;
    const logger = deps.logger ?? console;
    // 0. Shutdown gate. Once `gracefulShutdown` flips the flag we refuse
    //    new turns immediately — even if the relay socket is still open
    //    and even if the session already has a subscriber attached. The
    //    error surfaces on the same `ws_event` channel the browser is
    //    already listening on, so the UI sees it on the established
    //    stream and can show a "server shutting down" message instead of
    //    sitting on a perpetual spinner.
    //
    //    We do NOT detach the subscriber here — pre-existing in-flight
    //    turns still need to flush their final events before the grace
    //    window closes.
    if (shutdownState.isShuttingDown) {
        relay.send({
            kind: "ws_event",
            sessionId,
            event: {
                type: "error",
                message: "Server is shutting down; please retry shortly.",
            },
        });
        return;
    }
    // 1. Validate inner message shape. Only `user_message` is supported
    //    today; reject anything else loudly-but-locally.
    //    `content` is a required string but may be empty when the user
    //    sends only images (e.g. pastes a screenshot without text).
    if (message === null ||
        typeof message !== "object" ||
        message.type !== "user_message" ||
        typeof message.content !== "string") {
        logger.error?.(`[dispatcher] ignoring malformed client_message for ${sessionId}`);
        return;
    }
    // Reject messages that have neither content nor images — an empty
    // message is always a client bug, and we don't want to persist a
    // meaningless turn.
    if (!message.content.trim() &&
        !hasImages(message.images)) {
        logger.error?.(`[dispatcher] ignoring empty client_message for ${sessionId}`);
        return;
    }
    const content = message.content;
    const isLoop = message.loop === true;
    const loopMaxIterations = message.loopMaxIterations;
    const loopGoal = message.loopGoal;
    const validImages = coerceImages(message.images);
    // Set autonomous iterative loop state before firing the prompt.
    // loop:true  → start/renew loop with the current content as original prompt;
    // loop:false → stop any active loop.
    manager.setLoopActive(sessionId, isLoop, content, loopMaxIterations, loopGoal);
    // 2. Attach (idempotent). On first attach we capture the replay payload
    //    and synthesize a `session_ready` ws_event so the browser sees the
    //    same first frame it would have on a direct WS connection.
    let subscriber = subscribers.get(sessionId);
    if (!subscriber) {
        subscriber = makeRelaySubscriber(sessionId, relay);
        let attachResult;
        try {
            attachResult = manager.attach(sessionId, subscriber);
        }
        catch (err) {
            // Unknown session — surface as a ws_event so the browser's stream
            // handler sees it. Don't keep the subscriber around.
            const msg = err instanceof Error ? err.message : String(err);
            relay.send({
                kind: "ws_event",
                sessionId,
                event: { type: "error", message: msg },
            });
            return;
        }
        subscribers.set(sessionId, subscriber);
        // Synthesize the initial frame the WS route used to send. The
        // browser's protocol layer expects `session_ready` as the first
        // event on a new stream.
        relay.send({
            kind: "ws_event",
            sessionId,
            event: {
                type: "session_ready",
                sessionId,
                history: attachResult.history,
                currentTurn: attachResult.currentTurn,
                forkCompactPending: attachResult.forkCompactPending || undefined,
                contextWindowUsed: attachResult.contextWindowUsed,
                contextWindowMax: attachResult.contextWindowMax,
            },
        });
        // Surface bridge-start failures as `error` events; otherwise the
        // browser would sit on a `session_ready` with no further frames.
        attachResult.ready.catch((err) => {
            const msg = err instanceof Error ? err.message : String(err);
            relay.send({
                kind: "ws_event",
                sessionId,
                event: { type: "error", message: msg },
            });
        });
    }
    // 3. Fire the prompt. `prompt()` resolves on `agent_end`; errors are
    //    converted to `error` events by the manager itself, so we just log.
    //    `modelId` (Phase 3 — Available Models whitelist) is forwarded as
    //    sticky next-prompt selection. The backend has already validated it
    //    against the team-scoped whitelist; the CLI resolves it via spectral's
    //    own model registry inside the manager → bridge.
    //
    //    When `loop: true`, loop state is set before prompting; the normal
    //    model is used — loop replay is handled by session-stream on agent_end.
    manager.prompt(sessionId, content, modelId, validImages, reasoningEffort).catch((err) => {
        logger.error?.(`[dispatcher] manager.prompt failed for ${sessionId}:`, err);
    });
}
/**
 * Dispatch a `subscribe` frame from the backend. Handles the case where a
 * browser enters an old session — we load history from SQLite immediately
 * via `manager.attach()` and synthesize `session_ready` so the browser sees
 * the full chat history without needing to send a first `client_message`.
 *
 * Idempotent: if a subscriber already exists for this session, we re-send
 * `session_ready` (history may have changed, and the newly-joined browser
 * tab needs it). The `ready.catch` handler is only registered once — on the
 * first subscriber creation — to avoid duplicate error events.
 */
export function handleSubscribe(frame, deps) {
    const { sessionId } = frame;
    const { manager, relay, subscribers } = deps;
    const logger = deps.logger ?? console;
    let subscriber = subscribers.get(sessionId);
    let isNewSubscriber = false;
    if (!subscriber) {
        subscriber = makeRelaySubscriber(sessionId, relay);
        subscribers.set(sessionId, subscriber);
        isNewSubscriber = true;
    }
    let attachResult;
    try {
        attachResult = manager.attach(sessionId, subscriber);
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        relay.send({
            kind: "ws_event",
            sessionId,
            event: { type: "error", message: msg },
        });
        return;
    }
    // Send history to all browser subscribers. The backend's fan-out
    // delivers this to the newly-subscribed tab (and any others).
    relay.send({
        kind: "ws_event",
        sessionId,
        event: {
            type: "session_ready",
            sessionId,
            history: attachResult.history,
            currentTurn: attachResult.currentTurn,
            forkCompactPending: attachResult.forkCompactPending || undefined,
            contextWindowUsed: attachResult.contextWindowUsed,
            contextWindowMax: attachResult.contextWindowMax,
        },
    });
    if (isNewSubscriber) {
        // Surface bridge-start failures as `error` events; otherwise the
        // browser would sit on a `session_ready` with no further frames.
        attachResult.ready.catch((err) => {
            const msg = err instanceof Error ? err.message : String(err);
            relay.send({
                kind: "ws_event",
                sessionId,
                event: { type: "error", message: msg },
            });
        });
    }
}
/**
 * Dispatch a `cancel_turn` frame. Disposes the session's agent bridge and
 * removes the stream so the next user message creates a fresh one. The
 * bridge dispose triggers `agent_end` broadcast to all subscribers.
 *
 * Idempotent: no-ops when no stream exists for the session.
 */
export function handleCancelTurn(frame, deps) {
    deps.manager.cancelTurn(frame.sessionId);
}
/**
 * Detach every subscriber the dispatcher has attached. Called by
 * `serve.ts` on relay disconnect / shutdown so the underlying spectral
 * processes don't keep an unreachable subscriber pinned.
 *
 * NOTE: this does NOT dispose the streams themselves — spectral keeps running
 * so a future browser reconnect can resume mid-turn. Use
 * `manager.dispose()` at full shutdown.
 */
export function detachAllSubscribers(manager, subscribers) {
    for (const [sessionId, sub] of subscribers) {
        try {
            manager.detach(sessionId, sub);
        }
        catch {
            // ignore — best-effort cleanup
        }
    }
    subscribers.clear();
}
/**
 * Dispatch an `auto_research` frame. Sends the auto-research task through
 * the existing AgentBridge (backend proxy) instead of spawning a separate spectral
 * subprocess. This ensures auto-research uses the same model and API keys
 * as the active session.
 *
 * Progress is streamed back as `ws_event` frames carrying
 * `auto_research_*` ServerEvent types on the `sessionId` channel.
 *
 * Errors are surfaced as `auto_research_error` events.
 */
export function handleAutoResearchFrame(frame, deps) {
    handleAutoResearch({
        projectId: frame.projectId,
        sessionId: frame.sessionId,
    }, deps);
}
