/**
 * Main agent loop — the top-level orchestrator.
 *
 * This module wires together the subsystems and runs the infinite
 * sleep/wake loop. Each concern is delegated to a focused module:
 *
 *  - HeartbeatManager:  runtime heartbeat lifecycle
 *  - ReplyWatchdog:     fast-responder fallback timer
 *  - WakeCycleRunner:   session switch loop, stream processing, error retry,
 *                       transcript indexing
 *  - InterruptHandler:  mid-turn message injection
 *  - SessionManager:    dual-session management
 *  - ChannelManager:    messaging channel adapters
 */
import { NIGHT_HEARTBEAT_INTERVAL_MS } from "../config/types.js";
import { buildSystemPrompt } from "./system-prompt.js";
import { SessionManager } from "./session-manager.js";
import { buildWakeContext } from "./context.js";
import { MessageQueue } from "../channels/queue.js";
import { ChannelManager } from "../channels/manager.js";
import { ActivityTracker } from "./activity-tracker.js";
import { shouldNotifyUpdate } from "../utils/version-check.js";
import { loadOwnerConfig, consumePendingUpgrade, consumePendingRestart, getConfigDir, buildPricingUrl, } from "../config/index.js";
import { logger } from "../logger.js";
import { cleanOldScreenshots } from "../tools/screenshot.js";
import { maybeStartApiProxy } from "./model-provider.js";
import { ProviderFallbackManager } from "./provider-fallback.js";
import { cleanupOldTranscripts } from "./session-trimmer.js";
import { initAgentState, registerShutdownCallback } from "./state.js";
import { runOnboardingGate } from "./onboarding-gate.js";
import { runSubscriptionGate } from "./subscription-gate.js";
import { runUsageGate, shouldEnforceUsageGate, USAGE_GATE_MESSAGE } from "./usage/usage-gate.js";
import { InterruptHandler } from "./interrupt-handler.js";
import { gatherWakeContext } from "./gather-context.js";
import { ScreenshotInjector } from "./screenshot-injector.js";
import { registerCommandHandlers } from "./command-handlers.js";
import { runContextRefresh, shouldAutoRefresh } from "./context-refresh.js";
import { AUTO_REFRESH_ENABLED, AUTO_REFRESH_TIMEOUT_STREAK, AUTO_REFRESH_COOLDOWN_MS, AUTO_REFRESH_NOTIFY_OWNER, } from "./compaction-policy.js";
import { t, resolveLocale } from "../i18n/index.js";
import { formatMessageSummary, previewIncomingForConsole, buildIncomingLogData } from "./message-format.js";
import { buildReplyTarget, buildTaskReplyTarget } from "./reply-target.js";
import { buildRuntimeSurface } from "./runtime-surface.js";
import { getAgentEngine } from "./engines/engine-factory.js";
import { prepareEngineSwitchBoundary } from "./engine-switch.js";
import { getClaudeTranscriptDirForProfileDir } from "../utils/claude-transcripts.js";
import { closeTranscriptMemoryDb } from "./transcript/transcript-indexer.js";
import { closeUsageReportQueue } from "./usage/usage-report-queue.js";
import { maybeAutoBackfill, cancelAutoBackfill } from "./transcript/transcript-backfill.js";
import { HeartbeatManager } from "./heartbeat-manager.js";
import { ReplyWatchdog } from "./reply-watchdog.js";
import { WakeCycleRunner } from "./wake-cycle-runner.js";
import { UsageReporter } from "./usage/usage-reporter.js";
import { hasPendingSkillLearning, runSkillLearningGate } from "./skill-learning-gate.js";
import { createNotifyHandler } from "./notification-handler.js";
import { hasAnyChatDb, startChatMonitor, stopLegacyMonitors } from "../utils/chat-monitor.js";
import { startHeartbeatWatchdog } from "./heartbeat-watchdog.js";
export { getAgentState } from "./state.js";
// Track the current SIGUSR2 handler so we can swap it out without
// removing listeners registered by other subsystems.
let activeSigusr2Handler = null;
function formatVoiceFollowups(transcripts) {
    return transcripts.map((transcript) => {
        const when = transcript.endedAt ?? transcript.startedAt ?? "unknown time";
        const summary = transcript.summaryText ?? transcript.summary?.headline ?? "Voice session completed.";
        return `- Source: ${transcript.filename} (${when})\n${summary}`;
    }).join("\n\n");
}
function formatRealtimeActiveSession(info) {
    const owner = info.ownerName ? ` with ${info.ownerName}` : "";
    return [
        `A realtime voice session${owner} is currently active as a side-channel of the main agent.`,
        `Session started at ${info.startedAt}; last activity at ${info.lastActivityAt}.`,
        "Do not stop or pause normal message handling, but be aware the owner may be giving related instructions by voice.",
        "When the voice session ends, any actionable summary will be injected into a later wake context under Recent Voice Sessions.",
    ].join("\n");
}
/**
 * Main agent loop. This is the entry point that runs forever.
 */
export async function startAgentLoop(config, options) {
    // =====================================================================
    // 1. Initialization
    // =====================================================================
    // The child heartbeat watchdog is disabled by default. When explicitly
    // enabled, bring it up as early as possible so it can recover startup
    // hangs below this point.
    const heartbeatWatchdog = startHeartbeatWatchdog();
    const messageQueue = new MessageQueue();
    const channelManager = new ChannelManager(config, messageQueue);
    const activityTracker = new ActivityTracker();
    await channelManager.startAll();
    registerShutdownCallback(async () => {
        const SHUTDOWN_TIMEOUT_MS = 5_000;
        // Stop the watchdog first — we are about to do graceful work that
        // legitimately doesn't write heartbeats, and we don't want to be
        // SIGKILLed mid-shutdown.
        heartbeatWatchdog.stop();
        logger.system("Graceful shutdown: stopping channel adapters...");
        try {
            await Promise.race([
                channelManager.stopAll(),
                new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown timed out")), SHUTDOWN_TIMEOUT_MS)),
            ]);
        }
        catch (err) {
            logger.warn(`Graceful shutdown did not complete cleanly: ${err instanceof Error ? err.message : String(err)}`);
        }
        messageQueue.close();
        logger.system("Graceful shutdown complete.");
    });
    // --- Onboarding gate ---
    const justOnboarded = await runOnboardingGate(config, channelManager, messageQueue);
    const justUpgraded = consumePendingUpgrade();
    const justRestarted = consumePendingRestart();
    const ownerConfig = loadOwnerConfig();
    // --- Heartbeat manager ---
    // Pass `restartTunnel` eagerly (as an option) rather than via
    // `agentState`, because the startup heartbeat fires inside
    // `heartbeat.init(...)` below — agentState is still being assembled
    // at that point, so `getAgentState().restartTunnel` is not yet wired.
    const heartbeat = new HeartbeatManager(config, {
        restartTunnel: options?.restartTunnel,
        obsPort: config.obs.port,
    });
    await heartbeat.init(channelManager, ownerConfig.telegramChatId, justUpgraded);
    const providerFallback = new ProviderFallbackManager(config);
    const serverPriority = heartbeat.lastLlmProviderPriority;
    if (serverPriority && serverPriority.length > 0) {
        providerFallback.setPriorityList(serverPriority);
        providerFallback.resetAll();
    }
    heartbeat.setProviderFallback(providerFallback);
    // The runtime heartbeat loop is armed below once `agentState` exists,
    // because the loop reads `agentState.busy` on every tick.
    if (ownerConfig.telegramChatId) {
        channelManager.setTelegramOwnerUserId(ownerConfig.telegramChatId);
    }
    if (ownerConfig.allowedDiscordChannelIds.length > 0) {
        channelManager.setDiscordAllowedChannelIds(ownerConfig.allowedDiscordChannelIds);
    }
    let requestSessionStop = () => { };
    const agentState = {
        config,
        ownerConfig,
        channelManager,
        messageQueue,
        activityTracker,
        busy: false,
        stopRequested: false,
        requestStop: () => {
            agentState.stopRequested = true;
            requestSessionStop();
        },
        replyMap: new Map(),
        hasRepliedInCycle: false,
        restartTunnel: options?.restartTunnel,
        commandDispatcher: heartbeat.commandDispatcher,
        compacting: false,
        learning: false,
        gated: false,
        cardAvailable: heartbeat.lastCardAvailable,
    };
    initAgentState(agentState);
    // Arm the always-on runtime heartbeat loop now that `agentState` is
    // wired. The loop adapts its cadence to `agentState.busy` (30s busy /
    // 5min idle) and runs independently of the wake/sleep cycle so the
    // dashboard sees the agent as online even during long sleeps.
    heartbeat.startRuntime(() => agentState.busy);
    registerShutdownCallback(async () => {
        await heartbeat.stopRuntime();
    });
    logger.system(`Agent: ${config.agentName} <${config.gmail}>`);
    logger.system(`Owner: ${ownerConfig.ownerName} <${ownerConfig.ownerEmail}>`);
    logger.system(`Heartbeat interval: ${ownerConfig.heartbeatIntervalMs / 1000}s`);
    logger.system("");
    await maybeStartApiProxy(config);
    // Best-effort cleanup of old transcript archives (180-day retention)
    try {
        const transcriptDir = getClaudeTranscriptDirForProfileDir(getConfigDir());
        cleanupOldTranscripts(transcriptDir);
    }
    catch { /* best-effort */ }
    const { runtimeSurface, startupDynamicMcpServerCount } = await buildRuntimeSurface(config);
    if (startupDynamicMcpServerCount > 0) {
        logger.system(`Loaded ${startupDynamicMcpServerCount} dynamic MCP server(s) from disk`);
    }
    // --- Engine switch boundary -----------------------------------------
    // If any prior transcript was written by a different engine than
    // `config.json` currently selects, collapse general + coding into a
    // single fresh general session with a combined summary. This must
    // run BEFORE constructing SessionManager so the manager sees the
    // post-collapse mailbox (no coding session to resume).
    const currentEngine = getAgentEngine(config);
    let engineSwitchBoundary = null;
    try {
        engineSwitchBoundary = await prepareEngineSwitchBoundary(config, currentEngine);
    }
    catch (err) {
        logger.warn(`[engine-switch] boundary handling failed; continuing with fresh sessions: ${err instanceof Error ? err.message : String(err)}`);
    }
    const session = new SessionManager(config, () => buildSystemPrompt(config, ownerConfig), runtimeSurface, { dualSessionEnabled: ownerConfig.dualSessionEnabled, androidUseEnabled: ownerConfig.androidUseEnabled });
    // Hand the combined engine-switch summary to the freshly created
    // general session (rotation + mailbox clearing already happened
    // before SessionManager construction).
    if (engineSwitchBoundary) {
        session.injectSummary("general", engineSwitchBoundary.combinedSummary);
    }
    logger.system(`Dual session: ${ownerConfig.dualSessionEnabled ? "enabled" : "disabled"}`);
    logger.system(`Android use: ${ownerConfig.androidUseEnabled ? "enabled" : "disabled"}`);
    logger.system(`Chat monitor: ${ownerConfig.chatMonitorEnabled ? "enabled" : "disabled"}`);
    if (ownerConfig.chatMonitorEnabled) {
        stopLegacyMonitors();
        if (hasAnyChatDb()) {
            // Always invoke `startChatMonitor()` — it is idempotent and also
            // performs stale-path repair (e.g. after a pnpm global upgrade
            // leaves pm2 pointing at a removed `.pnpm/visionclaw@x.x.x/...`
            // path). When the running process is healthy and on the stable
            // path, this is a no-op.
            try {
                startChatMonitor();
            }
            catch (err) {
                logger.warn(`Failed to auto-start chat monitor: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
        else {
            logger.system("Chat monitor enabled, but no local chat databases found (WeChat, Feishu); skipping auto-start.");
        }
    }
    agentState.sessionManager = session;
    // Provider fallback mutates `config.provider` in place. The Claude
    // session bakes provider env into a pre-warmed subprocess at warmUp()
    // time and reuses those baked options on the warm path of the next
    // sendAndStream(), so without invalidation the new provider doesn't
    // actually take effect until the warm child is consumed once. Discard
    // every session's warm subprocess on switch so the next turn rebuilds
    // options from the updated config.
    providerFallback.setOnProviderSwitched((from, to) => {
        logger.debug(`Provider-switch callback: discarding warm subprocesses (${from} → ${to})`);
        session.discardAllWarmQueries();
        // Push the provider change to the dashboard immediately
        heartbeat.nudge();
    });
    const screenshotInjector = new ScreenshotInjector();
    session.setScreenshotInjector(screenshotInjector);
    requestSessionStop = () => session.requestStop();
    // --- Interrupt handling ---
    let wakeResolver = null;
    function wakeMainLoop() {
        if (wakeResolver !== null) {
            wakeResolver();
            wakeResolver = null;
        }
    }
    const interruptHandler = new InterruptHandler({
        session: session.activeSession,
        sessionManager: session,
        config,
        ownerConfig,
        channelManager,
        messageQueue,
        activityTracker,
        getAgentState: () => agentState,
        wakeMainLoop,
    });
    channelManager.on("message", () => {
        if (wakeResolver !== null) {
            wakeMainLoop();
            return;
        }
        if (agentState.busy) {
            interruptHandler.schedule();
        }
    });
    // Register the server→agent notification command handler.
    // Must happen after wakeMainLoop is defined so the handler can wake the
    // sleeping loop when a notification arrives via the next heartbeat.
    heartbeat.commandDispatcher.registerHandler("notify", createNotifyHandler(messageQueue, wakeMainLoop));
    // --- SIGUSR2: verified external wake signal ---
    // Always installed. External monitor scripts (WeChat, WhatsApp, Feishu, etc.)
    // follow a protocol: insert a queue row with meta.kind = "external-wake",
    // then send SIGUSR2. The handler verifies a matching wake marker exists in
    // the queue before waking. A bare SIGUSR2 without a marker is ignored.
    if (activeSigusr2Handler) {
        process.removeListener("SIGUSR2", activeSigusr2Handler);
    }
    activeSigusr2Handler = () => {
        if (messageQueue.hasExternalWakeMessage()) {
            logger.system("Received SIGUSR2 with verified external wake message, waking main loop...");
            wakeMainLoop();
        }
        else {
            logger.system("Received SIGUSR2 but no external wake message in queue, ignoring.");
        }
    };
    process.on("SIGUSR2", activeSigusr2Handler);
    registerCommandHandlers({
        agentState,
        channelManager,
        messageQueue,
        session,
        config,
        ownerConfig,
        heartbeat,
        wakeMainLoop,
        obsClose: options?.obsClose,
        realtimeClose: options?.realtimeClose,
    });
    // --- Prompt for missing preferences (language / timezone) ---
    if (ownerConfig.onboardingComplete && !ownerConfig.timezone) {
        const chatId = ownerConfig.telegramChatId;
        if (chatId) {
            logger.system("Owner config missing timezone — prompting user to set preferences");
            channelManager.sendTelegramSetMenu(chatId).catch((err) => {
                logger.warn(`Failed to send preferences prompt: ${err instanceof Error ? err.message : String(err)}`);
            });
        }
    }
    // --- Calendar interrupt poller ---
    const { CalendarInterruptPoller } = await import("../calendar/calendar-interrupt-poller.js");
    const calendarPoller = new CalendarInterruptPoller({
        config,
        sessionManager: session,
        messageQueue,
        getAgentState: () => agentState,
        wakeMainLoop,
    });
    calendarPoller.start();
    registerShutdownCallback(() => {
        calendarPoller.stop();
        return Promise.resolve();
    });
    // --- Transcript memory shutdown + auto-backfill ---
    const usageReporter = new UsageReporter(config);
    usageReporter.start();
    registerShutdownCallback(() => {
        usageReporter.stop();
        cancelAutoBackfill();
        closeTranscriptMemoryDb();
        closeUsageReportQueue();
        return Promise.resolve();
    });
    void maybeAutoBackfill(config).catch((err) => {
        logger.warn(`[transcript-backfill] Auto-backfill failed: ${err instanceof Error ? err.message : String(err)}`);
    });
    // --- Reply watchdog ---
    const watchdog = new ReplyWatchdog(interruptHandler, () => agentState);
    // --- Wake cycle runner ---
    const cycleRunner = new WakeCycleRunner({
        config,
        ownerConfig,
        session,
        activityTracker,
        interruptHandler,
        channelManager,
        messageQueue,
        providerFallback,
    });
    if (options?.onReady) {
        options.onReady({ agentState, sessionManager: session, messageQueue, ownerConfig });
    }
    logger.system("Agent loop started. Waiting for messages or heartbeat...");
    // =====================================================================
    // 2. Main loop
    // =====================================================================
    let consecutiveErrors = 0;
    let firstIteration = true;
    let consecutiveLearningCycles = 0;
    // Timestamp of the last automatic context refresh, for the cooldown guard.
    let lastAutoRefreshAtMs = 0;
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop
    while (true) {
        // --- Reset per-cycle state ---
        agentState.busy = false;
        agentState.gated = false;
        agentState.stopRequested = false;
        agentState.hasRepliedInCycle = false;
        agentState.learning = false;
        agentState.replyMap.clear();
        session.clearStop();
        activityTracker.clear();
        interruptHandler.clearFastResponseTracking();
        screenshotInjector.reset();
        // --- Kick off pre-warm to cover the sleep period ---
        //
        // The Claude provider maintains a pre-initialized CLI subprocess via
        // `warmUp()`, and internally refreshes it every `WARM_REFRESH_TTL_MS`
        // so it never gets stale. We kick off warmUp() here (fire-and-forget,
        // idempotent) so the subprocess is alive *during* sleep — then the
        // next wake consumes it instead of paying a cold-start. For the
        // OpenAI provider this is a no-op.
        void session.activeSession.warmUp().catch((err) => {
            logger.debug(`Pre-sleep warmUp failed: ${err instanceof Error ? err.message : String(err)}`);
        });
        // --- Sleep until heartbeat timer or incoming message ---
        // Use a longer interval during overnight hours (midnight–6 AM)
        // to reduce unnecessary wake cycles when no activity is expected.
        const hour = new Date().getHours();
        const isNightHours = hour >= 0 && hour < 6;
        const baseSleepMs = ownerConfig.heartbeatIntervalMs;
        const sleepMs = firstIteration
            ? 3000
            : isNightHours
                ? Math.max(baseSleepMs, NIGHT_HEARTBEAT_INTERVAL_MS)
                : baseSleepMs;
        const isFirstWake = firstIteration;
        firstIteration = false;
        await new Promise((resolve) => {
            wakeResolver = resolve;
            const timer = setTimeout(() => {
                if (wakeResolver === resolve) {
                    wakeResolver = null;
                    resolve();
                }
            }, sleepMs);
            if (!messageQueue.isEmpty()) {
                clearTimeout(timer);
                wakeResolver = null;
                resolve();
            }
        });
        // If a /refresh (or other compaction) is in progress on the active
        // session, wait for it to finish before starting a new turn. The
        // compacting flag acts as a mutex on the session's SDK subprocess
        // so we don't race a new query against an in-flight /compact.
        while (agentState.compacting) {
            await new Promise((r) => setTimeout(r, 100));
        }
        // --- Auto-refresh gate ---
        // When compaction repeatedly times out the session never shrinks and
        // every turn stays slow. Force a full /refresh (tail summary + new
        // session) once the timeout streak crosses the threshold. Runs here, at
        // the serialized loop boundary before busy=true: it's where we force the
        // refresh before starting the next wake. (A compaction timeout happens
        // between turns, so there's no in-flight turn to interrupt anyway.)
        {
            const autoRefresh = {
                enabled: AUTO_REFRESH_ENABLED,
                timeoutStreak: AUTO_REFRESH_TIMEOUT_STREAK,
                cooldownMs: AUTO_REFRESH_COOLDOWN_MS,
            };
            const timeoutStreak = session.activeSession.getConsecutiveCompactionTimeouts?.() ?? 0;
            if (shouldAutoRefresh(autoRefresh, timeoutStreak, lastAutoRefreshAtMs, Date.now())) {
                logger.system(`[auto-refresh] forcing context refresh after ${timeoutStreak} consecutive compaction timeout(s)`);
                // Same shared runner as the manual `/refresh` command — no
                // stop-and-wait (we're already at the idle gate) and no inbound
                // requester; it only notifies the owner. We never re-emit a /refresh
                // command here.
                const outcome = await runContextRefresh({ session, config, messageQueue, agentState }, {
                    reason: "compaction-timeout-streak",
                    systemMessage: "Compaction repeatedly timed out, so a full context refresh was performed automatically. " +
                        "A new session has been created and the summary of your previous session is injected above. " +
                        "Review it, check your memory and todo list, and continue where you left off.",
                    hooks: {
                        onRefreshed: async () => {
                            if (!AUTO_REFRESH_NOTIFY_OWNER)
                                return;
                            const channel = ownerConfig.telegramChatId
                                ? "telegram"
                                : ownerConfig.ownerEmail
                                    ? "gmail"
                                    : undefined;
                            const recipient = ownerConfig.telegramChatId
                                ? String(ownerConfig.telegramChatId)
                                : ownerConfig.ownerEmail;
                            if (channel && recipient) {
                                await channelManager
                                    .sendMessage(channel, recipient, t("auto_refresh.notice", resolveLocale(ownerConfig.language)))
                                    .catch((err) => {
                                    logger.warn(`[auto-refresh] owner notification failed: ${err instanceof Error ? err.message : String(err)}`);
                                });
                            }
                        },
                    },
                }).catch((err) => {
                    logger.warn(`[auto-refresh] failed: ${err instanceof Error ? err.message : String(err)}`);
                    return null;
                });
                // Record the cooldown anchor whenever the runner actually ran (refreshed
                // or found no session) so a streak can't spin the gate every tick.
                if (outcome) {
                    lastAutoRefreshAtMs = Date.now();
                }
            }
        }
        // --- Wake ---
        agentState.busy = true;
        activityTracker.clear();
        cleanOldScreenshots().catch(() => { });
        // Push the busy=true flip to the dashboard immediately rather than
        // waiting for the next 30s tick of the runtime heartbeat loop.
        heartbeat.nudge();
        // --- Subscription gate ---
        if (heartbeat.lastSubscriptionStatus === "expired") {
            await runSubscriptionGate(config, channelManager, heartbeat);
            // Gate returned — subscription is now active/skip, continue normally
        }
        // --- Usage gate ---
        // Usage gate is subordinate to billing: only enforced when subscription
        // is active (billing on, subscription valid). If billing is "skip" or
        // "expired", the usage gate does not apply.
        if (shouldEnforceUsageGate(heartbeat.lastUsageStatus, heartbeat.lastSubscriptionStatus)) {
            await runUsageGate(config, channelManager, heartbeat);
        }
        // --- Skill learning gate ---
        // Learning Mode only runs during the overnight quiet-hours window
        // (2:00--6:00 AM local time) and yields to queued user messages
        // after a bounded number of consecutive learning cycles.
        //
        // Scheduling rules:
        //  - Outside 2:00--6:00 AM: skip learning, fall through to normal work.
        //  - Inside window, no queued messages: learn one skill per cycle.
        //  - Inside window, queued messages present:
        //      * streak < MAX: learn first.
        //      * streak >= MAX: yield to messages, reset streak.
        //
        // New messages arriving mid-session are handled by InterruptHandler
        // (which, seeing agentState.learning, acknowledges via the fast
        // responder and re-queues them -- see interrupt-handler.ts).
        const MAX_CONSECUTIVE_LEARNING_CYCLES = 2;
        if (hasPendingSkillLearning()) {
            const hour = new Date().getHours();
            const withinLearningWindow = hour >= 2 && hour < 6;
            if (!withinLearningWindow) {
                // Outside overnight window -- skip learning, fall through to normal work.
                logger.debug("Skill learning deferred: outside overnight window (2:00--6:00 AM).");
                consecutiveLearningCycles = 0;
            }
            else if (!messageQueue.isEmpty() && consecutiveLearningCycles >= MAX_CONSECUTIVE_LEARNING_CYCLES) {
                // Inside window but queued messages are waiting and we've already
                // spent our learning budget -- yield to normal message handling.
                logger.system(`Yielding Skill Learning Mode to process queued messages after ${consecutiveLearningCycles} consecutive learning cycle(s).`);
                consecutiveLearningCycles = 0;
            }
            else {
                // Learn one skill this cycle.
                consecutiveLearningCycles++;
                agentState.learning = true;
                logger.system(`Entering Skill Learning Mode (streak ${consecutiveLearningCycles}/${MAX_CONSECUTIVE_LEARNING_CYCLES}).`);
                await runSkillLearningGate(cycleRunner);
                logger.system("Exited Skill Learning Mode.");
                agentState.learning = false;
                watchdog.cancel();
                // Mirror the cycle finally below: flip busy=false BEFORE the
                // nudge so the heartbeat reports the correct idle state. The
                // top-of-loop reset would also clear `busy`, but the nudge
                // happens first.
                agentState.busy = false;
                heartbeat.nudge();
                continue;
            }
        }
        else {
            // No pending learning -- reset streak.
            consecutiveLearningCycles = 0;
        }
        // --- Drain messages ---
        const hasMessages = !messageQueue.isEmpty();
        const trigger = hasMessages ? "message" : "heartbeat";
        const messages = [];
        while (!messageQueue.isEmpty()) {
            const msg = messageQueue.dequeue();
            if (!msg)
                continue;
            if (shouldEnforceUsageGate(heartbeat.lastUsageStatus, heartbeat.lastSubscriptionStatus) && msg.channel !== "system") {
                const pricingUrl = buildPricingUrl(config);
                const gateReply = `${USAGE_GATE_MESSAGE}\n\nBrowse plans or top up: ${pricingUrl}`;
                channelManager.sendMessage(msg.channel, msg.sender, gateReply, undefined, { messageId: msg.id, meta: msg.meta }).catch((err) => {
                    logger.warn(`Failed to send usage gate direct reply: ${err instanceof Error ? err.message : String(err)}`);
                });
                continue;
            }
            messages.push(msg);
        }
        for (const msg of messages) {
            messageQueue.markHandled(msg);
        }
        if (messages.length > 0) {
            interruptHandler.recordInitialChatMonitorMessages(messages);
            for (const msg of messages) {
                agentState.replyMap.set(`${msg.channel}::${msg.sender}`, buildReplyTarget(msg));
            }
            agentState.currentTaskReplyTarget = buildTaskReplyTarget(messages[0]);
        }
        const taskDesc = messages.length > 0
            ? messages.map((m) => formatMessageSummary(m)).join("; ")
            : "heartbeat";
        activityTracker.startTask(taskDesc);
        for (const msg of messages) {
            activityTracker.recordUserMessage(formatMessageSummary(msg));
        }
        logger.wake(trigger, messages.length);
        if (session.dualSessionEnabled) {
            logger.system(`Active session: ${session.activeMode}`);
        }
        if (trigger === "heartbeat") {
            try {
                await session.activeSession.maybeCompact();
            }
            catch (err) {
                logger.warn(`Compaction request failed: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
        for (const msg of messages) {
            // Keep the console line compact — e.g. Gmail reduces to sender + subject
            // with no structured JSON blob appended, avoiding raw HTML flooding the
            // terminal. Non-gmail channels retain full structured data for JSONL
            // post-hoc analysis.
            logger.log("info", "incoming", `[${msg.channel}] ${msg.sender}: ${previewIncomingForConsole(msg)}`, buildIncomingLogData(msg));
        }
        // --- Fire pre-warm in parallel with context gathering ---
        //
        // The system prompt is time-agnostic, so it's safe to pre-initialize the
        // Claude CLI subprocess as soon as we know we're awake. This overlaps the
        // subprocess cold-start (hundreds of ms) with screenshot capture,
        // calendar fetch, and version checks. `warmUp()` is a no-op when the SDK
        // doesn't expose `startup()` (e.g., OpenAI provider, older SDK), and is
        // idempotent — the wake-cycle runner's own `warmUp()` call will just
        // await this in-flight promise rather than starting a second one.
        void session.activeSession.warmUp().catch((err) => {
            logger.debug(`Pre-warm kick-off failed: ${err instanceof Error ? err.message : String(err)}`);
        });
        // --- Gather context ---
        const doVersionCheck = trigger === "heartbeat" && shouldNotifyUpdate();
        const skipScreenshot = consecutiveErrors > 0;
        const { screenshot, calendarSummary, versionUpdate } = await gatherWakeContext(config, doVersionCheck, skipScreenshot, ownerConfig.heartbeatIntervalMs);
        let voiceFollowups = null;
        let realtimeActiveSession = null;
        let injectedVoiceTranscripts = [];
        if (options?.realtimeHandle) {
            try {
                const activeRealtimeSession = options.realtimeHandle.getActiveSessionInfo();
                if (activeRealtimeSession) {
                    realtimeActiveSession = formatRealtimeActiveSession(activeRealtimeSession);
                }
                injectedVoiceTranscripts = (await options.realtimeHandle.getUnprocessedTranscripts()).slice(-3);
                if (injectedVoiceTranscripts.length > 0) {
                    voiceFollowups = formatVoiceFollowups(injectedVoiceTranscripts);
                }
            }
            catch (err) {
                logger.warn(`[Realtime] Failed to load realtime context: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
        const contentBlocks = await buildWakeContext({
            config,
            trigger,
            messages,
            calendarSummary,
            screenshot,
            versionUpdate,
            activeSession: session.dualSessionEnabled ? session.activeMode : undefined,
            sessionMemos: session.consumeMemosForActive(),
            codingSessionStatus: session.getCodingSessionStatus(),
            justOnboarded: isFirstWake ? justOnboarded : false,
            justUpgraded: isFirstWake ? justUpgraded : null,
            justRestarted: isFirstWake ? justRestarted : null,
            voiceFollowups,
            realtimeActiveSession,
        });
        logger.sendMultimodal(contentBlocks);
        if (trigger === "message") {
            for (const msg of messages) {
                channelManager.sendTypingIndicator(msg.channel, msg.sender).catch(() => { });
            }
        }
        // --- Arm watchdog + run cycle ---
        watchdog.arm(trigger, messages);
        try {
            const result = await cycleRunner.run(contentBlocks, trigger, messages, calendarSummary, consecutiveErrors);
            consecutiveErrors = result.consecutiveErrors;
            if (options?.realtimeHandle && injectedVoiceTranscripts.length > 0) {
                for (const transcript of injectedVoiceTranscripts) {
                    try {
                        await options.realtimeHandle.markTranscriptProcessed(transcript.filename);
                    }
                    catch (err) {
                        logger.warn(`[Realtime] Failed to mark voice transcript processed: ${err instanceof Error ? err.message : String(err)}`);
                    }
                }
            }
        }
        finally {
            // Cleanup must always run, even if the extracted cycle runner throws
            // unexpectedly, otherwise the reply watchdog timer can leak across
            // wake cycles. The runtime heartbeat loop runs independently and
            // does not need wake-cycle teardown.
            watchdog.cancel();
            // Mirror the busy=true nudge above: tell the dashboard we're
            // idle right away rather than waiting up to 5min for the next
            // idle tick.
            agentState.busy = false;
            heartbeat.nudge();
        }
    }
}
//# sourceMappingURL=loop.js.map