/**
 * SpecCliAdapter — bridges SpecDriver into the daemon's CliAdapter
 * interface so an existing CliProviderInstance can drive a spec-backed
 * provider without rewriting the surrounding session machinery.
 *
 * Translation:
 *   spec state.id 'approval' (with modal_buttons that produced a modal)
 *     → CliAdapterStatus.status='waiting_approval', activeModal={...}
 *   spec state.id 'busy' / any non-decision state with a 'busy' label
 *     → status='generating'
 *   spec state.id 'idle' or default
 *     → status='idle'
 *
 * Methods that the round-3 spec model doesn't have an opinion on
 * (transcript reading, slash commands, history, runtime metadata
 * surfacing) are minimal stubs. They satisfy the daemon's call sites
 * without pretending to implement anything.
 */
'use strict';

import { FsmDriver, type DashboardEvent, type ISpecDriver } from './fsm-driver.js';
import { lastContiguousNumberedBlock } from './evaluator.js';
import { executeNativeHistory } from './native-history-executor.js';
import { detectBackgroundTaskActive } from './background-task-detector.js';
import * as fs from 'node:fs';
import { createHash } from 'node:crypto';
import type { NativeHistoryConfig, Control, ControlAction } from './types.js';
import type { CliAdapter, CliAdapterStatus } from '../../cli-adapter-types.js';
import type { ChatMessage } from '../../types.js';
import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
import {
    encodeMeshSendKeys,
    truncateToByteTailByLine,
    type MeshSendKeyItem,
    type MeshSendKeyName,
} from '../../cli-adapters/provider-cli-shared.js';
import { LOG } from '../../logging/logger.js';
import {
    buildClaudeInteractiveTuiAnswerSteps,
    buildClaudeInteractiveToolResult,
    detectClaudeAskUserQuestionPromptFromJson,
    detectClaudeAskUserQuestionPromptFromTuiPages,
    detectClaudeTuiMultiSelect,
    readFocusedClaudeTuiQuestion,
    type ClaudeInteractiveTuiPage,
    type InteractivePrompt,
    type InteractivePromptResponse,
} from '../types/interactive-prompt.js';


function stripAnsi(text: string): string {
    // eslint-disable-next-line no-control-regex
    return String(text || '')
        .replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
        .replace(/\x1B[P^_X][\s\S]*?(?:\x07|\x1B\\)/g, '')
        .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
}

function delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
}

export class SpecCliAdapter implements CliAdapter {
    readonly cliType: string;
    readonly cliName: string;
    readonly workingDir: string;
    /**
     * Marker the daemon's finalization gate checks: `getStatus()` returns
     * `messages: []` by design here (chat history lives in the daemon's
     * native-history pipeline, not the adapter). Without this flag,
     * cli-provider-instance's `missing_final_assistant` gate would stall
     * every turn until the 30s safety timeout because it expects the
     * adapter to surface the final assistant message.
     */
    readonly chatMessagesOwnedExternally = true as const;

    private driver: ISpecDriver;
    /** Common spec fields the adapter reads, present in both v3 and v4. */
    private spec: {
        id: string;
        name: string;
        control_bar?: Control[];
        native_history?: NativeHistoryConfig;
    };
    private lastEvent: DashboardEvent | null = null;
    private latestState: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' } | null = null;
    private latestModal: { title: string | null; buttons: { index: number; label: string }[]; kind?: 'approval' | 'picker' | 'confirm' | null } | null = null;
    private statusCallback: (() => void) | null = null;
    private ptyDataCallback: ((data: string) => void) | null = null;
    private partialResponse = '';
    private activeInteractivePrompt: InteractivePrompt | null = null;
    private interactivePromptTransport: 'stream-json' | 'tui' | null = null;
    private claudeTuiPromptCaptureInFlight = false;
    /**
     * Wall clock of the first frame on which a held interactive prompt was
     * observed to have left the screen. Mirrors the approval FSM's
     * `modalLostAt` hysteresis (see cli-state-engine.ts): claude-cli's TUI
     * repaints the choice picker as several PTY chunks, so a single frame
     * with no "Enter to select" footer is not proof the prompt is gone — it
     * may just be mid-repaint. We only clear the held prompt once it has
     * been absent across a short grace window. Reset to null the moment the
     * prompt footer reappears.
     *
     * Without this, a choice prompt resolved *directly in the terminal* (the
     * user picked an option without going through ADHDev's
     * setInteractivePromptResponse) was never cleared from
     * `activeInteractivePrompt`, so getStatus() re-emitted the same prompt
     * forever — the choice-resolve-stuck bug.
     */
    private interactivePromptLostAt: number | null = null;
    private jsonLineTail = '';
    private exited = false;
    private spawned = false;
    private providerSessionId: string | undefined;
    /** Wall clock at the moment spawn() ran. Used as the cutoff for
     *  native-history file selection so a prior session's transcript
     *  can't leak into this session before the agent has written its
     *  own records. */
    private spawnedAtMs = 0;
    /** Env vars the daemon set on the spawned child. Mesh coordinator
     *  points hermes at a per-coordinator HERMES_HOME so the dashboard's
     *  native-history reader needs that override to find the right
     *  state.db; without it the reader sees ~/.hermes/state.db which
     *  the coordinator-launched hermes never writes to. The choice to
     *  redirect HERMES_HOME is a workaround for an unresolved hermes
     *  upstream feature gap (see hermes-agent#23130 — runtime-supplied
     *  MCP config), so this routing keeps the dashboard honest until
     *  hermes ships a runtime MCP override. */
    private spawnedEnv: Record<string, string> = {};

    constructor(
        specPath: string,
        workingDir: string,
        cliArgs: string[],
        extraEnv: Record<string, string>,
        transportFactory?: PtyTransportFactory,
    ) {
        const raw = JSON.parse(fs.readFileSync(specPath, 'utf8'));
        this.spec = {
            id: raw.id,
            name: raw.name,
            control_bar: raw.control_bar,
            native_history: raw.native_history,
        };
        this.cliType = this.spec.id;
        this.cliName = this.spec.name;
        this.workingDir = workingDir;
        this.spawnedEnv = { ...extraEnv };

        // cli-manager.ts allocates providerSessionId per launch and threads
        // it through resume.newSessionArgs as additional cliArgs (e.g.
        // ["--session-id", "<uuid>"]). We must hand those to the driver
        // so the agent uses the daemon's id, otherwise (claude case) the
        // agent generates its own id and the chat-history pipeline can't
        // pair the on-disk transcript with the live session.
        this.driver = new FsmDriver({
            specPath,
            workingDir,
            extraEnv,
            hotReload: true,
            emitTrace: false,
            transportFactory,
            extraCliArgs: cliArgs,
        });
        this.driver.subscribe((ev) => this.handleEvent(ev));
    }

    async spawn(): Promise<void> {
        if (this.spawned) return;
        this.driver.start();
        this.spawned = true;
        this.spawnedAtMs = Date.now();
    }

    async sendMessage(text: string): Promise<void> {
        // Content-free at info — the prompt body is user data.
        LOG.info('SpecAdapter', `[${this.cliType}] sendMessage(len=${text.length})`);
        LOG.debug('SpecAdapter', `[${this.cliType}] sendMessage body=${JSON.stringify(text.slice(0, 80))}${text.length > 80 ? '…' : ''}`);
        this.driver.dispatch({ kind: 'send_message', text });
    }

    getStatus(): CliAdapterStatus {
        const sessionFields = this.providerSessionId ? { providerSessionId: this.providerSessionId } : {};
        if (this.exited) return { status: 'stopped', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
        if (!this.spawned) return { status: 'starting', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };

        // Refresh native history lazily — the watch_path is cheap to stat,
        // but parsing a full session.jsonl every call would be wasteful.
        this.maybeRefreshNativeHistory();

        const state = this.latestState;
        if (!state) return { status: 'starting', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };

        // The FSM state is authoritative for status. We do NOT infer status from whether
        // a modal was parsed this frame: a modal/approval state whose buttons briefly fail
        // to parse (PTY repaint) must still report waiting_approval, not collapse to idle —
        // that collapse fired false completions while a session sat at an approval prompt.
        const modal = this.latestModal;
        if (state.status === 'approval') {
            return {
                status: 'waiting_approval',
                messages: [],
                // Surface buttons when we have them; an approval state with no parsed
                // modal this frame still stays waiting_approval (no activeModal yet).
                // `kind` carries the semantic modal class through to the auto-approve
                // gate so a /model picker (kind='picker') is never auto-answered.
                // BUTTON-INDEX-MISMAP (Fix C.1): keep `buttons` as the label list every
                // existing consumer (pickApprovalButton, mesh_approve, auto-approve) reads,
                // but ALSO surface `buttonMeta` carrying each button's real FSM display index
                // alongside its label. A partial/non-contiguous modal (display indices [1,3,4]
                // at array positions [0,1,2]) then no longer loses the index → label mapping
                // once it leaves the adapter: a consumer that has an array position can recover
                // the true FSM index without re-parsing. resolveModal() below relies on the same
                // ordered list to translate an array position to the correct FSM index.
                activeModal: modal
                    ? {
                        message: modal.title ?? state.label,
                        buttons: modal.buttons.map(b => b.label),
                        buttonMeta: modal.buttons.map(b => ({ index: b.index, label: b.label })),
                        kind: modal.kind ?? null,
                    }
                    : null,
                activeInteractivePrompt: this.activeInteractivePrompt,
                ...sessionFields,
            };
        }
        if (state.status === 'generating') {
            return { status: 'generating', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
        }
        // fsmReadySeen lets CliProviderInstance re-arm the queue-claim agent:ready
        // on the first genuine ready (prompt drawn), independent of the boot-time
        // starting→idle one-shot that the provider-instance otherwise relies on.
        // Surfaced only on idle so the provider-instance fires agent:ready exactly
        // when the worker is actually ready to claim.
        return { status: 'idle', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, fsmReadySeen: this.driver.hasSeenReady?.() ?? false, ...sessionFields };
    }

    private maybeRefreshNativeHistory(): void {
        // Native history is now sourced by daemon's chat-history pipeline
        // (which calls provider.scripts.readNativeHistory wired by
        // provider-loader). SpecCliAdapter no longer polls or caches.
    }

    getScriptParsedStatus(): unknown {
        const providerSessionId = this.extractProviderSessionIdFromScreen();
        if (providerSessionId) this.providerSessionId = providerSessionId;
        const status = this.getStatus();
        // Background-task passthrough: read the claude-cli native-history
        // transcript at poll time for an unresolved run_in_background bash. This
        // is a NEW signal that rides alongside `status` (it is NOT run through
        // the 5-value FSM normalization). Absent for every non-claude-cli
        // provider (detector short-circuits on agentType). Read at each poll —
        // the JSONL trails the live idle transition, so it must be observed
        // BEFORE completion fires, which SUB-B's hold + settle window give us.
        const bg = this.detectBackgroundTask();
        return {
            ...status,
            messages: this.readClaudeScreenAssistantMessages(),
            ...(this.providerSessionId ? { providerSessionId: this.providerSessionId } : {}),
            ...(bg.active ? { backgroundTaskActive: true, backgroundTaskCount: bg.count, backgroundTaskIds: bg.ids } : {}),
        };
    }

    private detectBackgroundTask(): { active: boolean; count: number; ids: string[] } {
        if (this.cliType !== 'claude-cli') return { active: false, count: 0, ids: [] };
        if (!this.spec.native_history?.source) return { active: false, count: 0, ids: [] };
        try {
            return detectBackgroundTaskActive(this.spec.native_history, {
                agentType: this.cliType,
                providerSessionId: this.providerSessionId,
                sessionStartedAtMs: this.spawnedAtMs,
                envOverrides: this.spawnedEnv,
                workspace: this.workingDir,
            });
        } catch {
            return { active: false, count: 0, ids: [] };
        }
    }

    getPartialResponse(): string {
        return this.partialResponse;
    }

    shutdown(): void {
        try { this.driver.dispatch({ kind: 'shutdown' }); } catch { /* ignore */ }
    }

    cancel(): void {
        try { this.driver.dispatch({ kind: 'cancel' }); } catch { /* ignore */ }
    }

    isProcessing(): boolean {
        return this.getStatus().status === 'generating';
    }

    isReady(): boolean {
        return this.spawned && !this.exited;
    }

    // Process liveness for the MESH-STALL-WATCH watchdog (checkMeshWorkerStall).
    // The spec path drives the child through the transport/driver rather than a
    // directly-held ptyProcess handle, so liveness is tracked by the spawned/exited
    // lifecycle flags — the same pair isReady() uses. A spawned, not-yet-exited
    // session is alive. ProviderCliAdapter exposes the equivalent via `ptyProcess !== null`.
    isAlive(): boolean {
        return this.spawned && !this.exited;
    }

    // MESH-READ-TERMINAL / MESH-SEND-KEYS byte caps — same envelope as
    // ProviderCliAdapter (32KiB default view, 64KiB absolute hard cap). Bytes,
    // not chars: a multi-byte-glyph screen can exceed an MCP payload cap while
    // the char count still looks safe.
    private static readonly TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES = 32 * 1024;
    private static readonly TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES = 64 * 1024;

    /**
     * MESH-READ-TERMINAL (feature 2: RAW terminal read). Least-privilege read
     * of the CURRENT rendered viewport for mesh_read_terminal on the spec path
     * (claude-cli / antigravity / codex-cli — the native-source providers that
     * route through SpecCliAdapter). Mirrors ProviderCliAdapter.getTerminalScreenSnapshot:
     *  - returns ONLY the driver's current viewport snapshot, the cursor
     *    position and the terminal geometry — NO scrollback, NO parser/FSM
     *    state, NO debug buffers;
     *  - the payload is byte-bounded (UTF-8) with bottom-tail preservation so a
     *    screen of multi-byte glyphs can never exceed the MCP payload cap;
     *  - `hash` is over the FULL untruncated viewport so a caller can detect a
     *    screen change across polls even when the returned text was truncated.
     *
     * SECURITY: the raw viewport can carry tokens / command args / env / user
     * data. Callers MUST gate this on mesh ownership and MUST NOT log the text.
     */
    getTerminalScreenSnapshot(maxBytes = SpecCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES): {
        text: string;
        cursor: { col: number; row: number };
        cols: number;
        rows: number;
        truncated: boolean;
        originalBytes: number;
        returnedBytes: number;
        hash: string;
    } {
        const cap = Math.min(
            SpecCliAdapter.TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES,
            Math.max(1024, Math.floor(maxBytes) || SpecCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES),
        );
        let rawViewport = '';
        try { rawViewport = this.driver.snapshot() || ''; } catch { rawViewport = ''; }
        let cursor = { row: 0, col: 0 };
        try { cursor = this.driver.getCursorPosition(); } catch { /* keep 0,0 */ }
        // getScreenSize is optional on ISpecDriver; a test double may omit it.
        let size = { cols: 0, rows: 0 };
        try { size = this.driver.getScreenSize?.() ?? size; } catch { /* keep 0,0 */ }
        const truncation = truncateToByteTailByLine(rawViewport, cap);
        const hash = createHash('sha256').update(rawViewport, 'utf8').digest('hex').slice(0, 16);
        return {
            text: truncation.text,
            cursor: { col: cursor.col, row: cursor.row },
            cols: size.cols,
            rows: size.rows,
            truncated: truncation.truncated,
            originalBytes: truncation.originalBytes,
            returnedBytes: truncation.returnedBytes,
            hash,
        };
    }

    /**
     * MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key
     * sequence into the spec-driven PTY for mesh_send_keys. Mirrors
     * ProviderCliAdapter.injectKeys' modal fail-closed guard, then writes the
     * whole encoded sequence in ONE pty_write dispatch (text+ENTER is a single
     * contiguous string, so a submit key can never be separated from the text
     * it submits).
     *
     * The spec path drives the child through the FsmDriver, not a directly-held
     * ptyProcess — there is no adapter-level echo-gate/submit-retry FIFO to race
     * against here (the driver serializes its own writes), so the only guard is
     * the modal fail-closed: a NON-destructive injection into an actionable
     * approval modal is refused (use mesh_approve) unless explicitly overridden.
     * A destructive ESC/CTRL_C dismisses rather than confirms, so it is allowed
     * past this gate (the tool layer owns the destructive double-gate + audit).
     * This method NEVER logs the literal text — only key enums / byte length.
     */
    async injectKeys(
        items: MeshSendKeyItem[],
        opts: { allowModalOverride?: boolean } = {},
    ): Promise<
        | { ok: true; keys: MeshSendKeyName[]; hasDestructive: boolean; submits: boolean; bytes: number }
        | { ok: false; refused: 'submit_race' | 'actionable_modal'; keys: MeshSendKeyName[]; hasDestructive: boolean }
    > {
        if (!this.spawned || this.exited) throw new Error(`${this.cliName} is not running`);
        const encoded = encodeMeshSendKeys(items);

        // Modal fail-closed — a NON-destructive injection while parked on an
        // actionable approval modal is refused so a modal choice can't be
        // confirmed via send_keys and bypass the approval policy.
        const modalActive = this.latestState?.status === 'approval';
        if (modalActive && !encoded.hasDestructive && !opts.allowModalOverride) {
            LOG.warn('SpecAdapter', `[${this.cliType}] send_keys refused (actionable_modal): keys=${encoded.keys.join(',')} — use mesh_approve`);
            return { ok: false, refused: 'actionable_modal', keys: encoded.keys, hasDestructive: encoded.hasDestructive };
        }

        // Atomic write: the full encoded sequence goes out in ONE pty_write.
        this.driver.dispatch({ kind: 'pty_write', data: encoded.sequence });
        LOG.info('SpecAdapter', `[${this.cliType}] send_keys injected keys=${encoded.keys.join(',') || '(text-only)'} bytes=${Buffer.byteLength(encoded.sequence, 'utf8')} destructive=${encoded.hasDestructive}`);
        return {
            ok: true,
            keys: encoded.keys,
            hasDestructive: encoded.hasDestructive,
            submits: encoded.submits,
            bytes: Buffer.byteLength(encoded.sequence, 'utf8'),
        };
    }

    setOnStatusChange(cb: () => void): void {
        this.statusCallback = cb;
    }

    setOnPtyData(cb: (data: string) => void): void {
        this.ptyDataCallback = cb;
    }

    writeRaw(data: string): void {
        // Raw pty input — typed characters, escape codes, arrow keys —
        // goes straight to the underlying terminal. send_message would
        // append submit_key after every chunk, which is why typing in
        // the dashboard terminal felt like "enter on every keystroke".
        this.driver.dispatch({ kind: 'pty_write', data });
    }

    resize(cols: number, rows: number): void {
        this.driver.dispatch({ kind: 'resize', cols, rows });
    }

    resolveModal(buttonIndex: number): void {
        this.resolveModalMatched(buttonIndex);
    }

    resolveModalMatched(buttonIndex: number): boolean {
        // BUTTON-INDEX-MISMAP (Fix C): `buttonIndex` is an ARRAY POSITION into the
        // label list this adapter surfaced via getStatus().activeModal.buttons (the
        // same order pickApprovalButton / mesh_approve pick from). The FSM matches a
        // click by the button's DISPLAYED number (evaluator sets button.index =
        // Number(m[1])), which is NOT `arrayPos + 1` for a partial / non-contiguous
        // modal — e.g. a "1. Yes / 3. Always / 4. No" set parses to display indices
        // [1,3,4] at array positions [0,1,2]. Blindly sending `arrayPos + 1` then
        // targets a non-existent display index (2) and handleClickModalButton finds
        // no button → nothing is pressed. Look up the real FSM display index from the
        // same ordered button list instead, and fall back to the legacy +1 only when
        // no modal is captured (defensive; the driver's own guard rejects a miss).
        const buttons = this.latestModal?.buttons ?? [];
        const target = (buttonIndex >= 0 && buttonIndex < buttons.length)
            ? buttons[buttonIndex].index
            : buttonIndex + 1;
        // clickModalButton returns whether the FSM actually found a button for `target`
        // and dispatched its confirm keys — surfaced so mesh_approve can distinguish a
        // real press from a silent miss (the exact false-success the mis-map produced).
        return this.driver.clickModalButton(target);
    }

    async resolveAction(data: unknown): Promise<void> {
        const args = (data && typeof data === 'object') ? (data as any) : {};
        const explicitIndex = typeof args.buttonIndex === 'number' ? args.buttonIndex : -1;
        if (explicitIndex >= 0) { this.resolveModal(explicitIndex); return; }
        const action = typeof args.action === 'string' ? args.action : 'approve';
        const buttons = this.latestModal?.buttons ?? [];
        if (buttons.length === 0) return;
        let target = -1;
        if (action === 'reject' || action === 'deny') {
            target = buttons.findIndex(b => /^(no|deny|reject|cancel)\b/i.test(b.label));
            if (target < 0) target = buttons.length - 1;
        } else {
            target = buttons.findIndex(b => /^(yes|allow|approve|accept|continue|proceed|update)\b/i.test(b.label));
            if (target < 0) target = 0;
        }
        this.resolveModal(target);
    }

    async setInteractivePromptResponse(response: InteractivePromptResponse): Promise<void> {
        const prompt = this.activeInteractivePrompt;
        if (!prompt || prompt.promptId !== response.promptId) throw new Error('Interactive prompt response does not match active prompt');
        if (this.cliType !== 'claude-cli') return;
        if (this.interactivePromptTransport === 'tui') {
            const steps = buildClaudeInteractiveTuiAnswerSteps(prompt, response);
            for (const step of steps) {
                this.driver.dispatch({ kind: 'pty_write', data: step });
                await new Promise(resolve => setTimeout(resolve, 180));
            }
        } else {
            this.driver.dispatch({ kind: 'pty_write', data: `${buildClaudeInteractiveToolResult(response)}\n` });
        }
        this.activeInteractivePrompt = null;
        this.interactivePromptTransport = null;
        this.statusCallback?.();
    }

    isApprovalRecentlyResolved(): boolean { return false; }
    clearHistory(): void { /* no transcript buffer yet */ }
    updateRuntimeSettings(): void { /* no runtime settings in spec model yet */ }
    setServerConn(): void { /* server conn unused by SpecDriver */ }
    /**
     * Map an invokeScript(name, args) call onto a control_bar entry.
     *
     * scriptName is matched against control.id. The control's action.type
     * drives the dispatch:
     *
     *   send_keys     → click_control                   (e.g. stop)
     *   open_picker   → two roles, driven by the screen, not a hardcoded list:
     *                   - LIST  (no choice arg): open the picker, wait for it
     *                     to render, parse the on-screen options via
     *                     `extract_choices`, and return them as
     *                     `controlResult.options` (+ `currentValue`). This is
     *                     how the dashboard's Model/Mode controls learn what is
     *                     actually selectable in this CLI right now.
     *                   - SELECT (args.choiceIndex / args.choiceLabel): drive
     *                     the picker to that option using `submit_key`.
     *   attach_image  → attach_image dispatch; expects args.blob (data url
     *                   or base64) and args.mime
     *
     * Callers that pass an unknown control id get a { not_found } response.
     * No control matched, no driver call — keeps the surface honest.
     */
    invokeScript(scriptName: string, args?: Record<string, unknown>): Promise<unknown> {
        const controls = this.spec.control_bar ?? [];
        const ctl = controls.find(c => c.id === scriptName);
        if (!ctl) {
            return Promise.resolve({ ok: false, error: `unknown control: ${scriptName}` });
        }
        // Args may arrive as either { blob, mime } (direct invocation) or
        // { params: { blob, mime } } (when the dashboard wraps script args
        // in a params bag). Look at both.
        const flat: Record<string, unknown> = { ...(args || {}) };
        if (args && typeof args.params === 'object' && args.params) {
            Object.assign(flat, args.params as Record<string, unknown>);
        }
        const action = ctl.action;
        if (action.type === 'attach_image') {
            const blob = typeof flat.blob === 'string' ? flat.blob : '';
            const mime = typeof flat.mime === 'string' ? flat.mime : 'image/png';
            if (!blob) return Promise.resolve({ ok: false, error: 'attach_image requires args.blob (base64 or data URL)' });
            this.driver.dispatch({ kind: 'attach_image', blob, mime });
            return Promise.resolve({ ok: true, effects: [{ type: 'attached_image', controlId: ctl.id }] });
        }
        if (action.type === 'open_picker') {
            const choiceIndex = typeof flat.choiceIndex === 'number' ? flat.choiceIndex
                : typeof flat.choiceIndex === 'string' && flat.choiceIndex.trim() ? Number(flat.choiceIndex)
                : undefined;
            // `value` is the arg the dashboard's generic value-control set path
            // sends ({ value: <chosen option> }). control_bar pickers are
            // surfaced to the dashboard as dynamic `select` controls whose
            // option values are the screen-parsed labels, so a bare `value`
            // is just a label to match against the live choices.
            const choiceLabel = typeof flat.choiceLabel === 'string' ? flat.choiceLabel
                : typeof flat.choice === 'string' ? flat.choice
                : typeof flat.value === 'string' ? flat.value
                : undefined;
            if ((typeof choiceIndex === 'number' && Number.isFinite(choiceIndex)) || (choiceLabel && choiceLabel.trim())) {
                return this.selectPickerChoice(ctl, action, choiceIndex, choiceLabel);
            }
            return this.openPickerAndListChoices(ctl, action);
        }
        // send_keys routes through click_control.
        this.driver.dispatch({ kind: 'click_control', control_id: ctl.id, payload: flat });
        return Promise.resolve({ ok: true, effects: [{ type: 'sent_keys', controlId: ctl.id }] });
    }

    /**
     * Open an `open_picker` control and return the options the CLI is showing,
     * parsed live from the screen via `extract_choices`. Nothing is selected —
     * the picker is left open so a follow-up SELECT invoke can commit a choice.
     */
    private async openPickerAndListChoices(
        ctl: Control,
        action: Extract<ControlAction, { type: 'open_picker' }>,
    ): Promise<unknown> {
        this.driver.dispatch({ kind: 'click_control', control_id: ctl.id });
        const ready = await this.waitForPickerRendered(action);
        const options = this.extractPickerChoices(action);
        const currentValue = options.find(o => o.current)?.label;
        return {
            ok: true,
            effects: [{ type: 'opened_picker', controlId: ctl.id }],
            controlResult: {
                options: options.map(o => ({ value: o.label, label: o.label, current: o.current })),
                ...(currentValue ? { currentValue } : {}),
                source: 'screen-parse',
                ...(ready ? {} : { warning: 'picker_render_timeout' }),
            },
        };
    }

    /**
     * Drive an already-listable picker to a specific option. The option can be
     * named (choiceLabel — matched against the parsed on-screen labels) or
     * positional (choiceIndex — the on-screen number). The actual keystrokes
     * come from the spec's `submit_key` with `{index}` substituted, so the spec
     * — not this code — decides how a selection is keyed for each CLI.
     */
    private async selectPickerChoice(
        ctl: Control,
        action: Extract<ControlAction, { type: 'open_picker' }>,
        choiceIndex: number | undefined,
        choiceLabel: string | undefined,
    ): Promise<unknown> {
        // Open + wait so the choice list is on screen before we resolve the
        // label → index mapping. The picker is normally ALREADY open here (a
        // preceding list invoke leaves it rendered), so only send the trigger
        // when it is not on screen. Re-sending the trigger to an open picker is
        // NOT a harmless no-op on claude-cli: the trailing CR of `/model\r`
        // lands as Enter on the cursor's current row and commits the wrong
        // model before we navigate. De-dup the open to avoid that.
        let options = this.extractPickerChoicesIfRendered(action);
        if (!options) {
            this.driver.dispatch({ kind: 'click_control', control_id: ctl.id });
            await this.waitForPickerRendered(action);
            options = this.extractPickerChoices(action);
        }

        let index = choiceIndex;
        if ((index == null || !Number.isFinite(index)) && choiceLabel) {
            const needle = choiceLabel.trim().toLowerCase();
            const match = options.find(o => o.label.toLowerCase().includes(needle));
            if (!match) {
                return { ok: false, error: `choice not found on screen: ${choiceLabel}`, controlResult: { options: options.map(o => ({ value: o.label, label: o.label })) } };
            }
            index = match.index;
        }
        if (index == null || !Number.isFinite(index)) {
            return { ok: false, error: 'choiceIndex or choiceLabel required to select' };
        }

        if (action.select_mode === 'arrow_keys') {
            // Cursor-list picker (claude-cli /model): number keys are ignored.
            // The cursor starts on the active row (extract flags it `current`);
            // step it to the target row with arrows, then confirm.
            const current = options.find(o => o.current);
            if (current == null) {
                // Without a known cursor position a blind Enter would commit
                // whatever row the cursor sits on — fail loud instead.
                return {
                    ok: false,
                    error: 'arrow-nav picker: current cursor row not detected on screen',
                    controlResult: { options: options.map(o => ({ value: o.label, label: o.label, current: o.current })) },
                };
            }
            const up = action.cursor_keys?.up ?? '[A';
            const down = action.cursor_keys?.down ?? '[B';
            const delta = index - current.index;
            const step = delta >= 0 ? down : up;
            const nav = step.repeat(Math.abs(delta));
            // Confirm key = submit_key with the (unused) {index} placeholder
            // stripped — e.g. `{index}\r` → `\r`.
            const confirm = (action.submit_key || '\r').replace(/\{index\}/g, '') || '\r';
            if (nav) this.driver.dispatch({ kind: 'pty_write', data: nav });
            this.driver.dispatch({ kind: 'pty_write', data: confirm });
        } else {
            const keys = (action.submit_key || '{index}\r').replace(/\{index\}/g, String(index));
            this.driver.dispatch({ kind: 'pty_write', data: keys });
        }
        const selected = options.find(o => o.index === index);
        return {
            ok: true,
            effects: [{ type: 'selected_choice', controlId: ctl.id }],
            controlResult: {
                ok: true,
                ...(selected ? { currentValue: selected.label } : {}),
                selectedIndex: index,
            },
        };
    }

    /** Parse the picker choices only if the picker already appears rendered on
     *  the live screen (its `wait_for` condition currently matches and at least
     *  one choice parses). Returns the parsed choices when open, else null so
     *  the caller knows it must send the trigger to open it. Used to de-dup the
     *  picker open in {@link selectPickerChoice}. */
    private extractPickerChoicesIfRendered(
        action: Extract<ControlAction, { type: 'open_picker' }>,
    ): Array<{ index: number; label: string; current: boolean }> | null {
        const wf = action.wait_for;
        if (wf?.regex) {
            const re = new RegExp(wf.regex, wf.flags ?? 'i');
            if (!re.test(this.readScreenSectionText(wf.section))) return null;
        }
        const options = this.extractPickerChoices(action);
        return options.length > 0 ? options : null;
    }

    /** Poll the live screen until the picker's `wait_for` condition matches,
     *  up to a short budget. Returns true if it rendered, false on timeout. */
    private async waitForPickerRendered(action: Extract<ControlAction, { type: 'open_picker' }>): Promise<boolean> {
        const wf = action.wait_for;
        if (!wf?.regex) { await delay(250); return true; }
        const re = new RegExp(wf.regex, wf.flags ?? 'i');
        const deadline = Date.now() + 2500;
        while (Date.now() < deadline) {
            await delay(120);
            const hay = this.readScreenSectionText(wf.section);
            if (re.test(hay)) return true;
        }
        return false;
    }

    /** Parse the picker's `extract_choices` pattern against the live screen.
     *  Each match yields { index, label, current }. `current` is true for the
     *  line the CLI marks with its cursor glyph (❯ ›). Purely screen-driven —
     *  no model/mode names are baked in. */
    private extractPickerChoices(action: Extract<ControlAction, { type: 'open_picker' }>): Array<{ index: number; label: string; current: boolean }> {
        const ec = action.extract_choices;
        if (!ec?.pattern) return [];
        const text = this.readScreenSectionText(ec.section);
        // Collect EVERY matching line in screen order with no top-down de-dup.
        // The picker section can include conversation history above it (a stray
        // "1./2./3." list, blockquote `>` lines); a `seen.has(idx)` first-wins
        // scan would let those body lines claim the option indices and shadow
        // the real choices — committing the wrong model under arrow-key nav.
        const all: Array<{ index: number; label: string; current: boolean }> = [];
        for (const rawLine of text.split('\n')) {
            const line = rawLine.replace(/\r$/, '');
            const m = new RegExp(ec.pattern, ec.flags ?? '').exec(line);
            if (!m) continue;
            const idx = Number(m[1]);
            if (!Number.isFinite(idx) || idx <= 0) continue;
            const label = (m[2] ?? '').replace(/\s+/g, ' ').trim();
            if (!label) continue;
            const current = /^\s*[❯›>]/.test(line) || /[✔✓●]\s*$/.test(label);
            all.push({ index: idx, label, current });
        }
        // Real options are the bottom-most contiguous numbered block; this also
        // confines the `current` cursor flag to that block so a body `>` line is
        // never mistaken for the cursor row.
        return lastContiguousNumberedBlock(all);
    }

    /** Live text of a named screen section (or the whole screen when no
     *  section is named), resolved from the driver's current sections. */
    private readScreenSectionText(sectionId?: string): string {
        try {
            const sections = this.driver.getSections();
            if (sectionId && sections) {
                const hit = sections.find(s => s.id === sectionId);
                if (hit) return hit.text;
            }
            return this.driver.getScreen();
        } catch {
            return '';
        }
    }
    getDebugSnapshot(): unknown {
        let screen = '';
        let sections: Record<string, string> | undefined;
        try {
            screen = this.driver.snapshot();
            const driverSections = this.driver.getSections?.();
            if (driverSections) {
                sections = Object.fromEntries(driverSections.map(s => [s.id, s.text]));
            } else {
                sections = this.readCurrentScreenSections(screen);
            }
        } catch { /* best-effort */ }
        // Read native transcript messages for the debug snapshot
        let messages: any[] = [];
        if (this.spec.native_history?.source) {
            try {
                const nhResult = executeNativeHistory(this.spec.native_history, {
                    agentType: this.cliType,
                    providerSessionId: this.providerSessionId,
                    sessionStartedAtMs: this.spawnedAtMs,
                    envOverrides: this.spawnedEnv,
                    workspace: this.workingDir,
                });
                if (nhResult && Array.isArray(nhResult.messages)) messages = nhResult.messages;
            } catch { /* best-effort */ }
        } else {
            messages = this.readClaudeScreenAssistantMessages();
        }
        return {
            cliType: this.cliType,
            spec_id: this.spec.id,
            current_state: this.latestState,
            current_modal: this.latestModal,
            activeInteractivePrompt: this.activeInteractivePrompt,
            exited: this.exited,
            screen,
            sections,
            stateHistory: this.driver.getStateHistory(),
            idleHoldPending: this.driver.hasIdleHoldPending(),
            lastBusyAt: this.driver.getLastBusyAt(),
            specPath: this.driver.getSpecPath(),
            cursorPosition: this.driver.getCursorPosition(),
            completionIdleDebounce: this.driver.getCompletionIdleDebounceState(),
            // v4 FSM live transition table (null for v3 specs). Every outgoing
            // transition from the current state with its per-condition match
            // result + countdown — the canonical "why isn't it moving" answer.
            fsm: this.driver.getFsmDebug?.() ?? null,
            // v4 FSM transition snapshot history (null for v3 specs). The full
            // pre-transition evaluation table captured at each transition —
            // answers "why did this rule fire" after the fact, unlike the live
            // `fsm` field which only reflects the current instant.
            fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
            // PTY input/output/resize/cursor event timeline (debug-only) so the
            // snapshot shows what we typed / what the PTY printed around each
            // status transition. Null for drivers without the timeline.
            eventTimeline: this.driver.getEventTimeline?.() ?? null,
            // Extended fields
            name: this.cliName,
            status: this.getStatus().status,
            workingDir: this.workingDir,
            spawnedAtMs: this.spawnedAtMs,
            providerSessionId: this.providerSessionId ?? null,
            messages,
            committedMessages: messages,
        };
    }
    getRuntimeMetadata(): unknown {
        return {
            runtimeId: this.spec.id,
            runtimeKey: this.spec.id,
            displayName: this.spec.name,
            spawnedAtMs: this.spawnedAtMs,
            spawnedEnv: this.spawnedEnv,
            ...(this.providerSessionId ? { providerSessionId: this.providerSessionId } : {}),
        };
    }
    updateRuntimeMeta(meta?: Record<string, unknown>): void {
        if (!meta) return;
        if (typeof meta.providerSessionId === 'string') {
            this.providerSessionId = meta.providerSessionId;
        }
        // Forward the FULL meta (meshNodeId / meshNodeFor / workspaceLabel /
        // lifecycle / …) down to the transport so it reaches the session
        // registry. The legacy ProviderCliAdapter.updateRuntimeMeta does the
        // same via ptyProcess.updateMeta; the spec path previously dropped
        // everything but providerSessionId, leaving autoLaunch's meshNodeId
        // stamp unbound on the record — the root of SESSION-ACCUMULATION-LEAK.
        try { this.driver.updateMeta(meta); } catch { /* transport may not support meta */ }
    }
    refreshProviderDefinition(): void { /* hot reload handled by SpecDriver fs.watch */ }

    private handleEvent(ev: DashboardEvent): void {
        this.lastEvent = ev;
        switch (ev.kind) {
            case 'state_changed':
                this.latestState = ev.state;
                this.latestModal = ev.modal;
                // info-level keeps only spec-defined identifiers (state.id /
                // state.label / button count). The extracted title can carry
                // user data — file paths, command text, ticket titles — so
                // it stays at debug.
                LOG.info('SpecAdapter', `[${this.cliType}] state=${ev.state.id} (${ev.state.label}) modal=${ev.modal ? `${ev.modal.buttons.length}-buttons` : 'none'}`);
                if (ev.state.title) {
                    LOG.debug('SpecAdapter', `[${this.cliType}] state.title=${JSON.stringify(ev.state.title)}`);
                }
                this.maybeClearResolvedClaudeTuiPrompt();
                this.maybeCaptureClaudeTuiPrompt();
                this.maybeUpgradeClaudeTuiMultiSelect();
                this.statusCallback?.();
                return;
            case 'pty_data':
                this.detectInteractivePromptFromPtyChunk(ev.chunk);
                this.maybeClearResolvedClaudeTuiPrompt();
                this.maybeCaptureClaudeTuiPrompt();
                this.maybeUpgradeClaudeTuiMultiSelect();
                try { this.ptyDataCallback?.(ev.chunk); } catch { /* ignore */ }
                return;
            case 'exit':
                this.exited = true;
                this.statusCallback?.();
                return;
            case 'spec_error':
                LOG.warn('SpecAdapter', `[${this.cliType}] spec reload error: ${ev.errors.join('; ')}`);
                return;
            default:
                return;
        }
    }

    private detectInteractivePromptFromPtyChunk(chunk: string): void {
        if (this.cliType !== 'claude-cli' || !chunk) return;
        this.jsonLineTail += chunk;
        if (this.jsonLineTail.length > 64 * 1024) this.jsonLineTail = this.jsonLineTail.slice(-64 * 1024);
        const lines = this.jsonLineTail.split(/\r?\n/);
        this.jsonLineTail = lines.pop() || '';
        for (const line of lines) {
            const trimmed = line.trim();
            if (!trimmed.startsWith('{') || !trimmed.includes('AskUserQuestion')) continue;
            try {
                const parsed = JSON.parse(trimmed);
                const prompt = detectClaudeAskUserQuestionPromptFromJson(parsed, this.cliType);
                if (!prompt) continue;
                this.activeInteractivePrompt = prompt;
                this.interactivePromptTransport = 'stream-json';
                this.interactivePromptLostAt = null;
                this.statusCallback?.();
            } catch {
                // PTY output is not guaranteed to be machine JSON.
            }
        }
    }

    private readCurrentScreenSections(_screenText: string): Record<string, string> {
        try {
            const sections = this.driver.getSections() ?? [];
            return Object.fromEntries(sections.map(section => [section.id, section.text]));
        } catch {
            return {};
        }
    }

    private extractProviderSessionIdFromScreen(): string | undefined {
        if (this.cliType !== 'codex-cli') return this.providerSessionId;
        let screenText = '';
        try {
            screenText = this.driver.snapshot();
        } catch {
            return this.providerSessionId;
        }
        const clean = stripAnsi(screenText);
        const match = clean.match(/(?:gpt-|o\d|codex-)[^\n·]*·[^\n·]*·\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
        return match?.[1] || this.providerSessionId;
    }

    private readClaudeScreenAssistantMessages(): ChatMessage[] {
        if (this.cliType !== 'claude-cli') return [];
        let screenText = '';
        try {
            screenText = this.driver.snapshot();
        } catch {
            return [];
        }
        const sections = this.readCurrentScreenSections(screenText);
        const body = sections.body || screenText;
        const messages: ChatMessage[] = [];
        const seen = new Set<string>();
        for (const line of body.split(/\r?\n/)) {
            const match = line.match(/^\s*⏺\s+(.+?)\s*$/);
            const content = match?.[1]?.trim();
            if (!content || seen.has(content)) continue;
            seen.add(content);
            messages.push({
                role: 'assistant',
                kind: 'standard',
                content,
                source: 'assistant_text',
                userFacing: true,
                bubbleState: 'final',
            });
        }
        return messages;
    }

    /**
     * Grace window a held interactive prompt must be absent from the screen
     * before we treat it as resolved-in-terminal and clear it. claude-cli
     * repaints the picker across multiple PTY chunks, so a single
     * footer-less frame is not proof the prompt is gone. Sized in the same
     * spirit as the approval FSM's `approvalCooldown` modal-lost hysteresis.
     */
    private static readonly INTERACTIVE_PROMPT_LOST_GRACE_MS = 1500;

    /**
     * Multi-question TUI capture: after Tabbing to a page, re-snapshot at this
     * interval until its checkbox glyph column has settled, up to the timeout.
     * The interval matches the legacy single fixed wait (120ms); the timeout
     * bounds total capture time so a single-select page (which never shows
     * glyphs) doesn't stall the capture indefinitely.
     */
    private static readonly CLAUDE_TUI_PAGE_POLL_INTERVAL_MS = 120;
    private static readonly CLAUDE_TUI_PAGE_SETTLE_TIMEOUT_MS = 600;

    /**
     * Clear a held interactive prompt once the user has resolved it directly
     * in the terminal (the choice picker leaves the screen without going
     * through setInteractivePromptResponse). The approval path already does
     * this via the FSM's modal-lost hysteresis; the interactive-prompt path
     * had no equivalent, so a terminal-side answer left activeInteractivePrompt
     * set and getStatus() re-emitted the same choice modal forever.
     *
     * Detection mirrors capture: the claude TUI picker is on-screen exactly
     * while its "Enter to select" footer is rendered. When the footer is gone
     * for INTERACTIVE_PROMPT_LOST_GRACE_MS the prompt is genuinely resolved.
     */
    private maybeClearResolvedClaudeTuiPrompt(): void {
        if (this.cliType !== 'claude-cli' || !this.activeInteractivePrompt) return;
        // stream-json prompts are tracked by their tool-call lifecycle, not by
        // screen footer, but claude renders the same TUI picker for both
        // transports while awaiting an answer — so screen presence is a valid
        // resolved-signal for either. (If the screen read fails, keep holding.)
        let screenText = '';
        try {
            screenText = this.driver.snapshot();
        } catch {
            return;
        }
        const stillOnScreen = screenText.includes('Enter to select');
        if (stillOnScreen) {
            // Prompt reappeared / never left — reset the hysteresis timer.
            this.interactivePromptLostAt = null;
            return;
        }
        const lostAt = this.interactivePromptLostAt ?? Date.now();
        if (this.interactivePromptLostAt === null) this.interactivePromptLostAt = lostAt;
        if (Date.now() - lostAt < SpecCliAdapter.INTERACTIVE_PROMPT_LOST_GRACE_MS) return;
        // Resolved in the terminal — drop the held prompt so getStatus() stops
        // re-emitting it.
        this.activeInteractivePrompt = null;
        this.interactivePromptTransport = null;
        this.interactivePromptLostAt = null;
        this.statusCallback?.();
    }

    private maybeCaptureClaudeTuiPrompt(): void {
        if (this.cliType !== 'claude-cli'
            || this.activeInteractivePrompt
            || this.claudeTuiPromptCaptureInFlight) return;
        const screenText = this.driver.snapshot();
        const headers = this.readClaudeTuiHeaders(screenText);
        if (!screenText.includes('Enter to select')) return;
        if (headers.length === 0) {
            const prompt = detectClaudeAskUserQuestionPromptFromTuiPages([{ screenText }], {
                promptId: `ask-user-${this.providerSessionId || 'claude'}-${Date.now()}`,
                providerType: this.cliType,
            });
            if (!prompt) return;
            this.activeInteractivePrompt = prompt;
            this.interactivePromptTransport = 'tui';
            this.interactivePromptLostAt = null;
            this.statusCallback?.();
            return;
        }
        this.claudeTuiPromptCaptureInFlight = true;
        void this.captureClaudeTuiPrompt(screenText, headers).finally(() => {
            this.claudeTuiPromptCaptureInFlight = false;
        });
    }

    /**
     * The TUI prompt is captured on the FIRST frame that renders the
     * "Enter to select" footer. At that instant the option rows' checkbox
     * column may not have drawn yet, so `detectClaudeTuiMultiSelect` returns
     * false and the prompt is frozen as single-select — the dashboard then
     * renders radio buttons even though the picker is multi-select.
     *
     * While the same TUI prompt is still on screen, re-check the live snapshot:
     * if checkbox glyphs have since appeared, promote any single-select
     * question to multi-select and re-emit status. Promotion is one-way
     * (false→true only) — once a question is known multi-select we never demote
     * it, since the glyph column can scroll out of view on later frames.
     *
     * For MULTI-question prompts the per-page Tab capture is the actual source
     * of the bug: pages 2..N are snapshotted ~120ms after the Tab keypress,
     * before their option-row glyph column has redrawn, so those questions
     * freeze as single-select while page 1 (already settled) is correct. We
     * cannot upgrade blindly — the live snapshot shows only ONE focused page —
     * but we CAN read that page's question text/header and upgrade the matching
     * question. As the user navigates the picker (or it settles), each page is
     * eventually re-read and repaired.
     */
    private maybeUpgradeClaudeTuiMultiSelect(): void {
        if (this.cliType !== 'claude-cli'
            || this.interactivePromptTransport !== 'tui'
            || !this.activeInteractivePrompt) return;
        const questions = this.activeInteractivePrompt.questions;
        if (questions.every(q => q.multiSelect)) return;
        let screenText = '';
        try {
            screenText = this.driver.snapshot();
        } catch {
            return;
        }
        if (!screenText.includes('Enter to select')) return;

        if (questions.length === 1) {
            if (questions[0].multiSelect) return;
            if (!detectClaudeTuiMultiSelect(screenText)) return;
            questions[0].multiSelect = true;
            this.statusCallback?.();
            return;
        }

        // Multi-question: attribute the focused page's glyphs to its question by
        // matching header (preferred) or question text, then upgrade just that
        // one. Never demote — a settled non-multi page is left as captured.
        const focused = readFocusedClaudeTuiQuestion(screenText);
        if (!focused || !focused.multiSelect) return;
        const match = questions.find(q =>
            (focused.header && q.header && q.header === focused.header)
            || q.question === focused.question);
        if (!match || match.multiSelect) return;
        match.multiSelect = true;
        this.statusCallback?.();
    }

    private readClaudeTuiHeaders(screenText: string): string[] {
        const navLine = screenText.split(/\r?\n/).find(line => line.includes('✔ Submit') && /[☐☒]/.test(line));
        if (!navLine) return [];
        const headers: string[] = [];
        for (const match of navLine.matchAll(/[☐☒]\s+(.+?)(?=\s+[☐☒]|\s+✔\s+Submit)/g)) {
            const header = match[1]?.trim();
            if (header) headers.push(header);
        }
        return headers;
    }

    /**
     * Snapshot the currently-focused claude TUI page, polling until its
     * option-row checkbox glyph column has settled (or a bounded timeout).
     *
     * Why poll: right after a Tab keypress the newly-focused page's glyph
     * column has not redrawn yet, so an immediate snapshot shows the question +
     * option labels but NO per-option checkbox markers — freezing that page as
     * single-select. A fixed delay either races (too short) or is wasteful (too
     * long). Instead we re-snapshot at a fixed interval and stop as soon as the
     * frame shows multi-select glyphs, falling back to the last frame at the
     * timeout. Single-select pages never show glyphs, so they always poll to the
     * timeout — bounded small to keep capture snappy.
     */
    private async snapshotSettledClaudeTuiPage(): Promise<string> {
        let screenText = this.driver.snapshot();
        const deadline = Date.now() + SpecCliAdapter.CLAUDE_TUI_PAGE_SETTLE_TIMEOUT_MS;
        while (!detectClaudeTuiMultiSelect(screenText) && Date.now() < deadline) {
            await new Promise(resolve => setTimeout(resolve, SpecCliAdapter.CLAUDE_TUI_PAGE_POLL_INTERVAL_MS));
            screenText = this.driver.snapshot();
        }
        return screenText;
    }

    private async captureClaudeTuiPrompt(firstScreen: string, headers: string[]): Promise<void> {
        const pages: ClaudeInteractiveTuiPage[] = [{ screenText: firstScreen, header: headers[0] }];
        // Forward pass: Tab to each page 2..N and snapshot once its glyph column
        // has settled, so pages 2+ capture their checkbox markers (not a racy
        // pre-redraw frame).
        for (let index = 1; index < headers.length; index += 1) {
            this.driver.dispatch({ kind: 'pty_write', data: '\t' });
            await new Promise(resolve => setTimeout(resolve, SpecCliAdapter.CLAUDE_TUI_PAGE_POLL_INTERVAL_MS));
            pages.push({ screenText: await this.snapshotSettledClaudeTuiPage(), header: headers[index] });
        }
        // Return pass: Shift-Tab back through pages N..2. As we land on each page
        // again re-read it and OR-in any now-visible multi-select glyphs — a
        // second chance to repair a page whose forward-pass frame was still racy.
        for (let index = headers.length - 1; index > 0; index -= 1) {
            this.driver.dispatch({ kind: 'pty_write', data: '\x1b[Z' });
            await new Promise(resolve => setTimeout(resolve, SpecCliAdapter.CLAUDE_TUI_PAGE_POLL_INTERVAL_MS));
            const reread = await this.snapshotSettledClaudeTuiPage();
            // The page we just Shift-Tab'd ONTO is index-1 (we move backwards).
            const landed = pages[index - 1];
            if (landed
                && !detectClaudeTuiMultiSelect(landed.screenText)
                && detectClaudeTuiMultiSelect(reread)) {
                landed.screenText = reread;
            }
        }

        const prompt = detectClaudeAskUserQuestionPromptFromTuiPages(pages, {
            promptId: `ask-user-${this.providerSessionId || 'claude'}-${Date.now()}`,
            providerType: this.cliType,
        });
        if (!prompt) return;
        this.activeInteractivePrompt = prompt;
        this.interactivePromptTransport = 'tui';
        this.interactivePromptLostAt = null;
        this.statusCallback?.();
    }

    getDebugState(): Record<string, any> {
        const screen = this.driver.getScreen?.() ?? '';
        const history = this.driver.getStateHistory();
        const status = this.getStatus();
        
        let messages: any[] = [];
        if (this.spec.native_history?.source) {
            try {
                const result = executeNativeHistory(this.spec.native_history, {
                    agentType: this.cliType,
                    providerSessionId: this.providerSessionId,
                    sessionStartedAtMs: this.spawnedAtMs,
                    envOverrides: this.spawnedEnv,
                    workspace: this.workingDir,
                });
                if (result && Array.isArray(result.messages)) {
                    messages = result.messages;
                }
            } catch (e) {
                // Ignore native history read errors in debug state
            }
        } else {
            messages = this.readClaudeScreenAssistantMessages();
        }

        const latestState = this.latestState;
        const latestModal = this.latestModal;
        return {
            type: this.cliType,
            name: this.cliName,
            status: status.status,
            rawStatus: status.status,
            projectedStatus: status.status,
            ready: this.spawned,
            // Legacy snapshot-style fields for panels that read getDebugSnapshot shape
            spec_id: this.spec.id,
            current_state: latestState ?? null,
            current_modal: latestModal ?? null,
            exited: this.exited,
            idleHoldPending: this.driver.hasIdleHoldPending?.() ?? false,
            lastBusyAt: this.driver.getLastBusyAt?.() ?? 0,
            screen: screen,
            screenText: screen,
            workingDir: this.workingDir,
            spawnedAtMs: this.spawnedAtMs,
            providerSessionId: this.providerSessionId ?? null,
            sections: this.driver.getSections?.() ?? null,
            stateHistory: history,
            specPath: this.driver.getSpecPath?.() ?? null,
            // v4 FSM live transition table — present only for FsmDriver. Lets
            // the panel (and the daemon API) show, for the current instant,
            // every outgoing transition with its per-condition match result
            // and countdown. This is the canonical "why isn't it transitioning"
            // answer — no screenshots needed.
            fsm: this.driver.getFsmDebug?.() ?? null,
            // v4 FSM transition snapshot history — the captured pre-transition
            // evaluation table at each transition (null for v3 specs).
            fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
            // PTY input/output/resize/cursor event timeline (debug-only).
            eventTimeline: this.driver.getEventTimeline?.() ?? null,
            messages,
            committedMessages: messages,
        };
    }

    getTraceState(limit = 120): Record<string, any> {
        const history = this.driver.getStateHistory();
        return {
            status: this.getStatus().status,
            stateHistory: history.slice(-limit),
            screenText: this.driver.getScreen?.() ?? '',
        };
    }

    getProviderResolutionMeta(): Record<string, any> {
        return {
            type: this.cliType,
            providerDir: null,
            resolvedVersion: null,
        };
    }
}
