import fs from "node:fs/promises";
import path from "node:path";
import { resolveAgentConfig, resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../agents/agent-scope.js";
import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, DEFAULT_HEARTBEAT_EVERY, isHeartbeatContentEffectivelyEmpty, resolveHeartbeatPrompt as resolveHeartbeatPromptText, stripHeartbeatToken, } from "../auto-reply/heartbeat.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { loadConfig } from "../config/config.js";
import { canonicalizeMainSessionAlias, loadSessionStore, resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveStorePath, saveSessionStore, updateSessionStore, } from "../config/sessions.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js";
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { formatErrorMessage } from "./errors.js";
import { isWithinActiveHours } from "./heartbeat-active-hours.js";
import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js";
import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
import { requestHeartbeatNow, setHeartbeatWakeHandler, } from "./heartbeat-wake.js";
import { deliverOutboundPayloads } from "./outbound/deliver.js";
import { resolveHeartbeatDeliveryTarget, resolveHeartbeatSenderContext, } from "./outbound/targets.js";
import { peekSystemEvents } from "./system-events.js";
const log = createSubsystemLogger("gateway/heartbeat");
let heartbeatsEnabled = true;
export function setHeartbeatsEnabled(enabled) {
    heartbeatsEnabled = enabled;
}
const DEFAULT_HEARTBEAT_TARGET = "last";
// Prompt used when an async exec has completed and the result should be relayed to the user.
// This overrides the standard heartbeat prompt to ensure the model responds with the exec result
// instead of just "HEARTBEAT_OK".
const EXEC_EVENT_PROMPT = "An async command you ran earlier has completed. The result is shown in the system messages above. " +
    "Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
    "If it failed, explain what went wrong.";
// Build a dynamic prompt for cron events by embedding the actual event content.
// This ensures the model sees the reminder text directly instead of relying on
// "shown in the system messages above" which may not be visible in context.
function buildCronEventPrompt(pendingEvents) {
    const eventText = pendingEvents.join("\n").trim();
    if (!eventText) {
        return ("A scheduled cron event was triggered, but no event content was found. " +
            "Reply HEARTBEAT_OK.");
    }
    return ("A scheduled reminder has been triggered. The reminder content is:\n\n" +
        eventText +
        "\n\nPlease relay this reminder to the user in a helpful and friendly way.");
}
const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase();
// Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events.
function isHeartbeatAckEvent(evt) {
    const trimmed = evt.trim();
    if (!trimmed) {
        return false;
    }
    const lower = trimmed.toLowerCase();
    if (!lower.startsWith(HEARTBEAT_OK_PREFIX)) {
        return false;
    }
    const suffix = lower.slice(HEARTBEAT_OK_PREFIX.length);
    if (suffix.length === 0) {
        return true;
    }
    return !/[a-z0-9_]/.test(suffix[0]);
}
function isHeartbeatNoiseEvent(evt) {
    const lower = evt.trim().toLowerCase();
    if (!lower) {
        return false;
    }
    return (isHeartbeatAckEvent(lower) ||
        lower.includes("heartbeat poll") ||
        lower.includes("heartbeat wake"));
}
function isExecCompletionEvent(evt) {
    return evt.toLowerCase().includes("exec finished");
}
// Returns true when a system event should be treated as real cron reminder content.
export function isCronSystemEvent(evt) {
    if (!evt.trim()) {
        return false;
    }
    return !isHeartbeatNoiseEvent(evt) && !isExecCompletionEvent(evt);
}
function hasExplicitHeartbeatAgents(cfg) {
    const list = cfg.agents?.list ?? [];
    return list.some((entry) => Boolean(entry?.heartbeat));
}
export function isHeartbeatEnabledForAgent(cfg, agentId) {
    const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
    const list = cfg.agents?.list ?? [];
    const hasExplicit = hasExplicitHeartbeatAgents(cfg);
    if (hasExplicit) {
        return list.some((entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId);
    }
    return resolvedAgentId === resolveDefaultAgentId(cfg);
}
function resolveHeartbeatConfig(cfg, agentId) {
    const defaults = cfg.agents?.defaults?.heartbeat;
    if (!agentId) {
        return defaults;
    }
    const overrides = resolveAgentConfig(cfg, agentId)?.heartbeat;
    if (!defaults && !overrides) {
        return overrides;
    }
    return { ...defaults, ...overrides };
}
export function resolveHeartbeatSummaryForAgent(cfg, agentId) {
    const defaults = cfg.agents?.defaults?.heartbeat;
    const overrides = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined;
    const enabled = isHeartbeatEnabledForAgent(cfg, agentId);
    if (!enabled) {
        return {
            enabled: false,
            every: "disabled",
            everyMs: null,
            prompt: resolveHeartbeatPromptText(defaults?.prompt),
            target: defaults?.target ?? DEFAULT_HEARTBEAT_TARGET,
            model: defaults?.model,
            ackMaxChars: Math.max(0, defaults?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS),
        };
    }
    const merged = defaults || overrides ? { ...defaults, ...overrides } : undefined;
    const every = merged?.every ?? defaults?.every ?? overrides?.every ?? DEFAULT_HEARTBEAT_EVERY;
    const everyMs = resolveHeartbeatIntervalMs(cfg, undefined, merged);
    const prompt = resolveHeartbeatPromptText(merged?.prompt ?? defaults?.prompt ?? overrides?.prompt);
    const target = merged?.target ?? defaults?.target ?? overrides?.target ?? DEFAULT_HEARTBEAT_TARGET;
    const model = merged?.model ?? defaults?.model ?? overrides?.model;
    const ackMaxChars = Math.max(0, merged?.ackMaxChars ??
        defaults?.ackMaxChars ??
        overrides?.ackMaxChars ??
        DEFAULT_HEARTBEAT_ACK_MAX_CHARS);
    return {
        enabled: true,
        every,
        everyMs,
        prompt,
        target,
        model,
        ackMaxChars,
    };
}
function resolveHeartbeatAgents(cfg) {
    const list = cfg.agents?.list ?? [];
    if (hasExplicitHeartbeatAgents(cfg)) {
        return list
            .filter((entry) => entry?.heartbeat)
            .map((entry) => {
            const id = normalizeAgentId(entry.id);
            return { agentId: id, heartbeat: resolveHeartbeatConfig(cfg, id) };
        })
            .filter((entry) => entry.agentId);
    }
    const fallbackId = resolveDefaultAgentId(cfg);
    return [{ agentId: fallbackId, heartbeat: resolveHeartbeatConfig(cfg, fallbackId) }];
}
export function resolveHeartbeatIntervalMs(cfg, overrideEvery, heartbeat) {
    const raw = overrideEvery ??
        heartbeat?.every ??
        cfg.agents?.defaults?.heartbeat?.every ??
        DEFAULT_HEARTBEAT_EVERY;
    if (!raw) {
        return null;
    }
    const trimmed = String(raw).trim();
    if (!trimmed) {
        return null;
    }
    let ms;
    try {
        ms = parseDurationMs(trimmed, { defaultUnit: "m" });
    }
    catch {
        return null;
    }
    if (ms <= 0) {
        return null;
    }
    return ms;
}
export function resolveHeartbeatPrompt(cfg, heartbeat) {
    return resolveHeartbeatPromptText(heartbeat?.prompt ?? cfg.agents?.defaults?.heartbeat?.prompt);
}
function resolveHeartbeatAckMaxChars(cfg, heartbeat) {
    return Math.max(0, heartbeat?.ackMaxChars ??
        cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
        DEFAULT_HEARTBEAT_ACK_MAX_CHARS);
}
function resolveHeartbeatSession(cfg, agentId, heartbeat) {
    const sessionCfg = cfg.session;
    const scope = sessionCfg?.scope ?? "per-sender";
    const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
    const mainSessionKey = scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId });
    const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId;
    const storePath = resolveStorePath(sessionCfg?.store, {
        agentId: storeAgentId,
    });
    const store = loadSessionStore(storePath);
    const mainEntry = store[mainSessionKey];
    if (scope === "global") {
        return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
    }
    const trimmed = heartbeat?.session?.trim() ?? "";
    if (!trimmed) {
        return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
    }
    const normalized = trimmed.toLowerCase();
    if (normalized === "main" || normalized === "global") {
        return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
    }
    const candidate = toAgentStoreSessionKey({
        agentId: resolvedAgentId,
        requestKey: trimmed,
        mainKey: cfg.session?.mainKey,
    });
    const canonical = canonicalizeMainSessionAlias({
        cfg,
        agentId: resolvedAgentId,
        sessionKey: candidate,
    });
    if (canonical !== "global") {
        const sessionAgentId = resolveAgentIdFromSessionKey(canonical);
        if (sessionAgentId === normalizeAgentId(resolvedAgentId)) {
            return {
                sessionKey: canonical,
                storePath,
                store,
                entry: store[canonical],
            };
        }
    }
    return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
}
function resolveHeartbeatReplyPayload(replyResult) {
    if (!replyResult) {
        return undefined;
    }
    if (!Array.isArray(replyResult)) {
        return replyResult;
    }
    for (let idx = replyResult.length - 1; idx >= 0; idx -= 1) {
        const payload = replyResult[idx];
        if (!payload) {
            continue;
        }
        if (payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0)) {
            return payload;
        }
    }
    return undefined;
}
function resolveHeartbeatReasoningPayloads(replyResult) {
    const payloads = Array.isArray(replyResult) ? replyResult : replyResult ? [replyResult] : [];
    return payloads.filter((payload) => {
        const text = typeof payload.text === "string" ? payload.text : "";
        return text.trimStart().startsWith("Reasoning:");
    });
}
async function restoreHeartbeatUpdatedAt(params) {
    const { storePath, sessionKey, updatedAt } = params;
    if (typeof updatedAt !== "number") {
        return;
    }
    const store = loadSessionStore(storePath);
    const entry = store[sessionKey];
    if (!entry) {
        return;
    }
    const nextUpdatedAt = Math.max(entry.updatedAt ?? 0, updatedAt);
    if (entry.updatedAt === nextUpdatedAt) {
        return;
    }
    await updateSessionStore(storePath, (nextStore) => {
        const nextEntry = nextStore[sessionKey] ?? entry;
        if (!nextEntry) {
            return;
        }
        const resolvedUpdatedAt = Math.max(nextEntry.updatedAt ?? 0, updatedAt);
        if (nextEntry.updatedAt === resolvedUpdatedAt) {
            return;
        }
        nextStore[sessionKey] = { ...nextEntry, updatedAt: resolvedUpdatedAt };
    });
}
function normalizeHeartbeatReply(payload, responsePrefix, ackMaxChars) {
    const stripped = stripHeartbeatToken(payload.text, {
        mode: "heartbeat",
        maxAckChars: ackMaxChars,
    });
    const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
    if (stripped.shouldSkip && !hasMedia) {
        return {
            shouldSkip: true,
            text: "",
            hasMedia,
        };
    }
    let finalText = stripped.text;
    if (responsePrefix && finalText && !finalText.startsWith(responsePrefix)) {
        finalText = `${responsePrefix} ${finalText}`;
    }
    return { shouldSkip: false, text: finalText, hasMedia };
}
export async function runHeartbeatOnce(opts) {
    const cfg = opts.cfg ?? loadConfig();
    const agentId = normalizeAgentId(opts.agentId ?? resolveDefaultAgentId(cfg));
    const heartbeat = opts.heartbeat ?? resolveHeartbeatConfig(cfg, agentId);
    if (!heartbeatsEnabled) {
        return { status: "skipped", reason: "disabled" };
    }
    if (!isHeartbeatEnabledForAgent(cfg, agentId)) {
        return { status: "skipped", reason: "disabled" };
    }
    if (!resolveHeartbeatIntervalMs(cfg, undefined, heartbeat)) {
        return { status: "skipped", reason: "disabled" };
    }
    const startedAt = opts.deps?.nowMs?.() ?? Date.now();
    if (!isWithinActiveHours(cfg, heartbeat, startedAt)) {
        return { status: "skipped", reason: "quiet-hours" };
    }
    const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)("main" /* CommandLane.Main */);
    if (queueSize > 0) {
        return { status: "skipped", reason: "requests-in-flight" };
    }
    // Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
    // This saves API calls/costs when the file is effectively empty (only comments/headers).
    // EXCEPTION: Don't skip for exec events, cron events, or explicit wake requests -
    // they have pending system events to process regardless of HEARTBEAT.md content.
    const isExecEventReason = opts.reason === "exec-event";
    const isCronEventReason = Boolean(opts.reason?.startsWith("cron:"));
    const isWakeReason = opts.reason === "wake" || Boolean(opts.reason?.startsWith("hook:"));
    const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
    const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
    try {
        const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
        if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&
            !isExecEventReason &&
            !isCronEventReason &&
            !isWakeReason) {
            emitHeartbeatEvent({
                status: "skipped",
                reason: "empty-heartbeat-file",
                durationMs: Date.now() - startedAt,
            });
            return { status: "skipped", reason: "empty-heartbeat-file" };
        }
    }
    catch {
        // File doesn't exist or can't be read - proceed with heartbeat.
        // The LLM prompt says "if it exists" so this is expected behavior.
    }
    const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
    const previousUpdatedAt = entry?.updatedAt;
    const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
    // @ts-ignore -- cherry-pick upstream type mismatch
    // @ts-ignore -- cherry-pick upstream type mismatch
    const heartbeatAccountId = heartbeat?.accountId?.trim();
    if (delivery.reason === "unknown-account") {
        log.warn("heartbeat: unknown accountId", {
            accountId: delivery.accountId ?? heartbeatAccountId ?? null,
            target: heartbeat?.target ?? "last",
        });
    }
    else if (heartbeatAccountId) {
        log.info("heartbeat: using explicit accountId", {
            accountId: delivery.accountId ?? heartbeatAccountId,
            target: heartbeat?.target ?? "last",
            channel: delivery.channel,
        });
    }
    const visibility = delivery.channel !== "none"
        ? resolveHeartbeatVisibility({
            cfg,
            channel: delivery.channel,
            accountId: delivery.accountId,
        })
        : { showOk: false, showAlerts: true, useIndicator: true };
    const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
    const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId, {
        channel: delivery.channel !== "none" ? delivery.channel : undefined,
        accountId: delivery.accountId,
    }).responsePrefix;
    // Check if this is an exec event or cron event with pending system events.
    // If so, use a specialized prompt that instructs the model to relay the result
    // instead of the standard heartbeat prompt with "reply HEARTBEAT_OK".
    const isExecEvent = opts.reason === "exec-event";
    const isCronEvent = Boolean(opts.reason?.startsWith("cron:"));
    const pendingEvents = isExecEvent || isCronEvent ? peekSystemEvents(sessionKey) : [];
    const cronEvents = pendingEvents.filter((evt) => isCronSystemEvent(evt));
    const hasExecCompletion = pendingEvents.some(isExecCompletionEvent);
    const hasCronEvents = isCronEvent && cronEvents.length > 0;
    const prompt = hasExecCompletion
        ? EXEC_EVENT_PROMPT
        : hasCronEvents
            ? buildCronEventPrompt(cronEvents)
            : resolveHeartbeatPrompt(cfg, heartbeat);
    const ctx = {
        Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),
        From: sender,
        To: sender,
        Provider: hasExecCompletion ? "exec-event" : hasCronEvents ? "cron-event" : "heartbeat",
        SessionKey: sessionKey,
    };
    if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
        emitHeartbeatEvent({
            status: "skipped",
            reason: "alerts-disabled",
            durationMs: Date.now() - startedAt,
            channel: delivery.channel !== "none" ? delivery.channel : undefined,
            accountId: delivery.accountId,
        });
        return { status: "skipped", reason: "alerts-disabled" };
    }
    const heartbeatOkText = responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
    const canAttemptHeartbeatOk = Boolean(visibility.showOk && delivery.channel !== "none" && delivery.to);
    const maybeSendHeartbeatOk = async () => {
        if (!canAttemptHeartbeatOk || delivery.channel === "none" || !delivery.to) {
            return false;
        }
        const heartbeatPlugin = getChannelPlugin(delivery.channel);
        if (heartbeatPlugin?.heartbeat?.checkReady) {
            const readiness = await heartbeatPlugin.heartbeat.checkReady({
                cfg,
                accountId: delivery.accountId,
                deps: opts.deps,
            });
            if (!readiness.ok) {
                return false;
            }
        }
        await deliverOutboundPayloads({
            cfg,
            channel: delivery.channel,
            to: delivery.to,
            accountId: delivery.accountId,
            payloads: [{ text: heartbeatOkText }],
            deps: opts.deps,
        });
        return true;
    };
    try {
        const heartbeatModelOverride = heartbeat?.model?.trim() || undefined;
        const replyOpts = heartbeatModelOverride
            ? { isHeartbeat: true, heartbeatModelOverride }
            : { isHeartbeat: true };
        const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);
        const replyPayload = resolveHeartbeatReplyPayload(replyResult);
        const includeReasoning = heartbeat?.includeReasoning === true;
        const reasoningPayloads = includeReasoning
            ? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
            : [];
        if (!replyPayload ||
            (!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length)) {
            await restoreHeartbeatUpdatedAt({
                storePath,
                sessionKey,
                updatedAt: previousUpdatedAt,
            });
            const okSent = await maybeSendHeartbeatOk();
            emitHeartbeatEvent({
                status: "ok-empty",
                reason: opts.reason,
                durationMs: Date.now() - startedAt,
                channel: delivery.channel !== "none" ? delivery.channel : undefined,
                accountId: delivery.accountId,
                silent: !okSent,
                indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined,
            });
            return { status: "ran", durationMs: Date.now() - startedAt };
        }
        const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
        const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
        // For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
        // The model should be responding with exec results, not ack tokens.
        // Also, if normalized.text is empty due to token stripping but we have exec completion,
        // fall back to the original reply text.
        const execFallbackText = hasExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
            ? replyPayload.text.trim()
            : null;
        if (execFallbackText) {
            normalized.text = execFallbackText;
            normalized.shouldSkip = false;
        }
        const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia && !hasExecCompletion;
        if (shouldSkipMain && reasoningPayloads.length === 0) {
            await restoreHeartbeatUpdatedAt({
                storePath,
                sessionKey,
                updatedAt: previousUpdatedAt,
            });
            const okSent = await maybeSendHeartbeatOk();
            emitHeartbeatEvent({
                status: "ok-token",
                reason: opts.reason,
                durationMs: Date.now() - startedAt,
                channel: delivery.channel !== "none" ? delivery.channel : undefined,
                accountId: delivery.accountId,
                silent: !okSent,
                indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined,
            });
            return { status: "ran", durationMs: Date.now() - startedAt };
        }
        const mediaUrls = replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
        // Suppress duplicate heartbeats (same payload) within a short window.
        // This prevents "nagging" when nothing changed but the model repeats the same items.
        const prevHeartbeatText = typeof entry?.lastHeartbeatText === "string" ? entry.lastHeartbeatText : "";
        const prevHeartbeatAt = typeof entry?.lastHeartbeatSentAt === "number" ? entry.lastHeartbeatSentAt : undefined;
        const isDuplicateMain = !shouldSkipMain &&
            !mediaUrls.length &&
            Boolean(prevHeartbeatText.trim()) &&
            normalized.text.trim() === prevHeartbeatText.trim() &&
            typeof prevHeartbeatAt === "number" &&
            startedAt - prevHeartbeatAt < 24 * 60 * 60 * 1000;
        if (isDuplicateMain) {
            await restoreHeartbeatUpdatedAt({
                storePath,
                sessionKey,
                updatedAt: previousUpdatedAt,
            });
            emitHeartbeatEvent({
                status: "skipped",
                reason: "duplicate",
                preview: normalized.text.slice(0, 200),
                durationMs: Date.now() - startedAt,
                hasMedia: false,
                channel: delivery.channel !== "none" ? delivery.channel : undefined,
                accountId: delivery.accountId,
            });
            return { status: "ran", durationMs: Date.now() - startedAt };
        }
        // Reasoning payloads are text-only; any attachments stay on the main reply.
        const previewText = shouldSkipMain
            ? reasoningPayloads
                .map((payload) => payload.text)
                .filter((text) => Boolean(text?.trim()))
                .join("\n")
            : normalized.text;
        if (delivery.channel === "none" || !delivery.to) {
            emitHeartbeatEvent({
                status: "skipped",
                reason: delivery.reason ?? "no-target",
                preview: previewText?.slice(0, 200),
                durationMs: Date.now() - startedAt,
                hasMedia: mediaUrls.length > 0,
                accountId: delivery.accountId,
            });
            return { status: "ran", durationMs: Date.now() - startedAt };
        }
        if (!visibility.showAlerts) {
            await restoreHeartbeatUpdatedAt({
                storePath,
                sessionKey,
                updatedAt: previousUpdatedAt,
            });
            emitHeartbeatEvent({
                status: "skipped",
                reason: "alerts-disabled",
                preview: previewText?.slice(0, 200),
                durationMs: Date.now() - startedAt,
                channel: delivery.channel,
                hasMedia: mediaUrls.length > 0,
                accountId: delivery.accountId,
                indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
            });
            return { status: "ran", durationMs: Date.now() - startedAt };
        }
        const deliveryAccountId = delivery.accountId;
        const heartbeatPlugin = getChannelPlugin(delivery.channel);
        if (heartbeatPlugin?.heartbeat?.checkReady) {
            const readiness = await heartbeatPlugin.heartbeat.checkReady({
                cfg,
                accountId: deliveryAccountId,
                deps: opts.deps,
            });
            if (!readiness.ok) {
                emitHeartbeatEvent({
                    status: "skipped",
                    reason: readiness.reason,
                    preview: previewText?.slice(0, 200),
                    durationMs: Date.now() - startedAt,
                    hasMedia: mediaUrls.length > 0,
                    channel: delivery.channel,
                    accountId: delivery.accountId,
                });
                log.info("heartbeat: channel not ready", {
                    channel: delivery.channel,
                    reason: readiness.reason,
                });
                return { status: "skipped", reason: readiness.reason };
            }
        }
        await deliverOutboundPayloads({
            cfg,
            channel: delivery.channel,
            to: delivery.to,
            accountId: deliveryAccountId,
            payloads: [
                ...reasoningPayloads,
                ...(shouldSkipMain
                    ? []
                    : [
                        {
                            text: normalized.text,
                            mediaUrls,
                        },
                    ]),
            ],
            deps: opts.deps,
        });
        // Record last delivered heartbeat payload for dedupe.
        if (!shouldSkipMain && normalized.text.trim()) {
            const store = loadSessionStore(storePath);
            const current = store[sessionKey];
            if (current) {
                store[sessionKey] = {
                    ...current,
                    lastHeartbeatText: normalized.text,
                    lastHeartbeatSentAt: startedAt,
                };
                await saveSessionStore(storePath, store);
            }
        }
        emitHeartbeatEvent({
            status: "sent",
            to: delivery.to,
            preview: previewText?.slice(0, 200),
            durationMs: Date.now() - startedAt,
            hasMedia: mediaUrls.length > 0,
            channel: delivery.channel,
            accountId: delivery.accountId,
            indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
        });
        return { status: "ran", durationMs: Date.now() - startedAt };
    }
    catch (err) {
        const reason = formatErrorMessage(err);
        emitHeartbeatEvent({
            status: "failed",
            reason,
            durationMs: Date.now() - startedAt,
            channel: delivery.channel !== "none" ? delivery.channel : undefined,
            accountId: delivery.accountId,
            indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined,
        });
        log.error(`heartbeat failed: ${reason}`, { error: reason });
        return { status: "failed", reason };
    }
}
export function startHeartbeatRunner(opts) {
    const runtime = opts.runtime ?? defaultRuntime;
    const runOnce = opts.runOnce ?? runHeartbeatOnce;
    const state = {
        cfg: opts.cfg ?? loadConfig(),
        runtime,
        agents: new Map(),
        timer: null,
        stopped: false,
    };
    let initialized = false;
    const resolveNextDue = (now, intervalMs, prevState) => {
        if (typeof prevState?.lastRunMs === "number") {
            return prevState.lastRunMs + intervalMs;
        }
        if (prevState && prevState.intervalMs === intervalMs && prevState.nextDueMs > now) {
            return prevState.nextDueMs;
        }
        return now + intervalMs;
    };
    const advanceAgentSchedule = (agent, now) => {
        agent.lastRunMs = now;
        agent.nextDueMs = now + agent.intervalMs;
    };
    const scheduleNext = () => {
        if (state.stopped) {
            return;
        }
        if (state.timer) {
            clearTimeout(state.timer);
            state.timer = null;
        }
        if (state.agents.size === 0) {
            return;
        }
        const now = Date.now();
        let nextDue = Number.POSITIVE_INFINITY;
        for (const agent of state.agents.values()) {
            if (agent.nextDueMs < nextDue) {
                nextDue = agent.nextDueMs;
            }
        }
        if (!Number.isFinite(nextDue)) {
            return;
        }
        const delay = Math.max(0, nextDue - now);
        state.timer = setTimeout(() => {
            state.timer = null;
            requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
        }, delay);
        state.timer.unref?.();
    };
    const updateConfig = (cfg) => {
        if (state.stopped) {
            return;
        }
        const now = Date.now();
        const prevAgents = state.agents;
        const prevEnabled = prevAgents.size > 0;
        const nextAgents = new Map();
        const intervals = [];
        for (const agent of resolveHeartbeatAgents(cfg)) {
            const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
            if (!intervalMs) {
                continue;
            }
            intervals.push(intervalMs);
            const prevState = prevAgents.get(agent.agentId);
            const nextDueMs = resolveNextDue(now, intervalMs, prevState);
            nextAgents.set(agent.agentId, {
                agentId: agent.agentId,
                heartbeat: agent.heartbeat,
                intervalMs,
                lastRunMs: prevState?.lastRunMs,
                nextDueMs,
            });
        }
        state.cfg = cfg;
        state.agents = nextAgents;
        const nextEnabled = nextAgents.size > 0;
        if (!initialized) {
            if (!nextEnabled) {
                log.info("heartbeat: disabled", { enabled: false });
            }
            else {
                log.info("heartbeat: started", { intervalMs: Math.min(...intervals) });
            }
            initialized = true;
        }
        else if (prevEnabled !== nextEnabled) {
            if (!nextEnabled) {
                log.info("heartbeat: disabled", { enabled: false });
            }
            else {
                log.info("heartbeat: started", { intervalMs: Math.min(...intervals) });
            }
        }
        scheduleNext();
    };
    const run = async (params) => {
        if (state.stopped) {
            return {
                status: "skipped",
                reason: "disabled",
            };
        }
        if (!heartbeatsEnabled) {
            return {
                status: "skipped",
                reason: "disabled",
            };
        }
        if (state.agents.size === 0) {
            return {
                status: "skipped",
                reason: "disabled",
            };
        }
        const reason = params?.reason;
        const isInterval = reason === "interval";
        const startedAt = Date.now();
        const now = startedAt;
        let ran = false;
        for (const agent of state.agents.values()) {
            if (isInterval && now < agent.nextDueMs) {
                continue;
            }
            let res;
            try {
                res = await runOnce({
                    cfg: state.cfg,
                    agentId: agent.agentId,
                    heartbeat: agent.heartbeat,
                    reason,
                    deps: { runtime: state.runtime },
                });
            }
            catch (err) {
                // If runOnce throws (e.g. during session compaction), we must still
                // advance the timer and call scheduleNext so heartbeats keep firing.
                const errMsg = formatErrorMessage(err);
                log.error(`heartbeat runner: runOnce threw unexpectedly: ${errMsg}`, { error: errMsg });
                advanceAgentSchedule(agent, now);
                continue;
            }
            if (res.status === "skipped" && res.reason === "requests-in-flight") {
                advanceAgentSchedule(agent, now);
                scheduleNext();
                return res;
            }
            if (res.status !== "skipped" || res.reason !== "disabled") {
                advanceAgentSchedule(agent, now);
            }
            if (res.status === "ran") {
                ran = true;
            }
        }
        scheduleNext();
        if (ran) {
            return { status: "ran", durationMs: Date.now() - startedAt };
        }
        return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
    };
    const wakeHandler = async (params) => run({ reason: params.reason });
    const disposeWakeHandler = setHeartbeatWakeHandler(wakeHandler);
    updateConfig(state.cfg);
    const cleanup = () => {
        if (state.stopped) {
            return;
        }
        state.stopped = true;
        disposeWakeHandler();
        if (state.timer) {
            clearTimeout(state.timer);
        }
        state.timer = null;
    };
    opts.abortSignal?.addEventListener("abort", cleanup, { once: true });
    return { stop: cleanup, updateConfig };
}
