switchroom 0.19.13 → 0.19.15

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 (35) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +4 -2
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +1 -1
  6. package/telegram-plugin/dist/gateway/gateway.js +1027 -509
  7. package/telegram-plugin/dist/server.js +1 -1
  8. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  9. package/telegram-plugin/gateway/gateway.ts +4 -0
  10. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  11. package/telegram-plugin/gateway/outbound-send-path.ts +9 -3
  12. package/telegram-plugin/gateway/outbox-sweep.ts +73 -5
  13. package/telegram-plugin/gateway/rich-message-handler.ts +235 -0
  14. package/telegram-plugin/gateway/stream-render.ts +107 -15
  15. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  16. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  17. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  18. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +33 -7
  19. package/telegram-plugin/hooks/silent-end-scan.mjs +171 -85
  20. package/telegram-plugin/narrative-flush.ts +35 -0
  21. package/telegram-plugin/outbox.ts +87 -0
  22. package/telegram-plugin/shown-ledger.ts +145 -0
  23. package/telegram-plugin/silent-end.ts +42 -0
  24. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  25. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  26. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  27. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  28. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  29. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  30. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +600 -0
  31. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +19 -11
  32. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  33. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  34. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  35. package/telegram-plugin/turn-flush-safety.ts +66 -53
@@ -58,12 +58,18 @@
58
58
  import { statSync } from 'node:fs'
59
59
  import { join } from 'node:path'
60
60
  import { createHash } from 'node:crypto'
61
+ import {
62
+ isNarrationBlock,
63
+ isEphemeralTool,
64
+ selectBackstopDelivery,
65
+ SUBSTANTIVE_MIN_CHARS,
66
+ } from './narration-classify.mjs'
61
67
 
62
68
  const REPLY_TOOLS = new Set([
63
69
  'mcp__switchroom-telegram__reply',
64
70
  'mcp__switchroom-telegram__stream_reply',
65
71
  ])
66
- const FINAL_ANSWER_MIN_CHARS = 200
72
+ const FINAL_ANSWER_MIN_CHARS = SUBSTANTIVE_MIN_CHARS
67
73
  // Match the gateway's silent-marker classifier (gateway.ts:6692 — the
68
74
  // `isSilentFlushMarker` helper accepts trailing punctuation + case
69
75
  // variants like "NO_REPLY." / "no_reply").
@@ -101,29 +107,13 @@ export function endsWithSilentMarker(text) {
101
107
  return SILENT_MARKER_RE.test(lines[lines.length - 1])
102
108
  }
103
109
 
104
- // ── Narration heuristics ported from `turn-flush-safety.ts:198-274` ──
105
- //
106
- // Kept byte-parallel with `selectFlushDeliveryText` / `isNarrationBlock` so
107
- // the JOINED multi-block prose the scan persists as `pendingText` (for the
108
- // capture-divergence corner) matches what the gateway flush would itself have
109
- // delivered. The scan has no structural `followedByToolUse` provenance, so it
110
- // uses the opener/trailer heuristic fallback (the same branch the TS side
111
- // takes when the flag is absent). MUST stay in sync with the TS source; a
112
- // drift only affects the rare capture-empty corner, never the primary flush.
113
- const NARRATION_OPENER =
114
- /^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i
115
- const NARRATION_TRAILER = /(?:\.{3}|…|:)\s*$/
116
-
117
- function isTrailingNarrationLine(block) {
118
- const t = block.trim()
119
- if (t.length === 0 || t.length >= FINAL_ANSWER_MIN_CHARS) return false
120
- if (t.includes('\n')) return false
121
- return NARRATION_TRAILER.test(t)
122
- }
123
-
124
- function isNarrationBlock(block) {
125
- return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block)
126
- }
110
+ // The narration heuristic (`isNarrationBlock`) and the STRUCTURAL rule
111
+ // (`isStructuralNarration`) live in the ONE shared classifier
112
+ // `./narration-classify.mjs`, imported at the top. Before switchroom#3513 this
113
+ // `.mjs` carried a byte-parallel COPY of the TS heuristic with a "MUST stay in
114
+ // sync" comment the exact hand-synced drift that let intent-narration leak.
115
+ // Both the bundled gateway TS (`turn-flush-safety.ts`) and this unbundled hook
116
+ // now import the same module, so a single edit governs every classifier.
127
117
 
128
118
  /**
129
119
  * Choose the prose the captured-prose bridge should deliver from the trailing
@@ -403,7 +393,8 @@ export function scanTurnForFinalReply(jsonl) {
403
393
  if (obj?.type !== 'assistant') continue
404
394
  const content = obj?.message?.content
405
395
  if (!Array.isArray(content)) continue
406
- for (const c of content) {
396
+ for (let ci = 0; ci < content.length; ci++) {
397
+ const c = content[ci]
407
398
  if (c?.type === 'text') {
408
399
  // Plain assistant text carve-out (#2053): a turn that ends with
409
400
  // a trailing bare NO_REPLY / HEARTBEAT_OK line — emitted as
@@ -428,16 +419,36 @@ export function scanTurnForFinalReply(jsonl) {
428
419
  // bridge): when this turn ends up blocked, the joined undelivered
429
420
  // text becomes `pendingText` so the gateway can deliver the model's
430
421
  // real answer directly. Trimmed per-block; joined below.
422
+ //
423
+ // #3513 follow-up (MF1): carry the block's text/length; the STRUCTURAL
424
+ // provenance `followedByToolUse` is NOT computed per-message here (the
425
+ // old `content.slice(ci+1)` only saw a tool later in this SAME message,
426
+ // missing the cross-message shape [text-only message] → [tool_use in the
427
+ // NEXT message]). It is filled by the PER-TURN two-pass below, which
428
+ // sees a turn-continuing tool_use ANYWHERE later in the turn.
431
429
  blocks.push({
432
430
  kind: 'text',
433
431
  chars: String(c.text ?? '').trim().length,
434
432
  text: String(c.text ?? '').trim(),
433
+ followedByToolUse: false,
435
434
  })
436
435
  }
437
436
  continue
438
437
  }
439
438
  if (c?.type !== 'tool_use') continue
440
- if (!REPLY_TOOLS.has(c.name)) continue
439
+ if (!REPLY_TOOLS.has(c.name)) {
440
+ // #3513 follow-up (MF1): a turn-CONTINUING tool_use (any tool NOT in the
441
+ // ephemeral surface set and NOT a reply tool) is the deterministic signal
442
+ // that every PRIOR text block — including ones in earlier messages — was
443
+ // intra-turn narration. Record it as an ordered provenance marker so the
444
+ // two-pass below can retro-mark those blocks. Ephemeral surface tools
445
+ // (react / pin / typing / edit / delete) are NOT turn-continuing: a text
446
+ // block followed only by them is still the terminal answer.
447
+ if (!isEphemeralTool(c.name)) {
448
+ blocks.push({ kind: 'work-tool' })
449
+ }
450
+ continue
451
+ }
441
452
  const input = c.input ?? {}
442
453
  const text = String(input.text ?? '')
443
454
  // Silent-marker carve-out: the operator explicitly signaled
@@ -464,6 +475,28 @@ export function scanTurnForFinalReply(jsonl) {
464
475
  }
465
476
  }
466
477
 
478
+ // 2b. #3513 follow-up (MF1) — PER-TURN two-pass structural provenance. Walk
479
+ // the flattened blocks in REVERSE and mark each text block
480
+ // `followedByToolUse` iff a turn-continuing tool_use (`kind:'work-tool'`,
481
+ // recorded above for any non-ephemeral, non-reply tool) appears LATER
482
+ // anywhere in the turn — not just later in its own message. This is the
483
+ // root-cause fix: the per-message computation missed the cross-message
484
+ // shape ([text-only message] → [tool_use in the NEXT message]), leaking
485
+ // intra-turn narration to the bridge/sweep. A `deliver` (reply-tool) marker
486
+ // does NOT reset the flag: reply provenance is handled by the deliver
487
+ // cursor (lastAllowBlockIdx), not the structural mark.
488
+ {
489
+ let sawWorkToolLater = false
490
+ for (let i = blocks.length - 1; i >= 0; i--) {
491
+ const b = blocks[i]
492
+ if (b.kind === 'work-tool') {
493
+ sawWorkToolLater = true
494
+ continue
495
+ }
496
+ if (b.kind === 'text') b.followedByToolUse = sawWorkToolLater
497
+ }
498
+ }
499
+
467
500
  // 3. Find the LAST delivery event's position, then check whether any
468
501
  // plain-text block appears strictly after it. This is the fix for
469
502
  // the "at least once" bug: a naive scan that stops at the FIRST
@@ -478,60 +511,50 @@ export function scanTurnForFinalReply(jsonl) {
478
511
  }
479
512
  }
480
513
  const undeliveredSlice = blocks.slice(lastAllowBlockIdx + 1)
481
- const sawUndeliveredTextAfterAllow = undeliveredSlice
482
- .some((b) => b.kind === 'text' && (b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS)
514
+ // #3513 follow-up — the ONE shared backstop coalescer selects the terminal
515
+ // delivery from the trailing text blocks (in source order, carrying the
516
+ // per-TURN structural provenance filled by the two-pass above).
517
+ // `selectBackstopDelivery` UNCONDITIONALLY excludes any block a
518
+ // turn-continuing tool followed (no length/wording gate — the #3515 substance
519
+ // heuristic is gone on the backstop path) and joins the maximal terminal
520
+ // suffix run of non-tool-followed blocks. `null` ⇒ nothing deliverable
521
+ // (pure narration run, or an empty-terminal fragment below the floor).
522
+ const backstopBlocks = undeliveredSlice
523
+ .filter((b) => b.kind === 'text' && typeof b.text === 'string')
524
+ .map((b) => ({ text: b.text, followedByToolUse: b.followedByToolUse }))
525
+ const backstopSelected = selectBackstopDelivery(backstopBlocks)
526
+ const backstopText =
527
+ backstopSelected != null && typeof backstopSelected.text === 'string'
528
+ ? backstopSelected.text
529
+ : undefined
483
530
 
484
- // Option A transcript-prose bridge: isolate the undelivered final-answer
485
- // prose so the gateway can deliver it directly.
486
- //
487
- // Finding 2 (#3228): a real dropped answer is a SINGLE substantive block —
488
- // NOT concatenated inter-tool narration ("Let me check…", "Still querying…")
489
- // that only crosses the floor once joined. The old code joined ALL post-
490
- // delivery text blocks and surfaced the join whenever the COMBINED length
491
- // hit the floor, so a run of short narration masqueraded as a final answer
492
- // (and in the zero-delivery case that join was every text block in the
493
- // turn). Instead, deliver only the LAST block that CLEARS the substance
494
- // floor ON ITS OWN. This mirrors the block decision itself
495
- // (`sawUndeliveredTextAfterAllow`, which requires a single ≥floor block) and
496
- // handles the "big answer then short closer" shape by delivering the answer,
497
- // not the closer. When no single block clears the floor, `pendingText` stays
498
- // undefined and the gateway falls through to the re-prompt / represent nets.
499
- const substantiveBlocks = undeliveredSlice.filter(
500
- (b) =>
501
- b.kind === 'text' &&
502
- typeof b.text === 'string' &&
503
- (b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS,
504
- )
531
+ // The interim-ack path (`trailing-text-after-reply`) keeps the 200-char
532
+ // substance floor: a short trailing closer after a real reply is not a
533
+ // dropped answer. A qualifying delivery already happened, so blocking here
534
+ // only re-nags for a genuine ≥floor dropped answer.
535
+ const sawUndeliveredTextAfterAllow =
536
+ backstopText != null && backstopText.trim().length >= FINAL_ANSWER_MIN_CHARS
505
537
  const pendingText =
506
- substantiveBlocks.length > 0
507
- ? substantiveBlocks[substantiveBlocks.length - 1].text
538
+ backstopText != null && backstopText.trim().length >= FINAL_ANSWER_MIN_CHARS
539
+ ? backstopText
508
540
  : undefined
541
+ // `hasTrailingProse` INTENTIONALLY still counts ANY non-empty trailing prose
542
+ // (narration included): in the zero-reply case it gates the single-writer
543
+ // election to ALLOW the stop (`flush-will-deliver`) rather than BLOCK+nag —
544
+ // the gateway flush then suppresses the narration structurally (its own
545
+ // `selectBackstopDelivery` returns null), so the turn ends silently (no leak,
546
+ // no nag loop). Only the DELIVERED text is narrowed by the coalescer.
509
547
  const trailingTextBlocks = undeliveredSlice.filter(
510
548
  (b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0,
511
549
  )
512
550
  const hasTrailingProse = trailingTextBlocks.length > 0
513
- // Capture-divergence bridge (#duplicate-message fix): in the ZERO-reply
514
- // case, persist the last trailing block even when no single block clears
515
- // the 200-char substance floor. The gateway's flush normally delivers any
516
- // non-empty captured text, but when the gateway's own capture diverged
517
- // (captured empty) the captured-prose bridge is the only delivery machine
518
- // left, and it reads `pendingText` the gateway lowers `minChars` for
519
- // exactly this corner (`capturedProseMinCharsFor`, silent-end.ts). The
520
- // interim-ack case (`trailing-text-after-reply`) keeps the substantive
521
- // floor unchanged — a short closer after a real reply is not a dropped
522
- // answer.
523
- //
524
- // Multi-block corner (review item 3): a real answer split across ≥2
525
- // individually-sub-200 blocks (e.g. two ~150-char paragraphs) would
526
- // otherwise yield NO pendingText — and in the capture-divergence-empty
527
- // corner (gateway `capturedText` empty → flush skips 'empty-text') the
528
- // bridge would then have nothing to deliver and the hook already allowed the
529
- // stop: a DROPPED ANSWER. `selectBridgePendingText` mirrors the flush's own
530
- // `selectFlushDeliveryText` narration-strip/join, so the bridge delivers the
531
- // joined prose (with the lowered `minChars`) instead of dropping. The #3228
532
- // Finding 2 guard is preserved: a pure narration run still yields undefined.
533
- const zeroReplyPendingText =
534
- pendingText ?? selectBridgePendingText(trailingTextBlocks.map((b) => b.text))
551
+ // Zero-reply / capture-divergence corner: the coalesced terminal run is the
552
+ // deliverable prose regardless of the 200-char interim-ack floor a real
553
+ // answer split across ≥2 individually-sub-200 terminal blocks is joined by
554
+ // `selectBackstopDelivery` (its terminal-run join has no floor), so the bridge
555
+ // delivers the joined prose instead of dropping. A pure narration run still
556
+ // yields `undefined` (the #3228 Finding 2 guard, now structural).
557
+ const zeroReplyPendingText = backstopText
535
558
 
536
559
  if (lastAllowBlockIdx === -1) {
537
560
  // No qualifying delivery/silence event anywhere in the turn.
@@ -894,7 +917,7 @@ function resolveSessionOriginChat(lines) {
894
917
  *
895
918
  * @param {string} jsonl
896
919
  * @param {number} [now]
897
- * @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string }}
920
+ * @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string, replyAlreadyDeliveredThisTurn: boolean, deliveredReplySha256: string|null }}
898
921
  */
899
922
  export function scanForOutboxCapture(jsonl, now = Date.now()) {
900
923
  const lines = jsonl.split('\n')
@@ -911,13 +934,17 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
911
934
  if (obj?.type !== 'assistant') continue
912
935
  const content = obj?.message?.content
913
936
  if (!Array.isArray(content)) continue
914
- for (const c of content) {
937
+ for (let ci = 0; ci < content.length; ci++) {
938
+ const c = content[ci]
915
939
  if (c?.type === 'text') {
916
940
  const raw = String(c.text ?? '')
917
941
  // H6: strip a trailing bare marker; if substantive non-narration prose
918
942
  // remains, it is a genuine (undelivered) answer — capture it. If nothing
919
943
  // but the marker (or narration) remains, the block is a silence event.
920
944
  const { prose, hadMarker } = stripTrailingSilentMarker(raw)
945
+ // #3513 follow-up (MF1): `followedByToolUse` is filled by the PER-TURN
946
+ // two-pass below (a turn-continuing tool_use later ANYWHERE in the turn),
947
+ // not per-message — the cross-message shape needs the whole-turn view.
921
948
  if (hadMarker) {
922
949
  // H6: a block ending "…real answer…\nNO_REPLY" carries a genuine
923
950
  // answer — capture the prose and do NOT treat the block as a silence
@@ -928,15 +955,22 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
928
955
  // last-delivery cursor past that prose and mask it (multi-block H6).
929
956
  // A bare/narration marker is simply not a delivery — push nothing.
930
957
  if (prose.length > 0 && !isNarrationBlock(prose)) {
931
- blocks.push({ kind: 'text', chars: prose.length, text: prose })
958
+ blocks.push({ kind: 'text', chars: prose.length, text: prose, followedByToolUse: false })
932
959
  }
933
960
  } else if (prose.length > 0) {
934
- blocks.push({ kind: 'text', chars: prose.length, text: prose })
961
+ blocks.push({ kind: 'text', chars: prose.length, text: prose, followedByToolUse: false })
935
962
  }
936
963
  continue
937
964
  }
938
965
  if (c?.type !== 'tool_use') continue
939
- if (!REPLY_TOOLS.has(c.name)) continue
966
+ if (!REPLY_TOOLS.has(c.name)) {
967
+ // #3513 follow-up (MF1): record a turn-continuing (non-ephemeral,
968
+ // non-reply) tool_use as an ordered provenance marker for the two-pass.
969
+ if (!isEphemeralTool(c.name)) {
970
+ blocks.push({ kind: 'work-tool' })
971
+ }
972
+ continue
973
+ }
940
974
  const input = c.input ?? {}
941
975
  const text = String(input.text ?? '')
942
976
  if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
@@ -948,24 +982,74 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
948
982
  disableNotification: input.disable_notification === true,
949
983
  done: input.done === true,
950
984
  })) {
951
- blocks.push({ kind: 'deliver', reason: 'final-reply' })
985
+ // #3510: keep the delivered reply's text so the caller can log its
986
+ // sha256 next to the captured trailing prose's sha256 — a double-send
987
+ // becomes provable from logs alone, without transcript reconstruction.
988
+ blocks.push({ kind: 'deliver', reason: 'final-reply', text })
952
989
  }
953
990
  // interim ack — neither delivery nor undelivered prose.
954
991
  }
955
992
  }
956
993
 
994
+ // #3513 follow-up (MF1) — PER-TURN two-pass structural provenance (same as
995
+ // scanTurnForFinalReply): mark each text block `followedByToolUse` iff a
996
+ // turn-continuing tool_use appears LATER anywhere in the turn.
997
+ {
998
+ let sawWorkToolLater = false
999
+ for (let i = blocks.length - 1; i >= 0; i--) {
1000
+ const b = blocks[i]
1001
+ if (b.kind === 'work-tool') {
1002
+ sawWorkToolLater = true
1003
+ continue
1004
+ }
1005
+ if (b.kind === 'text') b.followedByToolUse = sawWorkToolLater
1006
+ }
1007
+ }
1008
+
957
1009
  let lastDeliverIdx = -1
1010
+ let lastFinalReplyBlock = null
958
1011
  for (let i = 0; i < blocks.length; i++) {
959
- if (blocks[i].kind === 'deliver') lastDeliverIdx = i
1012
+ if (blocks[i].kind !== 'deliver') continue
1013
+ lastDeliverIdx = i
1014
+ if (blocks[i].reason === 'final-reply') lastFinalReplyBlock = blocks[i]
960
1015
  }
961
- const trailing = blocks
1016
+ // #3510: single source of truth for BOTH the fix's branching and the
1017
+ // instrumentation. When true, a genuine FINAL-ANSWER reply/stream_reply
1018
+ // already went through the gateway THIS turn — so a gateway anchor provably
1019
+ // exists (`turn.finalAnswerDelivered` is set by the SAME `isFinalAnswerReply`
1020
+ // classifier at outbound-send-path.ts ~2293) and the single-writer election
1021
+ // in the Stop hook is reachable and safe. The caller
1022
+ // (silent-end-interrupt-stop.mjs) MUST NOT self-exit a capture in that case;
1023
+ // doing so creates a third, uncoordinated delivery path that re-sends a
1024
+ // trailing recap of the reply as a second message (the #3510 double-send).
1025
+ //
1026
+ // #3511 review finding 1: ONLY a `'final-reply'` deliver block counts. A
1027
+ // `'silent-marker'` block (reply-tool NO_REPLY / HEARTBEAT_OK) delivered
1028
+ // NOTHING to the user, and the gateway routes such turns to sentinel
1029
+ // suppression (`decideTurnEndGate` → 'silent_end') BEFORE the captured-prose
1030
+ // bridge ever runs — deferring to the election there would orphan the
1031
+ // trailing answer (a drop). A silent marker still advances `lastDeliverIdx`
1032
+ // (the trailing-prose CURSOR — H6 semantics: NO_REPLY intentionally silences
1033
+ // what came BEFORE it), but it must keep the shape on the durable
1034
+ // outbox/sweep path, never route it to the election.
1035
+ const replyAlreadyDeliveredThisTurn = lastFinalReplyBlock != null
1036
+ const deliveredReplySha256 =
1037
+ lastFinalReplyBlock != null && typeof lastFinalReplyBlock.text === 'string'
1038
+ ? createHash('sha256').update(lastFinalReplyBlock.text, 'utf8').digest('hex')
1039
+ : null
1040
+ // #3513 follow-up — the ONE shared coalescer selects the terminal delivery,
1041
+ // UNCONDITIONALLY excluding any block a turn-continuing tool followed (no
1042
+ // length/wording gate) and joining the terminal suffix run. The durable outbox
1043
+ // never captures intra-turn narration for the sweep to deliver.
1044
+ const trailingTextBlocks = blocks
962
1045
  .slice(lastDeliverIdx + 1)
963
1046
  .filter((b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0)
964
- .map((b) => b.text)
965
-
966
- const text = selectBridgePendingText(trailing)
1047
+ const backstopSelected = selectBackstopDelivery(
1048
+ trailingTextBlocks.map((b) => ({ text: b.text, followedByToolUse: b.followedByToolUse })),
1049
+ )
1050
+ const text = backstopSelected != null ? backstopSelected.text : null
967
1051
  if (text == null || text.trim().length < FINAL_ANSWER_MIN_CHARS) {
968
- return { capture: false, reason: trailing.length === 0 ? 'no-trailing-prose' : 'below-floor' }
1052
+ return { capture: false, reason: trailingTextBlocks.length === 0 ? 'no-trailing-prose' : 'below-floor' }
969
1053
  }
970
1054
 
971
1055
  const turnNonce = deriveTurnNonce({
@@ -995,5 +1079,7 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
995
1079
  anchorContent: anchor.anchorContent,
996
1080
  originChatId: origin.chatId,
997
1081
  originThreadId: origin.threadId,
1082
+ replyAlreadyDeliveredThisTurn,
1083
+ deliveredReplySha256,
998
1084
  }
999
1085
  }
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import { isReplyTool, isDraftOfReply } from './narrative-dedup.js'
43
+ import { isStructuralNarration } from './hooks/narration-classify.mjs'
43
44
 
44
45
  /**
45
46
  * Time-box for the parked-narrative early-paint (the kernel's home for the
@@ -70,6 +71,18 @@ export interface NarrativeFlushEffects {
70
71
  * Caller removes it from the feed. No-op-safe if already gone.
71
72
  */
72
73
  retractShown(text: string): void
74
+ /**
75
+ * #3513 (correction 4): durably mark a block that has surfaced ONLY on the
76
+ * ephemeral card as structural narration, so the out-of-process turn-end
77
+ * backstops (E3 captured-prose bridge, E4 outbox sweep) refuse to re-emit it
78
+ * as a real chat message. Called ONLY from the mid-turn SHOW paths (`stage`,
79
+ * `resolveOnTool`), where the block is provably followed by more content
80
+ * (another text block / a tool_use) and is therefore structural narration —
81
+ * NEVER from `onTimerFire` (the timer paints the LAST/terminal block, which
82
+ * could still be the genuine unsent answer) nor from `flushAtTurnEnd`. The
83
+ * effect is optional; callers without a durable ledger omit it.
84
+ */
85
+ markDurableNarration?(text: string): void
73
86
  }
74
87
 
75
88
  /** Arms / disarms the real early-paint timer. Injected so tests can fake it. */
@@ -113,6 +126,10 @@ export class NarrativeFlushController {
113
126
  this.scheduler.disarm()
114
127
  if (this.pending != null) {
115
128
  this.effects.show(this.pending)
129
+ // The just-shown block is provably followed by MORE narration text (this
130
+ // new block is its lookahead) → structural narration. Mark it durably so
131
+ // the turn-end backstops never re-deliver it (#3513, correction 4).
132
+ this.markShown(this.pending)
116
133
  }
117
134
  this.pending = text
118
135
  this.scheduler.arm(() => this.onTimerFire(), this.flushMs)
@@ -146,6 +163,10 @@ export class NarrativeFlushController {
146
163
  this.pending = null
147
164
  if (replyText != null && isDraftOfReply(pending, replyText)) return // draft → SUPPRESS
148
165
  this.effects.show(pending)
166
+ // The just-shown block is provably followed by a tool_use (this lookahead) →
167
+ // structural narration. Mark it durably so the turn-end backstops never
168
+ // re-deliver it as a real chat message (#3513, correction 4).
169
+ this.markShown(pending)
149
170
  }
150
171
 
151
172
  /**
@@ -170,6 +191,20 @@ export class NarrativeFlushController {
170
191
  this.timerShown = null
171
192
  }
172
193
 
194
+ /**
195
+ * Durably mark a card-only block as narration — but ONLY when it is
196
+ * structurally narration under the shared classifier (substance-gated:
197
+ * `isNarrationBlock || < SUBSTANTIVE_MIN_CHARS`). This is the belt-and-braces
198
+ * guard: a substantive real message that happens to precede a non-delivering
199
+ * tool (react / pin / typing) is NOT marked, so it can still be delivered by a
200
+ * backstop if it was the genuine unsent answer (#3513, corrections 3 & 4).
201
+ */
202
+ private markShown(text: string): void {
203
+ if (this.effects.markDurableNarration == null) return
204
+ if (!isStructuralNarration(text, true)) return
205
+ this.effects.markDurableNarration(text)
206
+ }
207
+
173
208
  /** Retract the timer-painted block iff this reply is its draft-then-send. */
174
209
  private maybeRetract(replyText: string): void {
175
210
  const shown = this.timerShown
@@ -104,6 +104,15 @@ export interface OutboxRecord {
104
104
  originChatId?: string | null
105
105
  /** Forum thread of the per-session origin chat (see `originChatId`). */
106
106
  originThreadId?: number | null
107
+ /**
108
+ * #3510 instrumentation: was a qualifying reply already delivered through
109
+ * the gateway in the turn that produced this record? Stamped by the Stop
110
+ * hook from the SAME boolean that gates its capture-vs-election branch.
111
+ * After #3510 this is always `false` for a written record (a `true` routes
112
+ * to the single-writer election instead of the outbox), so a `true` here —
113
+ * or in a sweep journal entry — is direct evidence of a regression.
114
+ */
115
+ replyAlreadyDeliveredThisTurn?: boolean
107
116
  }
108
117
 
109
118
  /** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
@@ -112,6 +121,16 @@ export interface DeliveredEntry {
112
121
  textSha256: string
113
122
  tgMessageId?: number
114
123
  ts: number
124
+ /**
125
+ * #3510 instrumentation: which machine delivered — the outbox sweep, the
126
+ * gateway reply-path machinery (reply/stream_reply send, silent-anchor edit,
127
+ * captured-prose bridge), or the turn-end / answer-ready quiescence flush
128
+ * (`'flush'`, added in the #3513 exactly-once-among-backstops follow-up).
129
+ * Absent on pre-#3510 journal lines.
130
+ */
131
+ deliverySource?: 'sweep' | 'reply-tool' | 'flush'
132
+ /** #3510 instrumentation: see `OutboxRecord.replyAlreadyDeliveredThisTurn`. */
133
+ replyAlreadyDeliveredThisTurn?: boolean
115
134
  }
116
135
 
117
136
  export function sha256Hex(s: string): string {
@@ -290,6 +309,59 @@ export function outboxAlreadyDelivered(nonce: string, stateDir?: string): boolea
290
309
  return readDeliveredNonces(stateDir).has(nonce)
291
310
  }
292
311
 
312
+ /**
313
+ * True iff a journal entry is a prior BACKSTOP delivery (turn-flush E1/E2,
314
+ * captured-prose bridge E3, outbox sweep E4) — NOT an explicit E0 `reply` /
315
+ * `stream_reply` send (#3513 follow-up, MF2).
316
+ *
317
+ * - `deliverySource === 'sweep'` → E4 backstop.
318
+ * - `deliverySource === 'flush'` → E1/E2 backstop.
319
+ * - `deliverySource === 'reply-tool'` AND `replyAlreadyDeliveredThisTurn ===
320
+ * false` → E3 captured-prose bridge (a backstop; the bridge only fires when
321
+ * NO genuine final answer was delivered this turn).
322
+ * - `deliverySource === 'reply-tool'` AND `replyAlreadyDeliveredThisTurn ===
323
+ * true` → an explicit E0 reply send — NOT a backstop. E0 replies are
324
+ * ungated by design (a turn may send N of them), so they must NOT satisfy a
325
+ * backstop's exactly-once guard (else the guard would eat a legitimate
326
+ * #3510 trailing recap or a multi-reply turn's later bridge).
327
+ *
328
+ * A pre-#3510 line with no `deliverySource` is treated conservatively as NOT a
329
+ * backstop (fail open — never suppress a backstop on ambiguous provenance).
330
+ */
331
+ export function isBackstopDeliveredEntry(e: DeliveredEntry): boolean {
332
+ if (e.deliverySource === 'sweep' || e.deliverySource === 'flush') return true
333
+ if (e.deliverySource === 'reply-tool' && e.replyAlreadyDeliveredThisTurn === false) return true
334
+ return false
335
+ }
336
+
337
+ /**
338
+ * True iff `nonce` already has a prior BACKSTOP delivery journaled (see
339
+ * `isBackstopDeliveredEntry`). This is the BACKSTOP-SCOPED exactly-once read the
340
+ * turn-flush (E1/E2) and captured-prose bridge (E3) consult before delivering —
341
+ * unlike `outboxAlreadyDelivered` (any journal line) it does NOT count an
342
+ * explicit E0 reply, so it can never suppress a legitimate second explicit
343
+ * message (#3513 follow-up, MF2).
344
+ */
345
+ export function backstopAlreadyDelivered(nonce: string, stateDir?: string): boolean {
346
+ if (nonce == null || nonce === '') return false
347
+ const path = join(resolveOutboxDir(stateDir), JOURNAL_FILE)
348
+ if (!existsSync(path)) return false
349
+ try {
350
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
351
+ if (!line) continue
352
+ try {
353
+ const e = JSON.parse(line) as DeliveredEntry
354
+ if (e.turnNonce === nonce && isBackstopDeliveredEntry(e)) return true
355
+ } catch {
356
+ /* skip corrupt line */
357
+ }
358
+ }
359
+ } catch {
360
+ /* best-effort */
361
+ }
362
+ return false
363
+ }
364
+
293
365
  /**
294
366
  * Max delivered-keys kept in the journal. On exceeding `JOURNAL_ROTATE_AT` lines
295
367
  * the journal is compacted down to the newest `JOURNAL_KEEP` entries — bounding
@@ -361,6 +433,7 @@ export type OutboxSweepAction =
361
433
  | 'skip-quiet'
362
434
  | 'skip-dedup'
363
435
  | 'skip-unroutable'
436
+ | 'skip-ephemeral-shown'
364
437
 
365
438
  export interface OutboxSweepDecision {
366
439
  action: OutboxSweepAction
@@ -391,6 +464,18 @@ export function decideOutboxSweep(input: {
391
464
  routePrefix?: string
392
465
  quietMs?: number
393
466
  maxAgeMs?: number
467
+ /**
468
+ * #3513 (correction 1): was this record's text marked ephemeral-shown on the
469
+ * progress card for its turnNonce (the durable shown-ledger)? A hit means the
470
+ * block was already assigned to the ephemeral surface — the single-surface
471
+ * invariant forbids ANY delivery machine, including this out-of-process late
472
+ * sweep, from ALSO delivering it to chat. The ledger only ever contains
473
+ * STRUCTURAL narration (correction 4: never a possibly-terminal answer), so
474
+ * this can never suppress a genuine answer. Checked here (in the backstop
475
+ * decision itself) rather than only at a send seam, because the sweep bypasses
476
+ * `normalizeOutboundBody` (it sends via `bot.api.sendMessage` directly).
477
+ */
478
+ shownLedgerHit?: boolean
394
479
  }): OutboxSweepDecision {
395
480
  const {
396
481
  record,
@@ -401,8 +486,10 @@ export function decideOutboxSweep(input: {
401
486
  routePrefix = '',
402
487
  quietMs = OUTBOX_QUIET_MS,
403
488
  maxAgeMs = OUTBOX_MAX_AGE_MS,
489
+ shownLedgerHit = false,
404
490
  } = input
405
491
  if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
492
+ if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
406
493
  const age = now - record.createdAt
407
494
  if (age < quietMs) return { action: 'skip-quiet' }
408
495
  if (textAlreadyDelivered) return { action: 'skip-dedup' }