comfyui-mcp 0.49.4 → 0.49.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/orchestrator/ask-answer-journal.js +1484 -0
  2. package/dist/orchestrator/ask-answer-journal.js.map +1 -0
  3. package/dist/orchestrator/index.js +287 -20
  4. package/dist/orchestrator/index.js.map +1 -1
  5. package/dist/orchestrator/panel-agent.js +133 -4
  6. package/dist/orchestrator/panel-agent.js.map +1 -1
  7. package/dist/orchestrator/panel-tools.js +270 -27
  8. package/dist/orchestrator/panel-tools.js.map +1 -1
  9. package/dist/services/flatten-workflow.js +341 -20
  10. package/dist/services/flatten-workflow.js.map +1 -1
  11. package/dist/services/launcher-env.js +532 -0
  12. package/dist/services/launcher-env.js.map +1 -0
  13. package/dist/services/listener-ownership.js +258 -0
  14. package/dist/services/listener-ownership.js.map +1 -0
  15. package/dist/services/live-interpreter.js +27 -10
  16. package/dist/services/live-interpreter.js.map +1 -1
  17. package/dist/services/node-management.js +34 -5
  18. package/dist/services/node-management.js.map +1 -1
  19. package/dist/services/panel-installer.js +2769 -25
  20. package/dist/services/panel-installer.js.map +1 -1
  21. package/dist/services/panel-pin-guard.js +33 -8
  22. package/dist/services/panel-pin-guard.js.map +1 -1
  23. package/dist/services/panel-recovery.js +210 -0
  24. package/dist/services/panel-recovery.js.map +1 -0
  25. package/dist/services/panel-sync.js +93 -12
  26. package/dist/services/panel-sync.js.map +1 -1
  27. package/dist/services/panel-workspace.js +360 -0
  28. package/dist/services/panel-workspace.js.map +1 -0
  29. package/dist/services/port-owner.js +407 -27
  30. package/dist/services/port-owner.js.map +1 -1
  31. package/dist/services/process-control.js +841 -52
  32. package/dist/services/process-control.js.map +1 -1
  33. package/dist/services/ui-bridge.js +412 -28
  34. package/dist/services/ui-bridge.js.map +1 -1
  35. package/dist/services/workflow-converter.js +790 -103
  36. package/dist/services/workflow-converter.js.map +1 -1
  37. package/dist/services/workspace-env.js +26 -1
  38. package/dist/services/workspace-env.js.map +1 -1
  39. package/dist/tools/install-panel.js +17 -6
  40. package/dist/tools/install-panel.js.map +1 -1
  41. package/dist/tools/workflow-library.js +7 -2
  42. package/dist/tools/workflow-library.js.map +1 -1
  43. package/dist/utils/errors.js +7 -0
  44. package/dist/utils/errors.js.map +1 -1
  45. package/package.json +1 -1
@@ -0,0 +1,1484 @@
1
+ // Durable-across-a-tool-timeout delivery of a VALIDATED panel_ask answer
2
+ // (issue #486).
3
+ //
4
+ // THE FAILURE. `panel_ask` renders a choice card and BLOCKS on the user's pick.
5
+ // The user's answer has exactly one delivery channel: the return value of the
6
+ // enclosing MCP `tools/call`. That call has its own budget (~300s) and its own
7
+ // lifetime — a turn that ends, a client that gives up, a session torn down — and
8
+ // when it dies the answer dies with it, in three distinct ways:
9
+ //
10
+ // 1. ANSWERED IN TIME, NOBODY LEFT TO RETURN TO. `bridge.send` resolves with a
11
+ // validated pick, `askUserWithGrace` returns it, and the ToolResult is
12
+ // written to a request the client already abandoned. Nothing recorded it.
13
+ // 2. ANSWERED DURING THE GRACE POLL. Same, one layer down: the poll TAKES the
14
+ // answer out of the bridge's late-reply buffer (destroying it there) and
15
+ // returns it into the same dead request.
16
+ // 3. ANSWERED AFTER THE GRACE. The handler has already returned "not answered
17
+ // in time". The answer lands in the bridge's late-reply buffer keyed by
18
+ // `ask_id` and NOBODY EVER POLLS IT AGAIN — it is TTL-pruned five minutes
19
+ // later, unread.
20
+ //
21
+ // In every case the user answered, the answer validated, and the agent either
22
+ // asked again or proceeded without it. That is the whole of #486.
23
+ //
24
+ // THE CONTRACT HERE — deliberately the one #468/PR #786 arrived at for run
25
+ // completions (see run-completion-journal.ts), because this is the same problem
26
+ // class and the same traps are waiting:
27
+ //
28
+ // • Asks are TICKETED by their `ask_id`, opened when the card is dispatched.
29
+ // • Every answer is CORRELATED ONCE, AT ARRIVAL, by EXACT ask-id equality
30
+ // against an open ticket — never by recency, never re-derived later. The
31
+ // ticket's QUESTION FINGERPRINT is frozen onto the entry at that moment.
32
+ // • An answer that cannot be correlated is still journaled and still delivered,
33
+ // labelled UNDETERMINED. It is never swallowed, and it can never be presented
34
+ // as the answer to a question it isn't provably an answer to.
35
+ // • THE JOURNAL NEVER MERGES AND NEVER SUPPRESSES — IT ONLY EVER LABELS. Every
36
+ // "already delivered" proof available here (tickets, entries, the bridge's own
37
+ // 5-minute buffer) is BOUNDED, so any rule that SUPPRESSES on one becomes a
38
+ // silent LOSS the moment it expires at the wrong time. #468 removed
39
+ // suppression outright after five successive fixes were each defeated one
40
+ // bound away; nothing here reintroduces it.
41
+ // • Undelivered answers are REPLAYED at the next delivery opportunity, and an
42
+ // entry is cleared only when the turn that CARRIED it ended (or when a
43
+ // matching re-ask provably took it as its result).
44
+ //
45
+ // THE CROSS-QUESTION GUARD is the one rule this file adds over #468's, and it is
46
+ // strictly the more important direction. Losing an answer costs a re-ask;
47
+ // MISATTRIBUTING one makes the agent act on a decision the user never made about
48
+ // the thing at hand. So a recovered answer may only ever satisfy an ask whose
49
+ // QUESTION FINGERPRINT (question text + option labels, in order + multi-select +
50
+ // header) is EXACTLY equal to the fingerprint frozen on the entry at arrival, on
51
+ // the SAME panel tab. There is deliberately no fuzzy match, no "closest
52
+ // question", and no "the only outstanding ask" fallback: an answer that doesn't
53
+ // match exactly is reported as an unattributed answer to ITS OWN question —
54
+ // quoted with that question, so it cannot be read as an answer to anything else.
55
+ //
56
+ // WHAT IS DELIBERATELY NOT GUARANTEED. Three residuals survived four rounds of
57
+ // adversarial review; each is an ACCEPTED trade with its reasoning, not an
58
+ // oversight, and each is stated where it lives as well as here.
59
+ //
60
+ // 1. A RETURNED ANSWER EVICTED BY THE PER-TAB CEILING IS LOGGED, NOT COUNTED as
61
+ // an undetermined loss (see trimEntries). It reached a caller; what its
62
+ // eviction costs is the ability to RECOVER it. Counting each one would fire
63
+ // after ~48 ordinary successful asks and tell the agent that answers it
64
+ // almost certainly received are unknown — and a warning that is usually
65
+ // false is its own kind of silence.
66
+ // 2. A RESTART IS NOT DEFERRED FOR A RETURNED ANSWER (see hasOutstanding vs.
67
+ // allOutstanding). Deferring on one would stall the self-restarter for the
68
+ // whole recovery window after EVERY ask. The fatal-exit disclosure names it
69
+ // instead: saying so on the way out costs nothing.
70
+ // 3. A CARD ANSWERED BEYOND THE BRIDGE'S OPEN-CARD CEILING cannot be recognised
71
+ // at all (UiBridge.MAX_ASK_RID_MAPPINGS). Reaching it needs 1024 unanswered
72
+ // cards outstanding at once; it is logged at ERROR, and reporting it as a
73
+ // "dropped answer" would be a lie about questions nobody answered.
74
+ //
75
+ // LOCAL trust domain: this is accidental-loss bookkeeping, not a defence against
76
+ // a hostile panel. Everything is in-memory and process-scoped.
77
+ import { createHash } from "node:crypto";
78
+ import { logger } from "../utils/logger.js";
79
+ import { tabIncarnationSlot } from "../services/ui-bridge.js";
80
+ /**
81
+ * Cards tracked at once. A ticket is a handful of small fields, and it is what
82
+ * makes an answer ATTRIBUTABLE, so this is sized for a whole session's asks
83
+ * rather than for the two that are usually open. There is deliberately no age
84
+ * limit: a question card sits on screen until the user deals with it, and any
85
+ * clock here would be a bound quietly deciding that an answer no longer counts.
86
+ * Overflowing is logged at ERROR and only ever WEAKENS a correlation to
87
+ * UNDETERMINED — it never drops an answer.
88
+ */
89
+ const MAX_TICKETS = 1024;
90
+ /**
91
+ * Prefix every `panel_ask` card's ask id carries.
92
+ *
93
+ * The bridge's late-answer sink is fed by EVERY `ask_user` card — the confirm
94
+ * gate, the 18+ consent card, the secret prompt — and only `panel_ask` answers
95
+ * belong in this journal. Ownership therefore has to be decidable from the id
96
+ * ITSELF: an earlier draft answered it from a bounded set of remembered ids,
97
+ * which meant an eviction turned a validated answer into nothing. A bounded
98
+ * store must never be what protects against loss, so the test is syntactic and
99
+ * cannot expire, evict, or be raced.
100
+ */
101
+ export const PANEL_ASK_ID_PREFIX = "pa-";
102
+ /** Undelivered/unconsumed answers held per panel tab. */
103
+ const MAX_ENTRIES_PER_KEY = 48;
104
+ /** …and across all tabs. */
105
+ const MAX_ENTRIES_TOTAL = 192;
106
+ /**
107
+ * How long a journaled answer may still be RECOVERED as the result of a re-ask
108
+ * of the identical question.
109
+ *
110
+ * This bound is a LABEL boundary, not a suppression: past it the answer is not
111
+ * returned as "the user's answer to the question you just asked" (it is stale
112
+ * enough that the user plausibly meant it for the earlier moment), but it is
113
+ * still DISCLOSED, quoted with its own question, so it is never silently
114
+ * discarded.
115
+ */
116
+ const RECOVER_MAX_AGE_MS = 10 * 60_000;
117
+ /** Turns that may carry a pushed answer and end without a provable ack before we
118
+ * settle it anyway. Same rationale as MAX_CARRIED_RELEASES in #468: each of
119
+ * those put the text into a turn the agent read, so settling risks a duplicate,
120
+ * never a loss. */
121
+ const MAX_CARRIED_RELEASES = 3;
122
+ /**
123
+ * EXACT identity of a question, and the whole of the cross-question guard.
124
+ *
125
+ * EVERYTHING THE USER READS ON THE CARD GOES IN, in order: the question text,
126
+ * the header chip, the multi-select flag, and every option's LABEL **and
127
+ * DESCRIPTION**. Two asks are "the same question" only when a user looking at
128
+ * both cards would see the same thing.
129
+ *
130
+ * The descriptions are not decoration: they are the one-line explanations the
131
+ * user reads to decide, so "euler / fast, lower quality" and "euler / now the
132
+ * recommended default" are different decisions wearing the same label. Leaving
133
+ * them out would let a re-worded card recover an answer the user gave to
134
+ * different information. The guard errs strict in every direction: a stricter
135
+ * fingerprint costs at most a re-ask, a looser one lets an answer satisfy a
136
+ * question it was not given for.
137
+ *
138
+ * Deliberately NOT included: the tab id (an entry carries its tab separately, so
139
+ * a tab-id migration re-keys it without invalidating every fingerprint) and any
140
+ * timestamp.
141
+ *
142
+ * Whitespace is trimmed and internal runs collapsed on the text fields only,
143
+ * because a re-ask is often the model re-emitting the same prompt with different
144
+ * wrapping. Nothing else is normalized: case, punctuation and ordering are all
145
+ * significant, so "Delete the file?" never matches "delete the file".
146
+ */
147
+ export function askFingerprint(ask) {
148
+ const norm = (s) => s.replace(/\s+/g, " ").trim();
149
+ const labels = Array.isArray(ask.options)
150
+ ? ask.options.map((o) => {
151
+ const opt = o;
152
+ if (typeof opt?.label !== "string")
153
+ return JSON.stringify(o ?? null);
154
+ // Label AND description — an option is the whole thing the user reads.
155
+ return JSON.stringify([
156
+ norm(opt.label),
157
+ typeof opt.description === "string" ? norm(opt.description) : "",
158
+ ]);
159
+ })
160
+ : [];
161
+ // JSON.stringify is the delimiter: it escapes the payload itself, so no
162
+ // separator character can ever be smuggled in by a question or an option label
163
+ // to make two DIFFERENT questions hash the same. (And it keeps this file pure
164
+ // ASCII — no control bytes, no exotic separators.)
165
+ const parts = [
166
+ norm(ask.question ?? ""),
167
+ typeof ask.header === "string" ? norm(ask.header) : "",
168
+ ask.multi_select === true ? "multi" : "single",
169
+ ...labels,
170
+ ];
171
+ return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
172
+ }
173
+ /** Coerce a bridge reply into the answer text the user actually gave. Panel
174
+ * ask cards reply with a string; anything else is preserved verbatim as JSON so
175
+ * nothing the user chose is ever silently reshaped away. */
176
+ export function answerText(reply) {
177
+ if (typeof reply === "string")
178
+ return reply;
179
+ try {
180
+ return JSON.stringify(reply);
181
+ }
182
+ catch {
183
+ return String(reply);
184
+ }
185
+ }
186
+ export class AskAnswerJournalImpl {
187
+ /** ask id → ticket. Ask ids are UUIDs, so exact equality is proof of identity. */
188
+ tickets = new Map();
189
+ /**
190
+ * Ask ids this journal has EVER opened, kept long after their ticket is gone.
191
+ *
192
+ * This is what `tracks()` answers on, and it exists because the ticket map is
193
+ * bounded: if "is this one of ours?" were answered from the tickets alone, an
194
+ * eviction would make the bridge's late-answer sink DROP a validated answer
195
+ * outright — a bounded store silently protecting against a bounded store,
196
+ * which is the exact shape of every defect #468 kept re-growing. Losing a
197
+ * ticket may only ever WEAKEN a correlation (matched → foreign, i.e.
198
+ * UNDETERMINED); it must never turn an answer into nothing.
199
+ */
200
+ /** Panel tab -> the generation of the conversation currently attached to it.
201
+ * Bumped by closeAsks(); a ticket minted under an older generation belongs to
202
+ * a conversation that is gone. See AskTicket.epoch. */
203
+ tabEpoch = new Map();
204
+ /** token → entry (insertion-ordered, which is also delivery order). */
205
+ entries = new Map();
206
+ /** Answers this tab lost to an eviction and has not yet been told about. */
207
+ dropped = new Map();
208
+ /** How many tool results have already carried each tab's eviction debt. The
209
+ * debt is spent by a COUNT of reports, never by a single hand-off — see
210
+ * reportDropped(). */
211
+ /** Outstanding eviction-disclosure tokens: token -> panel tab. Retired only
212
+ * by the ack of the turn that carried the warning (see reportDropped). */
213
+ debtTokens = new Map();
214
+ /**
215
+ * Per tab: the eviction total that has already been SURFACED and confirmed.
216
+ *
217
+ * A warning names a number, and only that number is settled when the turn
218
+ * carrying it ends. Without this the ack cleared the tab's whole current debt,
219
+ * so an eviction that happened AFTER the warning was rendered — while its turn
220
+ * was still running — was wiped by an ack that never mentioned it. A watermark
221
+ * also makes the ack idempotent: two results reporting the same total settle
222
+ * the same total, and a later one settles more.
223
+ */
224
+ disclosedUpTo = new Map();
225
+ seq = 0;
226
+ ticketSeq = 0;
227
+ /**
228
+ * Which browser-tab INCARNATION currently holds a panel tab (#486).
229
+ *
230
+ * A `wf:<hash>` tab id names a saved workflow, so it recurs: a second browser
231
+ * tab opening that workflow takes the key over. Anything that holds or settles
232
+ * per-tab state must therefore be scoped to (tab, incarnation) — otherwise the
233
+ * newcomer reads, reports and ACKS the departed tab's bookkeeping, and the
234
+ * departed tab's own disclosure is silently marked told. Undefined when the tab
235
+ * is not currently connected.
236
+ */
237
+ incarnationOf = null;
238
+ /** Deliver a tab's newly-orphaned answers (see setFlusher). */
239
+ flush = null;
240
+ /** Pull a still-queued answer event back off its agent (see setRevoker). */
241
+ revoke = null;
242
+ /** Ride a token on the tab's in-flight turn, returning the CARRIER IDENTITY it
243
+ * attached to (null when there was no live turn). See setTurnAttacher. */
244
+ attachTurn = null;
245
+ /**
246
+ * Wire the push channel (#486).
247
+ *
248
+ * An answer becomes an ORPHAN at moments the tool layer cannot act on — the
249
+ * bridge's late-answer sink, or an ask handler unwinding on an error without
250
+ * having returned one. Whoever owns the agents registers here so those
251
+ * transitions deliver immediately instead of waiting for some unrelated later
252
+ * trigger (which is how "journaled" quietly becomes "never delivered").
253
+ */
254
+ setFlusher(flush) {
255
+ this.flush = flush;
256
+ }
257
+ /**
258
+ * Wire the "unsend" hook, mirroring #468's.
259
+ *
260
+ * An answer's WORDING is materialised when it is queued into an agent, and it
261
+ * says "a question card YOU put up". If the conversation is replaced before
262
+ * that item is read, the sentence becomes false — the fork never put that card
263
+ * up. This pulls the stale copy back while it is still unread. Nothing is
264
+ * re-delivered afterwards: the addressee is gone, which is the whole point.
265
+ */
266
+ setRevoker(revoke) {
267
+ this.revoke = revoke;
268
+ }
269
+ /**
270
+ * Wire the TOOL-RESULT ack (#486).
271
+ *
272
+ * An answer PUSHED as an event is acked when the turn carrying it ends. An
273
+ * answer handed back as a TOOL RESULT had no such proof — and treating the
274
+ * hand-off itself as proof is exactly the assumption #486 exists to demolish.
275
+ * This rides the token on the turn the tool call is running inside, so the
276
+ * SAME ack-on-carry machinery settles it: either that turn's own marked result
277
+ * lands (the model ran to completion after receiving the answer), or it never
278
+ * does and the answer stays accounted for.
279
+ */
280
+ setTurnAttacher(attach) {
281
+ this.attachTurn = attach;
282
+ }
283
+ /** Wire the per-tab incarnation lookup — see `incarnationOf`. */
284
+ setIncarnationResolver(resolve) {
285
+ this.incarnationOf = resolve;
286
+ }
287
+ /**
288
+ * May the tab's CURRENT occupant act on this answer — recover it, or be pushed
289
+ * it?
290
+ *
291
+ * Only the occupant it was given by. A different browser tab holding the same
292
+ * recurring key never asked the question and must not be handed its answer;
293
+ * unknown provenance on either side is not a match either, because "we could
294
+ * not tell who this belongs to" must never resolve to "it belongs to you".
295
+ * When NOTHING can resolve incarnations at all (no resolver wired) the check is
296
+ * inert and tab-keying alone governs, exactly as before.
297
+ */
298
+ /**
299
+ * May this answer be handed to a LIVE TURN — recovered, replayed, or pushed?
300
+ *
301
+ * The single gate for the whole set. Both boundaries are checked here, because
302
+ * they are independent and each alone is insufficient:
303
+ * • RETIRED — its conversation is gone (New chat, rewind, provider switch,
304
+ * workflow replacement, takeover). Same tab, same incarnation, so no amount
305
+ * of incarnation keying can see it.
306
+ * • a DIFFERENT OCCUPANT — another browser tab holds this recurring key. Same
307
+ * conversation identity as far as the tab is concerned, so no amount of
308
+ * epoch bumping can see it.
309
+ *
310
+ * Gating the paths one at a time is how the rid bypass, the re-arm bypass and
311
+ * the debt-reporting bypass each arrived separately; making it a property of
312
+ * the SET is the fix.
313
+ */
314
+ mayReachLiveTurn(entry) {
315
+ return entry.retired !== true && this.sameOccupant(entry);
316
+ }
317
+ sameOccupant(entry) {
318
+ if (this.incarnationOf === null)
319
+ return true; // nothing to distinguish
320
+ const now = this.incarnationOf(entry.key);
321
+ if (entry.incarnation === undefined || now === undefined)
322
+ return false;
323
+ return entry.incarnation === now;
324
+ }
325
+ /** The debt bucket for a tab's CURRENT occupant. */
326
+ debtSlot(key) {
327
+ return tabIncarnationSlot(key, this.incarnationOf?.(key));
328
+ }
329
+ /**
330
+ * A `panel_ask` card is being dispatched. Opens the ticket that makes any
331
+ * answer for `askId` attributable to THIS question.
332
+ *
333
+ * Must be called BEFORE the card is sent: the answer can come back on the
334
+ * bridge's late-reply sink at any moment after that, and an answer that
335
+ * arrives with no ticket is (correctly, but uselessly) foreign.
336
+ */
337
+ openAsk(askId, meta) {
338
+ const epoch = this.tabEpoch.get(meta.tabId) ?? 0;
339
+ const existing = this.tickets.get(askId);
340
+ // REUSE is also proven by an ANSWER already on file for this id, not only by
341
+ // a surviving ticket. The ticket map is bounded, so keying the guard on it
342
+ // alone would let an eviction restore a "fresh" identity to an id that a
343
+ // still-rendered card can answer — the old card's click would then be frozen
344
+ // with the NEW question's fingerprint and could satisfy it.
345
+ const answered = [...this.entries.values()].some((e) => e.askId === askId);
346
+ if (existing || answered) {
347
+ // The same ask id dispatched AGAIN. Reopen rather than stack a second
348
+ // ticket (one ask id always means one ticket) — but the id now stands for
349
+ // MORE THAN ONE question, and the panel's reply carries only the id, so
350
+ // nothing can ever say which card an answer for it came from. Mark it
351
+ // reused: every answer for it is reported UNDETERMINED from here on, and
352
+ // in particular the older card's late click can no longer be frozen with
353
+ // the newer question's fingerprint and recovered as its answer.
354
+ this.tickets.set(askId, {
355
+ ...(existing ?? {}),
356
+ askId,
357
+ tabId: meta.tabId,
358
+ fingerprint: meta.fingerprint,
359
+ question: meta.question,
360
+ openedAt: Date.now(),
361
+ epoch,
362
+ seq: ++this.ticketSeq,
363
+ awaiting: true,
364
+ reused: true,
365
+ });
366
+ 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`);
367
+ // FREEZE THE AMBIGUITY ONTO THE ENTRIES THAT ALREADY EXIST for this id.
368
+ // The ticket is evictable and this fact is not: once it is trimmed away,
369
+ // anything keyed on `ticket.reused` silently reverts to treating the id as
370
+ // a clean identity. Each such entry keeps its OWN question text — that is
371
+ // still honest and is what makes its disclosure useful — but loses the
372
+ // licence to answer anything.
373
+ for (const entry of this.entries.values()) {
374
+ if (entry.askId !== askId)
375
+ continue;
376
+ entry.ambiguousId = true;
377
+ entry.recoverable = false;
378
+ entry.fingerprint = null;
379
+ if (entry.correlation.status === "matched") {
380
+ entry.correlation = { status: "foreign", askId };
381
+ }
382
+ }
383
+ this.trimTickets();
384
+ return;
385
+ }
386
+ this.tickets.set(askId, {
387
+ askId,
388
+ tabId: meta.tabId,
389
+ fingerprint: meta.fingerprint,
390
+ question: meta.question,
391
+ openedAt: Date.now(),
392
+ epoch,
393
+ seq: ++this.ticketSeq,
394
+ awaiting: true,
395
+ });
396
+ this.trimTickets();
397
+ }
398
+ /**
399
+ * Is the conversation that opened this ask STILL the one on the tab?
400
+ *
401
+ * The debt footnote rides whatever result is going back, and `reportDropped`
402
+ * attaches its token to the turn that is live NOW. So a result belonging to a
403
+ * conversation that has since been replaced must not carry it: the live turn's
404
+ * ack would settle a warning that conversation never saw. Unknown ticket → true,
405
+ * because a caller with no ticket has nothing to have been replaced.
406
+ */
407
+ askBelongsToLiveConversation(askId) {
408
+ const ticket = this.tickets.get(askId);
409
+ if (!ticket)
410
+ return true;
411
+ return ticket.epoch === (this.tabEpoch.get(ticket.tabId) ?? 0);
412
+ }
413
+ /**
414
+ * May this answer's CONTENT be shown to whoever holds its tab right now?
415
+ *
416
+ * The public face of the boundary gate, for outlets that live outside this
417
+ * class. The fatal-exit notice is one: it renders answer text and pushes it to
418
+ * the tab, so it is a delivery like any other and a retired conversation's pick
419
+ * must not appear in it. Counts may always cross; content may not.
420
+ */
421
+ mayDisclose(entry) {
422
+ return this.mayReachLiveTurn(entry);
423
+ }
424
+ /** Does this journal know the ask id — i.e. is a late answer for it one of
425
+ * OURS? The bridge's late-answer sink is fed by every `ask_user` card
426
+ * (confirm/consent/secret gates included) and only `panel_ask` answers belong
427
+ * here; those other cards have their own, deliberately non-recoverable paths
428
+ * (a recovered "Yes, go ahead" must never authorise a different destructive
429
+ * operation). */
430
+ tracks(askId) {
431
+ return askId.startsWith(PANEL_ASK_ID_PREFIX);
432
+ }
433
+ /**
434
+ * The `panel_ask` handler that opened `askId` has RETURNED. Anything that
435
+ * arrives from here on has nobody to be handed to, so it is orphaned on
436
+ * arrival and pushed to the agent on its own.
437
+ *
438
+ * If an answer is already journaled and the handler never claimed it
439
+ * (`markReturned` was not called), arm it for the push now.
440
+ */
441
+ closeAsk(askId) {
442
+ const ticket = this.tickets.get(askId);
443
+ if (ticket)
444
+ ticket.awaiting = false;
445
+ let armed = null;
446
+ let answered = false;
447
+ for (const entry of this.entries.values()) {
448
+ if (entry.askId !== askId)
449
+ continue;
450
+ answered = true;
451
+ // A RETIRED answer is never armed. Arming it produces an entry that
452
+ // `pending()` correctly refuses to deliver and `hasOutstanding()` counted
453
+ // as owed — a permanent blocker on the self-restart gate, cleared only by
454
+ // some unrelated later boundary or eviction.
455
+ if (!entry.returned && entry.delivery === "none" && entry.retired !== true) {
456
+ entry.delivery = "pending";
457
+ armed = entry;
458
+ }
459
+ }
460
+ // The card has been ANSWERED and its handler has finished, so this ticket can
461
+ // never be needed again — the panel removes an answered card, and everything
462
+ // the ticket carried (tab, question, fingerprint, conversation generation) is
463
+ // frozen onto the entry. Releasing it keeps the ticket map to CARDS STILL ON
464
+ // SCREEN, which is what makes its ceiling unreachable in practice: otherwise
465
+ // a long session's ordinary successful asks fill it and evict a genuinely
466
+ // outstanding card's ticket, taking its retired-conversation marker with it.
467
+ // …EXCEPT under a reused id, where "an entry exists for this ask id" no
468
+ // longer means "this card was answered" — it may be the OTHER card's answer,
469
+ // and this one is still on screen. Keeping the ticket keeps its reused flag,
470
+ // which is what stops either answer satisfying either question.
471
+ if (answered && ticket?.reused !== true)
472
+ this.tickets.delete(askId);
473
+ // It is an orphan NOW — deliver it now. Leaving it merely `pending` would
474
+ // make it wait for an unrelated later flush, and this transition happens on
475
+ // the ask handler's unwind, where there may never be one.
476
+ if (armed)
477
+ this.flush?.(armed.key);
478
+ return armed;
479
+ }
480
+ /**
481
+ * Journal a validated answer. Correlation is computed HERE, once, by exact ask
482
+ * id.
483
+ *
484
+ * Idempotent per ask id: the same card can be observed twice (the handler's
485
+ * grace poll takes it out of the bridge buffer while the bridge's sink has
486
+ * already forwarded it), and both observations are the SAME answer to the SAME
487
+ * question. Collapsing them is identity, not suppression — nothing is ever
488
+ * dropped because it "looks like" something already seen.
489
+ */
490
+ record(askId, reply, meta) {
491
+ const text = answerText(reply);
492
+ const existing = [...this.entries.values()].find((e) => e.askId === askId);
493
+ const ticket = this.tickets.get(askId);
494
+ // Collapse ONLY an identical observation. The same card's reply can be seen
495
+ // twice (the handler's grace poll takes it out of the bridge buffer while the
496
+ // sink has already forwarded it) and that is one answer, not two — identity,
497
+ // not suppression.
498
+ //
499
+ // A DIFFERENT answer under the same id is a different validated answer (two
500
+ // cards, one reused id). Returning the old entry there would silently
501
+ // discard what the user just chose, so it gets its own entry, and BOTH are
502
+ // demoted to unattributable: nothing on the wire says which card either came
503
+ // from, so neither may satisfy a question.
504
+ //
505
+ // …and the collapse is OFF ENTIRELY for a REUSED id. "Same id, same text" is
506
+ // only evidence of one observation while the id means one card; once two
507
+ // cards share it, two users' picks that happen to read the same ("euler" on
508
+ // both) are two validated answers to two questions, and merging them leaves
509
+ // the second question unanswered with no record at all.
510
+ if (existing) {
511
+ // The ENTRY's own ambiguity flag is the load-bearing half — see
512
+ // AskEntry.ambiguousId. `ticket?.reused` is kept alongside it as the
513
+ // earliest signal, but it may have been evicted, and a bound must never be
514
+ // what decides whether an answer is allowed to disappear.
515
+ if (existing.answer === text && existing.ambiguousId !== true && ticket?.reused !== true) {
516
+ return existing;
517
+ }
518
+ 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`);
519
+ existing.correlation = { status: "foreign", askId };
520
+ existing.recoverable = false;
521
+ // …and the ambiguity is frozen here too, so it survives the ticket.
522
+ existing.ambiguousId = true;
523
+ }
524
+ // A REUSED id proves nothing: the panel sends only the id, so an answer for
525
+ // it could belong to either card. Report it as foreign — real, but
526
+ // UNDETERMINED — rather than claiming it answers the question now open.
527
+ // The conversation that opened this ticket has been replaced (New chat, or a
528
+ // resume of a historical session) while its card stayed on screen.
529
+ const conversationGone = ticket !== undefined && ticket.epoch !== (this.tabEpoch.get(ticket.tabId) ?? 0);
530
+ const attributable = ticket !== undefined && ticket.reused !== true && existing === undefined;
531
+ const correlation = attributable
532
+ ? { status: "matched", askId }
533
+ : { status: "foreign", askId };
534
+ const entry = {
535
+ token: `aa${++this.seq}`,
536
+ askId,
537
+ // The TICKET's tab is authoritative only while the ticket is ATTRIBUTABLE.
538
+ // A REUSED id can name a ticket opened by a DIFFERENT tab, and taking its
539
+ // tab would re-address the answer to a conversation that never rendered the
540
+ // card — labelled `foreign`, so it could not satisfy anything, but still
541
+ // delivered to the wrong place. The bridge-supplied tab is the PROVEN
542
+ // source of this reply (it comes from the routed send, not from the id), so
543
+ // it wins whenever the ticket cannot be trusted.
544
+ key: attributable ? ticket.tabId : meta.tabId,
545
+ // The fingerprint is the LICENCE to satisfy a re-ask, so a reused id gets
546
+ // none — its answer may only ever be reported, never returned as an answer.
547
+ fingerprint: attributable ? ticket.fingerprint : null,
548
+ // …and NOR is the question text, for a REUSED id. The ticket then holds the
549
+ // LATER card's question, and printing that beside the EARLIER card's answer
550
+ // states a false association — the very mistake this file exists to
551
+ // prevent, committed in the disclosure meant to be the honest fallback. A
552
+ // ticket that is merely RETIRED (its conversation replaced) is different:
553
+ // its question is still that card's own, and keeping it is what lets the
554
+ // report name what was actually asked.
555
+ question: ticket === undefined || ticket.reused === true ? null : ticket.question,
556
+ answer: text,
557
+ answeredAt: Date.now(),
558
+ correlation,
559
+ ticketSeq: ticket?.seq ?? 0,
560
+ // WHO was holding the tab when this answer arrived — see AskEntry.incarnation.
561
+ ...(this.incarnationOf?.(ticket?.tabId ?? meta.tabId) !== undefined
562
+ ? { incarnation: this.incarnationOf(ticket?.tabId ?? meta.tabId) }
563
+ : {}),
564
+ returned: false,
565
+ // An answer that lands while its own handler is still running is that
566
+ // handler's to return; one that lands afterwards has nobody left and is
567
+ // armed for the push immediately.
568
+ //
569
+ // TWO cases are never pushed, and are journaled for disclosure only:
570
+ // • its CONVERSATION was replaced — see closeAsks();
571
+ // • THERE IS NO TICKET AT ALL. Without one we cannot tell whether the card
572
+ // belongs to the conversation now on this tab or to a retired one (the
573
+ // ticket carried that generation), and announcing it anyway would fold
574
+ // "could not determine" into "determined not retired". It is also the
575
+ // one delivery with nothing to offer: the wording for an unattributable
576
+ // answer already says its meaning is UNDETERMINED and that nothing may
577
+ // be inferred from it, so pushing it into a conversation that may not be
578
+ // the one that asked buys nothing and risks exactly the
579
+ // wrong-conversation delivery the epoch guard exists to prevent. The
580
+ // answer is still logged with its text and still reported by a later ask
581
+ // on this tab — it is not swallowed, it is just not announced.
582
+ delivery: ticket === undefined || ticket.awaiting === true || conversationGone
583
+ ? "none"
584
+ : "pending",
585
+ // A question the CURRENT conversation never asked may be reported, never
586
+ // returned as its answer.
587
+ ...(conversationGone || existing !== undefined ? { recoverable: false } : {}),
588
+ // The BOUNDARY axis only — `existing !== undefined` is ambiguity, which is
589
+ // still deliverable (labelled), so it must not set this.
590
+ ...(conversationGone ? { retired: true } : {}),
591
+ // A SECOND answer under one ask id: the id is ambiguous for this entry too,
592
+ // and that must outlive the ticket that proved it.
593
+ ...(existing !== undefined || ticket?.reused === true ? { ambiguousId: true } : {}),
594
+ attempts: 0,
595
+ };
596
+ this.entries.set(entry.token, entry);
597
+ if (conversationGone) {
598
+ 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`);
599
+ }
600
+ else if (!attributable) {
601
+ 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`);
602
+ }
603
+ // Same release as closeAsk (and the same reused-id exception).
604
+ if (ticket && !ticket.awaiting && ticket.reused !== true)
605
+ this.tickets.delete(askId);
606
+ this.trimEntries(entry.key);
607
+ // Born orphaned (no handler is waiting on this ask) — push it straight away.
608
+ if (entry.delivery === "pending")
609
+ this.flush?.(entry.key);
610
+ return entry;
611
+ }
612
+ /** The ask handler put this answer into its ToolResult. A hand-off, not proof
613
+ * of consumption — see AskEntry.returned. */
614
+ markReturned(token, opts = {}) {
615
+ // BY TOKEN, not by ask id: under a reused id one ask id can own two entries,
616
+ // and marking the sibling returned would quietly retire an answer nobody has
617
+ // been given.
618
+ const entry = this.entries.get(token);
619
+ if (entry) {
620
+ entry.returned = true;
621
+ if (opts.replay)
622
+ entry.replayHint = true;
623
+ // Ride the turn this tool call is running inside, so that turn's result —
624
+ // and nothing less — acks the answer. No live turn to ride means no proof,
625
+ // so the answer simply stays unacked, which is the conservative reading.
626
+ //
627
+ // THE CARRIER IS CAPTURED HERE, at the instant of return, and frozen onto
628
+ // the entry. Resolving it later from "whoever owns this tab now" is how a
629
+ // switched provider's turn could ack an answer it never saw: a stale
630
+ // pre-switch ask handler resumes, attaches to the NEW provider's turn, and
631
+ // that turn's result settles an answer the new conversation was never
632
+ // shown. `ack` compares against this, so only the turn that actually
633
+ // carried the answer can settle it.
634
+ //
635
+ // …and an answer whose CONVERSATION is gone never attaches at all. Capturing
636
+ // the carrier is not enough on its own here: a stale pre-switch ask handler
637
+ // that resumes when its card is clicked would attach to whatever turn is
638
+ // running NOW, and that turn's result would then certify — accurately, and
639
+ // uselessly — an answer the current conversation was never shown. The
640
+ // conversation boundary already marked this entry unrecoverable; it makes it
641
+ // unackable too, so the answer stays honestly unproven.
642
+ // Gated on the BOUNDARY axis only. An AMBIGUOUS answer (an ask id that
643
+ // stopped identifying one card) is still handed to a caller, so it must
644
+ // still be ackable by the turn that carried it — refusing the carrier there
645
+ // left a delivered answer permanently unconfirmed, i.e. surfacing later as
646
+ // a loss that did not happen. Only a RETIRED answer, which no live turn may
647
+ // receive at all, gets no carrier.
648
+ entry.carrier =
649
+ entry.retired === true ? null : (this.attachTurn?.(entry.key, token) ?? null);
650
+ // It reached A caller. Do not ALSO push it as an orphan; a re-ask can
651
+ // still recover it if that caller was already dead.
652
+ //
653
+ // DEFENCE IN DEPTH, not a live path: an ask handler only marks an answer
654
+ // returned while its own ticket is still `awaiting`, and `record` never
655
+ // arms one in that state — so nothing reachable enters this branch and no
656
+ // test can fail on it. It is here because "a caller took this" and
657
+ // "announce it to the agent as unclaimed" are contradictory states, and a
658
+ // future caller marking an answer returned from outside the ask handler
659
+ // would otherwise double-report it.
660
+ if (entry.delivery === "pending")
661
+ entry.delivery = "none";
662
+ }
663
+ }
664
+ /**
665
+ * Find the answer the user already gave to THIS EXACT question on THIS tab.
666
+ *
667
+ * The match is exact fingerprint equality and nothing else — see the
668
+ * cross-question guard at the top of this file. When several qualify the
669
+ * NEWEST wins: it is the user's most recent statement of intent about that
670
+ * question.
671
+ *
672
+ * Returns a discriminated result: "no answer at all" and "answers exist but
673
+ * none is provably for this question" are DIFFERENT facts and must not be
674
+ * folded into one falsy value (#796).
675
+ *
676
+ * The CURRENT ask's own id is deliberately NOT excluded: an answer that landed
677
+ * for the very card we just gave up on is the most direct answer there is, and
678
+ * excluding it would re-open the race the grace poll cannot close (the sink
679
+ * journals an answer microseconds after the last poll).
680
+ */
681
+ recover(key, fingerprint) {
682
+ const now = Date.now();
683
+ const mine = [...this.entries.values()].filter((e) => e.key === key);
684
+ if (mine.length === 0)
685
+ return { status: "none" };
686
+ const eligible = mine
687
+ .filter((e) => e.correlation.status === "matched" &&
688
+ e.recoverable !== false &&
689
+ this.mayReachLiveTurn(e) &&
690
+ e.fingerprint !== null &&
691
+ e.fingerprint === fingerprint &&
692
+ now - e.answeredAt <= RECOVER_MAX_AGE_MS)
693
+ .sort((a, b) => b.answeredAt - a.answeredAt);
694
+ if (eligible.length > 0)
695
+ return { status: "recovered", entry: eligible[0] };
696
+ // SPLIT by what this conversation is allowed to see. `others` goes through
697
+ // exactly the same gate as every other outlet — nothing about a disclosure
698
+ // makes it exempt just because it is not a hand-off.
699
+ const others = mine.filter((e) => this.mayReachLiveTurn(e));
700
+ return { status: "unattributed", others, withheld: mine.length - others.length };
701
+ }
702
+ /**
703
+ * A recovery (or a push the agent provably consumed) took this answer. Drop
704
+ * it so the same answer can never be handed over twice.
705
+ *
706
+ * Deleting an entry that was already pushed and is sitting unread in an
707
+ * agent's queue is deliberate: the tool result the caller is about to return
708
+ * carries the SAME answer with the SAME question attached, so the worst case
709
+ * is the agent reading it twice — never acting on an answer it wasn't given.
710
+ *
711
+ * Any eviction disclosure the entry was CARRYING is handed back to the tab
712
+ * first. The disclosure is a debt owed to the tab, not a property of whichever
713
+ * entry happens to be carrying it: consuming the carrier without re-homing it
714
+ * would let an eviction that the journal promised to report disappear because
715
+ * an unrelated answer was recovered.
716
+ */
717
+ markSurfaced(token) {
718
+ const entry = this.entries.get(token);
719
+ if (!entry)
720
+ return;
721
+ // NOT DELETED. An earlier draft consumed a recovered answer outright, on the
722
+ // reasoning that it had now been handed over — which is the same mistake as
723
+ // every suppression #468 removed, one level up: the ToolResult carrying it
724
+ // can itself be abandoned (that IS #486), so deleting the entry destroys the
725
+ // only durable copy at exactly the moment it is most likely to be needed
726
+ // again. It is marked handed-over and flagged, so a further re-ask of the
727
+ // same question gets it again, labelled "you have seen this".
728
+ //
729
+ // IT GOES THROUGH markReturned, and must: a recovery hands the answer to a
730
+ // live turn exactly as a fresh answer does, so it has to attach a token and
731
+ // capture its carrier BY CONSTRUCTION. An earlier draft set the flags here
732
+ // by hand and forgot to attach — so a recovery that completed perfectly
733
+ // stayed unacked forever, aged into apparent loss, and eventually raised
734
+ // eviction debt and warnings despite the agent having read it. That is the
735
+ // routine-false-warning failure the ack split exists to prevent, arriving
736
+ // through a different door.
737
+ this.markReturned(token, { replay: true });
738
+ }
739
+ /** Every unconsumed ORPHAN answer for a tab — those that were never handed
740
+ * back to any tool result. These are what a failing ask discloses, so a
741
+ * validated answer is never silently dropped. */
742
+ orphansFor(key) {
743
+ return [...this.entries.values()].filter((e) => e.key === key && !e.returned);
744
+ }
745
+ /**
746
+ * Take the eviction debt that has NO other carrier — the count parked in the
747
+ * side map because this tab had no pending entry to stamp it onto, i.e. no
748
+ * push is coming to disclose it. The ask path reports it instead.
749
+ *
750
+ * Debt riding on an ENTRY is deliberately left alone: that copy goes out with
751
+ * a real delivery and is cleared only when the turn carrying it ends, so
752
+ * taking it here could drop a disclosure that push still owes.
753
+ *
754
+ * Reporting into a ToolResult is a hand-off, not a proof, so this can still be
755
+ * lost with an abandoned call — but the eviction itself was already logged at
756
+ * ERROR, and repeating the warning on every subsequent ask forever is worse
757
+ * than reporting it once at the first opportunity.
758
+ */
759
+ reportDropped(key) {
760
+ // THIS occupant's bucket only. A different browser tab that inherited the
761
+ // key has its own, so it can neither read nor settle the departed tab's.
762
+ const slot = this.debtSlot(key);
763
+ const total = this.dropped.get(slot) ?? 0;
764
+ const owed = total - (this.disclosedUpTo.get(slot) ?? 0);
765
+ if (owed <= 0)
766
+ return 0;
767
+ // The debt is NOT spent by being written into a tool result. A ToolResult is
768
+ // a hand-off, not a receipt — that IS #486 — so counting reports (an earlier
769
+ // draft retired the debt after three) lets three abandoned calls carry the
770
+ // only disclosure away with them, and the answer's payload is already gone.
771
+ //
772
+ // It rides the SAME ack as everything else instead: a token attached to the
773
+ // turn this tool call is running inside, retired only when that turn produces
774
+ // its own result. No live turn to ride means no proof and no retirement — the
775
+ // debt simply stands and the next result reports it again.
776
+ const token = `aad${++this.seq}`;
777
+ const carrier = this.attachTurn?.(key, token) ?? null;
778
+ // Bound to the turn that carried it, for the same reason an answer is: a
779
+ // switched provider must not be able to certify a warning it never showed.
780
+ // The token remembers the TOTAL it is showing, so its ack settles exactly
781
+ // that and nothing that arrives afterwards.
782
+ if (carrier !== null)
783
+ this.debtTokens.set(token, { key: slot, carrier, upTo: total });
784
+ return owed;
785
+ }
786
+ /**
787
+ * The tab's connection is gone. SURFACE any disclosure it is still owed, then
788
+ * retire it.
789
+ *
790
+ * This is the lifecycle wire that keeps the debt map from growing forever now
791
+ * that it has no ceiling — and the distinction matters: a debt must end because
792
+ * it was SAID or because its tab genuinely went away, never because a counter
793
+ * filled up and picked a victim. The durable ERROR log is the disclosure here;
794
+ * there is no live tab left to tell in-band, which is precisely why keeping the
795
+ * counter any longer would be bookkeeping for nobody.
796
+ *
797
+ * JOURNAL ENTRIES ARE NOT TOUCHED. A disconnect is often a reload, and the
798
+ * answers themselves are still deliverable to the tab when it comes back; only
799
+ * the count of answers whose TEXT is already gone is retired.
800
+ */
801
+ retireDebt(key, incarnation) {
802
+ this.surfaceAndClearDebt(key, incarnation, "disconnected");
803
+ }
804
+ /**
805
+ * SURFACE a debt that can no longer be delivered, then clear it.
806
+ *
807
+ * Used for both endings a debt can have: the tab went away, or the
808
+ * conversation it was owed to was replaced. Both are "there is no longer
809
+ * anywhere to deliver this", and both must REPORT before clearing — retiring a
810
+ * warning to stop it being misattributed, without saying it anywhere, would
811
+ * trade a misattribution for a silent loss, which is the one trade this file
812
+ * refuses everywhere else.
813
+ *
814
+ * Clears the entry-carried disclosures too: those ride out on a push, and a
815
+ * retired conversation's entries are no longer pushed, so leaving them there
816
+ * would strand the warning on text nobody will read.
817
+ */
818
+ surfaceAndClearDebt(key, incarnation, why) {
819
+ const slot = tabIncarnationSlot(key, incarnation);
820
+ // COUNT FIRST, WITHOUT MUTATING. An earlier draft cleared each carrier as it
821
+ // totalled them, so the only consolidated copy of the debt was gone BEFORE
822
+ // the report — and a logger that throws would take the whole disclosure with
823
+ // it, leaving nothing for a later ack to re-home. Clear-then-report is not
824
+ // report-then-clear, and this helper exists precisely so that fixing a
825
+ // misattribution cannot create a silent loss.
826
+ const carriers = [...this.entries.values()].filter((e) => e.key === key && e.incarnation === incarnation && (e.disclose ?? 0) > 0);
827
+ const owed = this.undisclosedDrop(slot) + carriers.reduce((n, e) => n + e.disclose, 0);
828
+ if (owed > 0) {
829
+ // If this throws, NOTHING below has run: the debt is still whole and still
830
+ // owed, and the next retirement (or the next report) will surface it.
831
+ 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`);
832
+ }
833
+ for (const entry of carriers)
834
+ delete entry.disclose;
835
+ this.dropped.delete(slot);
836
+ this.disclosedUpTo.delete(slot);
837
+ for (const [t, x] of [...this.debtTokens])
838
+ if (x.key === slot)
839
+ this.debtTokens.delete(t);
840
+ }
841
+ /** The part of a tab's eviction total that has NOT yet been surfaced and
842
+ * confirmed. See disclosedUpTo. */
843
+ undisclosedDrop(slot) {
844
+ return Math.max(0, (this.dropped.get(slot) ?? 0) - (this.disclosedUpTo.get(slot) ?? 0));
845
+ }
846
+ /** Every debt bucket belonging to a panel tab, across all its incarnations. */
847
+ debtSlotsFor(key) {
848
+ // A slot is `["<tabId>",<incarnation>]`, so the tab's slots are exactly those
849
+ // starting `["<tabId>",` — JSON-escaped, so no tab id can spoof another's.
850
+ const prefix = `[${JSON.stringify(key)},`;
851
+ return [...new Set([...this.dropped.keys(), ...this.disclosedUpTo.keys()])].filter((s) => s.startsWith(prefix));
852
+ }
853
+ /** Answers this tab lost to an eviction and has not yet been told about. */
854
+ droppedFor(key) {
855
+ const carried = [...this.entries.values()]
856
+ .filter((e) => e.key === key)
857
+ .reduce((n, e) => n + (e.disclose ?? 0), 0);
858
+ // Every incarnation's bucket: a diagnostic view of what this TAB is owed,
859
+ // whoever is holding it now.
860
+ return (carried + this.debtSlotsFor(key).reduce((n, slot) => n + this.undisclosedDrop(slot), 0));
861
+ }
862
+ /** Entries awaiting a push attempt for this key, in arrival order. */
863
+ pending(key) {
864
+ const out = [];
865
+ for (const entry of this.entries.values()) {
866
+ // A different browser tab now holds this key: its conversation never put
867
+ // the card up, so the answer waits (for its own tab to come back) rather
868
+ // than being announced to a stranger. It is still disclosed by a failing
869
+ // ask, and still named on the way out — held, never swallowed.
870
+ if (entry.key === key && entry.delivery === "pending" && this.mayReachLiveTurn(entry)) {
871
+ out.push(entry);
872
+ }
873
+ }
874
+ return out;
875
+ }
876
+ /**
877
+ * Push every orphaned answer for `key` to the tab's agent, in ARRIVAL order,
878
+ * stopping at the first refusal so a newer answer never overtakes an older one
879
+ * that is still stuck.
880
+ *
881
+ * `inject` returns whether the agent TOOK the payload onto its queue — not
882
+ * that it was read. The entry stays journaled either way; only `ack` (the turn
883
+ * that carried it ended) removes it. That is the durability property: hand it
884
+ * to an agent that then dies and it comes back here.
885
+ *
886
+ * NOTHING is re-correlated. The verdict and the question were frozen at
887
+ * arrival, so a replay can never be re-attributed to a question asked later.
888
+ */
889
+ deliverPending(key, inject) {
890
+ let delivered = 0;
891
+ for (const entry of this.pending(key)) {
892
+ const lost = (entry.disclose ?? 0) + this.undisclosedDrop(this.debtSlot(key));
893
+ const payload = {
894
+ kind: "ask_answer",
895
+ ask_question: entry.question,
896
+ ask_answer: entry.answer,
897
+ ask_correlation: entry.correlation.status,
898
+ ask_answered_at: entry.answeredAt,
899
+ ...(entry.attempts > 0 ? { replayed: true } : {}),
900
+ ...(entry.replayHint ? { possible_repeat: true } : {}),
901
+ ...(lost > 0 ? { dropped_answers: lost } : {}),
902
+ };
903
+ const handedOff = inject(payload, entry.token);
904
+ entry.attempts += 1;
905
+ entry.delivery = handedOff ? "handed_off" : "pending";
906
+ if (!handedOff)
907
+ return { delivered, blockedOn: entry };
908
+ if (lost > 0) {
909
+ // CONSOLIDATE onto the entry; the disclosure is NOT spent by a hand-off.
910
+ // If this agent dies before its turn runs, the entry is released and
911
+ // replayed, and the warning must go with it. Only ack() clears it.
912
+ entry.disclose = lost;
913
+ const slot = this.debtSlot(key);
914
+ this.dropped.delete(slot);
915
+ this.disclosedUpTo.delete(slot);
916
+ }
917
+ delivered += 1;
918
+ }
919
+ return { delivered, blockedOn: null };
920
+ }
921
+ /** The turn that CARRIED this answer ended — it genuinely reached the agent. */
922
+ ack(token, from) {
923
+ // An eviction-DISCLOSURE token (see reportDropped): the turn that carried the
924
+ // warning produced its result, so the tab has genuinely been told.
925
+ const debt = this.debtTokens.get(token);
926
+ if (debt !== undefined) {
927
+ if (!this.carriedBy(debt.carrier, from))
928
+ return;
929
+ this.debtTokens.delete(token);
930
+ // Settle ONLY what this warning actually showed. An eviction that landed
931
+ // after it was rendered is not covered by it, and must still be reported.
932
+ const seen = Math.max(this.disclosedUpTo.get(debt.key) ?? 0, debt.upTo);
933
+ const total = this.dropped.get(debt.key) ?? 0;
934
+ if (seen >= total) {
935
+ // Fully told — nothing outstanding, so drop both halves.
936
+ this.dropped.delete(debt.key);
937
+ this.disclosedUpTo.delete(debt.key);
938
+ for (const [t, x] of [...this.debtTokens])
939
+ if (x.key === debt.key)
940
+ this.debtTokens.delete(t);
941
+ }
942
+ else {
943
+ this.disclosedUpTo.set(debt.key, seen);
944
+ }
945
+ return;
946
+ }
947
+ const entry = this.entries.get(token);
948
+ if (!entry)
949
+ return;
950
+ // ONLY THE TURN THAT ACTUALLY CARRIED IT may settle it. The carrier was
951
+ // frozen at hand-off (see AskEntry.carrier); a result arriving from anything
952
+ // else — most concretely, the NEW provider's turn after a switch moved the
953
+ // tab's agent mapping — proves nothing about this answer, so it is refused
954
+ // and the answer stays honestly unacked.
955
+ if (!this.carriedBy(entry.carrier, from)) {
956
+ 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`);
957
+ return;
958
+ }
959
+ // The push is done, but the ANSWER stays journaled while it is still within
960
+ // the recovery window: a re-ask of the identical question must still be able
961
+ // to return it as its result rather than making the user answer twice. It is
962
+ // flagged so any later surfacing reads as "you have seen this".
963
+ //
964
+ // The eviction DISCLOSURE it carries is only SPENT when a real delivery
965
+ // actually rendered it — `deliverPending` stamps the count onto the entry and
966
+ // puts `dropped_answers` in that payload, and `attempts > 0` is the record of
967
+ // it. An entry acked without ever having been pushed (a TOOL-RESULT answer
968
+ // riding its turn) never showed anyone that count, so deleting it here would
969
+ // spend a warning nobody was given: re-home it instead.
970
+ if (entry.disclose) {
971
+ const owed = entry.disclose;
972
+ delete entry.disclose;
973
+ if (entry.attempts === 0)
974
+ this.noteDropped(entry.key, owed, entry.incarnation);
975
+ }
976
+ entry.delivery = "none";
977
+ entry.returned = true;
978
+ // THE proof. Everything that may forget an answer QUIETLY keys on this and
979
+ // never on `returned`.
980
+ entry.acked = true;
981
+ entry.replayHint = true;
982
+ }
983
+ /**
984
+ * An agent gave a push back undelivered. Re-arm it for replay.
985
+ *
986
+ * `carried` distinguishes the two causes, and ONLY the first is bounded:
987
+ * • carried: true — a turn actually DISPATCHED with this answer in it and
988
+ * then ended, but its result could not be proven to be that turn's own. The
989
+ * agent read the text; after MAX_CARRIED_RELEASES we settle rather than
990
+ * loop (a duplicate at worst).
991
+ * • carried: false — a teardown handed it back (agent stopped, session died).
992
+ * NOBODY read it. These must never count toward the bound.
993
+ */
994
+ release(token, opts = {}) {
995
+ // A disclosure token handed back: that turn never proved it was read, so the
996
+ // debt STANDS and the next result reports it again. Only the token mapping
997
+ // goes; there is deliberately no bound on how often a warning may be repeated
998
+ // when nothing has confirmed it.
999
+ if (this.debtTokens.delete(token))
1000
+ return;
1001
+ const entry = this.entries.get(token);
1002
+ if (!entry)
1003
+ return;
1004
+ // A TOOL-RESULT answer riding a turn (see setTurnAttacher) is not in a
1005
+ // delivery loop: nothing re-sends it, so there is no cycle to bound and
1006
+ // nothing to re-arm. Its turn ended without proving it was read, so the only
1007
+ // correct outcome is that it stays UNACKED — which is what keeps it
1008
+ // recoverable and makes its eviction a disclosed loss rather than a silent
1009
+ // one. Settling it here on a bound would forge the very proof this split
1010
+ // exists to withhold.
1011
+ if (entry.returned && entry.delivery === "none")
1012
+ return;
1013
+ // A boundary happened while a turn was carrying this. Re-arming it would
1014
+ // hand the OLD conversation's answer to the replacement one — the same bypass
1015
+ // as the rid path, through the release/push door. It stays journaled and
1016
+ // disclosable; it just stops being deliverable.
1017
+ if (entry.retired === true) {
1018
+ entry.delivery = "none";
1019
+ return;
1020
+ }
1021
+ if (opts.carried) {
1022
+ entry.carriedReleases = (entry.carriedReleases ?? 0) + 1;
1023
+ if (entry.carriedReleases >= MAX_CARRIED_RELEASES) {
1024
+ logger.warn(`[ask-answers] an answer was carried by ${entry.carriedReleases} turns that ended without a provable ack — settling it instead of replaying again`);
1025
+ this.ack(token);
1026
+ return;
1027
+ }
1028
+ }
1029
+ entry.delivery = "pending";
1030
+ }
1031
+ /** Is ANY orphaned answer still awaiting a push? The orchestrator's
1032
+ * self-restart gate reads this: the journal is in-memory, so restarting while
1033
+ * one is outstanding silently drops an answer the user actually gave. */
1034
+ hasOutstanding() {
1035
+ // The eviction DEBT counts too. It is a promise to tell the agent that an
1036
+ // answer was lost, and it is destroyed by a teardown exactly as an entry is —
1037
+ // tearing down while one is owed turns a disclosed loss back into a silent
1038
+ // one, which is the whole thing this journal exists to prevent.
1039
+ for (const slot of this.dropped.keys())
1040
+ if (this.undisclosedDrop(slot) > 0)
1041
+ return true;
1042
+ // …but a RETIRED answer is not owed to anyone. It can never be delivered —
1043
+ // the conversation it belongs to is gone — so it is finished, whatever its
1044
+ // `delivery` flag says, and holding the restart gate open for it would wait
1045
+ // forever. Two predicates disagreeing about what `pending` means is what made
1046
+ // this a deadlock: `pending()` reads it as "deliverable", this reads it as
1047
+ // "owed", and only a retirement makes those differ permanently.
1048
+ //
1049
+ // NOT extended to a different-occupant mismatch, which is TEMPORARY: that
1050
+ // answer becomes deliverable again the moment its own browser tab returns, so
1051
+ // tearing down while one is waiting would be a real loss.
1052
+ return [...this.entries.values()].some((e) => (e.delivery !== "none" && e.retired !== true) || (e.disclose ?? 0) > 0);
1053
+ }
1054
+ /**
1055
+ * Everything the last-ditch, we-are-about-to-die disclosure must name.
1056
+ *
1057
+ * Broader than `hasOutstanding` on purpose. A restart is a CHOICE we can defer,
1058
+ * so it waits only on what is still deliverable; a fatal exit is not, so it
1059
+ * reports everything a validated answer might still have been needed for:
1060
+ * • answers awaiting a push (nobody has them);
1061
+ * • answers that went into a ToolResult RECENTLY — "returned" is a hand-off,
1062
+ * not a receipt (that IS #486), and inside the recovery window a re-ask was
1063
+ * still able to produce it. Deferring restarts on these would stall the
1064
+ * self-restarter after every single ask; SAYING so on the way out costs
1065
+ * nothing and keeps the loss from being silent.
1066
+ */
1067
+ allOutstanding() {
1068
+ return [...this.entries.values()].filter((e) => e.delivery !== "none" ||
1069
+ (e.disclose ?? 0) > 0 ||
1070
+ // UNACKED, with NO TIME BOUND. An answer the agent provably read is not
1071
+ // news on the way out; one whose receipt was never confirmed is, and it
1072
+ // stays news for as long as it exists.
1073
+ //
1074
+ // An earlier draft also required it to be inside the recovery window,
1075
+ // which quietly undid the whole `returned`/`acked` split: past ten
1076
+ // minutes an unacked answer was neither pending nor debt-bearing, so the
1077
+ // fatal-exit path was handed nothing and a validated choice vanished in
1078
+ // silence. UNACKED MEANS UNPROVEN, and time does not turn unproven into
1079
+ // delivered — the window governs whether an answer may be RECOVERED, and
1080
+ // has no business governing whether a loss is reported.
1081
+ e.acked !== true);
1082
+ }
1083
+ /** Tabs still owed an eviction disclosure, with the count — so a fatal exit can
1084
+ * name a loss whose carrier is only a counter. */
1085
+ outstandingDebt() {
1086
+ const byTab = new Map();
1087
+ for (const slot of this.dropped.keys()) {
1088
+ const owed = this.undisclosedDrop(slot);
1089
+ if (owed <= 0)
1090
+ continue;
1091
+ // The exit disclosure names TABS, not incarnations — every incarnation's
1092
+ // loss belongs to the tab the user is looking at.
1093
+ const key = JSON.parse(slot)[0];
1094
+ byTab.set(key, (byTab.get(key) ?? 0) + owed);
1095
+ }
1096
+ for (const e of this.entries.values()) {
1097
+ if ((e.disclose ?? 0) > 0)
1098
+ byTab.set(e.key, (byTab.get(e.key) ?? 0) + e.disclose);
1099
+ }
1100
+ return [...byTab].map(([key, count]) => ({ key, count }));
1101
+ }
1102
+ /** Move every entry AND every open ticket from `from` onto `to` — a panel
1103
+ * tab-id migration re-keys the agent and both must move with it, or an answer
1104
+ * for a card dispatched under the old id becomes unattributable. */
1105
+ moveKey(from, to) {
1106
+ if (from === to)
1107
+ return;
1108
+ for (const entry of this.entries.values()) {
1109
+ if (entry.key === from)
1110
+ entry.key = to;
1111
+ }
1112
+ // The conversation GENERATION moves with the tickets, or they would all read
1113
+ // as belonging to a replaced conversation at the destination (or, worse, a
1114
+ // retired one would read as live there). A ticket whose conversation was
1115
+ // still current at the source is current at the destination — the agent moved
1116
+ // with it; one already retired stays retired.
1117
+ const fromEpoch = this.tabEpoch.get(from) ?? 0;
1118
+ const toEpoch = this.tabEpoch.get(to) ?? 0;
1119
+ for (const ticket of this.tickets.values()) {
1120
+ if (ticket.tabId !== from)
1121
+ continue;
1122
+ ticket.tabId = to;
1123
+ ticket.epoch = ticket.epoch === fromEpoch ? toEpoch : toEpoch - 1;
1124
+ }
1125
+ this.tabEpoch.delete(from);
1126
+ // BOTH HALVES OF THE DEBT MOVE TOGETHER. The count is tab-keyed and the
1127
+ // outstanding disclosure TOKENS carry that same key, but the ack that
1128
+ // settles one is bound to the AGENT INSTANCE (see AskEntry.carrier) — which
1129
+ // deliberately survives a tab-id migration. So a token left pointing at the
1130
+ // old key is acked by a perfectly valid result and clears a debt nobody
1131
+ // owes, while the real one sits under the new key forever: false outstanding
1132
+ // debt that blocks the restart gate and later emits a spurious warning. That
1133
+ // is the cry-wolf failure the acked/unacked split exists to prevent,
1134
+ // arriving through migration.
1135
+ for (const debt of this.debtTokens.values()) {
1136
+ if (debt.key === from)
1137
+ debt.key = to;
1138
+ }
1139
+ // Every incarnation's bucket moves with the tab id, keeping its own
1140
+ // incarnation: the id changed, the browser tabs did not.
1141
+ for (const slot of this.debtSlotsFor(from)) {
1142
+ const [, incarnation] = JSON.parse(slot);
1143
+ // Carry the UNDISCLOSED remainder only — what was already told is told,
1144
+ // and re-importing it would warn the destination twice.
1145
+ const lost = this.undisclosedDrop(slot);
1146
+ this.dropped.delete(slot);
1147
+ this.disclosedUpTo.delete(slot);
1148
+ if (lost <= 0)
1149
+ continue;
1150
+ const moved = tabIncarnationSlot(to, incarnation ?? undefined);
1151
+ this.dropped.set(moved, (this.dropped.get(moved) ?? 0) + lost);
1152
+ }
1153
+ for (const debt of this.debtTokens.values()) {
1154
+ const [tab, incarnation] = JSON.parse(debt.key);
1155
+ if (tab === from)
1156
+ debt.key = tabIncarnationSlot(to, incarnation ?? undefined);
1157
+ }
1158
+ }
1159
+ /** Drop everything belonging to a tab that will never come back. Logs every
1160
+ * answer that dies unconsumed; a loss must never be silent. */
1161
+ forget(key) {
1162
+ for (const [token, entry] of [...this.entries]) {
1163
+ if (entry.key !== key)
1164
+ continue;
1165
+ this.entries.delete(token);
1166
+ // …and its queued copy goes with it: the journal's record and the agent's
1167
+ // queue must be dropped together, or the text outlives the entry that was
1168
+ // supposed to bound it.
1169
+ if (entry.delivery === "handed_off")
1170
+ this.revoke?.(entry.key, entry.token);
1171
+ // EVERY answer is logged, returned ones included: "it went into a
1172
+ // ToolResult" is not proof it was received, so a returned answer inside
1173
+ // the recovery window is still the only copy of a decision that may never
1174
+ // have reached the model.
1175
+ 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)" : ""}`);
1176
+ }
1177
+ // The TAB is gone, so every conversation it hosted is gone too: bump the
1178
+ // epoch rather than deleting the tickets. A card left on screen can still be
1179
+ // clicked, and the surviving ticket is what names the question that answer
1180
+ // belongs to — while the newer epoch keeps it from being armed for a push at
1181
+ // a tab id no agent answers to, exactly as for a replaced conversation.
1182
+ this.tabEpoch.set(key, (this.tabEpoch.get(key) ?? 0) + 1);
1183
+ const slots = this.debtSlotsFor(key);
1184
+ const owed = slots.reduce((n, slot) => n + this.undisclosedDrop(slot), 0);
1185
+ if (owed > 0) {
1186
+ 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`);
1187
+ }
1188
+ for (const slot of slots) {
1189
+ this.dropped.delete(slot);
1190
+ this.disclosedUpTo.delete(slot);
1191
+ }
1192
+ // Both halves together — see moveKey. A token left behind here would let a
1193
+ // later ack clear a debt for a tab that no longer exists.
1194
+ for (const [t, x] of [...this.debtTokens]) {
1195
+ if (JSON.parse(x.key)[0] === key)
1196
+ this.debtTokens.delete(t);
1197
+ }
1198
+ }
1199
+ /**
1200
+ * The CONVERSATION that asked this tab's questions is gone — New chat, or a
1201
+ * switch to a historical session.
1202
+ *
1203
+ * Drop the tab's TICKETS and DOWNGRADE its journaled answers to `foreign`, so
1204
+ * an answer given to the old conversation's question can never be returned to
1205
+ * the replacement agent as "the answer to the question YOU just asked". The
1206
+ * answers themselves are kept and still delivered — labelled UNDETERMINED,
1207
+ * quoted with their own question. A correlation may only ever get WEAKER.
1208
+ */
1209
+ closeAsks(key) {
1210
+ // ORDER IS THE WHOLE POINT HERE. The rule this and surfaceAndClearDebt share:
1211
+ //
1212
+ // FENCE BEFORE YOU REPORT. REPORT BEFORE YOU DESTROY.
1213
+ //
1214
+ // Nothing that can throw may stand between a decision and the state change
1215
+ // that makes it safe. In surfaceAndClearDebt the irreversible act is the
1216
+ // clear, so the report goes first. HERE the protective act is the retire, so
1217
+ // it goes first — an earlier draft reported the debt (through a logger, which
1218
+ // can fail) before marking anything retired, and a logger failure left the
1219
+ // fence half-built. The caller has already reset the old agent by then, so
1220
+ // the replacement's ready-flush would push the still-unretired answer and an
1221
+ // identical re-ask could recover it.
1222
+ //
1223
+ // PASS 1 is pure state: no logging, no callbacks, nothing that can throw. By
1224
+ // the end of it every answer on this tab is fenced.
1225
+ this.tabEpoch.set(key, (this.tabEpoch.get(key) ?? 0) + 1);
1226
+ const queued = [];
1227
+ for (const entry of this.entries.values()) {
1228
+ if (entry.key !== key)
1229
+ continue;
1230
+ if (entry.correlation.status === "matched") {
1231
+ entry.correlation = { status: "foreign", askId: entry.correlation.askId };
1232
+ }
1233
+ // Revoke the LICENCE to satisfy a re-ask, but keep the fingerprint: it is
1234
+ // also how a later ask of the SAME question notices this answer exists and
1235
+ // reports it. See AskEntry.recoverable.
1236
+ entry.recoverable = false;
1237
+ // …and mark it RETIRED, which is what every path that can reach a live turn
1238
+ // consults (see AskEntry.retired). Setting `delivery` alone is not enough:
1239
+ // a turn that was already carrying this answer releases it back to
1240
+ // `pending` afterwards, and the re-arm has no idea a boundary happened.
1241
+ entry.retired = true;
1242
+ // …and STOP PUSHING it. Its addressee is gone.
1243
+ //
1244
+ // This is where an ask answer parts company with a run completion (#468),
1245
+ // which is still delivered to the replacement conversation downgraded. A
1246
+ // completion's payload is independently useful — the images are on the
1247
+ // user's canvas either way. An answer to a question the replacement
1248
+ // conversation never asked is useful to nobody.
1249
+ //
1250
+ // NOT a silent discard: the entry stays journaled (so a later ask on this
1251
+ // tab that times out still reports its EXISTENCE) and the loss of the push
1252
+ // is logged in pass 2.
1253
+ if (entry.delivery === "pending")
1254
+ entry.delivery = "none";
1255
+ else if (entry.delivery === "handed_off")
1256
+ queued.push(entry);
1257
+ }
1258
+ // PASS 2 is fallible: pulling a queued copy back off an agent, and saying what
1259
+ // happened. Each is isolated, so one failure cannot skip the rest — and none
1260
+ // of it can undo pass 1, which has already made every answer here unusable.
1261
+ for (const entry of queued) {
1262
+ let recalled = false;
1263
+ try {
1264
+ // HANDED_OFF has already been materialised into the agent's queue with
1265
+ // wording that claims the reader put the card up, so it must be pulled
1266
+ // back; only once the carrying turn has STARTED is the text beyond
1267
+ // recall.
1268
+ recalled = this.revoke?.(entry.key, entry.token) === true;
1269
+ }
1270
+ catch (err) {
1271
+ // A CATCH BLOCK THAT CAN THROW IS NOT A GUARD. The try above exists so a
1272
+ // throwing revoker cannot abort the pass — but the report of that failure
1273
+ // is itself fallible, and an unguarded one hands the abort straight back:
1274
+ // a throwing revoker AND a throwing warn sink together would exit
1275
+ // closeAsks here, skipping the remaining recalls and, worse, pass 3. The
1276
+ // debt would then stay on the tab for a same-incarnation replacement to
1277
+ // read and settle as its own, and any entry-carried `disclose` would sit
1278
+ // on a retired, undeliverable entry holding hasOutstanding() — and so the
1279
+ // self-restart gate — open forever.
1280
+ //
1281
+ // Every step of this pass therefore has a TERMINAL error path: the report
1282
+ // of a failure must never become a new failure that skips the rest.
1283
+ try {
1284
+ logger.warn(`[ask-answers] could not recall a queued answer on tab ${key.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
1285
+ }
1286
+ catch {
1287
+ // The fence is already up; a log sink that is down cannot undo it, and
1288
+ // must not stop the debt from being surfaced.
1289
+ }
1290
+ }
1291
+ if (recalled)
1292
+ entry.delivery = "none";
1293
+ try {
1294
+ logger.warn(recalled
1295
+ ? `[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`
1296
+ : `[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`);
1297
+ }
1298
+ catch {
1299
+ // The fence is already up; a log sink that is down cannot undo it.
1300
+ }
1301
+ }
1302
+ // PASS 3, last because it is the one that DESTROYS. The eviction debt is owed
1303
+ // to the conversation being replaced, and scoping it by (tab, incarnation)
1304
+ // cannot fence this axis — New chat happens on the same tab, same
1305
+ // incarnation. Left alone, the replacement conversation's next ask would
1306
+ // report the old conversation's warning as its own and settle it with its own
1307
+ // ack. Surfaced and retired, exactly as a tab-gone does.
1308
+ this.surfaceAndClearDebt(key, this.incarnationOf?.(key), "its conversation was replaced");
1309
+ }
1310
+ /** Test/diagnostic helpers. */
1311
+ ticketFor(askId) {
1312
+ return this.tickets.get(askId);
1313
+ }
1314
+ /**
1315
+ * Is an ack from `from` allowed to settle something handed to `carrier`?
1316
+ *
1317
+ * A PUSHED entry has no carrier binding (its token was queued, not attached to
1318
+ * a running turn) and #468's own machinery already gates it — those pass. A
1319
+ * hand-off that never found a live turn (`carrier === null`) can never be
1320
+ * settled by anyone. Everything else must match EXACTLY.
1321
+ *
1322
+ * An ack that arrives with no identity at all is UNVERIFIABLE, and is refused
1323
+ * for a bound answer: "we could not tell who this came from" must never
1324
+ * collapse into "it came from the right place".
1325
+ */
1326
+ carriedBy(carrier, from) {
1327
+ if (carrier === undefined)
1328
+ return true; // never bound to a turn (a push)
1329
+ if (carrier === null)
1330
+ return false; // handed back with no live turn to ride
1331
+ return from?.carrier === carrier;
1332
+ }
1333
+ /** Simulate the ticket map's bound biting, without running 1024 asks. */
1334
+ dropTicketForTest(askId) {
1335
+ this.tickets.delete(askId);
1336
+ }
1337
+ /** Strand an eviction debt on a tab without staging 48 evictions. */
1338
+ noteDroppedForTest(key, count, incarnation) {
1339
+ this.noteDropped(key, count, incarnation ?? this.incarnationOf?.(key));
1340
+ }
1341
+ /** Record + hand back in one step, returning the token. */
1342
+ markReturnedForTest(askId, reply, tabId) {
1343
+ const token = this.record(askId, reply, { tabId }).token;
1344
+ this.markReturned(token);
1345
+ return token;
1346
+ }
1347
+ entriesFor(key) {
1348
+ return [...this.entries.values()].filter((e) => e.key === key);
1349
+ }
1350
+ reset() {
1351
+ // NOTE: the flusher is deliberately NOT cleared — it belongs to the process's
1352
+ // orchestrator, not to any one test's fixture.
1353
+ this.tickets.clear();
1354
+ this.tabEpoch.clear();
1355
+ this.entries.clear();
1356
+ this.dropped.clear();
1357
+ this.disclosedUpTo.clear();
1358
+ this.debtTokens.clear();
1359
+ this.seq = 0;
1360
+ this.ticketSeq = 0;
1361
+ }
1362
+ trimTickets() {
1363
+ while (this.tickets.size > MAX_TICKETS) {
1364
+ // Prefer a ticket no handler is waiting on; otherwise the oldest. Losing a
1365
+ // ticket does not lose an answer — it makes a later answer for it read as
1366
+ // UNATTRIBUTED, which is the honest verdict once we have forgotten the
1367
+ // question — but say so, because it is a real degradation.
1368
+ let victim = null;
1369
+ for (const [id, t] of this.tickets) {
1370
+ if (!t.awaiting) {
1371
+ victim = id;
1372
+ break;
1373
+ }
1374
+ }
1375
+ if (!victim)
1376
+ victim = this.tickets.keys().next().value ?? null;
1377
+ if (!victim)
1378
+ return;
1379
+ 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)`);
1380
+ this.tickets.delete(victim);
1381
+ }
1382
+ }
1383
+ /**
1384
+ * Record that an answer for `key` was destroyed by an eviction, so the next
1385
+ * delivery to that tab can report it as UNDETERMINED. Stamped onto a surviving
1386
+ * PENDING entry when there is one — it then rides out on a real delivery and
1387
+ * cannot be discarded.
1388
+ */
1389
+ noteDropped(key, count, incarnation) {
1390
+ if (count <= 0)
1391
+ return;
1392
+ // THE LOSS BELONGS TO THE OCCUPANT IT HAPPENED TO, and that is carried in,
1393
+ // never re-derived from the key at write time. The bucket is keyed by
1394
+ // incarnation, so deriving an owner here would file A's loss under whoever
1395
+ // holds the key NOW — B is then pushed A's `dropped_answers`, B's ack clears
1396
+ // it, and A's own lifecycle callback (which retires A's bucket) finds
1397
+ // nothing left to disclose. Keying the bucket and then guessing the key is
1398
+ // the same halfway pattern as scoping the clock but not the debt.
1399
+ const carrier = [...this.entries.values()].find((e) => e.key === key && e.delivery === "pending" && e.incarnation === incarnation);
1400
+ if (carrier) {
1401
+ carrier.disclose = (carrier.disclose ?? 0) + count;
1402
+ return;
1403
+ }
1404
+ const slot = tabIncarnationSlot(key, incarnation);
1405
+ this.dropped.set(slot, (this.dropped.get(slot) ?? 0) + count);
1406
+ // NO CEILING HERE, deliberately.
1407
+ //
1408
+ // This map is not payload — it is the PROMISE that a loss will be reported,
1409
+ // and it is the last record of answers whose text is already gone. Evicting
1410
+ // it on the same terms as data (as an earlier draft did, at 64 tabs) turns a
1411
+ // disclosed loss straight back into a silent one: the tab's next ask, the
1412
+ // restart gate and the fatal-exit report all lose their only trace, and the
1413
+ // log line that announced it said outright that the tab would never be told.
1414
+ // A bounded store must never be what decides whether a warning is owed.
1415
+ //
1416
+ // It cannot grow without limit anyway: one integer per PANEL TAB that has
1417
+ // stranded debt, removed by forget() when the tab goes away (which logs at
1418
+ // ERROR, the one place the promise genuinely cannot be kept) and merged by
1419
+ // moveKey() across a tab-id migration.
1420
+ }
1421
+ /**
1422
+ * Enforce the per-tab and global ceilings.
1423
+ *
1424
+ * EVICTION ORDER matters and is the same judgement #468 makes: an answer that
1425
+ * was already RETURNED to a tool result has reached a caller, so forgetting it
1426
+ * costs at most a re-ask; an ORPHANED one has reached nobody, so evicting it
1427
+ * is a real loss — those go last, are logged at ERROR, and are COUNTED so the
1428
+ * next delivery tells the agent those answers are UNDETERMINED.
1429
+ */
1430
+ trimEntries(key) {
1431
+ const evict = (scope, limit, label) => {
1432
+ let mine = [...this.entries.values()].filter(scope);
1433
+ while (mine.length > limit) {
1434
+ // EVICTION ORDER, and the one decision that may forget an answer
1435
+ // QUIETLY. It keys on ACKED — the turn that carried the answer produced
1436
+ // its own marked result — never on `returned`, which says only that the
1437
+ // answer was written to a transport that may already have been
1438
+ // abandoned. That is the premise of #486, so it cannot also be the
1439
+ // licence to forget.
1440
+ //
1441
+ // Ordinary asks ARE acked (the model reads the tool result and finishes
1442
+ // its turn), so the common path stays silent and no warning cries wolf.
1443
+ // An answer whose turn never completed is precisely the #486 failure, so
1444
+ // it is counted and disclosed exactly like one that reached nobody.
1445
+ const victim = mine.find((e) => e.acked === true) ??
1446
+ mine.find((e) => e.returned) ??
1447
+ mine[0];
1448
+ this.entries.delete(victim.token);
1449
+ mine = mine.filter((e) => e !== victim);
1450
+ if (victim.acked === true) {
1451
+ // ANY DISCLOSURE IT WAS CARRYING is re-homed, not dropped with it: this
1452
+ // answer's own recoverability is what may be forgotten, never the debt
1453
+ // owed for EARLIER answers that reached nobody and merely happened to
1454
+ // be stamped on this entry.
1455
+ if (victim.disclose)
1456
+ this.noteDropped(victim.key, victim.disclose, victim.incarnation);
1457
+ logger.debug(`[ask-answers] ${label} — forgetting an answer to "${preview(victim.question)}" that the agent provably read`);
1458
+ continue;
1459
+ }
1460
+ this.noteDropped(victim.key, 1 + (victim.disclose ?? 0), victim.incarnation);
1461
+ 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`);
1462
+ }
1463
+ };
1464
+ if (key) {
1465
+ evict((e) => e.key === key, MAX_ENTRIES_PER_KEY, `journal for tab ${key.slice(0, 8)} exceeded ${MAX_ENTRIES_PER_KEY} answers`);
1466
+ }
1467
+ evict(() => true, MAX_ENTRIES_TOTAL, `journal exceeded ${MAX_ENTRIES_TOTAL} answers overall`);
1468
+ }
1469
+ }
1470
+ /** Short, log-safe rendering of a question. */
1471
+ export function preview(question) {
1472
+ if (!question)
1473
+ return "(an unattributed question)";
1474
+ const one = question.replace(/\s+/g, " ").trim();
1475
+ return one.length > 60 ? `${one.slice(0, 57)}…` : one;
1476
+ }
1477
+ /** How long a recovered answer may be presented as the result of a re-ask. */
1478
+ export const ASK_RECOVER_MAX_AGE_MS = RECOVER_MAX_AGE_MS;
1479
+ /** Process-wide journal (mirrors the RunCompletions singleton): the panel_ask
1480
+ * tool opens tickets and consumes recoveries from the tool layer, while the
1481
+ * orchestrator's bridge sink records late answers and pushes the orphans, with
1482
+ * no ctx plumbing between them. */
1483
+ export const AskAnswers = new AskAnswerJournalImpl();
1484
+ //# sourceMappingURL=ask-answer-journal.js.map