UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

1,490 lines • 84.2 kB
// Durable-across-a-tool-timeout delivery of a VALIDATED panel_ask answer
// (issue #486).
//
// THE FAILURE. `panel_ask` renders a choice card and BLOCKS on the user's pick.
// The user's answer has exactly one delivery channel: the return value of the
// enclosing MCP `tools/call`. That call has its own budget (~300s) and its own
// lifetime — a turn that ends, a client that gives up, a session torn down — and
// when it dies the answer dies with it, in three distinct ways:
//
//   1. ANSWERED IN TIME, NOBODY LEFT TO RETURN TO. `bridge.send` resolves with a
//      validated pick, `askUserWithGrace` returns it, and the ToolResult is
//      written to a request the client already abandoned. Nothing recorded it.
//   2. ANSWERED DURING THE GRACE POLL. Same, one layer down: the poll TAKES the
//      answer out of the bridge's late-reply buffer (destroying it there) and
//      returns it into the same dead request.
//   3. ANSWERED AFTER THE GRACE. The handler has already returned "not answered
//      in time". The answer lands in the bridge's late-reply buffer keyed by
//      `ask_id` and NOBODY EVER POLLS IT AGAIN — it is TTL-pruned five minutes
//      later, unread.
//
// In every case the user answered, the answer validated, and the agent either
// asked again or proceeded without it. That is the whole of #486.
//
// THE CONTRACT HERE — deliberately the one #468/PR #786 arrived at for run
// completions (see run-completion-journal.ts), because this is the same problem
// class and the same traps are waiting:
//
//  • Asks are TICKETED by their `ask_id`, opened when the card is dispatched.
//  • Every answer is CORRELATED ONCE, AT ARRIVAL, by EXACT ask-id equality
//    against an open ticket — never by recency, never re-derived later. The
//    ticket's QUESTION FINGERPRINT is frozen onto the entry at that moment.
//  • An answer that cannot be correlated is still journaled and still delivered,
//    labelled UNDETERMINED. It is never swallowed, and it can never be presented
//    as the answer to a question it isn't provably an answer to.
//  • THE JOURNAL NEVER MERGES AND NEVER SUPPRESSES — IT ONLY EVER LABELS. Every
//    "already delivered" proof available here (tickets, entries, the bridge's own
//    5-minute buffer) is BOUNDED, so any rule that SUPPRESSES on one becomes a
//    silent LOSS the moment it expires at the wrong time. #468 removed
//    suppression outright after five successive fixes were each defeated one
//    bound away; nothing here reintroduces it.
//  • Undelivered answers are REPLAYED at the next delivery opportunity, and an
//    entry is cleared only when the turn that CARRIED it ended (or when a
//    matching re-ask provably took it as its result).
//
// THE CROSS-QUESTION GUARD is the one rule this file adds over #468's, and it is
// strictly the more important direction. Losing an answer costs a re-ask;
// MISATTRIBUTING one makes the agent act on a decision the user never made about
// the thing at hand. So a recovered answer may only ever satisfy an ask whose
// QUESTION FINGERPRINT (question text + option labels, in order + multi-select +
// header) is EXACTLY equal to the fingerprint frozen on the entry at arrival, on
// the SAME panel tab. There is deliberately no fuzzy match, no "closest
// question", and no "the only outstanding ask" fallback: an answer that doesn't
// match exactly is reported as an unattributed answer to ITS OWN question —
// quoted with that question, so it cannot be read as an answer to anything else.
//
// WHAT IS DELIBERATELY NOT GUARANTEED. Three residuals survived four rounds of
// adversarial review; each is an ACCEPTED trade with its reasoning, not an
// oversight, and each is stated where it lives as well as here.
//
//  1. A RETURNED ANSWER EVICTED BY THE PER-TAB CEILING IS LOGGED, NOT COUNTED as
//     an undetermined loss (see trimEntries). It reached a caller; what its
//     eviction costs is the ability to RECOVER it. Counting each one would fire
//     after ~48 ordinary successful asks and tell the agent that answers it
//     almost certainly received are unknown — and a warning that is usually
//     false is its own kind of silence.
//  2. A RESTART IS NOT DEFERRED FOR A RETURNED ANSWER (see hasOutstanding vs.
//     allOutstanding). Deferring on one would stall the self-restarter for the
//     whole recovery window after EVERY ask. The fatal-exit disclosure names it
//     instead: saying so on the way out costs nothing.
//  3. A CARD ANSWERED BEYOND THE BRIDGE'S OPEN-CARD CEILING cannot be recognised
//     at all (UiBridge.MAX_ASK_RID_MAPPINGS). Reaching it needs 1024 unanswered
//     cards outstanding at once; it is logged at ERROR, and reporting it as a
//     "dropped answer" would be a lie about questions nobody answered.
//
// LOCAL trust domain: this is accidental-loss bookkeeping, not a defence against
// a hostile panel. Everything is in-memory and process-scoped.
import { createHash } from "node:crypto";
import { logger } from "../utils/logger.js";
import { tabIncarnationSlot } from "../services/ui-bridge.js";
/**
 * Cards tracked at once. A ticket is a handful of small fields, and it is what
 * makes an answer ATTRIBUTABLE, so this is sized for a whole session's asks
 * rather than for the two that are usually open. There is deliberately no age
 * limit: a question card sits on screen until the user deals with it, and any
 * clock here would be a bound quietly deciding that an answer no longer counts.
 * Overflowing is logged at ERROR and only ever WEAKENS a correlation to
 * UNDETERMINED — it never drops an answer.
 */
const MAX_TICKETS = 1024;
/**
 * Prefix every `panel_ask` card's ask id carries.
 *
 * The bridge's late-answer sink is fed by EVERY `ask_user` card — the confirm
 * gate, the 18+ consent card, the secret prompt — and only `panel_ask` answers
 * belong in this journal. Ownership therefore has to be decidable from the id
 * ITSELF: an earlier draft answered it from a bounded set of remembered ids,
 * which meant an eviction turned a validated answer into nothing. A bounded
 * store must never be what protects against loss, so the test is syntactic and
 * cannot expire, evict, or be raced.
 */
export const PANEL_ASK_ID_PREFIX = "pa-";
/** Undelivered/unconsumed answers held per panel tab. */
const MAX_ENTRIES_PER_KEY = 48;
/** …and across all tabs. */
const MAX_ENTRIES_TOTAL = 192;
/**
 * How long a journaled answer may still be RECOVERED as the result of a re-ask
 * of the identical question.
 *
 * This bound is a LABEL boundary, not a suppression: past it the answer is not
 * returned as "the user's answer to the question you just asked" (it is stale
 * enough that the user plausibly meant it for the earlier moment), but it is
 * still DISCLOSED, quoted with its own question, so it is never silently
 * discarded.
 */
const RECOVER_MAX_AGE_MS = 10 * 60_000;
/** Turns that may carry a pushed answer and end without a provable ack before we
 *  settle it anyway. Same rationale as MAX_CARRIED_RELEASES in #468: each of
 *  those put the text into a turn the agent read, so settling risks a duplicate,
 *  never a loss. */
const MAX_CARRIED_RELEASES = 3;
/**
 * EXACT identity of a question, and the whole of the cross-question guard.
 *
 * EVERYTHING THE USER READS ON THE CARD GOES IN, in order: the question text,
 * the header chip, the multi-select flag, and every option's LABEL **and
 * DESCRIPTION**. Two asks are "the same question" only when a user looking at
 * both cards would see the same thing.
 *
 * The descriptions are not decoration: they are the one-line explanations the
 * user reads to decide, so "euler / fast, lower quality" and "euler / now the
 * recommended default" are different decisions wearing the same label. Leaving
 * them out would let a re-worded card recover an answer the user gave to
 * different information. The guard errs strict in every direction: a stricter
 * fingerprint costs at most a re-ask, a looser one lets an answer satisfy a
 * question it was not given for.
 *
 * Deliberately NOT included: the tab id (an entry carries its tab separately, so
 * a tab-id migration re-keys it without invalidating every fingerprint) and any
 * timestamp.
 *
 * Whitespace is trimmed and internal runs collapsed on the text fields only,
 * because a re-ask is often the model re-emitting the same prompt with different
 * wrapping. Nothing else is normalized: case, punctuation and ordering are all
 * significant, so "Delete the file?" never matches "delete the file".
 */
export function askFingerprint(ask) {
    const norm = (s) => s.replace(/\s+/g, " ").trim();
    const labels = Array.isArray(ask.options)
        ? ask.options.map((o) => {
            const opt = o;
            if (typeof opt?.label !== "string")
                return JSON.stringify(o ?? null);
            // Label AND description — an option is the whole thing the user reads.
            return JSON.stringify([
                norm(opt.label),
                typeof opt.description === "string" ? norm(opt.description) : "",
            ]);
        })
        : [];
    // JSON.stringify is the delimiter: it escapes the payload itself, so no
    // separator character can ever be smuggled in by a question or an option label
    // to make two DIFFERENT questions hash the same. (And it keeps this file pure
    // ASCII — no control bytes, no exotic separators.)
    const parts = [
        norm(ask.question ?? ""),
        typeof ask.header === "string" ? norm(ask.header) : "",
        ask.multi_select === true ? "multi" : "single",
        ...labels,
    ];
    return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
}
/** Coerce a bridge reply into the answer text the user actually gave. Panel
 *  ask cards reply with a string; anything else is preserved verbatim as JSON so
 *  nothing the user chose is ever silently reshaped away. */
export function answerText(reply) {
    if (typeof reply === "string")
        return reply;
    try {
        return JSON.stringify(reply);
    }
    catch {
        return String(reply);
    }
}
export class AskAnswerJournalImpl {
    /** ask id → ticket. Ask ids are UUIDs, so exact equality is proof of identity. */
    tickets = new Map();
    /**
     * Ask ids this journal has EVER opened, kept long after their ticket is gone.
     *
     * This is what `tracks()` answers on, and it exists because the ticket map is
     * bounded: if "is this one of ours?" were answered from the tickets alone, an
     * eviction would make the bridge's late-answer sink DROP a validated answer
     * outright — a bounded store silently protecting against a bounded store,
     * which is the exact shape of every defect #468 kept re-growing. Losing a
     * ticket may only ever WEAKEN a correlation (matched → foreign, i.e.
     * UNDETERMINED); it must never turn an answer into nothing.
     */
    /** Panel tab -> the generation of the conversation currently attached to it.
     *  Bumped by closeAsks(); a ticket minted under an older generation belongs to
     *  a conversation that is gone. See AskTicket.epoch. */
    tabEpoch = new Map();
    /** token → entry (insertion-ordered, which is also delivery order). */
    entries = new Map();
    /** Answers this tab lost to an eviction and has not yet been told about. */
    dropped = new Map();
    /** How many tool results have already carried each tab's eviction debt. The
     *  debt is spent by a COUNT of reports, never by a single hand-off — see
     *  reportDropped(). */
    /** Outstanding eviction-disclosure tokens: token -> panel tab. Retired only
     *  by the ack of the turn that carried the warning (see reportDropped). */
    debtTokens = new Map();
    /**
     * Per tab: the eviction total that has already been SURFACED and confirmed.
     *
     * A warning names a number, and only that number is settled when the turn
     * carrying it ends. Without this the ack cleared the tab's whole current debt,
     * so an eviction that happened AFTER the warning was rendered — while its turn
     * was still running — was wiped by an ack that never mentioned it. A watermark
     * also makes the ack idempotent: two results reporting the same total settle
     * the same total, and a later one settles more.
     */
    disclosedUpTo = new Map();
    seq = 0;
    ticketSeq = 0;
    /**
     * Which browser-tab INCARNATION currently holds a panel tab (#486).
     *
     * A `wf:` route tab id names a saved workflow, so it recurs: a second browser
     * tab opening that workflow takes the key over. Anything that holds or settles
     * per-tab state must therefore be scoped to (tab, incarnation) — otherwise the
     * newcomer reads, reports and ACKS the departed tab's bookkeeping, and the
     * departed tab's own disclosure is silently marked told. Undefined when the tab
     * is not currently connected.
     */
    incarnationOf = null;
    /** Deliver a tab's newly-orphaned answers (see setFlusher). */
    flush = null;
    /** Pull a still-queued answer event back off its agent (see setRevoker). */
    revoke = null;
    /** Ride a token on the tab's in-flight turn, returning the CARRIER IDENTITY it
     *  attached to (null when there was no live turn). See setTurnAttacher. */
    attachTurn = null;
    /**
     * Wire the push channel (#486).
     *
     * An answer becomes an ORPHAN at moments the tool layer cannot act on — the
     * bridge's late-answer sink, or an ask handler unwinding on an error without
     * having returned one. Whoever owns the agents registers here so those
     * transitions deliver immediately instead of waiting for some unrelated later
     * trigger (which is how "journaled" quietly becomes "never delivered").
     */
    setFlusher(flush) {
        this.flush = flush;
    }
    /**
     * Wire the "unsend" hook, mirroring #468's.
     *
     * An answer's WORDING is materialised when it is queued into an agent, and it
     * says "a question card YOU put up". If the conversation is replaced before
     * that item is read, the sentence becomes false — the fork never put that card
     * up. This pulls the stale copy back while it is still unread. Nothing is
     * re-delivered afterwards: the addressee is gone, which is the whole point.
     */
    setRevoker(revoke) {
        this.revoke = revoke;
    }
    /**
     * Wire the TOOL-RESULT ack (#486).
     *
     * An answer PUSHED as an event is acked when the turn carrying it ends. An
     * answer handed back as a TOOL RESULT had no such proof — and treating the
     * hand-off itself as proof is exactly the assumption #486 exists to demolish.
     * This rides the token on the turn the tool call is running inside, so the
     * SAME ack-on-carry machinery settles it: either that turn's own marked result
     * lands (the model ran to completion after receiving the answer), or it never
     * does and the answer stays accounted for.
     */
    setTurnAttacher(attach) {
        this.attachTurn = attach;
    }
    /** Wire the per-tab incarnation lookup — see `incarnationOf`. */
    setIncarnationResolver(resolve) {
        this.incarnationOf = resolve;
    }
    /**
     * May the tab's CURRENT occupant act on this answer — recover it, or be pushed
     * it?
     *
     * Only the occupant it was given by. A different browser tab holding the same
     * recurring key never asked the question and must not be handed its answer;
     * unknown provenance on either side is not a match either, because "we could
     * not tell who this belongs to" must never resolve to "it belongs to you".
     * When NOTHING can resolve incarnations at all (no resolver wired) the check is
     * inert and tab-keying alone governs, exactly as before.
     */
    /**
     * May this answer be handed to a LIVE TURN — recovered, replayed, or pushed?
     *
     * The single gate for the whole set. Both boundaries are checked here, because
     * they are independent and each alone is insufficient:
     *  • RETIRED — its conversation is gone (New chat, rewind, provider switch,
     *    workflow replacement, takeover). Same tab, same incarnation, so no amount
     *    of incarnation keying can see it.
     *  • a DIFFERENT OCCUPANT — another browser tab holds this recurring key. Same
     *    conversation identity as far as the tab is concerned, so no amount of
     *    epoch bumping can see it.
     *
     * Gating the paths one at a time is how the rid bypass, the re-arm bypass and
     * the debt-reporting bypass each arrived separately; making it a property of
     * the SET is the fix.
     */
    mayReachLiveTurn(entry) {
        return entry.retired !== true && this.sameOccupant(entry);
    }
    sameOccupant(entry) {
        if (this.incarnationOf === null)
            return true; // nothing to distinguish
        const now = this.incarnationOf(entry.key);
        if (entry.incarnation === undefined || now === undefined)
            return false;
        return entry.incarnation === now;
    }
    /** The debt bucket for a tab's CURRENT occupant. */
    debtSlot(key) {
        return tabIncarnationSlot(key, this.incarnationOf?.(key));
    }
    /**
     * A `panel_ask` card is being dispatched. Opens the ticket that makes any
     * answer for `askId` attributable to THIS question.
     *
     * Must be called BEFORE the card is sent: the answer can come back on the
     * bridge's late-reply sink at any moment after that, and an answer that
     * arrives with no ticket is (correctly, but uselessly) foreign.
     */
    openAsk(askId, meta) {
        const epoch = this.tabEpoch.get(meta.tabId) ?? 0;
        const existing = this.tickets.get(askId);
        // REUSE is also proven by an ANSWER already on file for this id, not only by
        // a surviving ticket. The ticket map is bounded, so keying the guard on it
        // alone would let an eviction restore a "fresh" identity to an id that a
        // still-rendered card can answer — the old card's click would then be frozen
        // with the NEW question's fingerprint and could satisfy it.
        const answered = [...this.entries.values()].some((e) => e.askId === askId);
        if (existing || answered) {
            // The same ask id dispatched AGAIN. Reopen rather than stack a second
            // ticket (one ask id always means one ticket) — but the id now stands for
            // MORE THAN ONE question, and the panel's reply carries only the id, so
            // nothing can ever say which card an answer for it came from. Mark it
            // reused: every answer for it is reported UNDETERMINED from here on, and
            // in particular the older card's late click can no longer be frozen with
            // the newer question's fingerprint and recovered as its answer.
            this.tickets.set(askId, {
                ...(existing ?? {}),
                askId,
                tabId: meta.tabId,
                fingerprint: meta.fingerprint,
                question: meta.question,
                openedAt: Date.now(),
                epoch,
                seq: ++this.ticketSeq,
                awaiting: true,
                reused: true,
            });
            logger.warn(`[ask-answers] ask id ${askId.slice(0, 12)} was opened again — it no longer identifies one question, so every answer for it is reported as UNDETERMINED`);
            // FREEZE THE AMBIGUITY ONTO THE ENTRIES THAT ALREADY EXIST for this id.
            // The ticket is evictable and this fact is not: once it is trimmed away,
            // anything keyed on `ticket.reused` silently reverts to treating the id as
            // a clean identity. Each such entry keeps its OWN question text — that is
            // still honest and is what makes its disclosure useful — but loses the
            // licence to answer anything.
            for (const entry of this.entries.values()) {
                if (entry.askId !== askId)
                    continue;
                entry.ambiguousId = true;
                entry.recoverable = false;
                entry.fingerprint = null;
                if (entry.correlation.status === "matched") {
                    entry.correlation = { status: "foreign", askId };
                }
            }
            this.trimTickets();
            return;
        }
        this.tickets.set(askId, {
            askId,
            tabId: meta.tabId,
            fingerprint: meta.fingerprint,
            question: meta.question,
            openedAt: Date.now(),
            epoch,
            seq: ++this.ticketSeq,
            awaiting: true,
        });
        this.trimTickets();
    }
    /**
     * Is the conversation that opened this ask STILL the one on the tab?
     *
     * The debt footnote rides whatever result is going back, and `reportDropped`
     * attaches its token to the turn that is live NOW. So a result belonging to a
     * conversation that has since been replaced must not carry it: the live turn's
     * ack would settle a warning that conversation never saw. Unknown ticket → true,
     * because a caller with no ticket has nothing to have been replaced.
     */
    askBelongsToLiveConversation(askId) {
        const ticket = this.tickets.get(askId);
        if (!ticket)
            return true;
        return ticket.epoch === (this.tabEpoch.get(ticket.tabId) ?? 0);
    }
    /**
     * May this answer's CONTENT be shown to whoever holds its tab right now?
     *
     * The public face of the boundary gate, for outlets that live outside this
     * class. The fatal-exit notice is one: it renders answer text and pushes it to
     * the tab, so it is a delivery like any other and a retired conversation's pick
     * must not appear in it. Counts may always cross; content may not.
     */
    mayDisclose(entry) {
        return this.mayReachLiveTurn(entry);
    }
    /** Does this journal know the ask id — i.e. is a late answer for it one of
     *  OURS? The bridge's late-answer sink is fed by the ask cards, and only
     *  `panel_ask` answers belong here; the others have their own, deliberately
     *  non-recoverable paths (a recovered "Yes, go ahead" must never authorise a
     *  different destructive operation).
     *
     *  SECRET CARDS NO LONGER REACH THE SINK AT ALL (#1352). They are recoverable
     *  now — a credential typed a moment late is applied rather than dropped — but
     *  only from the bridge's in-memory buffer, because journaling is durable by
     *  design and that is the one property a credential must not have. This prefix
     *  check would also decline them; the bridge declines first, deliberately, so
     *  the guarantee does not rest on a policy that lives in another file. */
    tracks(askId) {
        return askId.startsWith(PANEL_ASK_ID_PREFIX);
    }
    /**
     * The `panel_ask` handler that opened `askId` has RETURNED. Anything that
     * arrives from here on has nobody to be handed to, so it is orphaned on
     * arrival and pushed to the agent on its own.
     *
     * If an answer is already journaled and the handler never claimed it
     * (`markReturned` was not called), arm it for the push now.
     */
    closeAsk(askId) {
        const ticket = this.tickets.get(askId);
        if (ticket)
            ticket.awaiting = false;
        let armed = null;
        let answered = false;
        for (const entry of this.entries.values()) {
            if (entry.askId !== askId)
                continue;
            answered = true;
            // A RETIRED answer is never armed. Arming it produces an entry that
            // `pending()` correctly refuses to deliver and `hasOutstanding()` counted
            // as owed — a permanent blocker on the self-restart gate, cleared only by
            // some unrelated later boundary or eviction.
            if (!entry.returned && entry.delivery === "none" && entry.retired !== true) {
                entry.delivery = "pending";
                armed = entry;
            }
        }
        // The card has been ANSWERED and its handler has finished, so this ticket can
        // never be needed again — the panel removes an answered card, and everything
        // the ticket carried (tab, question, fingerprint, conversation generation) is
        // frozen onto the entry. Releasing it keeps the ticket map to CARDS STILL ON
        // SCREEN, which is what makes its ceiling unreachable in practice: otherwise
        // a long session's ordinary successful asks fill it and evict a genuinely
        // outstanding card's ticket, taking its retired-conversation marker with it.
        // …EXCEPT under a reused id, where "an entry exists for this ask id" no
        // longer means "this card was answered" — it may be the OTHER card's answer,
        // and this one is still on screen. Keeping the ticket keeps its reused flag,
        // which is what stops either answer satisfying either question.
        if (answered && ticket?.reused !== true)
            this.tickets.delete(askId);
        // It is an orphan NOW — deliver it now. Leaving it merely `pending` would
        // make it wait for an unrelated later flush, and this transition happens on
        // the ask handler's unwind, where there may never be one.
        if (armed)
            this.flush?.(armed.key);
        return armed;
    }
    /**
     * Journal a validated answer. Correlation is computed HERE, once, by exact ask
     * id.
     *
     * Idempotent per ask id: the same card can be observed twice (the handler's
     * grace poll takes it out of the bridge buffer while the bridge's sink has
     * already forwarded it), and both observations are the SAME answer to the SAME
     * question. Collapsing them is identity, not suppression — nothing is ever
     * dropped because it "looks like" something already seen.
     */
    record(askId, reply, meta) {
        const text = answerText(reply);
        const existing = [...this.entries.values()].find((e) => e.askId === askId);
        const ticket = this.tickets.get(askId);
        // Collapse ONLY an identical observation. The same card's reply can be seen
        // twice (the handler's grace poll takes it out of the bridge buffer while the
        // sink has already forwarded it) and that is one answer, not two — identity,
        // not suppression.
        //
        // A DIFFERENT answer under the same id is a different validated answer (two
        // cards, one reused id). Returning the old entry there would silently
        // discard what the user just chose, so it gets its own entry, and BOTH are
        // demoted to unattributable: nothing on the wire says which card either came
        // from, so neither may satisfy a question.
        //
        // …and the collapse is OFF ENTIRELY for a REUSED id. "Same id, same text" is
        // only evidence of one observation while the id means one card; once two
        // cards share it, two users' picks that happen to read the same ("euler" on
        // both) are two validated answers to two questions, and merging them leaves
        // the second question unanswered with no record at all.
        if (existing) {
            // The ENTRY's own ambiguity flag is the load-bearing half — see
            // AskEntry.ambiguousId. `ticket?.reused` is kept alongside it as the
            // earliest signal, but it may have been evicted, and a bound must never be
            // what decides whether an answer is allowed to disappear.
            if (existing.answer === text && existing.ambiguousId !== true && ticket?.reused !== true) {
                return existing;
            }
            logger.warn(`[ask-answers] a SECOND answer arrived under ask id ${askId.slice(0, 12)} ("${text}"${existing.answer === text ? " — same text, but the id is REUSED so this is a different card" : ` vs "${existing.answer}"`}) — both are kept and both are reported as UNDETERMINED; neither can satisfy a question`);
            existing.correlation = { status: "foreign", askId };
            existing.recoverable = false;
            // …and the ambiguity is frozen here too, so it survives the ticket.
            existing.ambiguousId = true;
        }
        // A REUSED id proves nothing: the panel sends only the id, so an answer for
        // it could belong to either card. Report it as foreign — real, but
        // UNDETERMINED — rather than claiming it answers the question now open.
        // The conversation that opened this ticket has been replaced (New chat, or a
        // resume of a historical session) while its card stayed on screen.
        const conversationGone = ticket !== undefined && ticket.epoch !== (this.tabEpoch.get(ticket.tabId) ?? 0);
        const attributable = ticket !== undefined && ticket.reused !== true && existing === undefined;
        const correlation = attributable
            ? { status: "matched", askId }
            : { status: "foreign", askId };
        const entry = {
            token: `aa${++this.seq}`,
            askId,
            // The TICKET's tab is authoritative only while the ticket is ATTRIBUTABLE.
            // A REUSED id can name a ticket opened by a DIFFERENT tab, and taking its
            // tab would re-address the answer to a conversation that never rendered the
            // card — labelled `foreign`, so it could not satisfy anything, but still
            // delivered to the wrong place. The bridge-supplied tab is the PROVEN
            // source of this reply (it comes from the routed send, not from the id), so
            // it wins whenever the ticket cannot be trusted.
            key: attributable ? ticket.tabId : meta.tabId,
            // The fingerprint is the LICENCE to satisfy a re-ask, so a reused id gets
            // none — its answer may only ever be reported, never returned as an answer.
            fingerprint: attributable ? ticket.fingerprint : null,
            // …and NOR is the question text, for a REUSED id. The ticket then holds the
            // LATER card's question, and printing that beside the EARLIER card's answer
            // states a false association — the very mistake this file exists to
            // prevent, committed in the disclosure meant to be the honest fallback. A
            // ticket that is merely RETIRED (its conversation replaced) is different:
            // its question is still that card's own, and keeping it is what lets the
            // report name what was actually asked.
            question: ticket === undefined || ticket.reused === true ? null : ticket.question,
            answer: text,
            answeredAt: Date.now(),
            correlation,
            ticketSeq: ticket?.seq ?? 0,
            // WHO was holding the tab when this answer arrived — see AskEntry.incarnation.
            ...(this.incarnationOf?.(ticket?.tabId ?? meta.tabId) !== undefined
                ? { incarnation: this.incarnationOf(ticket?.tabId ?? meta.tabId) }
                : {}),
            returned: false,
            // An answer that lands while its own handler is still running is that
            // handler's to return; one that lands afterwards has nobody left and is
            // armed for the push immediately.
            //
            // TWO cases are never pushed, and are journaled for disclosure only:
            //  • its CONVERSATION was replaced — see closeAsks();
            //  • THERE IS NO TICKET AT ALL. Without one we cannot tell whether the card
            //    belongs to the conversation now on this tab or to a retired one (the
            //    ticket carried that generation), and announcing it anyway would fold
            //    "could not determine" into "determined not retired". It is also the
            //    one delivery with nothing to offer: the wording for an unattributable
            //    answer already says its meaning is UNDETERMINED and that nothing may
            //    be inferred from it, so pushing it into a conversation that may not be
            //    the one that asked buys nothing and risks exactly the
            //    wrong-conversation delivery the epoch guard exists to prevent. The
            //    answer is still logged with its text and still reported by a later ask
            //    on this tab — it is not swallowed, it is just not announced.
            delivery: ticket === undefined || ticket.awaiting === true || conversationGone
                ? "none"
                : "pending",
            // A question the CURRENT conversation never asked may be reported, never
            // returned as its answer.
            ...(conversationGone || existing !== undefined ? { recoverable: false } : {}),
            // The BOUNDARY axis only — `existing !== undefined` is ambiguity, which is
            // still deliverable (labelled), so it must not set this.
            ...(conversationGone ? { retired: true } : {}),
            // A SECOND answer under one ask id: the id is ambiguous for this entry too,
            // and that must outlive the ticket that proved it.
            ...(existing !== undefined || ticket?.reused === true ? { ambiguousId: true } : {}),
            attempts: 0,
        };
        this.entries.set(entry.token, entry);
        if (conversationGone) {
            logger.warn(`[ask-answers] an answer arrived for a card whose conversation was replaced (ask ${askId.slice(0, 8)}, tab ${entry.key.slice(0, 8)}): "${entry.answer}" — journaled for disclosure, NOT announced to the replacement conversation`);
        }
        else if (!attributable) {
            logger.warn(`[ask-answers] a validated answer arrived for ask ${askId.slice(0, 8)} with ${ticket ? "a REUSED (ambiguous) ticket" : "no open ticket"} — journaled as UNATTRIBUTED; it can never satisfy a question, only be reported`);
        }
        // Same release as closeAsk (and the same reused-id exception).
        if (ticket && !ticket.awaiting && ticket.reused !== true)
            this.tickets.delete(askId);
        this.trimEntries(entry.key);
        // Born orphaned (no handler is waiting on this ask) — push it straight away.
        if (entry.delivery === "pending")
            this.flush?.(entry.key);
        return entry;
    }
    /** The ask handler put this answer into its ToolResult. A hand-off, not proof
     *  of consumption — see AskEntry.returned. */
    markReturned(token, opts = {}) {
        // BY TOKEN, not by ask id: under a reused id one ask id can own two entries,
        // and marking the sibling returned would quietly retire an answer nobody has
        // been given.
        const entry = this.entries.get(token);
        if (entry) {
            entry.returned = true;
            if (opts.replay)
                entry.replayHint = true;
            // Ride the turn this tool call is running inside, so that turn's result —
            // and nothing less — acks the answer. No live turn to ride means no proof,
            // so the answer simply stays unacked, which is the conservative reading.
            //
            // THE CARRIER IS CAPTURED HERE, at the instant of return, and frozen onto
            // the entry. Resolving it later from "whoever owns this tab now" is how a
            // switched provider's turn could ack an answer it never saw: a stale
            // pre-switch ask handler resumes, attaches to the NEW provider's turn, and
            // that turn's result settles an answer the new conversation was never
            // shown. `ack` compares against this, so only the turn that actually
            // carried the answer can settle it.
            //
            // …and an answer whose CONVERSATION is gone never attaches at all. Capturing
            // the carrier is not enough on its own here: a stale pre-switch ask handler
            // that resumes when its card is clicked would attach to whatever turn is
            // running NOW, and that turn's result would then certify — accurately, and
            // uselessly — an answer the current conversation was never shown. The
            // conversation boundary already marked this entry unrecoverable; it makes it
            // unackable too, so the answer stays honestly unproven.
            // Gated on the BOUNDARY axis only. An AMBIGUOUS answer (an ask id that
            // stopped identifying one card) is still handed to a caller, so it must
            // still be ackable by the turn that carried it — refusing the carrier there
            // left a delivered answer permanently unconfirmed, i.e. surfacing later as
            // a loss that did not happen. Only a RETIRED answer, which no live turn may
            // receive at all, gets no carrier.
            entry.carrier =
                entry.retired === true ? null : (this.attachTurn?.(entry.key, token) ?? null);
            // It reached A caller. Do not ALSO push it as an orphan; a re-ask can
            // still recover it if that caller was already dead.
            //
            // DEFENCE IN DEPTH, not a live path: an ask handler only marks an answer
            // returned while its own ticket is still `awaiting`, and `record` never
            // arms one in that state — so nothing reachable enters this branch and no
            // test can fail on it. It is here because "a caller took this" and
            // "announce it to the agent as unclaimed" are contradictory states, and a
            // future caller marking an answer returned from outside the ask handler
            // would otherwise double-report it.
            if (entry.delivery === "pending")
                entry.delivery = "none";
        }
    }
    /**
     * Find the answer the user already gave to THIS EXACT question on THIS tab.
     *
     * The match is exact fingerprint equality and nothing else — see the
     * cross-question guard at the top of this file. When several qualify the
     * NEWEST wins: it is the user's most recent statement of intent about that
     * question.
     *
     * Returns a discriminated result: "no answer at all" and "answers exist but
     * none is provably for this question" are DIFFERENT facts and must not be
     * folded into one falsy value (#796).
     *
     * The CURRENT ask's own id is deliberately NOT excluded: an answer that landed
     * for the very card we just gave up on is the most direct answer there is, and
     * excluding it would re-open the race the grace poll cannot close (the sink
     * journals an answer microseconds after the last poll).
     */
    recover(key, fingerprint) {
        const now = Date.now();
        const mine = [...this.entries.values()].filter((e) => e.key === key);
        if (mine.length === 0)
            return { status: "none" };
        const eligible = mine
            .filter((e) => e.correlation.status === "matched" &&
            e.recoverable !== false &&
            this.mayReachLiveTurn(e) &&
            e.fingerprint !== null &&
            e.fingerprint === fingerprint &&
            now - e.answeredAt <= RECOVER_MAX_AGE_MS)
            .sort((a, b) => b.answeredAt - a.answeredAt);
        if (eligible.length > 0)
            return { status: "recovered", entry: eligible[0] };
        // SPLIT by what this conversation is allowed to see. `others` goes through
        // exactly the same gate as every other outlet — nothing about a disclosure
        // makes it exempt just because it is not a hand-off.
        const others = mine.filter((e) => this.mayReachLiveTurn(e));
        return { status: "unattributed", others, withheld: mine.length - others.length };
    }
    /**
     * A recovery (or a push the agent provably consumed) took this answer. Drop
     * it so the same answer can never be handed over twice.
     *
     * Deleting an entry that was already pushed and is sitting unread in an
     * agent's queue is deliberate: the tool result the caller is about to return
     * carries the SAME answer with the SAME question attached, so the worst case
     * is the agent reading it twice — never acting on an answer it wasn't given.
     *
     * Any eviction disclosure the entry was CARRYING is handed back to the tab
     * first. The disclosure is a debt owed to the tab, not a property of whichever
     * entry happens to be carrying it: consuming the carrier without re-homing it
     * would let an eviction that the journal promised to report disappear because
     * an unrelated answer was recovered.
     */
    markSurfaced(token) {
        const entry = this.entries.get(token);
        if (!entry)
            return;
        // NOT DELETED. An earlier draft consumed a recovered answer outright, on the
        // reasoning that it had now been handed over — which is the same mistake as
        // every suppression #468 removed, one level up: the ToolResult carrying it
        // can itself be abandoned (that IS #486), so deleting the entry destroys the
        // only durable copy at exactly the moment it is most likely to be needed
        // again. It is marked handed-over and flagged, so a further re-ask of the
        // same question gets it again, labelled "you have seen this".
        //
        // IT GOES THROUGH markReturned, and must: a recovery hands the answer to a
        // live turn exactly as a fresh answer does, so it has to attach a token and
        // capture its carrier BY CONSTRUCTION. An earlier draft set the flags here
        // by hand and forgot to attach — so a recovery that completed perfectly
        // stayed unacked forever, aged into apparent loss, and eventually raised
        // eviction debt and warnings despite the agent having read it. That is the
        // routine-false-warning failure the ack split exists to prevent, arriving
        // through a different door.
        this.markReturned(token, { replay: true });
    }
    /** Every unconsumed ORPHAN answer for a tab — those that were never handed
     *  back to any tool result. These are what a failing ask discloses, so a
     *  validated answer is never silently dropped. */
    orphansFor(key) {
        return [...this.entries.values()].filter((e) => e.key === key && !e.returned);
    }
    /**
     * Take the eviction debt that has NO other carrier — the count parked in the
     * side map because this tab had no pending entry to stamp it onto, i.e. no
     * push is coming to disclose it. The ask path reports it instead.
     *
     * Debt riding on an ENTRY is deliberately left alone: that copy goes out with
     * a real delivery and is cleared only when the turn carrying it ends, so
     * taking it here could drop a disclosure that push still owes.
     *
     * Reporting into a ToolResult is a hand-off, not a proof, so this can still be
     * lost with an abandoned call — but the eviction itself was already logged at
     * ERROR, and repeating the warning on every subsequent ask forever is worse
     * than reporting it once at the first opportunity.
     */
    reportDropped(key) {
        // THIS occupant's bucket only. A different browser tab that inherited the
        // key has its own, so it can neither read nor settle the departed tab's.
        const slot = this.debtSlot(key);
        const total = this.dropped.get(slot) ?? 0;
        const owed = total - (this.disclosedUpTo.get(slot) ?? 0);
        if (owed <= 0)
            return 0;
        // The debt is NOT spent by being written into a tool result. A ToolResult is
        // a hand-off, not a receipt — that IS #486 — so counting reports (an earlier
        // draft retired the debt after three) lets three abandoned calls carry the
        // only disclosure away with them, and the answer's payload is already gone.
        //
        // It rides the SAME ack as everything else instead: a token attached to the
        // turn this tool call is running inside, retired only when that turn produces
        // its own result. No live turn to ride means no proof and no retirement — the
        // debt simply stands and the next result reports it again.
        const token = `aad${++this.seq}`;
        const carrier = this.attachTurn?.(key, token) ?? null;
        // Bound to the turn that carried it, for the same reason an answer is: a
        // switched provider must not be able to certify a warning it never showed.
        // The token remembers the TOTAL it is showing, so its ack settles exactly
        // that and nothing that arrives afterwards.
        if (carrier !== null)
            this.debtTokens.set(token, { key: slot, carrier, upTo: total });
        return owed;
    }
    /**
     * The tab's connection is gone. SURFACE any disclosure it is still owed, then
     * retire it.
     *
     * This is the lifecycle wire that keeps the debt map from growing forever now
     * that it has no ceiling — and the distinction matters: a debt must end because
     * it was SAID or because its tab genuinely went away, never because a counter
     * filled up and picked a victim. The durable ERROR log is the disclosure here;
     * there is no live tab left to tell in-band, which is precisely why keeping the
     * counter any longer would be bookkeeping for nobody.
     *
     * JOURNAL ENTRIES ARE NOT TOUCHED. A disconnect is often a reload, and the
     * answers themselves are still deliverable to the tab when it comes back; only
     * the count of answers whose TEXT is already gone is retired.
     */
    retireDebt(key, incarnation) {
        this.surfaceAndClearDebt(key, incarnation, "disconnected");
    }
    /**
     * SURFACE a debt that can no longer be delivered, then clear it.
     *
     * Used for both endings a debt can have: the tab went away, or the
     * conversation it was owed to was replaced. Both are "there is no longer
     * anywhere to deliver this", and both must REPORT before clearing — retiring a
     * warning to stop it being misattributed, without saying it anywhere, would
     * trade a misattribution for a silent loss, which is the one trade this file
     * refuses everywhere else.
     *
     * Clears the entry-carried disclosures too: those ride out on a push, and a
     * retired conversation's entries are no longer pushed, so leaving them there
     * would strand the warning on text nobody will read.
     */
    surfaceAndClearDebt(key, incarnation, why) {
        const slot = tabIncarnationSlot(key, incarnation);
        // COUNT FIRST, WITHOUT MUTATING. An earlier draft cleared each carrier as it
        // totalled them, so the only consolidated copy of the debt was gone BEFORE
        // the report — and a logger that throws would take the whole disclosure with
        // it, leaving nothing for a later ack to re-home. Clear-then-report is not
        // report-then-clear, and this helper exists precisely so that fixing a
        // misattribution cannot create a silent loss.
        const carriers = [...this.entries.values()].filter((e) => e.key === key && e.incarnation === incarnation && (e.disclose ?? 0) > 0);
        const owed = this.undisclosedDrop(slot) + carriers.reduce((n, e) => n + e.disclose, 0);
        if (owed > 0) {
            // If this throws, NOTHING below has run: the debt is still whole and still
            // owed, and the next retirement (or the next report) will surface it.
            logger.error(`[ask-answers] tab ${key.slice(0, 8)} ${why} still owed a disclosure for ${owed} validated answer(s) whose content was already dropped — recording it here, as there is no longer anywhere to deliver it; treat those answers as UNDETERMINED`);
        }
        for (const entry of carriers)
            delete entry.disclose;
        this.dropped.delete(slot);
        this.disclosedUpTo.delete(slot);
        for (const [t, x] of [...this.debtTokens])
            if (x.key === slot)
                this.debtTokens.delete(t);
    }
    /** The part of a tab's eviction total that has NOT yet been surfaced and
     *  confirmed. See disclosedUpTo. */
    undisclosedDrop(slot) {
        return Math.max(0, (this.dropped.get(slot) ?? 0) - (this.disclosedUpTo.get(slot) ?? 0));
    }
    /** Every debt bucket belonging to a panel tab, across all its incarnations. */
    debtSlotsFor(key) {
        // A slot is `["<tabId>",<incarnation>]`, so the tab's slots are exactly those
        // starting `["<tabId>",` — JSON-escaped, so no tab id can spoof another's.
        const prefix = `[${JSON.stringify(key)},`;
        return [...new Set([...this.dropped.keys(), ...this.disclosedUpTo.keys()])].filter((s) => s.startsWith(prefix));
    }
    /** Answers this tab lost to an eviction and has not yet been told about. */
    droppedFor(key) {
        const carried = [...this.entries.values()]
            .filter((e) => e.key === key)
            .reduce((n, e) => n + (e.disclose ?? 0), 0);
        // Every incarnation's bucket: a diagnostic view of what this TAB is owed,
        // whoever is holding it now.
        return (carried + this.debtSlotsFor(key).reduce((n, slot) => n + this.undisclosedDrop(slot), 0));
    }
    /** Entries awaiting a push attempt for this key, in arrival order. */
    pending(key) {
        const out = [];
        for (const entry of this.entries.values()) {
            // A different browser tab now holds this key: its conversation never put
            // the card up, so the answer waits (for its own tab to come back) rather
            // than being announced to a stranger. It is still disclosed by a failing
            // ask, and still named on the way out — held, never swallowed.
            if (entry.key === key && entry.delivery === "pending" && this.mayReachLiveTurn(entry)) {
                out.push(entry);
            }
        }
        return out;
    }
    /**
     * Push every orphaned answer for `key` to the tab's agent, in ARRIVAL order,
     * stopping at the first refusal so a newer answer never overtakes an older one
     * that is still stuck.
     *
     * `inject` returns whether the agent TOOK the payload onto its queue — not
     * that it was read. The entry stays journaled either way; only `ack` (the turn
     * that carried it ended) removes it. That is the durability property: hand it
     * to an agent that then dies and it comes back here.
     *
     * NOTHING is re-correlated. The verdict and the question were frozen at
     * arrival, so a replay can never be re-attributed to a question asked later.
     */
    deliverPending(key, inject) {
        let delivered = 0;
        for (const entry of this.pending(key)) {
            const lost = (entry.disclose ?? 0) + this.undisclosedDrop(this.debtSlot(key));
            const payload = {
                kind: "ask_answer",
                ask_question: entry.question,
                ask_answer: entry.answer,
                ask_correlation: entry.correlation.status,
                ask_answered_at: entry.answeredAt,
                ...(entry.attempts > 0 ? { replayed: true } : {}),
                ...(entry.replayHint ? { possible_repeat: true } : {}),
                ...(lost > 0 ? { dropped_answers: lost } : {}),
            };
            const handedOff = inject(payload, entry.token);
            entry.attempts += 1;
            entry.delivery = handedOff ? "handed_off" : "pending";
            if (!handedOff)
                return { delivered, blockedOn: entry };
            if (lost > 0) {
                // CONSOLIDATE onto the entry; the disclosure is NOT spent by a hand-off.
                // If this agent dies before its turn runs, the entry is released and
                // replayed, and the warning must go with it. Only ack() clears it.
                entry.disclose = lost;
                const slot = this.debtSlot(key);
                this.dropped.delete(slot);
                this.disclosedUpTo.delete(slot);
            }
            delivered += 1;
        }
        return { delivered, blockedOn: null };
    }
    /** The turn that CARRIED this answer ended — it genuinely reached the agent. */
    ack(token, from) {
        // An eviction-DISCLOSURE token (see reportDropped): the turn that carried the
        // warning produced its result, so the tab has genuinely been told.
        const debt = this.debtTokens.get(token);
        if (debt !== undefined) {
            if (!this.carriedBy(debt.carrier, from))
                return;
            this.debtTokens.delete(token);
            // Settle ONLY what this warning actually showed. An eviction that landed
            // after it was rendered is not covered by it, and must still be reported.
            const seen = Math.max(this.disclosedUpTo.get(debt.key) ?? 0, debt.upTo);
            const total = this.dropped.get(debt.key) ?? 0;
            if (seen >= total) {
                // Fully told — nothing outstanding, so drop both halves.
                this.dropped.delete(debt.key);
                this.disclosedUpTo.delete(debt.key);
                for (const [t, x] of [...this.debtTokens])
                    if (x.key === debt.key)
                        this.debtTokens.delete(t);
            }
            else {
                this.disclosedUpTo.set(debt.key, seen);
            }
            return;
        }
        const entry = this.entries.get(token);
        if (!entry)
            return;
        // ONLY THE TURN THAT ACTUALLY CARRIED IT may settle it. The carrier was
        // frozen at hand-off (see AskEntry.carrier); a result arriving from anything
        // else — most concretely, the NEW provider's turn after a switch moved the
        // tab's agent mapping — proves nothing about this answer, so it is refused
        // and the answer stays honestly unacked.
        if (!this.carriedBy(entry.carrier, from)) {
            logger.warn(`[ask-answers] refusing an ack for the user's answer to "${preview(entry.question)}" from ${from?.carrier ?? "an unidentified turn"} — it was handed to ${entry.carrier ?? "no turn at all"}; the answer stays unconfirmed`);
            return;
        }
        // The push is done, but the ANSWER stays journaled while it is still within
        // the recovery window: a re-ask of the identical question must still be able
        // to return it as its result rather than making the user answer twice. It is
        // flagged so any later surfacing reads as "you have seen this".
        //
        // The eviction DISCLOSURE it carries is only SPENT when a real delivery
        // actually rendered it — `deliverPending` stamps the count onto the entry and
        // puts `dropped_answers` in that payload, and `attempts > 0` is the record of
        // it. An entry acked without ever having been pushed (a TOOL-RESULT answer
        // riding its turn) never showed anyone that count, so deleting it here would
        // spend a warning nobody was given: re-home it instead.
        if (entry.disclose) {
            const owed = entry.disclose;
            delete entry.disclose;
            if (entry.attempts === 0)
                this.noteDropped(entry.key, owed, entry.incarnation);
        }
        entry.delivery = "none";
        entry.returned = true;
        // THE proof. Everything that may forget an answer QUIETLY keys on this and
        // never on `returned`.
        entry.acked = true;
        entry.replayHint = true;
    }
    /**
     * An agent gave a push back undelivered. Re-arm it for replay.
     *
     * `carried` distinguishes the two causes, and ONLY the first is bounded:
     *  • carried: true  — a turn actually DISPATCHED with this answer in it and
     *    then ended, but its result could not be proven to be that turn's own. The
     *    agent read the text; after MAX_CARRIED_RELEASES we settle rather than
     *    loop (a duplicate at worst).
     *  • carried: false — a teardown handed it back (agent stopped, session died).
     *    NOBODY read it. These must never count toward the bound.
     */
    release(token, opts = {}) {
        // A disclosure token handed back: that turn never proved it was read, so the
        // debt STANDS and the next result reports it again. Only the token mapping
        // goes; there is deliberately no bound on how often a warning may be repeated
        // when nothing has confirmed it.
        if (this.debtTokens.delete(token))
            return;
        const entry = this.entries.get(token);
        if (!entry)
            return;
        // A TOOL-RESULT answer riding a turn (see setTurnAttacher) is not in a
        // delivery loop: nothing re-sends it, so there is no cycle to bound and
        // nothing to re-arm. Its turn ended without proving it was read, so the only
        // correct outcome is that it stays UNACKED — which is what keeps it
        // recoverable and makes its eviction a disclosed loss rather than a silent
        // one. Settling it here on a bound would forge the very proof this split
        // exists to withhold.
        if (entry.returned && entry.delivery === "none")
            return;
        // A boundary happened while a turn was carrying this. Re-arming it would
        // hand the OLD conversation's answer to the replacement one — the same bypass
        // as the rid path, through the release/push door. It stays journaled and
        // disclosable; it just stops being deliverable.
        if (entry.retired === true) {
            entry.delivery = "none";
            return;
        }
        if (opts.carried) {
            entry.carriedReleases = (entry.carriedReleases ?? 0) + 1;
            if (entry.carriedReleases >= MAX_CARRIED_RELEASES) {
                logger.warn(`[ask-answers] an answer was carried by ${entry.carriedReleases} turns that ended without a provable ack — settling it instead of replaying again`);
                this.ack(token);
                return;
            }
        }
        entry.delivery = "pending";
    }
    /** Is ANY orphaned answer still awaiting a push? The orchestrator's
     *  self-restart gate reads this: the journal is in-memory, so restarting while
     *  one is outstanding silently drops an answer the user actually gave. */
    hasOutstanding() {
        // The eviction DEBT counts too. It is a promise to tell the agent that an
        // answer was lost, and it is destroyed by a teardown exactly as an entry is —
        // tearing down while one is owed turns a disclosed loss back into a silent
        // one, which is the whole thing this journal exists to prevent.
        for (const slot of this.dropped.keys())
            if (this.undisclosedDrop(slot) > 0)
                return true;
        // …but a RETIRED answer is not owed to anyone. It can never be delivered —
        // the conversation it belongs to is gone — so it is finished, whatever its
        // `delivery` flag says, and holding the restart gate open for it would wait
        // forever. Two predicates disagreeing about what `pending` means is what made
        // this a deadlock: `pending()` reads it as "deliverable", this reads it as
        // "owed", and only a retirement makes those differ permanently.
        //
        // NOT extended to a different-occupant mismatch, which is TEMPORARY: that
        // answer becomes deliverable again the moment its own browser tab returns, so
        // tearing down while one is waiting would be a real loss.
        return [...this.entries.values()].some((e) => (e.delivery !== "none" && e.retired !== true) || (e.disclose ?? 0) > 0);
    }
    /**
     * Everything the last-ditch, we-are-about-to-die disclosure must name.
     *
     * Broader than `hasOutstanding` on purpose. A restart is a CHOICE we can defer,
     * so it waits only on what is still deliverable; a fatal exit is not, so it
     * reports everything a validated answer might still have been needed for:
     *  • answers awaiting a push (nobody has them);
     *  • answers that went into a ToolResult RECENTLY — "returned" is a hand-off,
     *    not a receipt (that IS #486), and inside the recovery window a re-ask was
     *    still able to produce it. Deferring restarts on these would stall the
     *    self-restarter after every single ask; SAYING so on the way out costs
     *    nothing and keeps the loss from being silent.
     */
    allOutstanding() {
        return [...this.entries.values()].filter((e) => e.delivery !== "none" ||
            (e.disclose ?? 0) > 0 ||
            // UNACKED, with NO TIME BOUND. An answer the agent provably read is not
            // news on the way out; one whose receipt was never confirmed is, and it
            // stays news for as long as it exists.
            //
            // An earlier draft also required it to be inside the recovery window,
            // which quietly undid the whole `returned`/`acked` split: past ten
            // minutes an unacked answer was neither pending nor debt-bearing, so the
            // fatal-exit path was handed nothing and a validated choice vanished in
            // silence. UNACKED MEANS UNPROVEN, and time does not turn unproven into
            // delivered — the window governs whether an answer may be RECOVERED, and
            // has no business governing whether a loss is reported.
            e.acked !== true);
    }
    /** Tabs still owed an eviction disclosure, with the count — so a fatal exit can
     *  name a loss whose carrier is only a counter. */
    outstandingDebt() {
        const byTab = new Map();
        for (const slot of this.dropped.keys()) {
            const owed = this.undisclosedDrop(slot);
            if (owed <= 0)
                continue;
            // The exit disclosure names TABS, not incarnations — every incarnation's
            // loss belongs to the tab the user is looking at.
            const key = JSON.parse(slot)[0];
            byTab.set(key, (byTab.get(key) ?? 0) + owed);
        }
        for (const e of this.entries.values()) {
            if ((e.disclose ?? 0) > 0)
                byTab.set(e.key, (byTab.get(e.key) ?? 0) + e.disclose);
        }
        return [...byTab].map(([key, count]) => ({ key, count }));
    }
    /** Move every entry AND every open ticket from `from` onto `to` — a panel
     *  tab-id migration re-keys the agent and both must move with it, or an answer
     *  for a card dispatched under the old id becomes unattributable. */
    moveKey(from, to) {
        if (from === to)
            return;
        for (const entry of this.entries.values()) {
            if (entry.key === from)
                entry.key = to;
        }
        // The conversation GENERATION moves with the tickets, or they would all read
        // as belonging to a replaced conversation at the destination (or, worse, a
        // retired one would read as live there). A ticket whose conversation was
        // still current at the source is current at the destination — the agent moved
        // with it; one already retired stays retired.
        const fromEpoch = this.tabEpoch.get(from) ?? 0;
        const toEpoch = this.tabEpoch.get(to) ?? 0;
        for (const ticket of this.tickets.values()) {
            if (ticket.tabId !== from)
                continue;
            ticket.tabId = to;
            ticket.epoch = ticket.epoch === fromEpoch ? toEpoch : toEpoch - 1;
        }
        this.tabEpoch.delete(from);
        // BOTH HALVES OF THE DEBT MOVE TOGETHER. The count is tab-keyed and the
        // outstanding disclosure TOKENS carry that same key, but the ack that
        // settles one is bound to the AGENT INSTANCE (see AskEntry.carrier) — which
        // deliberately survives a tab-id migration. So a token left pointing at the
        // old key is acked by a perfectly valid result and clears a debt nobody
        // owes, while the real one sits under the new key forever: false outstanding
        // debt that blocks the restart gate and later emits a spurious warning. That
        // is the cry-wolf failure the acked/unacked split exists to prevent,
        // arriving through migration.
        for (const debt of this.debtTokens.values()) {
            if (debt.key === from)
                debt.key = to;
        }
        // Every incarnation's bucket moves with the tab id, keeping its own
        // incarnation: the id changed, the browser tabs did not.
        for (const slot of this.debtSlotsFor(from)) {
            const [, incarnation] = JSON.parse(slot);
            // Carry the UNDISCLOSED remainder only — what was already told is told,
            // and re-importing it would warn the destination twice.
            const lost = this.undisclosedDrop(slot);
            this.dropped.delete(slot);
            this.disclosedUpTo.delete(slot);
            if (lost <= 0)
                continue;
            const moved = tabIncarnationSlot(to, incarnation ?? undefined);
            this.dropped.set(moved, (this.dropped.get(moved) ?? 0) + lost);
        }
        for (const debt of this.debtTokens.values()) {
            const [tab, incarnation] = JSON.parse(debt.key);
            if (tab === from)
                debt.key = tabIncarnationSlot(to, incarnation ?? undefined);
        }
    }
    /** Drop everything belonging to a tab that will never come back. Logs every
     *  answer that dies unconsumed; a loss must never be silent. */
    forget(key) {
        for (const [token, entry] of [...this.entries]) {
            if (entry.key !== key)
                continue;
            this.entries.delete(token);
            // …and its queued copy goes with it: the journal's record and the agent's
            // queue must be dropped together, or the text outlives the entry that was
            // supposed to bound it.
            if (entry.delivery === "handed_off")
                this.revoke?.(entry.key, entry.token);
            // EVERY answer is logged, returned ones included: "it went into a
            // ToolResult" is not proof it was received, so a returned answer inside
            // the recovery window is still the only copy of a decision that may never
            // have reached the model.
            logger.warn(`[ask-answers] dropping a validated answer ("${entry.answer}") to "${preview(entry.question)}" — its tab (${key.slice(0, 8)}) is gone${entry.returned ? " (it had been handed to a tool result, which is not proof it was received)" : ""}`);
        }
        // The TAB is gone, so every conversation it hosted is gone too: bump the
        // epoch rather than deleting the tickets. A card left on screen can still be
        // clicked, and the surviving ticket is what names the question that answer
        // belongs to — while the newer epoch keeps it from being armed for a push at
        // a tab id no agent answers to, exactly as for a replaced conversation.
        this.tabEpoch.set(key, (this.tabEpoch.get(key) ?? 0) + 1);
        const slots = this.debtSlotsFor(key);
        const owed = slots.reduce((n, slot) => n + this.undisclosedDrop(slot), 0);
        if (owed > 0) {
            logger.error(`[ask-answers] tab ${key.slice(0, 8)} is gone still owed a disclosure for ${owed} evicted answer(s) — it will never be told`);
        }
        for (const slot of slots) {
            this.dropped.delete(slot);
            this.disclosedUpTo.delete(slot);
        }
        // Both halves together — see moveKey. A token left behind here would let a
        // later ack clear a debt for a tab that no longer exists.
        for (const [t, x] of [...this.debtTokens]) {
            if (JSON.parse(x.key)[0] === key)
                this.debtTokens.delete(t);
        }
    }
    /**
     * The CONVERSATION that asked this tab's questions is gone — New chat, or a
     * switch to a historical session.
     *
     * Drop the tab's TICKETS and DOWNGRADE its journaled answers to `foreign`, so
     * an answer given to the old conversation's question can never be returned to
     * the replacement agent as "the answer to the question YOU just asked". The
     * answers themselves are kept and still delivered — labelled UNDETERMINED,
     * quoted with their own question. A correlation may only ever get WEAKER.
     */
    closeAsks(key) {
        // ORDER IS THE WHOLE POINT HERE. The rule this and surfaceAndClearDebt share:
        //
        //   FENCE BEFORE YOU REPORT. REPORT BEFORE YOU DESTROY.
        //
        // Nothing that can throw may stand between a decision and the state change
        // that makes it safe. In surfaceAndClearDebt the irreversible act is the
        // clear, so the report goes first. HERE the protective act is the retire, so
        // it goes first — an earlier draft reported the debt (through a logger, which
        // can fail) before marking anything retired, and a logger failure left the
        // fence half-built. The caller has already reset the old agent by then, so
        // the replacement's ready-flush would push the still-unretired answer and an
        // identical re-ask could recover it.
        //
        // PASS 1 is pure state: no logging, no callbacks, nothing that can throw. By
        // the end of it every answer on this tab is fenced.
        this.tabEpoch.set(key, (this.tabEpoch.get(key) ?? 0) + 1);
        const queued = [];
        for (const entry of this.entries.values()) {
            if (entry.key !== key)
                continue;
            if (entry.correlation.status === "matched") {
                entry.correlation = { status: "foreign", askId: entry.correlation.askId };
            }
            // Revoke the LICENCE to satisfy a re-ask, but keep the fingerprint: it is
            // also how a later ask of the SAME question notices this answer exists and
            // reports it. See AskEntry.recoverable.
            entry.recoverable = false;
            // …and mark it RETIRED, which is what every path that can reach a live turn
            // consults (see AskEntry.retired). Setting `delivery` alone is not enough:
            // a turn that was already carrying this answer releases it back to
            // `pending` afterwards, and the re-arm has no idea a boundary happened.
            entry.retired = true;
            // …and STOP PUSHING it. Its addressee is gone.
            //
            // This is where an ask answer parts company with a run completion (#468),
            // which is still delivered to the replacement conversation downgraded. A
            // completion's payload is independently useful — the images are on the
            // user's canvas either way. An answer to a question the replacement
            // conversation never asked is useful to nobody.
            //
            // NOT a silent discard: the entry stays journaled (so a later ask on this
            // tab that times out still reports its EXISTENCE) and the loss of the push
            // is logged in pass 2.
            if (entry.delivery === "pending")
                entry.delivery = "none";
            else if (entry.delivery === "handed_off")
                queued.push(entry);
        }
        // PASS 2 is fallible: pulling a queued copy back off an agent, and saying what
        // happened. Each is isolated, so one failure cannot skip the rest — and none
        // of it can undo pass 1, which has already made every answer here unusable.
        for (const entry of queued) {
            let recalled = false;
            try {
                // HANDED_OFF has already been materialised into the agent's queue with
                // wording that claims the reader put the card up, so it must be pulled
                // back; only once the carrying turn has STARTED is the text beyond
                // recall.
                recalled = this.revoke?.(entry.key, entry.token) === true;
            }
            catch (err) {
                // A CATCH BLOCK THAT CAN THROW IS NOT A GUARD. The try above exists so a
                // throwing revoker cannot abort the pass — but the report of that failure
                // is itself fallible, and an unguarded one hands the abort straight back:
                // a throwing revoker AND a throwing warn sink together would exit
                // closeAsks here, skipping the remaining recalls and, worse, pass 3. The
                // debt would then stay on the tab for a same-incarnation replacement to
                // read and settle as its own, and any entry-carried `disclose` would sit
                // on a retired, undeliverable entry holding hasOutstanding() — and so the
                // self-restart gate — open forever.
                //
                // Every step of this pass therefore has a TERMINAL error path: the report
                // of a failure must never become a new failure that skips the rest.
                try {
                    logger.warn(`[ask-answers] could not recall a queued answer on tab ${key.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
                }
                catch {
                    // The fence is already up; a log sink that is down cannot undo it, and
                    // must not stop the debt from being surfaced.
                }
            }
            if (recalled)
                entry.delivery = "none";
            try {
                logger.warn(recalled
                    ? `[ask-answers] the conversation that asked "${preview(entry.question)}" on tab ${key.slice(0, 8)} was replaced — the user's answer ("${entry.answer}") will NOT be announced to the replacement conversation; it is kept only for disclosure`
                    : `[ask-answers] tab ${key.slice(0, 8)}: the user's answer to "${preview(entry.question)}" was already being read when the conversation was replaced — it could not be recalled`);
            }
            catch {
                // The fence is already up; a log sink that is down cannot undo it.
            }
        }
        // PASS 3, last because it is the one that DESTROYS. The eviction debt is owed
        // to the conversation being replaced, and scoping it by (tab, incarnation)
        // cannot fence this axis — New chat happens on the same tab, same
        // incarnation. Left alone, the replacement conversation's next ask would
        // report the old conversation's warning as its own and settle it with its own
        // ack. Surfaced and retired, exactly as a tab-gone does.
        this.surfaceAndClearDebt(key, this.incarnationOf?.(key), "its conversation was replaced");
    }
    /** Test/diagnostic helpers. */
    ticketFor(askId) {
        return this.tickets.get(askId);
    }
    /**
     * Is an ack from `from` allowed to settle something handed to `carrier`?
     *
     * A PUSHED entry has no carrier binding (its token was queued, not attached to
     * a running turn) and #468's own machinery already gates it — those pass. A
     * hand-off that never found a live turn (`carrier === null`) can never be
     * settled by anyone. Everything else must match EXACTLY.
     *
     * An ack that arrives with no identity at all is UNVERIFIABLE, and is refused
     * for a bound answer: "we could not tell who this came from" must never
     * collapse into "it came from the right place".
     */
    carriedBy(carrier, from) {
        if (carrier === undefined)
            return true; // never bound to a turn (a push)
        if (carrier === null)
            return false; // handed back with no live turn to ride
        return from?.carrier === carrier;
    }
    /** Simulate the ticket map's bound biting, without running 1024 asks. */
    dropTicketForTest(askId) {
        this.tickets.delete(askId);
    }
    /** Strand an eviction debt on a tab without staging 48 evictions. */
    noteDroppedForTest(key, count, incarnation) {
        this.noteDropped(key, count, incarnation ?? this.incarnationOf?.(key));
    }
    /** Record + hand back in one step, returning the token. */
    markReturnedForTest(askId, reply, tabId) {
        const token = this.record(askId, reply, { tabId }).token;
        this.markReturned(token);
        return token;
    }
    entriesFor(key) {
        return [...this.entries.values()].filter((e) => e.key === key);
    }
    reset() {
        // NOTE: the flusher is deliberately NOT cleared — it belongs to the process's
        // orchestrator, not to any one test's fixture.
        this.tickets.clear();
        this.tabEpoch.clear();
        this.entries.clear();
        this.dropped.clear();
        this.disclosedUpTo.clear();
        this.debtTokens.clear();
        this.seq = 0;
        this.ticketSeq = 0;
    }
    trimTickets() {
        while (this.tickets.size > MAX_TICKETS) {
            // Prefer a ticket no handler is waiting on; otherwise the oldest. Losing a
            // ticket does not lose an answer — it makes a later answer for it read as
            // UNATTRIBUTED, which is the honest verdict once we have forgotten the
            // question — but say so, because it is a real degradation.
            let victim = null;
            for (const [id, t] of this.tickets) {
                if (!t.awaiting) {
                    victim = id;
                    break;
                }
            }
            if (!victim)
                victim = this.tickets.keys().next().value ?? null;
            if (!victim)
                return;
            logger.error(`[ask-answers] over ${MAX_TICKETS} question cards have been tracked — forgetting ask ${victim.slice(0, 12)}; a late answer for it will be reported as UNATTRIBUTED (its question can no longer be named)`);
            this.tickets.delete(victim);
        }
    }
    /**
     * Record that an answer for `key` was destroyed by an eviction, so the next
     * delivery to that tab can report it as UNDETERMINED. Stamped onto a surviving
     * PENDING entry when there is one — it then rides out on a real delivery and
     * cannot be discarded.
     */
    noteDropped(key, count, incarnation) {
        if (count <= 0)
            return;
        // THE LOSS BELONGS TO THE OCCUPANT IT HAPPENED TO, and that is carried in,
        // never re-derived from the key at write time. The bucket is keyed by
        // incarnation, so deriving an owner here would file A's loss under whoever
        // holds the key NOW — B is then pushed A's `dropped_answers`, B's ack clears
        // it, and A's own lifecycle callback (which retires A's bucket) finds
        // nothing left to disclose. Keying the bucket and then guessing the key is
        // the same halfway pattern as scoping the clock but not the debt.
        const carrier = [...this.entries.values()].find((e) => e.key === key && e.delivery === "pending" && e.incarnation === incarnation);
        if (carrier) {
            carrier.disclose = (carrier.disclose ?? 0) + count;
            return;
        }
        const slot = tabIncarnationSlot(key, incarnation);
        this.dropped.set(slot, (this.dropped.get(slot) ?? 0) + count);
        // NO CEILING HERE, deliberately.
        //
        // This map is not payload — it is the PROMISE that a loss will be reported,
        // and it is the last record of answers whose text is already gone. Evicting
        // it on the same terms as data (as an earlier draft did, at 64 tabs) turns a
        // disclosed loss straight back into a silent one: the tab's next ask, the
        // restart gate and the fatal-exit report all lose their only trace, and the
        // log line that announced it said outright that the tab would never be told.
        // A bounded store must never be what decides whether a warning is owed.
        //
        // It cannot grow without limit anyway: one integer per PANEL TAB that has
        // stranded debt, removed by forget() when the tab goes away (which logs at
        // ERROR, the one place the promise genuinely cannot be kept) and merged by
        // moveKey() across a tab-id migration.
    }
    /**
     * Enforce the per-tab and global ceilings.
     *
     * EVICTION ORDER matters and is the same judgement #468 makes: an answer that
     * was already RETURNED to a tool result has reached a caller, so forgetting it
     * costs at most a re-ask; an ORPHANED one has reached nobody, so evicting it
     * is a real loss — those go last, are logged at ERROR, and are COUNTED so the
     * next delivery tells the agent those answers are UNDETERMINED.
     */
    trimEntries(key) {
        const evict = (scope, limit, label) => {
            let mine = [...this.entries.values()].filter(scope);
            while (mine.length > limit) {
                // EVICTION ORDER, and the one decision that may forget an answer
                // QUIETLY. It keys on ACKED — the turn that carried the answer produced
                // its own marked result — never on `returned`, which says only that the
                // answer was written to a transport that may already have been
                // abandoned. That is the premise of #486, so it cannot also be the
                // licence to forget.
                //
                // Ordinary asks ARE acked (the model reads the tool result and finishes
                // its turn), so the common path stays silent and no warning cries wolf.
                // An answer whose turn never completed is precisely the #486 failure, so
                // it is counted and disclosed exactly like one that reached nobody.
                const victim = mine.find((e) => e.acked === true) ??
                    mine.find((e) => e.returned) ??
                    mine[0];
                this.entries.delete(victim.token);
                mine = mine.filter((e) => e !== victim);
                if (victim.acked === true) {
                    // ANY DISCLOSURE IT WAS CARRYING is re-homed, not dropped with it: this
                    // answer's own recoverability is what may be forgotten, never the debt
                    // owed for EARLIER answers that reached nobody and merely happened to
                    // be stamped on this entry.
                    if (victim.disclose)
                        this.noteDropped(victim.key, victim.disclose, victim.incarnation);
                    logger.debug(`[ask-answers] ${label} — forgetting an answer to "${preview(victim.question)}" that the agent provably read`);
                    continue;
                }
                this.noteDropped(victim.key, 1 + (victim.disclose ?? 0), victim.incarnation);
                logger.error(`[ask-answers] ${label} — dropped a VALIDATED answer to "${preview(victim.question)}"${victim.returned ? " that went into a tool call whose receipt was never confirmed" : " that had reached nobody"}; the next delivery will report it as undetermined`);
            }
        };
        if (key) {
            evict((e) => e.key === key, MAX_ENTRIES_PER_KEY, `journal for tab ${key.slice(0, 8)} exceeded ${MAX_ENTRIES_PER_KEY} answers`);
        }
        evict(() => true, MAX_ENTRIES_TOTAL, `journal exceeded ${MAX_ENTRIES_TOTAL} answers overall`);
    }
}
/** Short, log-safe rendering of a question. */
export function preview(question) {
    if (!question)
        return "(an unattributed question)";
    const one = question.replace(/\s+/g, " ").trim();
    return one.length > 60 ? `${one.slice(0, 57)}…` : one;
}
/** How long a recovered answer may be presented as the result of a re-ask. */
export const ASK_RECOVER_MAX_AGE_MS = RECOVER_MAX_AGE_MS;
/** Process-wide journal (mirrors the RunCompletions singleton): the panel_ask
 *  tool opens tickets and consumes recoveries from the tool layer, while the
 *  orchestrator's bridge sink records late answers and pushes the orphans, with
 *  no ctx plumbing between them. */
export const AskAnswers = new AskAnswerJournalImpl();
//# sourceMappingURL=ask-answer-journal.js.map