/**
 * driver-runner.ts — Phase B B5 (minimum-demo cut) driver hot-loop.
 *
 * Composes the existing primitives into a runnable agent process:
 *   transport (B1) → operator-pickup filter (C6) → SDK query → adapter (C1) → POST chunks
 *
 * Entry point: spawned as a child of B6 supervisor; reads env for
 * HOST_CP_URL / HOST_CP_BEARER / WORLD_ID / SESSION_ID /
 * DRIVER_SYSTEM_PROMPT_APPEND (legacy: DRIVER_SYSTEM_PROMPT — falls back to
 * the new name post-`olam-sdk-latency-tuning` Phase A) /
 * AGENT_DRIVER_TOOLS_ENABLED (set to "1" to re-enable Bash/Edit/Write/Read/
 * Glob/Grep/Task for the conversational driver; default off per Phase A).
 *
 * Demo-cut simplifications (per minimum-demo decision; full contract
 * deferred to follow-up Phase B B5-full):
 *   - lastConsumedSeq is IN-MEMORY only (no chunks_subscriber_state
 *     server-side table). Restart loses cursor; for demo this means a
 *     re-spawn replays operator chunks from `seq=0` (the cold-start
 *     sentinel from `interject` Fallback). Idempotency at the adapter
 *     level (PK message_id+seq) keeps the substrate clean.
 *   - No host-cp REST endpoint for cursor write/read.
 *   - No PID-recycle-safe cursor file naming (cursor is in process memory only).
 *   - Pure shared-secret auth (the bearer env value); JWT scope-claim
 *     migration is B9 / post-demo.
 *
 * Iteration model:
 *   - Wait for the next operator chunk via transport (or boot-time
 *     `dispatch` chunk on first poll).
 *   - Build the iteration's prompt prefix from accumulated pickups.
 *   - Invoke SDK query() with the prefix + a small system prompt.
 *   - Stream SDK output via streamSdkToChunks back to host-cp.
 *   - Loop until SIGTERM.
 *
 * Source: docs/design/olam-plan-chat-agent-runtime.md `interject` +
 * `subscriber` sections, minimum-demo cut.
 */
import { randomUUID } from 'node:crypto';
import { makeHostCpChunkPoster, streamMultiTurnSdkToChunks, } from './agent-sdk-to-chunks.js';
import { subscribeToChunks } from './chunks-subscriber-transport.js';
import { filterOperatorPickups, pickupConsumedKey, pickupsToPromptPrefix, } from './operator-subscription.js';
import { makeAskUserQuestionCanUseTool } from './question-broker-bridge.js';
/**
 * Default `append` text for the Claude Code preset system prompt.
 * Exported so the regression test can assert non-empty + coordinator-shaped
 * content (Phase A.1 adv-7 follow-up: the snapshot test alone doesn't catch
 * a "default emptied during refactor" regression).
 */
export const DEFAULT_SYSTEM_PROMPT_APPEND = `You are the driver agent inside an olam plan-chat session. The operator may interject mid-iteration; their interjects appear as a prefix to your next prompt. Acknowledge interjects explicitly in your response (e.g. "noted — switching to retry middleware") and continue the work. Keep responses focused and under 200 tokens unless deep analysis is required. You are a conversational coordinator — do not invoke filesystem/code tools unless explicitly asked.`;
/**
 * Builds the SDK options object used for every `query()` call. Exported
 * for snapshot testing (Phase A A2) — the snapshot is the regression
 * gate that prevents the pre-Phase-A malformation from recurring.
 *
 * The `abortController` field is NOT included here; the caller adds it
 * at the call site so this helper stays deterministic + snapshot-safe.
 *
 * Phase A levers applied here:
 *  - `systemPrompt: { type: 'preset', preset: 'claude_code', append: <hint> }` —
 *    fixes the pre-Phase-A `{type:'preset', preset:<custom-string>}`
 *    malformation; the SDK runtime extracts only `append` on the preset
 *    branch (Pass 2 source-read at sdk.mjs:847641; Pass 3 runtime probe).
 *  - `allowedTools: []` — empty allowlist filters out ALL tools from the
 *    Claude Code preset. Allowlist (not denylist — corrects adv-1 from
 *    Phase A CP3): the SDK's tool surface grows over time; allowlist
 *    means new tools stay disabled by default until explicitly opted in,
 *    which preserves the "coordinator-not-coder" seam.
 *  - `effort: 'low'` + `thinking: { type: 'disabled' }` — driver doesn't
 *    do deep reasoning; sub-agents inherit their own config.
 *  - `model: 'claude-sonnet-4-6'` — Pass 4 OQ7 resolution. Driver
 *    coordinator runs Sonnet, not Opus. (`claude-sonnet-4-6` is the
 *    SDK's documented model-ID example shape per sdk.d.ts:1472,1626.)
 *
 * `permissionMode` is intentionally omitted: with `allowedTools: []` the
 * permission decision is moot (zero tools to permission). The pre-fix
 * `permissionMode: 'dontAsk'` was kept as belt-and-suspenders but its
 * SDK semantic — "deny if not pre-approved" — is exactly what an empty
 * allowlist already enforces (corrects adv-2).
 *
 * Escape hatch: `AGENT_DRIVER_TOOLS_ENABLED=1` removes the allowedTools
 * field entirely so the SDK falls back to the preset's full tool set.
 * Documented per plan T2 mitigation. Note the env-var is read from the
 * driver child process; the supervisor propagates its env, so setting
 * this on the supervisor enables it FLEET-WIDE (adv-6 — by design).
 */
export function buildSdkOptions(input) {
    const driverToolsEnabled = process.env.AGENT_DRIVER_TOOLS_ENABLED === '1';
    const baseAllowedTools = [];
    // Phase B (answer-anywhere): AskUserQuestion MUST be in the allowlist whenever
    // a broker `canUseTool` is wired — otherwise the SDK filters it out of the
    // Claude Code preset and NEVER invokes it, leaving the canUseTool bridge
    // dormant (the question-broker-bridge's interceptor only fires when the SDK
    // actually calls the tool). Gated strictly on `canUseTool` presence so the
    // no-broker default stays byte-identical (`allowedTools: []`): the snapshot
    // regression gate + the D10 coordinator-only guards both pass unchanged.
    const brokerAllowedTools = input.canUseTool ? ['AskUserQuestion'] : [];
    const allowedTools = [
        ...baseAllowedTools,
        ...(input.extraAllowedTools ?? []),
        ...brokerAllowedTools,
    ];
    return {
        systemPrompt: {
            type: 'preset',
            preset: 'claude_code',
            ...(input.systemPromptAppend ? { append: input.systemPromptAppend } : {}),
        },
        // Empty allowlist = strict coordinator (no tools); undefined = full preset tools.
        ...(driverToolsEnabled ? {} : { allowedTools }),
        // Question-broker seam (Phase B). Only attached when the caller supplies a
        // callback — keeps the snapshot stable for the default path.
        ...(input.canUseTool ? { canUseTool: input.canUseTool } : {}),
        effort: 'low',
        thinking: { type: 'disabled' },
        model: 'claude-sonnet-4-6',
        // SDK 0.3.142: MCP servers now connect in the background by default.
        // Mark @olam/mcp-server as alwaysLoad so the SDK waits for it in turn 1.
        // This closes a turn-1 race where the agent could dispatch via
        // mcp__olam__olam_dispatch before the server finished connecting.
        // Operator-installed third-party servers are left background-loaded (no
        // entry here for them — they don't need to be ready before turn 1).
        mcpServers: {
            olam: {
                command: 'olam',
                args: ['mcp', 'serve'],
                alwaysLoad: true,
            },
        },
    };
}
/**
 * Factory: takes the raw SDK `query` function, returns the `SdkQueryFn`
 * that `runDriver` consumes. Extracted from `main()` so the call-site
 * composition (buildSdkOptions output + abortController forwarding) is
 * directly testable with a mocked `query` (Phase A.1 adv-5 follow-up).
 *
 * Behavior contract:
 *  - `options` passed to `query` is `buildSdkOptions(...)` output spread
 *    FIRST, then `abortController` added LAST (cannot be clobbered).
 *  - `abortController` is a fresh AbortController per call; the input
 *    `abortSignal` is forwarded via `addEventListener('abort', ...)`.
 *  - The `prompt` is passed through unchanged.
 */
export function createSdkQuery(query) {
    return ({ prompt, abortSignal, systemPromptAppend, extraAllowedTools, canUseTool }) => {
        // eslint-disable-next-line no-console
        console.error(`[driver] sdkQuery fired (prompt: ${typeof prompt === 'string' ? `${prompt.length} chars` : 'AsyncIterable<SdkUserMessage>'})`);
        // Forward the runDriver-owned abortSignal to a fresh AbortController
        // (the SDK's Options.abortController expects an AbortController, not
        // a bare AbortSignal). One per query() call — cheap; no global state.
        const sdkAbortController = new AbortController();
        if (abortSignal) {
            if (abortSignal.aborted)
                sdkAbortController.abort();
            else
                abortSignal.addEventListener('abort', () => sdkAbortController.abort(), {
                    once: true,
                });
        }
        const options = {
            ...buildSdkOptions({ systemPromptAppend, extraAllowedTools, canUseTool }),
            abortController: sdkAbortController,
        };
        return query({ prompt, options });
    };
}
/**
 * Run the driver hot-loop. Returns a handle to stop it cleanly.
 *
 * The driver doesn't terminate on its own — it runs until aborted
 * (SIGTERM via the supervisor → the abortSignal here).
 */
export function runDriver(opts) {
    const { hostCpUrl, bearer, worldId, sessionId, systemPromptAppend = DEFAULT_SYSTEM_PROMPT_APPEND, extraAllowedTools, sdkQuery, allocateMessageId = () => `driver-${randomUUID()}`, canUseTool, abortSignal: externalSignal, subscribeImpl = subscribeToChunks, postChunkImpl = (url, b) => makeHostCpChunkPoster({ sidecarUrl: url, bearer: b }), } = opts;
    const controller = new AbortController();
    if (externalSignal) {
        if (externalSignal.aborted)
            controller.abort();
        else
            externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
    }
    const signal = controller.signal;
    const postChunk = postChunkImpl(hostCpUrl, bearer);
    // In-memory dedup set (Phase D D6). Replaces the prior `lastConsumedSeq`
    // monotonic-cursor model which dropped every operator chunk after the
    // first one in a session (every SPA dispatch has seq=0 with its own
    // message_id; `seq <= lastConsumedSeq` rejected 0 <= 0). Set keyed on
    // `${messageId}:${seq}` matches the substrate's PK shape exactly.
    // Per crash-resume: process restart loses the set; operator chunks are
    // re-presented; substrate idempotency (PK message_id+seq) keeps the
    // adapter writes clean.
    const consumedKeys = new Set();
    // Pending pickups accumulate from the transport; drained by the
    // userMessageStream generator each time it wakes.
    const pendingPickups = [];
    let iterationWakeup = null;
    // P2 (plan risk candidate): bound the queue to prevent memory leak in
    // long-lived iteration (operator pickups accumulate over many-hour
    // sessions). Above this limit, drop the oldest pickups + log + emit
    // system error chunk so the operator sees something went wrong.
    const PENDING_PICKUPS_MAX = 100;
    const transport = subscribeImpl({
        baseUrl: hostCpUrl,
        bearer,
        worldId,
        sessionId,
        abortSignal: signal,
        onChunk: (chunk) => {
            const pickups = filterOperatorPickups([chunk], consumedKeys);
            if (pickups.length === 0)
                return;
            pendingPickups.push(...pickups);
            if (pendingPickups.length > PENDING_PICKUPS_MAX) {
                const overflow = pendingPickups.length - PENDING_PICKUPS_MAX;
                pendingPickups.splice(0, overflow);
                // eslint-disable-next-line no-console
                console.error(`[driver] pendingPickups overflow: dropped ${overflow} oldest pickups (queue cap=${PENDING_PICKUPS_MAX})`);
            }
            for (const p of pickups) {
                consumedKeys.add(pickupConsumedKey(p));
            }
            if (iterationWakeup) {
                iterationWakeup();
                iterationWakeup = null;
            }
        },
    });
    // Convert a batch of pickups into ONE SdkUserMessage (preserves the
    // pre-refactor batched-prompt behavior: multiple operator chunks
    // arriving in the same wakeup window combine into one user turn).
    function batchToUserMessage(batch) {
        const content = pickupsToPromptPrefix(batch);
        if (!content)
            return null;
        return {
            type: 'user',
            message: { role: 'user', content },
            parent_tool_use_id: null,
        };
    }
    // Iteration loop runs in parallel with the transport. NEW architecture
    // (Phase B B2): opens ONE long-lived sdkQuery with an AsyncIterable
    // prompt that pulls from pendingPickups. Each `'result'` boundary in
    // the SDK's response stream allocates a fresh messageId via the
    // turn-splitter helper.
    const iterationLoop = async () => {
        if (signal.aborted)
            return;
        async function* userMessageStream() {
            while (!signal.aborted) {
                if (pendingPickups.length === 0) {
                    await new Promise((resolve) => {
                        iterationWakeup = resolve;
                        const abortHandler = () => {
                            iterationWakeup = null;
                            resolve();
                        };
                        signal.addEventListener('abort', abortHandler, { once: true });
                    });
                    if (signal.aborted)
                        return;
                    if (pendingPickups.length === 0)
                        continue;
                }
                const batch = pendingPickups.splice(0);
                const userMessage = batchToUserMessage(batch);
                if (userMessage !== null)
                    yield userMessage;
            }
        }
        try {
            const messages = sdkQuery({
                prompt: userMessageStream(),
                abortSignal: signal,
                systemPromptAppend,
                extraAllowedTools,
                // Phase B: route AskUserQuestion through the operator's mobile inbox
                // when a broker is configured. Conditionally spread so the default
                // (no-broker) path is byte-identical (createSdkQuery/buildSdkOptions
                // only attach canUseTool when this is truthy).
                ...(canUseTool ? { canUseTool } : {}),
            });
            await streamMultiTurnSdkToChunks({
                messages,
                worldId,
                sessionId,
                allocateMessageId,
                postChunk,
            });
        }
        catch (err) {
            if (signal.aborted)
                return;
            // eslint-disable-next-line no-console
            console.error('[driver] iteration failed:', err);
            try {
                await postChunk({
                    world_id: worldId,
                    session_id: sessionId,
                    message_id: `driver-error-${randomUUID()}`,
                    seq: 0,
                    role: 'system',
                    kind: 'result',
                    chunk: JSON.stringify({ error: 'iteration failed', detail: String(err) }),
                    created_at: new Date().toISOString(),
                });
            }
            catch {
                // swallow — postChunk failure on top of iteration failure is unrecoverable
            }
        }
    };
    const done = Promise.all([transport.done, iterationLoop()]).then(() => undefined);
    return {
        stop: async () => {
            controller.abort();
            try {
                await done;
            }
            catch {
                // swallow — caller can inspect done directly if needed
            }
        },
        done,
    };
}
/**
 * Build the AskUserQuestion broker `canUseTool` callback from env, when both
 * broker env vars are present. Returns `undefined` otherwise → the SDK uses its
 * own local picker (byte-identical, no-broker default).
 *
 * Shared by `main()` (in-world supervisor fork) AND `launchHostDriver` (the
 * host-side planning driver, the production SPA-agent launcher) so the
 * question-broker seam wires identically at every launch site — no
 * two-call-site drift (the host path previously omitted the bridge entirely).
 *
 *   OLAM_QUESTION_BROKER_URL    — auth-worker origin hosting the per-operator
 *                                 broker DO (the same path-transport base the
 *                                 SPA inbox proxy resolves via
 *                                 resolveBearerBaseUrl).
 *   OLAM_QUESTION_BROKER_BEARER — operator Anthropic bearer (Basic
 *                                 b64(sub:secret)); routes register/poll to
 *                                 idFromName(sub).
 *
 * Explicit `brokerUrl`/`brokerBearer` args win over env (test seam); omit them
 * to read the env vars. `logEnabled` emits the one-line ENABLED breadcrumb only
 * when the seam actually activates.
 */
export function buildBrokerCanUseTool(input) {
    const brokerUrl = input.brokerUrl ?? process.env.OLAM_QUESTION_BROKER_URL;
    const brokerBearer = input.brokerBearer ?? process.env.OLAM_QUESTION_BROKER_BEARER;
    if (!brokerUrl || !brokerBearer)
        return undefined;
    if (input.logEnabled) {
        // eslint-disable-next-line no-console
        console.error(`[driver] AskUserQuestion broker ENABLED · broker=${brokerUrl}`);
    }
    return makeAskUserQuestionCanUseTool({
        brokerBaseUrl: brokerUrl,
        bearer: brokerBearer,
        worldId: input.worldId,
        sessionId: input.sessionId,
    });
}
/**
 * CLI entry point when this module is run directly (via supervisor fork).
 * Reads env, validates, calls runDriver, installs SIGTERM handler.
 */
export async function main() {
    const hostCpUrl = process.env.HOST_CP_URL;
    const bearer = process.env.HOST_CP_BEARER;
    const worldId = process.env.WORLD_ID;
    const sessionId = process.env.SESSION_ID;
    if (!hostCpUrl || !bearer || !worldId || !sessionId) {
        // eslint-disable-next-line no-console
        console.error('[driver] missing required env: HOST_CP_URL, HOST_CP_BEARER, WORLD_ID, SESSION_ID');
        process.exit(1);
    }
    // Lazy-load SDK so tests don't need the package installed.
    const { query } = await import('@anthropic-ai/claude-agent-sdk');
    const controller = new AbortController();
    process.on('SIGTERM', () => controller.abort());
    process.on('SIGINT', () => controller.abort());
    // Phase B (mobile-question-inbox): wire the AskUserQuestion broker seam when
    // a broker is configured. Both env vars must be present; otherwise the seam
    // stays off and the SDK uses its own local picker (byte-identical default).
    //   OLAM_QUESTION_BROKER_URL    — the auth-worker origin hosting the broker.
    //   OLAM_QUESTION_BROKER_BEARER — the operator's Anthropic bearer
    //                                 (Basic b64(sub:secret)); routes the
    //                                 register/poll to idFromName(sub).
    const canUseTool = buildBrokerCanUseTool({ worldId, sessionId, logEnabled: true });
    // eslint-disable-next-line no-console
    console.error(`[driver] starting · world=${worldId} session=${sessionId} ` +
        `hostCp=${hostCpUrl}`);
    const handle = runDriver({
        hostCpUrl,
        bearer,
        worldId,
        sessionId,
        // Backwards-compat: read both env var names. `DRIVER_SYSTEM_PROMPT_APPEND`
        // is the new (post-Phase-A) name; `DRIVER_SYSTEM_PROMPT` is honored for
        // operators with legacy supervisor configs.
        systemPromptAppend: process.env.DRIVER_SYSTEM_PROMPT_APPEND ?? process.env.DRIVER_SYSTEM_PROMPT,
        sdkQuery: createSdkQuery(query),
        canUseTool,
        abortSignal: controller.signal,
    });
    try {
        await handle.done;
        // eslint-disable-next-line no-console
        console.error('[driver] done.resolved — clean exit');
        process.exit(0);
    }
    catch (err) {
        // eslint-disable-next-line no-console
        console.error('[driver] done.rejected — fatal:', err);
        process.exit(1);
    }
}
// Run main() only when invoked directly (not when imported as a module).
// The check uses `process.argv[1]` to compare against this module's path.
// Note: this is a Node-specific pattern; tests bypass it by importing runDriver.
if (typeof process !== 'undefined' &&
    process.argv[1] &&
    (process.argv[1].endsWith('driver-runner.js') || process.argv[1].endsWith('driver-runner.ts'))) {
    main().catch((err) => {
        // eslint-disable-next-line no-console
        console.error('[driver] fatal:', err);
        process.exit(1);
    });
}
//# sourceMappingURL=driver-runner.js.map