/**
 * PilotDeck bridge — the only chat-execution entry point in `ui/server/`.
 *
 *
 *   1. Connects to the standalone PilotDeck gateway server
 *      (`pilotdeck server`, default ws://127.0.0.1:18789/ws) as a
 *      WebSocket client. We never instantiate an in-process gateway
 *      here — that would create a second, divergent agent runtime that
 *      doesn't share `~/.pilotdeck/projects/<id>/chats/*.jsonl` writes
 *      and permission state with the CLI/TUI surfaces. One process, one
 *      gateway.
 *   2. Maps each old "sessionId" → PilotDeck "sessionKey" (1:1, generated
 *      on first turn and remembered for resume).
 *   3. Translates GatewayEvent → NormalizedMessage and writes back via
 *      `writer.send(...)` so the existing UI rendering pipeline stays
 *      unchanged.
 *   4. Tracks active runs so `abort-session` and the `complete` ack work.
 *
 * Anything that is NOT chat execution (project listing, files, git, mcp,
 * skills, taskmaster, memory, cron management) still runs through the
 * existing `ui/server/` route handlers — those are local/disk operations
 * that do not need an agent runtime.
 *
 * Two-process launch:
 *
 *   - `pilotdeck server` (port 18789) owns the gateway, agent loop,
 *     model router, MCP runtime, cron daemon, and on-disk session
 *     transcripts. Edit `src/**` then restart this process to pick up
 *     changes — no `npm run build` required when running via `tsx`.
 *   - `ui/server/index.js` (port 3001) is the express bridge: REST
 *     endpoints for non-agent UI concerns + a WebSocket adapter that
 *     re-shapes gateway events into the legacy NormalizedMessage frames
 *     the React frontend reducer still expects.
 *
 * The pair is started together via `cd ui && npm run dev` (or
 * `npm start`), which uses `concurrently` to launch both. Either order
 * is fine — the bridge retries the WebSocket handshake for
 * `GATEWAY_CONNECT_TIMEOUT_MS` so race conditions resolve themselves.
 */

import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';
import { promises as fsPromises } from 'node:fs';
import { randomUUID } from 'node:crypto';

import { installGlobalProxy } from '../../src/cli/proxy.js';
installGlobalProxy();

import { resolvePilotHome, createProjectId, sanitizeSessionIdForPath } from './utils/pilotPaths.js';
// Read the gateway client straight from TypeScript source via tsx — the UI
// server is launched with `node --import tsx`, so no prior `npm run build`
// is required. (A prior tsx 4.x JSDoc dynamic-import parse bug was fixed by
// rewriting the offending @type annotation below to `ReturnType<typeof
// createRemoteGateway>`, which is why this import can live on `src/` again.)
import { createRemoteGateway } from '../../src/gateway/index.js';
import { createNormalizedMessage } from './pilotdeck-message.js';
import { readPermissionSettings } from './services/permissionSettings.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const GENERAL_HOME = resolvePilotHome(process.env);

const GATEWAY_URL =
    process.env.PILOTDECK_GATEWAY_URL || 'ws://127.0.0.1:18789/ws';
const GATEWAY_TOKEN_PATH =
    process.env.PILOTDECK_GATEWAY_TOKEN_PATH ||
    path.join(GENERAL_HOME, 'server-token');
// The two processes (gateway + bridge) are typically started in
// parallel by `concurrently`. We allow up to 30 s for the gateway to
// come up before failing the first call — covers cold MCP startup on
// slower machines.
const GATEWAY_CONNECT_TIMEOUT_MS = 30_000;
const GATEWAY_CONNECT_RETRY_INTERVAL_MS = 250;
const subagentActivityStarts = new Map();

function normalizeToolDisplayName(name) {
    const aliases = {
        agent: 'Task',
        ask_user_question: 'AskUserQuestion',
        bash: 'Bash',
        edit_file: 'Edit',
        glob: 'Glob',
        grep: 'Grep',
        read_file: 'Read',
        write_file: 'Write',
    };
    if (aliases[name]) return aliases[name];
    if (name === 'todo_write') return 'TodoWrite';
    if (name === 'todo_read') return 'TodoRead';
    return name;
}

function isPlanModeToolDenyText(text) {
    return typeof text === 'string' && /plan mode denies side-effecting tool\b/i.test(text);
}

function normalizeToolErrorCode(errorCode, resultPreview) {
    if (isPlanModeToolDenyText(resultPreview)) return 'plan_mode_denied';
    return errorCode;
}

/**
 * Default permission mode for sessions started from the Web UI. We use
 * `default` so PilotDeck's `Permission.decide()` fully evaluates rules
 * + tool semantics — read-only tools allow, side-effecting tools either
 * surface an interactive `permission_request` (resolved via the banner)
 * or short-circuit on an allow rule the user accumulated this session.
 * Override with `PILOTDECK_WEB_PERMISSION_MODE`.
 */
const WEB_DEFAULT_PERMISSION_MODE =
    process.env.PILOTDECK_WEB_PERMISSION_MODE || 'default';


// Resolves to the Gateway returned by `createRemoteGateway`. We express
// the type via `typeof createRemoteGateway` (the symbol is already imported
// above) instead of a JSDoc dynamic-import annotation, because some tsx 4.x
// builds mis-parse such tokens inside JSDoc when running through
// `node --import tsx`, producing a spurious "Parse error" at EOF during
// ESM rewriting on fresh installs.
/** @type {ReturnType<typeof createRemoteGateway> | null} */
let gatewayPromise = null;

async function readGatewayToken() {
    try {
        const raw = await fsPromises.readFile(GATEWAY_TOKEN_PATH, 'utf8');
        const trimmed = raw.trim();
        return trimmed || null;
    } catch {
        return null;
    }
}

async function connectWithRetry() {
    const deadline = Date.now() + GATEWAY_CONNECT_TIMEOUT_MS;
    let lastError;
    while (Date.now() < deadline) {
        const token = await readGatewayToken();
        if (token) {
            try {
                const gateway = await createRemoteGateway({
                    url: GATEWAY_URL,
                    token,
                    clientName: 'web',
                });
                console.log(
                    `[pilotdeck-bridge] connected → ${GATEWAY_URL}`,
                );
                return gateway;
            } catch (error) {
                lastError = error;
            }
        }
        await new Promise((resolve) =>
            setTimeout(resolve, GATEWAY_CONNECT_RETRY_INTERVAL_MS),
        );
    }
    const detail = lastError instanceof Error ? `: ${lastError.message}` : '';
    throw new Error(
        `[pilotdeck-bridge] gateway connect failed after ${GATEWAY_CONNECT_TIMEOUT_MS}ms${detail}`,
    );
}

function ensureGateway() {
    if (!gatewayPromise) {
        gatewayPromise = connectWithRetry().catch((error) => {
            // Reset so the next caller retries instead of cementing the
            // failure forever. The deadline inside connectWithRetry()
            // already bounds individual attempts.
            gatewayPromise = null;
            throw error;
        });
    }
    return gatewayPromise;
}

/**
 * Public accessor for the shared gateway client. Other ui/server modules
 * (`projects.js`, etc.) await this so they share one WebSocket
 * connection instead of opening their own.
 */
export async function getPilotDeckGateway() {
    return ensureGateway();
}

export function getPilotDeckRepoRoot() {
    return REPO_ROOT;
}

/**
 * Per-session bookkeeping kept locally so abort + permission flows can
 * find their target without round-tripping to the gateway just to
 * resolve a sessionId. The gateway is still the source of truth for
 * the transcript and the agent state machine.
 */
const sessionState = new Map();

function isPilotDeckSessionKey(value) {
    return typeof value === 'string' && /^web[:_-]s_/.test(value);
}

function newSessionKey() {
    // On Windows, colons are illegal in filenames. The on-disk session file
    // is named after the key via sanitizeSessionIdForPath which replaces ':'
    // with '-'. Using '-' from the start avoids a mismatch between the key
    // the frontend holds (from session_created) and the key the session
    // listing returns (from the filename).
    const sep = process.platform === 'win32' ? '-' : ':';
    return `web${sep}s_${randomUUID()}`;
}

function ensureSessionState(sessionKey, projectKey, channelKey) {
    let state = sessionState.get(sessionKey);
    if (!state) {
        state = {
            sessionKey,
            projectKey,
            channelKey,
            runId: undefined,
            active: false,
            tokenBudget: null,
        };
        sessionState.set(sessionKey, state);
    } else {
        state.projectKey = projectKey;
        state.channelKey = channelKey;
    }
    return state;
}

function clearActiveRunIfCurrent(state, runId) {
    if (!state || state.runId !== runId) return;
    state.active = false;
    state.runId = undefined;
}

export function getSessionTokenBudget(sessionKey) {
    const state = sessionState.get(sessionKey);
    return state?.tokenBudget || {
        used: 0,
        total: 0,
        unknown: true,
    };
}

/**
 * Convert UI-shape image attachments into Gateway-shape ChannelAttachment[].
 *
 * UI sends:
 *   { name, data: 'data:image/png;base64,XXX', size, mimeType }
 *
 * Gateway expects ChannelAttachment:
 *   { type: 'image', name, mimeType, content: <raw base64, no data: prefix>, bytes }
 *
 * The bare-base64 form matches how `CanonicalImageBlock` and the
 * AttachmentResolver store the payload elsewhere in the codebase.
 *
 * Returns undefined when there's nothing to forward — so callers can
 * spread it conditionally without injecting an empty array.
 *
 * @param {unknown} images
 * @returns {Array<{type:'image',name?:string,mimeType:string,content:string,bytes?:number}>|undefined}
 */
function uiImagesToAttachments(images) {
    if (!Array.isArray(images) || images.length === 0) return undefined;
    const out = [];
    for (const img of images) {
        if (!img || typeof img !== 'object') continue;
        const raw = typeof img.data === 'string' ? img.data : '';
        if (!raw) continue;
        // Accept both bare base64 and full data URLs. We pluck the
        // declared mime out of the data URL when the caller did not
        // pass one explicitly, since we can't reliably guess otherwise.
        const dataUrlMatch = raw.match(/^data:([^;]+);base64,(.*)$/);
        const mimeType = String(img.mimeType || dataUrlMatch?.[1] || 'image/png');
        const base64 = dataUrlMatch ? dataUrlMatch[2] : raw;
        if (!base64) continue;
        out.push({
            type: 'image',
            name: typeof img.name === 'string' ? img.name : undefined,
            mimeType,
            content: base64,
            ...(typeof img.size === 'number' ? { bytes: img.size } : {}),
        });
    }
    return out.length > 0 ? out : undefined;
}

function uiFilesToAttachments(files) {
    if (!Array.isArray(files) || files.length === 0) return undefined;
    const out = [];
    for (const file of files) {
        if (!file || typeof file !== 'object') continue;
        const filePath = typeof file.path === 'string' ? file.path : '';
        if (!filePath) continue;
        out.push({
            type: 'file',
            name: typeof file.name === 'string' ? file.name : undefined,
            path: filePath,
            mimeType: typeof file.mimeType === 'string' ? file.mimeType : undefined,
            ...(typeof file.size === 'number' ? { bytes: file.size } : {}),
        });
    }
    return out.length > 0 ? out : undefined;
}

function resolvePermissionMode(options) {
    const explicit = options?.permissionMode || options?.mode;
    // A literal "default" from the chat composer is the implicit
    // no-special-mode position of the per-turn picker, not a real
    // per-turn override. Let the user-level skipPermissions toggle
    // win over it. Genuine non-default picks (plan / acceptEdits /
    // bypassPermissions / dontAsk) still take precedence — they're a
    // deliberate per-turn decision.
    if (explicit && explicit !== 'default') return explicit;
    const persisted = readPermissionSettings();
    if (persisted.skipPermissions === true) {
        return 'bypassPermissions';
    }
    return explicit || WEB_DEFAULT_PERMISSION_MODE;
}

/**
 * Map a `GatewayEvent` to one or more legacy `NormalizedMessage` frames.
 *
 * @param {object} event Gateway event payload.
 * @param {string} sessionId UI-facing session id.
 * @param {string} provider Provider hint (pilotdeck).
 * @returns {object[]} NormalizedMessage frames.
 */
export function gatewayEventToFrames(event, sessionId, provider) {
    const base = { sessionId, provider };
    switch (event.type) {
        case 'turn_started':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'status',
                    text: 'started',
                }),
            ];
        case 'assistant_text_delta':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'stream_delta',
                    content: event.text,
                }),
            ];
        case 'assistant_thinking_delta':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'thinking',
                    content: event.text,
                }),
            ];
        case 'tool_call_started':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'tool_use',
                    toolId: event.toolCallId,
                    toolName: normalizeToolDisplayName(event.name),
                    toolInput: tryParseJson(event.argsPreview),
                }),
            ];
        case 'tool_call_finished': {
            const normalizedErrorCode = normalizeToolErrorCode(event.errorCode, event.resultPreview);
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'tool_result',
                    toolId: event.toolCallId,
                    content: event.resultPreview ?? '',
                    isError: !event.ok,
                    // errorCode lets the UI distinguish permission denials
                    // (`permission_denied` / `permission_required`) from
                    // ordinary execution failures (`tool_execution_failed`,
                    // `file_not_found`, …) so the "Add to Allowed Tools"
                    // affordance only fires for the former.
                    ...(normalizedErrorCode && { errorCode: normalizedErrorCode }),
                    // Inline tool-result images (e.g. read_file on a PNG).
                    // The wire shape uses raw base64; we wrap as data URLs
                    // here so the UI can drop them straight into <img src>.
                    ...(Array.isArray(event.images) && event.images.length > 0
                        ? {
                              toolResultImages: event.images.map((image) => ({
                                  data: `data:${image.mimeType};base64,${image.data}`,
                                  mimeType: image.mimeType,
                              })),
                          }
                        : {}),
                    ...(event.toolName === 'exit_plan_mode' && event.data?.planFilePath
                        ? {
                              planFilePath: event.data.planFilePath,
                              planTitle: event.data.planTitle,
                              planSummary: event.data.planSummary,
                          }
                        : {}),
                }),
            ];
        }
        case 'permission_request':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'permission_request',
                    requestId: event.requestId,
                    toolName: event.toolName,
                    input: event.payload,
                    context: { provider },
                }),
            ];
        case 'elicitation_request':
            // Route structured elicitation through the same `permission_request`
            // shape the UI already uses for the permission banner, so the
            // registered `AskUserQuestion` PermissionPanel (rich multi-step
            // multi-select dialog) renders inline in the chat instead of the
            // legacy "wait in CLI" yellow box. We force `toolName` to the
            // PascalCase alias that matches `registerPermissionPanel('AskUserQuestion', ...)`
            // and tag the frame with `isElicitation: true` so the composer can
            // route the user's answer back through `elicitation-response`
            // (GatewayPermissionBus).
            if (event.toolName === 'exit_plan_mode') {
                return [
                    createNormalizedMessage({
                        ...base,
                        kind: 'permission_request',
                        requestId: event.requestId,
                        toolCallId: event.toolCallId,
                        toolName: 'ExitPlanModeV2',
                        input: {
                            plan: event.metadata?.plan,
                            planFilePath: event.metadata?.planFilePath,
                            questions: event.questions,
                            metadata: event.metadata,
                        },
                        context: { provider, originalToolName: event.toolName },
                        isElicitation: true,
                    }),
                ];
            }
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'permission_request',
                    requestId: event.requestId,
                    toolCallId: event.toolCallId,
                    toolName: 'AskUserQuestion',
                    input: {
                        questions: event.questions,
                        metadata: event.metadata,
                    },
                    context: { provider, originalToolName: event.toolName },
                    isElicitation: true,
                }),
            ];
        case 'elicitation_cancelled':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'permission_cancelled',
                    requestId: event.requestId,
                }),
            ];
        case 'structured_output':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'status',
                    text: 'structured',
                    payload: event.payload,
                }),
            ];
        case 'plan_mode_changed':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'status',
                    text: `mode:${event.mode}`,
                }),
            ];
        case 'turn_completed':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'complete',
                    exitCode: 0,
                    success: true,
                    finishReason: event.finishReason,
                    usage: event.usage,
                }),
            ];
        case 'context_budget':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'status',
                    text: 'token_budget',
                    tokenBudget: {
                        used: event.used,
                        total: event.total,
                        ratio: event.ratio,
                        state: event.state,
                    },
                }),
            ];
        case 'error':
            return [
                createNormalizedMessage({
                    ...base,
                    kind: 'error',
                    content: event.message,
                    code: event.code,
                    recoverable: event.recoverable,
                }),
            ];
        case 'agent_status': {
            const subagentFrame = createSubagentStatusFrame(event, base);
            if (subagentFrame) return [subagentFrame];

            const detail = event.detail || {};
            if (event.event === 'compact_started') {
                const compactProgress = {
                    level: detail.level || 1,
                    stage: detail.stage || 'compacting',
                    label: detail.label || detail.stage || 'Compacting',
                    state: 'running',
                    pre_tokens: detail.preTokens,
                    reason: detail.trigger,
                };
                return [
                    createNormalizedMessage({
                        ...base,
                        kind: 'status',
                        text: 'compacting',
                        tokens: 0,
                        canInterrupt: true,
                        compactProgress,
                    }),
                ];
            }
            if (event.event === 'compact_completed') {
                return [
                    createNormalizedMessage({
                        ...base,
                        kind: 'compact_boundary',
                        trigger: detail.trigger || 'auto',
                        preTokens: detail.preTokens,
                        compactLevel: detail.level,
                        compactStage: detail.stage,
                        compactStageLabel: detail.stageLabel || detail.stage,
                        compactMetadata: detail,
                    }),
                ];
            }
            return [];
        }
        default:
            return [];
    }
}

function createSubagentStatusFrame(event, base) {
    const detail = event?.detail || {};
    const visibleEvents = [
        'subagent_started',
        'subagent_completed',
        'subagent_status',
    ];
    const hiddenEvents = [
        'subagent_text_delta',
        'subagent_thinking_delta',
        'subagent_tool_call_started',
        'subagent_tool_result',
        'subagent_model_error',
    ];
    if (hiddenEvents.includes(event?.event)) {
        return null;
    }
    if (!visibleEvents.includes(event?.event)) return null;

    const subagentId = String(detail.subagentId || 'unknown');
    const status = normalizeSubagentStatus(event.event, detail);
    const subagentType = detail.subagentType || 'agent';
    const activityKey = `${base.sessionId || ''}:${subagentId}`;
    const nowMs = Date.now();
    const reportedDurationMs = Number(detail.durationMs);
    let startedAtMs = subagentActivityStarts.get(activityKey);
    if (event.event === 'subagent_started' || !startedAtMs) {
        startedAtMs = Number.isFinite(reportedDurationMs) && reportedDurationMs > 0
            ? nowMs - reportedDurationMs
            : nowMs;
        subagentActivityStarts.set(activityKey, startedAtMs);
    }

    const durationMs = Number.isFinite(reportedDurationMs) && reportedDurationMs >= 0
        ? reportedDurationMs
        : Math.max(0, nowMs - startedAtMs);
    const isDone = status === 'completed' || status === 'failed';
    const title = formatSubagentActivityTitle(subagentType, status);
    const activity = createNormalizedMessage({
        ...base,
        id: `subagent_activity_${sanitizeMessageId(base.sessionId)}_${sanitizeMessageId(subagentId)}`,
        kind: 'agent_activity',
        activityId: `subagent:${subagentId}`,
        runId: `subagent:${subagentId}`,
        phase: 'subagent',
        state: status,
        title,
        detail: '',
        startedAt: new Date(startedAtMs).toISOString(),
        endedAt: isDone ? new Date(nowMs).toISOString() : null,
        durationMs,
        severity: status === 'failed' ? 'error' : undefined,
        toolName: 'agent',
    });
    if (isDone) {
        subagentActivityStarts.delete(activityKey);
    }
    return activity;
}

function formatSubagentActivityTitle(subagentType, status) {
    if (status === 'completed') {
        return `Subagent ${subagentType} completed`;
    }
    if (status === 'failed') {
        return `Subagent ${subagentType} failed`;
    }
    return `Subagent ${subagentType} running`;
}

function normalizeSubagentStatus(eventName, detail) {
    if (eventName === 'subagent_completed') {
        return detail.success === false ? 'failed' : 'completed';
    }
    return 'running';
}

function sanitizeMessageId(value) {
    return String(value || 'unknown').replace(/[^a-zA-Z0-9_.:-]/g, '_');
}

function tryParseJson(value) {
    if (typeof value !== 'string' || !value) return undefined;
    try {
        return JSON.parse(value);
    } catch {
        return value;
    }
}

/**
 * Run a chat command through the PilotDeck gateway.
 *
 * The frontend addresses sessions by the PilotDeck `sessionKey` itself
 * (`web:s_<uuid>`). On the first turn we mint a key and announce it via
 * a `session_created` frame; the frontend stores that and uses it on
 * every subsequent turn (and after page refresh, since the URL embeds
 * it). The transcript on disk is named after the same key, so
 * `/api/sessions/<sessionKey>/messages` resolves cleanly.
 *
 * Permission grants accumulated via the in-banner "Allow + remember"
 * action are stored server-side for the duration of the agent session
 * (see `createGatewayPermissionHook`) — `toolsSettings.allowedTools`
 * pre-population from the legacy settings panel is currently NOT
 * re-played here because the override map lives in another process.
 * That feature can be restored by extending `submitTurn` to carry an
 * optional `permissionAllow[]` payload; not needed for the common
 * banner-driven flow.
 *
 * @param {string} command User prompt text.
 * @param {object} options Legacy options blob from the WS frame.
 * @param {{send: (msg: object) => void}} writer Existing writer.
 * @param {string} provider Provider hint (kept for legacy frame branding).
 */
export async function runChatViaGateway(
    command,
    options = {},
    writer,
    provider = 'pilotdeck',
) {
    const gw = await ensureGateway();
    const projectKey = options.projectPath || options.cwd || GENERAL_HOME;
    const channelKey = 'web';

    const incoming = options.sessionId || options.sessionKey;
    const sessionKey = isPilotDeckSessionKey(incoming) ? incoming : newSessionKey();
    const isNewSession = sessionKey !== incoming;

    const state = ensureSessionState(sessionKey, projectKey, channelKey);

    // If a previous turn for this session is still in-flight (e.g. the
    // browser reloaded while a permission prompt was pending), abort it
    // before starting the new one. Without this the gateway rejects
    // with session_busy because the old turn's inFlightTurns slot is
    // still occupied.
    if (state.active && state.runId) {
        console.log(
            `[pilotdeck-bridge] aborting stale turn ${state.runId} for ${sessionKey} before resubmit`,
        );
        try {
            await gw.abortTurn({ sessionKey, runId: state.runId });
        } catch (err) {
            console.warn('[pilotdeck-bridge] stale abort failed (continuing):', err?.message || err);
        }
        state.active = false;
        state.runId = undefined;
    }

    if (isNewSession) {
        writer.send(
            createNormalizedMessage({
                provider,
                sessionId: sessionKey,
                kind: 'session_created',
                newSessionId: sessionKey,
                sessionKey,
            }),
        );
    }

    const runId = randomUUID();
    state.runId = runId;
    state.active = true;

    const attachments = [
        ...(uiImagesToAttachments(options?.images) || []),
        ...(uiFilesToAttachments(options?.attachments) || []),
    ];
    const resolvedMode = resolvePermissionMode(options);
    console.log(`[pilotdeck-bridge] submitTurn mode=${resolvedMode} (options.permissionMode=${options?.permissionMode}, options.mode=${options?.mode})`);

    try {
        const stream = gw.submitTurn({
            sessionKey,
            channelKey,
            projectKey,
            message: command ?? '',
            mode: resolvedMode,
            runId,
            ...(attachments.length > 0 ? { attachments } : {}),
            ...(options.workspaceCwd ? { workspaceCwd: options.workspaceCwd } : {}),
        });

        for await (const event of stream) {
            if (event && event.type === 'error') {
                console.error(
                    '[pilotdeck-bridge] gateway error event:',
                    JSON.stringify(
                        {
                            sessionKey,
                            projectKey,
                            runId,
                            code: event.code,
                            message: event.message,
                            recoverable: event.recoverable,
                        },
                        null,
                        2,
                    ),
                );
            }
            if (event && event.type === 'context_budget') {
                state.tokenBudget = {
                    used: event.used,
                    total: event.total,
                    ratio: event.ratio,
                    state: event.state,
                };
            }
            // Clear active flag as soon as we see turn_completed so that
            // a subsequent submitTurn from the user (who already sees the
            // input box) does NOT trigger the stale-abort path while we
            // wait for the async generator to fully close.
            if (event && event.type === 'turn_completed') {
                clearActiveRunIfCurrent(state, runId);
            }
            for (const frame of gatewayEventToFrames(event, sessionKey, provider)) {
                writer.send(frame);
            }
        }

        writer.send(
            createNormalizedMessage({
                provider,
                sessionId: sessionKey,
                kind: 'complete',
                exitCode: 0,
                success: true,
            }),
        );
    } catch (error) {

        console.error(
            '[pilotdeck-bridge] runChatViaGateway threw:',
            error instanceof Error ? (error.stack || error.message) : error,
        );
        writer.send(
            createNormalizedMessage({
                provider,
                sessionId: sessionKey,
                kind: 'error',
                content: error instanceof Error ? error.message : String(error),
            }),
        );
    } finally {
        clearActiveRunIfCurrent(state, runId);
    }
}

export async function abortViaGateway(sessionId, _provider = 'pilotdeck') {
    const gw = await ensureGateway();
    const sessionKey = isPilotDeckSessionKey(sessionId) ? sessionId : null;
    if (!sessionKey) return false;
    const state = sessionState.get(sessionKey);
    try {
        await gw.abortTurn({ sessionKey, runId: state?.runId });
        return true;
    } catch (error) {
        console.warn('[pilotdeck-bridge] abortTurn failed:', error);
        return false;
    }
}

export async function decidePermissionViaGateway(requestId, decision, options = {}) {
    const gw = await ensureGateway();
    // PermissionBus is keyed by sessionKey + requestId. We don't know
    // which session owns the request, so try each known session.
    for (const state of sessionState.values()) {
        try {
            const result = await gw.permissionDecide({
                sessionKey: state.sessionKey,
                requestId,
                decision: decision === 'allow' || decision === true ? 'allow' : 'deny',
                remember: options.remember,
                reason: options.reason,
            });
            if (result?.delivered) return true;
        } catch (error) {
            console.warn('[pilotdeck-bridge] permissionDecide failed:', error);
        }
    }
    return false;
}

export async function grantSessionPermissionViaGateway(sessionId, entry) {
    const gw = await ensureGateway();
    if (!isPilotDeckSessionKey(sessionId) || typeof entry !== 'string' || !entry.trim()) {
        return false;
    }
    try {
        const result = await gw.grantSessionPermission({
            sessionKey: sessionId,
            entry,
        });
        return Boolean(result?.granted);
    } catch (error) {
        console.warn('[pilotdeck-bridge] grantSessionPermission failed:', error);
        return false;
    }
}

export function isSessionActiveViaGateway(sessionId) {
    if (!isPilotDeckSessionKey(sessionId)) return false;
    return Boolean(sessionState.get(sessionId)?.active);
}

export async function getActiveTurnSnapshotFramesViaGateway(sessionId, provider = 'pilotdeck') {
    if (!isPilotDeckSessionKey(sessionId)) return [];
    const gw = await ensureGateway();
    if (typeof gw.getActiveTurnSnapshot !== 'function') return [];
    const snapshot = await gw.getActiveTurnSnapshot({ sessionKey: sessionId });
    if (!snapshot?.active || !Array.isArray(snapshot.events)) return [];
    return snapshot.events.flatMap((event) => gatewayEventToFrames(event, sessionId, provider) || []);
}

export function getActiveSessionIdsViaGateway() {
    return [...sessionState.values()]
        .filter((state) => state.active)
        .map((state) => state.sessionKey);
}

/**
 * Read persisted router stats from `~/.pilotdeck/router/stats.json`.
 * Falls back to the legacy `~/.pilotdeck/router-stats.json` path.
 *
 * Both the gateway server and this bridge run in different processes;
 * we no longer have an in-memory accessor (`getLocalGatewayRouterStats`
 * was tied to the bridge owning the gateway). The gateway server's
 * `TokenStatsCollector` periodically flushes to disk — this function
 * is the bridge's read-only window into that file.
 *
 * @returns {Map<string, {aggregate: object, records: object[]}>}
 */
/**
 * Build a sessionId->projectPath lookup from the filesystem.
 * Scans project chat directories under ~/.pilotdeck/projects/ and maps
 * each session filename back to the actual project path (resolved via
 * the .cwd marker or well-known directory names).
 *
 * @returns {{ sessionIndex: Map<string,string>, dirToPath: Map<string,string> }}
 */
function _buildSessionProjectIndex() {
    const sessionIndex = new Map();
    const dirToPath = new Map();
    try {
        const projectsDir = path.join(GENERAL_HOME, 'projects');
        const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
        for (const d of dirs) {
            if (!d.isDirectory()) continue;
            // Resolve actual project path from .cwd marker (handles lossy encoding)
            const cwdFile = path.join(projectsDir, d.name, '.cwd');
            try {
                const realPath = fs.readFileSync(cwdFile, 'utf-8').trim();
                if (realPath) dirToPath.set(d.name, realPath);
            } catch { /* no .cwd — will use fallback below */ }

            const chatsDir = path.join(projectsDir, d.name, 'chats');
            let files;
            try { files = fs.readdirSync(chatsDir); } catch { continue; }
            for (const f of files) {
                if (!f.endsWith('.jsonl')) continue;
                const sessionId = f.slice(0, -6);
                sessionIndex.set(sessionId, d.name);
            }
        }
    } catch { /* projects dir may not exist yet */ }
    return { sessionIndex, dirToPath };
}

function loadPersistedStatsFromDisk() {
    const result = new Map();
    try {
        // Prefer new JSONL format, fall back to legacy JSON.
        const jsonlPath = path.join(GENERAL_HOME, 'router', 'stats.jsonl');
        const jsonPath = path.join(GENERAL_HOME, 'router', 'stats.json');
        const legacyPath = path.join(GENERAL_HOME, 'router-stats.json');

        let records;
        if (fs.existsSync(jsonlPath)) {
            records = _loadRecordsFromJsonl(jsonlPath);
        } else {
            records = _loadRecordsFromJson(jsonPath, legacyPath);
        }
        if (!records || records.length === 0) return result;

        // Build a filesystem-based sessionId→projectDirName index for
        // backward compatibility (records written before projectPath existed).
        const { sessionIndex: fsIndex, dirToPath } = _buildSessionProjectIndex();
        const generalProjectDirName = createProjectId(GENERAL_HOME);

        const resolveProjectPath = (dirName) => {
            if (dirName === generalProjectDirName) return GENERAL_HOME;
            const fromCwd = dirToPath.get(dirName);
            if (fromCwd) return fromCwd;
            const repoProjectDirName = createProjectId(REPO_ROOT);
            if (dirName === repoProjectDirName) return REPO_ROOT;
            return GENERAL_HOME;
        };

        const byProject = new Map();

        for (const rec of records) {
            let projectKey = rec.projectPath;
            if (!projectKey) {
                const sessionId = rec.sessionId;
                if (sessionId) {
                    const safeId = sanitizeSessionIdForPath(sessionId);
                    const dirName = fsIndex.get(safeId) || fsIndex.get(sessionId);
                    if (dirName) {
                        projectKey = resolveProjectPath(dirName);
                    }
                }
            }
            if (!projectKey) projectKey = GENERAL_HOME;

            if (!byProject.has(projectKey)) {
                byProject.set(projectKey, []);
            }
            byProject.get(projectKey).push(rec);
        }

        for (const [projectKey, projRecords] of byProject.entries()) {
            projRecords.sort((a, b) => (a.startedAt || '').localeCompare(b.startedAt || ''));
            result.set(projectKey, {
                aggregate: {},
                records: projRecords.slice(-1000),
            });
        }
    } catch (err) {
        if (err?.code !== 'ENOENT') {
            console.warn('[router-dashboard] failed to load router stats:', err?.message || err);
        }
    }
    return result;
}

function _loadRecordsFromJsonl(filePath) {
    const raw = fs.readFileSync(filePath, 'utf-8');
    const records = [];
    for (const line of raw.split('\n')) {
        if (!line) continue;
        try {
            const rec = JSON.parse(line);
            if (rec?.sessionId && rec?.startedAt) records.push(rec);
        } catch { /* skip malformed */ }
    }
    return records;
}

function _loadRecordsFromJson(jsonPath, legacyPath) {
    const statsPath = fs.existsSync(jsonPath) ? jsonPath : legacyPath;
    const raw = fs.readFileSync(statsPath, 'utf-8');
    const parsed = JSON.parse(raw);
    if (!parsed?.sessions || typeof parsed.sessions !== 'object') return [];
    const records = [];
    for (const sess of Object.values(parsed.sessions)) {
        if (!sess || !Array.isArray(sess.requestLog)) continue;
        for (const rec of sess.requestLog) {
            if (rec?.sessionId && rec?.startedAt) records.push(rec);
        }
    }
    return records;
}

/**
 * Read the first user prompt from a session transcript file to use as
 * a human-readable title. Cached for the lifetime of the process.
 */
const _sessionTitleCache = new Map();

function lookupSessionTitle(sessionId, projectKey) {
    if (_sessionTitleCache.has(sessionId)) return _sessionTitleCache.get(sessionId);
    const title = _readFirstPrompt(sessionId, projectKey);
    _sessionTitleCache.set(sessionId, title);
    return title;
}

function _readFirstPrompt(sessionId, projectKey) {
    const pilotHome = GENERAL_HOME;
    // Sessions are stored on disk under a sanitized filename (raw sessionId
    // may contain /, :, = which would split into nested dirs). We try
    // both the sanitized and raw form so this also resolves any legacy files
    // that pre-date the sanitize fix.
    const safeId = sanitizeSessionIdForPath(sessionId);
    const fileVariants = safeId === sessionId ? [sessionId] : [safeId, sessionId];
    const candidates = [];
    if (projectKey) {
        for (const id of fileVariants) {
            candidates.push(path.join(pilotHome, 'projects', createProjectId(projectKey), 'chats', `${id}.jsonl`));
        }
    }
    // Also check the general workspace (sessions may live there)
    for (const id of fileVariants) {
        const generalChatPath = path.join(pilotHome, 'projects', createProjectId(pilotHome), 'chats', `${id}.jsonl`);
        if (!candidates.includes(generalChatPath)) candidates.push(generalChatPath);
    }
    try {
        const projectsDir = path.join(pilotHome, 'projects');
        const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
        for (const d of dirs) {
            if (!d.isDirectory()) continue;
            for (const id of fileVariants) {
                const p = path.join(projectsDir, d.name, 'chats', `${id}.jsonl`);
                if (!candidates.includes(p)) candidates.push(p);
            }
        }
    } catch { /* ignore */ }

    for (const filePath of candidates) {
        try {
            const fd = fs.openSync(filePath, 'r');
            try {
                const buf = Buffer.alloc(16384);
                const bytesRead = fs.readSync(fd, buf, 0, 16384, 0);
                const head = buf.toString('utf-8', 0, bytesRead);
                const firstLine = head.split('\n').find(l => l.includes('"type":"accepted_input"'));
                if (firstLine) {
                    const parsed = JSON.parse(firstLine);
                    const text = parsed.messages
                        ?.flatMap(m => m.content ?? [])
                        .find(b => b.type === 'text')?.text;
                    if (text?.trim()) {
                        const trimmed = text.trim();
                        return trimmed.length > 80 ? trimmed.slice(0, 77) + '…' : trimmed;
                    }
                }
            } finally {
                fs.closeSync(fd);
            }
        } catch { /* file not found or parse error — try next */ }
    }
    return null;
}

/**
 * Extract all user queries from a session's transcript JSONL file.
 * Returns up to `limit` trimmed strings (truncated at 120 chars).
 * Cache is invalidated when the transcript file changes (mtime check).
 */
const _userQueriesCache = new Map();

function extractUserQueries(sessionId, projectKey, limit = 20) {
    const cacheKey = `${sessionId}::${projectKey || ''}`;
    const cached = _userQueriesCache.get(cacheKey);
    if (cached) {
        const currentMtime = _getTranscriptMtime(sessionId, projectKey);
        if (currentMtime && currentMtime === cached.mtime) return cached.queries;
    }

    const queries = _readUserQueriesFromTranscript(sessionId, projectKey, limit);
    const mtime = _getTranscriptMtime(sessionId, projectKey);
    _userQueriesCache.set(cacheKey, { queries, mtime });
    return queries;
}

function _getTranscriptMtime(sessionId, projectKey) {
    const pilotHome = GENERAL_HOME;
    const safeId = sanitizeSessionIdForPath(sessionId);
    const fileVariants = safeId === sessionId ? [sessionId] : [safeId, sessionId];
    const candidates = [];
    if (projectKey) {
        for (const id of fileVariants) {
            candidates.push(path.join(pilotHome, 'projects', createProjectId(projectKey), 'chats', `${id}.jsonl`));
        }
    }
    try {
        const projectsDir = path.join(pilotHome, 'projects');
        const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
        for (const d of dirs) {
            if (!d.isDirectory()) continue;
            for (const id of fileVariants) {
                const p = path.join(projectsDir, d.name, 'chats', `${id}.jsonl`);
                if (!candidates.includes(p)) candidates.push(p);
            }
        }
    } catch { /* ignore */ }
    for (const filePath of candidates) {
        try {
            return fs.statSync(filePath).mtimeMs;
        } catch { /* next */ }
    }
    return null;
}

function _readUserQueriesFromTranscript(sessionId, projectKey, limit) {
    const pilotHome = GENERAL_HOME;
    const safeId = sanitizeSessionIdForPath(sessionId);
    const fileVariants = safeId === sessionId ? [sessionId] : [safeId, sessionId];
    const candidates = [];
    if (projectKey) {
        for (const id of fileVariants) {
            candidates.push(path.join(pilotHome, 'projects', createProjectId(projectKey), 'chats', `${id}.jsonl`));
        }
    }
    for (const id of fileVariants) {
        const generalChatPath = path.join(pilotHome, 'projects', createProjectId(pilotHome), 'chats', `${id}.jsonl`);
        if (!candidates.includes(generalChatPath)) candidates.push(generalChatPath);
    }
    try {
        const projectsDir = path.join(pilotHome, 'projects');
        const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
        for (const d of dirs) {
            if (!d.isDirectory()) continue;
            for (const id of fileVariants) {
                const p = path.join(projectsDir, d.name, 'chats', `${id}.jsonl`);
                if (!candidates.includes(p)) candidates.push(p);
            }
        }
    } catch { /* ignore */ }

    for (const filePath of candidates) {
        try {
            const raw = fs.readFileSync(filePath, 'utf-8');
            const queries = [];
            for (const line of raw.split('\n')) {
                if (!line.trim()) continue;
                try {
                    const entry = JSON.parse(line);
                    if (entry.type !== 'accepted_input') continue;
                    const text = entry.messages
                        ?.flatMap(m => m.content ?? [])
                        .find(b => b.type === 'text')?.text;
                    if (!text?.trim()) continue;
                    const trimmed = text.trim();
                    if (trimmed.length < 2) continue;
                    queries.push(trimmed.length > 120 ? trimmed.slice(0, 117) + '…' : trimmed);
                    if (queries.length >= limit) break;
                } catch { /* skip malformed lines */ }
            }
            if (queries.length > 0) return queries;
        } catch { /* file not found — try next */ }
    }
    return [];
}

/**
 * Extract per-turn structure from a session transcript.
 * Returns an array of turn objects:
 *   { tools: string[][], modelCalls: number }
 *
 * - tools: one entry per assistant_message that has tool_call blocks
 *   e.g. [["glob"], ["read_file", "read_file"], ["edit_file"]]
 * - modelCalls: total assistant_messages in the turn (including the
 *   final text-only response)
 *
 * Continuation #N shows the tools from model call #N-1 that triggered it.
 */
const _toolSequenceCache = new Map();

function _extractToolSequence(sessionId, projectKey) {
    const cacheKey = `${sessionId}::${projectKey || ''}::tools`;
    const cached = _toolSequenceCache.get(cacheKey);
    if (cached) {
        const currentMtime = _getTranscriptMtime(sessionId, projectKey);
        if (currentMtime && currentMtime === cached.mtime) return cached.result;
    }

    const result = _readToolSequenceFromTranscript(sessionId, projectKey);
    const mtime = _getTranscriptMtime(sessionId, projectKey);
    _toolSequenceCache.set(cacheKey, { result, mtime });
    return result;
}

function _readToolSequenceFromTranscript(sessionId, projectKey) {
    const pilotHome = GENERAL_HOME;
    const safeId = sanitizeSessionIdForPath(sessionId);
    const fileVariants = safeId === sessionId ? [sessionId] : [safeId, sessionId];
    const candidates = [];
    if (projectKey) {
        for (const id of fileVariants) {
            candidates.push(path.join(pilotHome, 'projects', createProjectId(projectKey), 'chats', `${id}.jsonl`));
        }
    }
    for (const id of fileVariants) {
        const generalChatPath = path.join(pilotHome, 'projects', createProjectId(pilotHome), 'chats', `${id}.jsonl`);
        if (!candidates.includes(generalChatPath)) candidates.push(generalChatPath);
    }
    try {
        const projectsDir = path.join(pilotHome, 'projects');
        const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
        for (const d of dirs) {
            if (!d.isDirectory()) continue;
            for (const id of fileVariants) {
                const p = path.join(projectsDir, d.name, 'chats', `${id}.jsonl`);
                if (!candidates.includes(p)) candidates.push(p);
            }
        }
    } catch { /* ignore */ }

    for (const filePath of candidates) {
        try {
            const raw = fs.readFileSync(filePath, 'utf-8');
            const turns = [];
            let currentTurn = null;

            for (const line of raw.split('\n')) {
                if (!line.trim()) continue;
                try {
                    const entry = JSON.parse(line);
                    if (entry.type === 'accepted_input') {
                        currentTurn = { tools: [], modelCalls: 0 };
                        turns.push(currentTurn);
                    } else if (entry.type === 'assistant_message' && currentTurn) {
                        currentTurn.modelCalls++;
                        const content = entry.message?.content ?? [];
                        const toolNames = content
                            .filter(b => b.type === 'tool_call' || b.type === 'tool_use')
                            .map(b => b.name)
                            .filter(Boolean);
                        if (toolNames.length > 0) {
                            currentTurn.tools.push(toolNames);
                        }
                    }
                } catch { /* skip */ }
            }
            if (turns.length > 0) return turns;
        } catch { /* file not found */ }
    }
    return [];
}

/**
 * Assign user queries and tool names to requestLog entries.
 *
 * Primary method: group by `turnId` from router stats (each user turn
 * shares one turnId; all continuations within that turn have the same
 * turnId). The first request per turnId gets the user query; subsequent
 * requests become tool continuations with tool names from the transcript.
 *
 * Fallback: when turnId is absent (older stats without the field), uses
 * transcript model-call counts to partition entries.
 */
/**
 * Extract subagent prompts from a session transcript.
 * Returns a Map<turnId, promptPreview[]> for assigning prompts to subagent entries.
 */
const _subagentPromptCache = new Map();

function _extractSubagentPrompts(sessionId, projectKey) {
    const cacheKey = `${sessionId}::${projectKey || ''}::subprompts`;
    const cached = _subagentPromptCache.get(cacheKey);
    if (cached) {
        const currentMtime = _getTranscriptMtime(sessionId, projectKey);
        if (currentMtime && currentMtime === cached.mtime) return cached.result;
    }
    const result = _readSubagentPromptsFromTranscript(sessionId, projectKey);
    const mtime = _getTranscriptMtime(sessionId, projectKey);
    _subagentPromptCache.set(cacheKey, { result, mtime });
    return result;
}

function _readSubagentPromptsFromTranscript(sessionId, projectKey) {
    const pilotHome = GENERAL_HOME;
    const safeId = sanitizeSessionIdForPath(sessionId);
    const fileVariants = safeId === sessionId ? [sessionId] : [safeId, sessionId];
    const candidates = [];
    if (projectKey) {
        for (const id of fileVariants) {
            candidates.push(path.join(pilotHome, 'projects', createProjectId(projectKey), 'chats', `${id}.jsonl`));
        }
    }
    for (const id of fileVariants) {
        const generalChatPath = path.join(pilotHome, 'projects', createProjectId(pilotHome), 'chats', `${id}.jsonl`);
        if (!candidates.includes(generalChatPath)) candidates.push(generalChatPath);
    }
    try {
        const projectsDir = path.join(pilotHome, 'projects');
        const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
        for (const d of dirs) {
            if (!d.isDirectory()) continue;
            for (const id of fileVariants) {
                const p = path.join(projectsDir, d.name, 'chats', `${id}.jsonl`);
                if (!candidates.includes(p)) candidates.push(p);
            }
        }
    } catch { /* ignore */ }

    const promptsByTurn = new Map();
    for (const filePath of candidates) {
        try {
            const raw = fs.readFileSync(filePath, 'utf-8');
            for (const line of raw.split('\n')) {
                if (!line.trim()) continue;
                try {
                    const entry = JSON.parse(line);
                    if (entry.type === 'subagent_started' && entry.turnId && entry.promptPreview) {
                        const list = promptsByTurn.get(entry.turnId) || [];
                        const preview = entry.promptPreview.length > 80
                            ? entry.promptPreview.slice(0, 80) + '…'
                            : entry.promptPreview;
                        list.push(preview);
                        promptsByTurn.set(entry.turnId, list);
                    }
                } catch { /* skip */ }
            }
            if (promptsByTurn.size > 0) return promptsByTurn;
        } catch { /* file not found */ }
    }
    return promptsByTurn;
}

function _assignQueriesToRequestLog(sessionEntry) {
    const log = sessionEntry.routing?.requestLog;
    const queries = sessionEntry.userQueries;
    if (!log || log.length === 0 || !queries || queries.length === 0) return;

    const turnStructure = _extractToolSequence(sessionEntry.sessionId, sessionEntry._projectKey);
    const subagentPrompts = _extractSubagentPrompts(sessionEntry.sessionId, sessionEntry._projectKey);
    const hasTurnIds = log.some(e => e.turnId);

    if (hasTurnIds) {
        _assignByTurnId(log, queries, turnStructure, subagentPrompts);
    } else {
        const mainEntries = log.filter(e => e.role === 'main');
        if (mainEntries.length === 0) return;
        _assignByModelCallCount(mainEntries, queries, turnStructure);
    }
}

function _assignByTurnId(allEntries, queries, turnStructure, subagentPrompts) {
    let turnIndex = 0;
    let currentTurnId = null;

    for (let i = 0; i < allEntries.length; i++) {
        const entry = allEntries[i];
        if (entry.turnId !== currentTurnId) {
            currentTurnId = entry.turnId;
            if (entry.role === 'main') {
                entry.query = queries[Math.min(turnIndex, queries.length - 1)];
            }
            turnIndex++;
        } else {
            if (entry.role === 'main') {
                entry._savedTier = entry.tier;
                entry.role = 'sub';
                delete entry.tier;
            }
        }
    }

    const turnIds = [...new Set(allEntries.map(e => e.turnId).filter(Boolean))];
    for (let tIdx = 0; tIdx < turnIds.length; tIdx++) {
        const turnId = turnIds[tIdx];
        const turnEntries = allEntries.filter(e => e.turnId === turnId);
        const turnTools = turnStructure[tIdx]?.tools || [];
        const prompts = subagentPrompts?.get(turnId);
        let toolIdx = 0;
        let promptIdx = 0;

        for (let j = 1; j < turnEntries.length; j++) {
            const entry = turnEntries[j];
            if (entry.query === 'sub-agent') {
                if (prompts && promptIdx < prompts.length) {
                    entry.query = prompts[promptIdx];
                    entry.isSubagentDispatch = true;
                    if (entry._savedTier) { entry.tier = entry._savedTier; }
                    delete entry._savedTier;
                    promptIdx++;
                }
            } else if (!entry.query) {
                if (toolIdx < turnTools.length) {
                    const names = turnTools[toolIdx];
                    const isAgentCall = names.some(n => n === 'agent' || n === 'sessions_spawn' || n === 'dispatch_agent');
                    if (isAgentCall && prompts && promptIdx < prompts.length) {
                        entry.query = prompts[promptIdx];
                        entry.isSubagentDispatch = true;
                        if (entry._savedTier) { entry.tier = entry._savedTier; }
                        delete entry._savedTier;
                        promptIdx++;
                    } else {
                        entry.query = '→ ' + [...new Set(names)].join(', ');
                    }
                }
                toolIdx++;
            }
            delete entry._savedTier;
        }
    }
}

function _assignByModelCallCount(mainEntries, queries, turnStructure) {
    let turnIndex = 0;
    let posInTurn = 0;

    for (let i = 0; i < mainEntries.length; i++) {
        const turnInfo = turnStructure[turnIndex];
        const turnModelCalls = turnInfo ? turnInfo.modelCalls : 0;

        if (posInTurn === 0) {
            mainEntries[i].query = queries[Math.min(turnIndex, queries.length - 1)];
            posInTurn++;
        } else {
            mainEntries[i].role = 'sub';
            delete mainEntries[i].tier;
            const continuationIdx = posInTurn - 1;
            const turnTools = turnInfo?.tools;
            if (turnTools && continuationIdx < turnTools.length) {
                const names = turnTools[continuationIdx];
                mainEntries[i].query = '→ ' + [...new Set(names)].join(', ');
            }
            posInTurn++;
        }

        if (turnModelCalls > 0 && posInTurn >= turnModelCalls) {
            turnIndex++;
            posInTurn = 0;
        }
    }
}

/**
 * Build a `DashboardData` payload from persisted router stats. Shape
 * mirrors what `ui/src/hooks/useRoutingDashboard.ts` expects so the V2
 * Dashboard tab renders without changing any frontend code.
 */
export function getRouterDashboardData() {
    const statsByProject = loadPersistedStatsFromDisk();

    const projects = [];
    const overall = makeBucket();
    const overallByTier = {};
    const overallByRole = {};
    let overallSessionCount = 0;

    for (const [projectKey, snapshot] of statsByProject.entries()) {
        const records = Array.isArray(snapshot.records) ? snapshot.records : [];
        const sessionMap = new Map();
        for (const record of records) {
            if (record.sessionId && record.sessionId.includes('::sub::')) continue;
            let sessionEntry = sessionMap.get(record.sessionId);
            if (!sessionEntry) {
                sessionEntry = {
                    sessionId: record.sessionId,
                    _projectKey: projectKey,
                    title: lookupSessionTitle(record.sessionId, projectKey) || record.sessionId,
                    provider: record.provider || 'pilotdeck',
                    lastActivity: record.endedAt,
                    userQueries: extractUserQueries(record.sessionId, projectKey),
                    routing: {
                        total: makeBucket(),
                        byTier: {},
                        byScenario: {},
                        byRole: {},
                        byModel: {},
                        requestLog: [],
                        firstSeenAt: Date.parse(record.startedAt) || 0,
                        lastActiveAt: Date.parse(record.endedAt) || 0,
                    },
                };
                sessionMap.set(record.sessionId, sessionEntry);
            }
            const logRole = record.role === 'subagent' ? 'sub' : 'main';
            sessionEntry.routing.requestLog.push({
                ts: Date.parse(record.startedAt) || 0,
                turnId: record.turnId || undefined,
                role: logRole,
                tier: record.tier || record.scenarioType || undefined,
                model: `${record.provider || 'unknown'}/${record.model || 'unknown'}`,
                ...(record.role === 'subagent' ? { query: 'sub-agent' } : {}),
                tokens: (record.usage?.totalTokens ?? (record.usage?.inputTokens || 0) + (record.usage?.outputTokens || 0)),
                cost: record.cost?.total || 0,
                baselineCost: record.baselineCost ?? (record.cost?.total || 0),
                savedCost: (record.baselineCost ?? (record.cost?.total || 0)) - (record.cost?.total || 0),
            });
            mergeRecordIntoSession(sessionEntry.routing, record);
            const ended = Date.parse(record.endedAt) || 0;
            if (ended > (sessionEntry.routing.lastActiveAt || 0)) {
                sessionEntry.routing.lastActiveAt = ended;
                sessionEntry.lastActivity = record.endedAt;
            }
        }

        for (const sessionEntry of sessionMap.values()) {
            _assignQueriesToRequestLog(sessionEntry);
            delete sessionEntry._projectKey;
        }

        const sessions = [...sessionMap.values()];
        const aggregated = {
            total: makeBucket(),
            byTier: {},
            byRole: {},
            sessionCount: sessions.length,
            routedSessionCount: sessions.length,
        };
        for (const session of sessions) {
            addBuckets(aggregated.total, session.routing.total);
            for (const [tier, bucket] of Object.entries(session.routing.byTier)) {
                aggregated.byTier[tier] = aggregated.byTier[tier] || makeBucket();
                addBuckets(aggregated.byTier[tier], bucket);
            }
            for (const [role, bucket] of Object.entries(session.routing.byRole)) {
                aggregated.byRole[role] = aggregated.byRole[role] || makeBucket();
                addBuckets(aggregated.byRole[role], bucket);
            }
        }

        addBuckets(overall, aggregated.total);
        for (const [tier, bucket] of Object.entries(aggregated.byTier)) {
            overallByTier[tier] = overallByTier[tier] || makeBucket();
            addBuckets(overallByTier[tier], bucket);
        }
        for (const [role, bucket] of Object.entries(aggregated.byRole)) {
            overallByRole[role] = overallByRole[role] || makeBucket();
            addBuckets(overallByRole[role], bucket);
        }
        overallSessionCount += sessions.length;

        projects.push({
            name: deriveProjectName(projectKey),
            displayName: deriveProjectDisplayName(projectKey),
            fullPath: projectKey,
            sessions,
            aggregated,
        });
    }

    return {
        projects,
        overall: {
            total: overall,
            byTier: overallByTier,
            byRole: overallByRole,
            projectCount: projects.length,
            sessionCount: overallSessionCount,
        },
        unmatchedSessions: [],
    };
}

function makeBucket() {
    return {
        inputTokens: 0,
        outputTokens: 0,
        cacheReadTokens: 0,
        totalTokens: 0,
        requestCount: 0,
        estimatedCost: 0,
        baselineCost: 0,
        savedCost: 0,
    };
}

function addBuckets(target, source) {
    target.inputTokens += source.inputTokens || 0;
    target.outputTokens += source.outputTokens || 0;
    target.cacheReadTokens += source.cacheReadTokens || 0;
    target.totalTokens += source.totalTokens || 0;
    target.requestCount += source.requestCount || 0;
    target.estimatedCost += source.estimatedCost || 0;
    if (typeof target.baselineCost !== 'number') target.baselineCost = 0;
    if (typeof target.savedCost !== 'number') target.savedCost = 0;
    target.baselineCost += source.baselineCost || 0;
    target.savedCost += source.savedCost || 0;
}

function mergeRecordIntoSession(routing, record) {
    const usage = record.usage || {};
    const cost = record.cost || {};
    const actualCost = cost.total || 0;
    const baseline = record.baselineCost ?? actualCost;
    const bucket = {
        inputTokens: usage.inputTokens || 0,
        outputTokens: usage.outputTokens || 0,
        cacheReadTokens: usage.cacheReadTokens || 0,
        totalTokens:
            usage.totalTokens ??
            (usage.inputTokens || 0) + (usage.outputTokens || 0),
        requestCount: 1,
        estimatedCost: actualCost,
        baselineCost: baseline,
        savedCost: baseline - actualCost,
    };
    addBuckets(routing.total, bucket);

    const tierKey = record.tier || record.scenarioType || 'default';
    routing.byTier[tierKey] = routing.byTier[tierKey] || makeBucket();
    addBuckets(routing.byTier[tierKey], bucket);

    const scenarioKey = record.scenarioType || 'default';
    routing.byScenario[scenarioKey] = routing.byScenario[scenarioKey] || makeBucket();
    addBuckets(routing.byScenario[scenarioKey], bucket);

    const roleKey = record.resolvedFrom === 'subagent' ? 'sub' : 'main';
    routing.byRole[roleKey] = routing.byRole[roleKey] || makeBucket();
    addBuckets(routing.byRole[roleKey], bucket);

    const modelKey = `${record.provider || 'unknown'}/${record.model || 'unknown'}`;
    routing.byModel[modelKey] = routing.byModel[modelKey] || makeBucket();
    addBuckets(routing.byModel[modelKey], bucket);
}

function isGeneralProject(projectKey) {
    return path.resolve(projectKey) === path.resolve(GENERAL_HOME);
}

function deriveProjectName(projectKey) {
    if (isGeneralProject(projectKey)) return 'general';
    return projectKey
        .replace(/^\/+/, '')
        .replace(/[^A-Za-z0-9._-]+/g, '-');
}

function deriveProjectDisplayName(projectKey) {
    if (isGeneralProject(projectKey)) return 'general';
    const parts = projectKey.split('/').filter(Boolean);
    return parts.length > 0 ? parts[parts.length - 1] : projectKey;
}

/**
 * Per-session stats payload for `/api/ccr/stats/sessions/:id`. Returns
 * `null` when no router activity has been observed for the session yet.
 */
export function getRouterSessionStats(sessionId) {
    const dashboard = getRouterDashboardData();
    for (const project of dashboard.projects) {
        const session = project.sessions.find((s) => s.sessionId === sessionId);
        if (session) {
            return {
                sessionId,
                projectName: project.name,
                routing: session.routing,
            };
        }
    }
    return null;
}

/**
 * Lifetime aggregate suitable for `/api/ccr/stats/summary`.
 */
export function getRouterStatsSummary() {
    const data = getRouterDashboardData();
    const byScenario = {};
    const byProvider = {};
    const byTier = data.overall.byTier;
    for (const project of data.projects) {
        for (const session of project.sessions) {
            for (const [scenario, bucket] of Object.entries(session.routing.byScenario)) {
                byScenario[scenario] = byScenario[scenario] || makeBucket();
                addBuckets(byScenario[scenario], bucket);
            }
            for (const [model, bucket] of Object.entries(session.routing.byModel)) {
                const provider = model.includes('/') ? model.split('/', 1)[0] : model;
                byProvider[provider] = byProvider[provider] || makeBucket();
                addBuckets(byProvider[provider], bucket);
            }
        }
    }
    return {
        lifetime: {
            total: data.overall.total,
            byScenario,
            byProvider,
            byTier,
        },
        lastUpdatedAt: new Date().toISOString(),
    };
}

/**
 * Register a notification handler that forwards Always-On turn events
 * to all connected browser WebSocket clients as NormalizedMessage frames.
 *
 * Called once from `index.js` after the WebSocket server is ready, passing
 * the shared `connectedClients` set.
 *
 * @param {Set<import('ws').WebSocket>} clients
 */
export function registerAlwaysOnNotificationForwarding(clients) {
    const knownSessions = new Set();

    ensureGateway().then((gw) => {
        gw.onNotification((name, payload) => {
            if (name !== 'always-on:turn-event') return;
            const { sessionKey, channelKey, event } = payload ?? {};
            if (!sessionKey || !event) return;

            const provider = 'pilotdeck';

            if (!knownSessions.has(sessionKey)) {
                knownSessions.add(sessionKey);
                const createdFrame = createNormalizedMessage({
                    provider,
                    sessionId: sessionKey,
                    kind: 'session_created',
                    newSessionId: sessionKey,
                    sessionKey,
                    channelKey,
                });
                const createdMsg = JSON.stringify(createdFrame);
                for (const client of clients) {
                    if (client.readyState === 1) client.send(createdMsg);
                }
            }

            if (event.type === 'context_budget') {
                const aoState = ensureSessionState(sessionKey, '', channelKey || 'web');
                aoState.tokenBudget = {
                    used: event.used,
                    total: event.total,
                    ratio: event.ratio,
                    state: event.state,
                };
            }
            for (const frame of gatewayEventToFrames(event, sessionKey, provider)) {
                const msg = JSON.stringify(frame);
                for (const client of clients) {
                    if (client.readyState === 1) client.send(msg);
                }
            }

            if (event.type === 'turn_completed') {
                knownSessions.delete(sessionKey);
            }
        });
    }).catch((err) => {
        console.warn('[pilotdeck-bridge] failed to register always-on notification forwarding:', err?.message || err);
    });
}

export async function elicitationRespondViaGateway(requestId, answer) {
    const gw = await ensureGateway();
    for (const state of sessionState.values()) {
        try {
            const result = await gw.respondElicitation({
                sessionKey: state.sessionKey,
                requestId,
                answer,
            });
            if (result?.delivered) return true;
        } catch (error) {
            console.warn('[pilotdeck-bridge] respondElicitation failed:', error);
        }
    }
    return false;
}