switchroom 0.18.32 → 0.19.0

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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,2166 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Stream / render dispatcher — the `handleSessionEvent` switch, relocated
3
+ // VERBATIM from gateway.ts (switchroom#2996 P4-A, plan Amendments 1/5/9/10).
4
+ //
5
+ // WHAT THIS IS
6
+ // -----------
7
+ // `handleSessionEvent` is the giant per-session-event switch that drives every
8
+ // live surface (progress card, answer stream, activity/liveness lanes, turn
9
+ // lifecycle, silent-end recovery). It was ~1,970 inline lines in gateway.ts;
10
+ // this module holds the body so the file drains toward its ratchet ceiling.
11
+ //
12
+ // DI CONTRACT (plan Amendment 1/5/9 — read before editing)
13
+ // ---------------------------------------------------------
14
+ // - The body is BYTE-IDENTICAL to the pre-move gateway.ts inline body except
15
+ // for the enumerated turn/mutable-state spellings below and the deps
16
+ // destructure preamble. Do NOT "clean up" while moving — behavior-changing
17
+ // work ships as SEPARATE PRs (plan §1).
18
+ // - SHARED SINGLETONS (Amendment 1, BLOCKING): `outboundDedup` and
19
+ // `backstopDeliveryLedger` are injected — THE one live instance each. The
20
+ // answer-stream dedup sites in this module record into the SAME
21
+ // `OutboundDedupCache` that P2's `sendReply` checks; a re-`new` here would
22
+ // reinstate the cross-surface duplicate-reply class. This file NEVER
23
+ // re-constructs the dedup cache or the delivery ledger — the golden
24
+ // harness pins that (send-reply-golden + stream-render-golden).
25
+ // - TURN HANDLE (Amendment 9 / #1664): the module NEVER reads the
26
+ // `currentTurn` module global directly. Every live re-read is routed
27
+ // through the injected `getCurrentTurn()` accessor, preserving each call
28
+ // site's pin-vs-live choice VERBATIM (a `const turn = currentTurn` pin
29
+ // becomes `const turn = getCurrentTurn()`; a late `currentTurn === turn`
30
+ // liveness check stays live via `getCurrentTurn() === turn`). Writes to the
31
+ // turn go through the injected `setCurrentTurn` closure, exactly as before.
32
+ // - Two other reassigned module-globals are routed through get/set accessors
33
+ // so the extracted body cannot fork gateway state: `pendingPtyPartial`
34
+ // (`getPendingPtyPartial()`/`setPendingPtyPartial()`) and
35
+ // `lastContextExhaustionWarningAt`
36
+ // (`getLastContextExhaustionWarningAt()`/`setLastContextExhaustionWarningAt()`).
37
+ // One PTY-partial guard is spelled "capture-once-then-guard" (a call
38
+ // expression can't be narrowed like the pre-move variable was) — provably
39
+ // equivalent (no await/mutation between the two reads), flagged inline.
40
+ // - Pure / leaf-module helpers are IMPORTED (same modules gateway.ts imports
41
+ // them from — ES modules are singletons, so the ambient trackers
42
+ // `pendingProgress` / `signalTracker` / `silencePoke` are the SAME
43
+ // instances). Everything gateway-scoped (state singletons, config values,
44
+ // local closures, `bot`, `turnsDb`) is INJECTED via `StreamRenderDeps`.
45
+ //
46
+ // ORACLE (Amendment 10): gateway.ts cannot be driven in-place from a test
47
+ // runner (`handleSessionEvent` was unexported; `bot` is assigned only inside
48
+ // the isGatewayMain boot). Behavior preservation rests on (a) the verbatim
49
+ // relocation, (b) the deps type being `ReturnType<typeof gatewayStreamRenderDeps>`
50
+ // (exact-by-construction — the gateway wiring IS the type), and (c) the
51
+ // extracted-module golden harness (tests/stream-render-golden.test.ts) driving
52
+ // this function against a fake bot recorder + the REAL OutboundDedupCache,
53
+ // including the cross-surface stream-then-reply dedup proof spanning P2+P4.
54
+ // ─────────────────────────────────────────────────────────────────────────
55
+
56
+ import { createAnswerStream } from '../answer-stream.js'
57
+ import { LivenessTracker, isContextExhaustionText } from '../context-exhaustion.js'
58
+ import { normalizeParagraphBreaks, normalizePunctuation, repairEscapedWhitespace, stripExcessBold } from '../format.js'
59
+ import { hasOutboundDeliveredSince, recordOutbound } from '../history.js'
60
+ import { isReplyTool } from '../narrative-dedup.js'
61
+ import { NarrativeFlushController } from '../narrative-flush.js'
62
+ import { recordTurnEnd, recordTurnStart } from '../registry/turns-schema.js'
63
+ import { retryWithThreadFallback } from '../retry-api-call.js'
64
+ import { richMessage } from '../rich-send.js'
65
+ import { emitRuntimeMetric } from '../runtime-metrics.js'
66
+ import { CAPTURED_PROSE_MIN_CHARS, clearSilentEndState, decideCapturedProseDelivery, recordUndeliveredTurnEnd, silentEndFallbackText, writeSilentEndState } from '../silent-end.js'
67
+ import { logStreamingEvent } from '../streaming-metrics.js'
68
+ import { scrubVoice } from '../text-voice-scrub.js'
69
+ import { appendActivityLabel } from '../tool-activity-summary.js'
70
+ import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
71
+ import { decideTurnFlush } from '../turn-flush-safety.js'
72
+ import { decideTerminalReason, deriveTurnRole } from '../turn-liveness-floor.js'
73
+ import { chatKey, chatKeyWithSuffix } from './chat-key.js'
74
+ import { deriveTurnId } from './derive-turn-id.js'
75
+ import { EMISSION_AUTHORITY_ENABLED, EmissionAuthority } from './emission-authority.js'
76
+ import { decideFeedReopen } from './feed-reopen-gate.js'
77
+ import { ackDelivery } from './inbound-delivery-confirm.js'
78
+ import { shadowEmit } from './inbound-delivery-machine-shadow.js'
79
+ import { parseSourceMessageId } from './source-message-id.js'
80
+ import { formatTurnLifecycle } from './status-surface-log.js'
81
+ import { removeTurnActiveMarker, touchTurnActiveMarker, writeTurnActiveMarker } from './turn-active-marker.js'
82
+ import { withTurnEndGateBackstop } from './turn-end-gate-backstop.js'
83
+ import { decideTurnEndGate } from './turn-end-gate.js'
84
+ import { finalizeBackstopSendGated } from './turn-record-status.js'
85
+ import type { SilentEndDeps } from '../silent-end.js'
86
+ import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
87
+ import type { SessionEvent } from '../session-tail.js'
88
+ import type { CurrentTurn, StreamRenderDeps } from './gateway.js'
89
+ import * as pendingProgress from '../pending-work-progress.js'
90
+ import * as signalTracker from '../turn-signal-tracker.js'
91
+ import * as silencePoke from '../silence-poke.js'
92
+
93
+ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): void {
94
+ const {
95
+ ANSWER_LANE,
96
+ CAPTURED_PROSE_DELIVERY_ENABLED,
97
+ CONTEXT_EXHAUSTION_COOLDOWN_MS,
98
+ DELIVERY_CONFIRM_ENABLED,
99
+ FEED_REOPEN_AFTER_ACK_ENABLED,
100
+ HANDBACK_PRETURN_ENABLED,
101
+ HISTORY_ENABLED,
102
+ LIVENESS_TERMINAL_HONESTY,
103
+ OBLIGATION_LEDGER_ENABLED,
104
+ ORPHANED_REPLY_STREAM_WINDOW_MS,
105
+ SILENCE_LIVENESS_PRODUCTION,
106
+ STATE_DIR,
107
+ TURN_FLUSH_SAFETY_ENABLED,
108
+ TURN_PREVIEW_MAX,
109
+ activeDraftStreams,
110
+ activeStatusReactions,
111
+ activeTurnStartedAt,
112
+ backstopDeliveryLedger,
113
+ bot,
114
+ cardDrainGate,
115
+ clearActivitySummary,
116
+ clearAnswerReadyFlushTimeout,
117
+ closeActivityLane,
118
+ closeProgressLane,
119
+ completeProgressCardTurn,
120
+ composeTurnActivity,
121
+ confirmMemoryLegibility,
122
+ deliverAnswer,
123
+ deliverCapturedProse,
124
+ deliveryQueue,
125
+ drainActivitySummary,
126
+ emissionAuthorityFor,
127
+ emitTurnRecord,
128
+ endCurrentTurnAtomic,
129
+ extractUserPromptPreview,
130
+ finalizeStatusReaction,
131
+ flushPendingNarrativeAtTurnEnd,
132
+ flushedTurnSupersede,
133
+ getCurrentTurn,
134
+ getLastContextExhaustionWarningAt,
135
+ getPendingPtyPartial,
136
+ getPinnedProgressCardMessageId,
137
+ handbackPreturnSignal,
138
+ handlePtyPartial,
139
+ idleTracker,
140
+ isDmChatId,
141
+ isLegitimatelyWorking,
142
+ lastPtyPreviewByChat,
143
+ makeNarrativeGate,
144
+ obligationLedger,
145
+ outboundDedup,
146
+ pendingCrossTurnGate,
147
+ preambleSuppressor,
148
+ progressDriver,
149
+ promoteQueuedStatus,
150
+ purgeReactionTracking,
151
+ reactionTransitionCounts,
152
+ redactOutboundText,
153
+ rememberRecentTurn,
154
+ resetAnswerReadyFlushTimeout,
155
+ resetOrphanedReplyTimeout,
156
+ resolvePendingNarrativeOnTool,
157
+ robustApiCall,
158
+ scheduleEarlyLivenessOpen,
159
+ sessionModelSource,
160
+ setCurrentTurn,
161
+ setLastContextExhaustionWarningAt,
162
+ setPendingPtyPartial,
163
+ stagePendingNarrative,
164
+ startTurnTypingLoop,
165
+ statusKey,
166
+ streamKey,
167
+ suppressPtyPreview,
168
+ surfaceMemoryLegibility,
169
+ swallowingApiCall,
170
+ toolFlightTracker,
171
+ turnLiveForItsTopic,
172
+ turnsDb,
173
+ typingWrapper,
174
+ unpinProgressCardForChat,
175
+ } = deps
176
+
177
+ // Per-turn liveness stamp (orphaned-reply thinking-pause fix). Stamp
178
+ // lastStreamEventAt AND reset the rearm counter on ANY genuine stream event,
179
+ // under ONE shared predicate: a live turn is present and this is not the
180
+ // synthetic durationMs===-1 turn_end (the fire callback's own re-dispatch).
181
+ // The counter reset MUST live here at the dispatcher — NOT inside
182
+ // resetOrphanedReplyTimeout() (which is called from the fire callback one
183
+ // line after the counter increments) and NOT tied to a single case (e.g.
184
+ // tool_result does not call resetOrphanedReplyTimeout). onStreamEvent applies
185
+ // the `!(turn_end && -1)` half of the predicate internally.
186
+ {
187
+ const liveTurn = getCurrentTurn()
188
+ if (liveTurn != null) {
189
+ const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
190
+ liveTurn.liveness.onStreamEvent(ev.kind, durationMs, Date.now())
191
+ }
192
+ }
193
+ // Idle-clear clocks (#3084 follow-up). EVERY genuine session event is
194
+ // activity — an agent that is thinking, calling a tool, streaming text or
195
+ // driving a sub-agent is NOT idle, whether or not the gateway currently has a
196
+ // turn open. Stamping only at turn START (the old behaviour) is what let a
197
+ // 3-hour working stretch be scored as zero activity and `/clear`ed the moment
198
+ // the window elapsed. A turn ending stamps the turn-end clock too, so a turn
199
+ // that outran the window is not wiped the instant `turnInFlight` goes false.
200
+ // This runs for the whole event stream, including every `sub_agent_*` kind —
201
+ // background workers keep the timer warm exactly as long as they are working.
202
+ {
203
+ const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
204
+ idleTracker.noteEvent(ev.kind, Date.now(), durationMs)
205
+ }
206
+ switch (ev.kind) {
207
+ case 'enqueue': {
208
+ // Drain any orphaned typing-wrap entries left over from a crashed
209
+ // prior turn before resetting focus.
210
+ typingWrapper.drainAll()
211
+ if (ev.chatId) {
212
+ // #1445 cross-turn pending-async ambient — backstop for the
213
+ // `handleInbound` path's `clearPending('inbound')`. The
214
+ // inbound path covers real user messages, but synthesised
215
+ // wakes (subagent-handback channel turn, cron fires, vault
216
+ // grant resumes, restart markers) push directly to
217
+ // `pendingInboundBuffer` and bypass `handleInbound`. The
218
+ // `enqueue` session-event fires for EVERY fresh turn atom
219
+ // regardless of source — clearing here drops any prior turn's
220
+ // ambient before the new turn's `noteOutbound` lands. The
221
+ // call is idempotent so it's safe to fire in addition to the
222
+ // inbound-path clear (for the real-inbound case, this is a
223
+ // no-op because state was already deleted by then).
224
+ const enqThreadId = ev.threadId != null ? Number(ev.threadId) : undefined
225
+ pendingProgress.clearPending(
226
+ statusKey(ev.chatId, enqThreadId),
227
+ 'handback',
228
+ )
229
+ }
230
+ if (ev.chatId) {
231
+ // Issue #195: if a previous turn left an answer-lane stream open
232
+ // (rapid steer/queue), force it to a new generation so its in-flight
233
+ // edits don't mutate the new turn's message. Materialize is best-effort
234
+ // — we don't await here because turn_end on the prior turn should
235
+ // have already done it; this is a defensive supersession guard.
236
+ const prior = getCurrentTurn()
237
+ if (prior?.answerStream != null) {
238
+ prior.answerStream.forceNewMessage()
239
+ prior.answerStream.stop()
240
+ prior.answerStream = null
241
+ }
242
+ // Bounded-leak hardening (A5): clear the prior turn's orphaned-reply
243
+ // fuse before it is superseded. The fire callback re-reads currentTurn
244
+ // and no-ops on a stale turn, but proactively clearing the timer avoids
245
+ // a bounded pile-up of dangling timers across rapid steer/queue turns.
246
+ if (prior?.orphanedReplyTimeoutId != null) {
247
+ clearTimeout(prior.orphanedReplyTimeoutId)
248
+ prior.orphanedReplyTimeoutId = null
249
+ }
250
+ // Same bounded-leak class (early-paint 250ms setTimeout): the prior
251
+ // turn may have armed its narrative gate's early-paint timer before
252
+ // being superseded. Left untorn, ~250ms later it fires showNarrativeStep
253
+ // on the dead turn and can paint a stale narration card below the new
254
+ // turn's surface. Teardown is guard-safe and idempotent (no-op when never
255
+ // armed / already fired / already disarmed by the prior turn's turn_end).
256
+ prior?.narrativeGate?.teardown()
257
+ // #1067: swap the entire turn atom in one assignment. Every
258
+ // handler captures `const turn = currentTurn` at entry, so a
259
+ // captured-then-awaited read can't reattribute to the new turn.
260
+ const startedAt = Date.now()
261
+ // Component 3 — stable per-turn identity. For a real inbound this
262
+ // matches the `origin_turn_id` stamped into the inbound meta at
263
+ // build time (same chat/thread/messageId). Synthetic turns (cron /
264
+ // handback — no messageId) get a unique startedAt-based fallback id
265
+ // that no reply will ever echo, so they correctly fall through to
266
+ // the live-turn routing in resolveAnswerThreadId.
267
+ const enqThreadIdNum = ev.threadId != null ? Number(ev.threadId) : undefined
268
+ const turnId =
269
+ deriveTurnId(ev.chatId, enqThreadIdNum ?? null, ev.messageId)
270
+ ?? `${chatKey(ev.chatId, enqThreadIdNum ?? null)}#synthetic-${startedAt}`
271
+ // PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Consume any
272
+ // pending cross-turn gate `obligationSweep` armed for THIS exact turn
273
+ // when it pushed an `obligation_represent` inbound. The gate is keyed on
274
+ // the obligation's `originTurnId`, and the represent inbound reuses the
275
+ // original chat/thread/messageId, so this turn's `turnId` (derived just
276
+ // above) equals that key iff this turn IS the represent surface armed for.
277
+ // An unrelated foreground turn on the same chat/thread derives a
278
+ // different `turnId` → finds no entry → no gate → its card opens normally
279
+ // (correct). Consume-once: delete on read so the matched gate can't leak
280
+ // forward, and a never-matched stale gate can never suppress another turn.
281
+ const xTurnGateKey = turnId
282
+ const consumedCrossTurnGate = pendingCrossTurnGate.get(xTurnGateKey)
283
+ if (consumedCrossTurnGate != null) pendingCrossTurnGate.delete(xTurnGateKey)
284
+ const next: CurrentTurn = {
285
+ sessionChatId: ev.chatId,
286
+ sessionThreadId: enqThreadIdNum,
287
+ // Accept the inbound id as a reply anchor only when it is a plausible
288
+ // Telegram message id. Synthetic boot-resume inbounds fabricate a
289
+ // 13-digit Date.now() message_id (for ack-tracking); if that reached
290
+ // the activity-feed reply anchor it 400'd every feed send and darkened
291
+ // the live feed for the whole resume turn (2026-06-05). The ack-queue
292
+ // still keys on ev.messageId independently — only the anchor is gated.
293
+ sourceMessageId: parseSourceMessageId(ev.messageId),
294
+ startedAt,
295
+ gatewayReceiveAt: startedAt,
296
+ // #2527 — stamp the loop role once, from the enqueue envelope.
297
+ role: deriveTurnRole(ev.rawContent),
298
+ // PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Only a
299
+ // synthetic represent/owed-reply turn carries this; a foreground turn
300
+ // leaves it undefined and the cross-turn card-OPEN gate is inert.
301
+ ...(consumedCrossTurnGate != null ? { crossTurnGate: consumedCrossTurnGate } : {}),
302
+ replyCalled: false,
303
+ finalAnswerDelivered: false,
304
+ finalAnswerSubstantive: false,
305
+ // Sticky latch — reset ONLY here (turn start), never by reopen.
306
+ finalAnswerEverDelivered: false,
307
+ // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
308
+ // latch, reset at turn start alongside the other answer flags.
309
+ answerDelivered: false,
310
+ // 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
311
+ endedAt: null,
312
+ firstPingAt: null,
313
+ // Notification ownership (R8 / PR-2): no slot claimed yet, so the
314
+ // "claimer was substantive" flag starts false. Set atomically with
315
+ // firstPingAt at the over-ping decision site.
316
+ firstPingWasSubstantive: false,
317
+ silentAnchorMessageId: null,
318
+ silentAnchorText: '',
319
+ capturedText: [],
320
+ capturedBlockMeta: [],
321
+ orphanedReplyTimeoutId: null,
322
+ answerReadyFlushTimeoutId: null,
323
+ // Fresh liveness tracker: lastStreamEventAt seeded to the turn start
324
+ // so a turn that never streams still trips the fuse after windowMs.
325
+ liveness: new LivenessTracker(startedAt),
326
+ turnId,
327
+ registryKey: null,
328
+ noReplyDrainTimer: null,
329
+ lastAssistantMsgId: null,
330
+ lastAssistantDone: false,
331
+ toolCallCount: 0,
332
+ labeledToolCount: 0,
333
+ totalTokens: 0,
334
+ seenUsageMessageIds: new Set<string>(),
335
+ activityMessageId: null,
336
+ activityInFlight: null,
337
+ activityPendingRender: null,
338
+ activityLastSentRender: null,
339
+ activityEverOpened: false,
340
+ activityDrainFailures: 0,
341
+ mirrorLines: [],
342
+ // Assigned immediately after this literal via makeNarrativeGate(next) —
343
+ // the controller's SHOW/RETRACT effects close over the turn object, which
344
+ // can't reference itself inside its own initializer.
345
+ narrativeGate: undefined as unknown as NarrativeFlushController,
346
+ lastReplyText: '',
347
+ foregroundSubAgents: new Map(),
348
+ answerStream: null,
349
+ isDm: isDmChatId(ev.chatId),
350
+ // PR-4a — construct ONE emission-authority façade per turn, passing
351
+ // the chat/thread key in EXPLICITLY (the PR-4e seam; today equal to
352
+ // the singleton-sourced key). Per-turn: born with this turn literal,
353
+ // discarded with it — never persists across turns.
354
+ emissionAuthority: new EmissionAuthority(
355
+ statusKey(ev.chatId, enqThreadIdNum),
356
+ ),
357
+ }
358
+ // Wire the per-turn narrative gate now that `next` exists (its SHOW/RETRACT
359
+ // effects close over the turn). Born with this turn, torn down at turn end.
360
+ next.narrativeGate = makeNarrativeGate(next)
361
+ // Dead-air pre-turn signal — ADOPT by inbound identity (design lever 2).
362
+ // If a subagent-handback pre-turn signal was emitted for THIS exact turn
363
+ // (matched on `turnId`, not the bare topic key, so a racing user inbound
364
+ // can't mis-adopt), consume it. A card-bearing adoption seeds
365
+ // `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
366
+ // EDITS the existing card instead of opening a second one, and so the
367
+ // turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
368
+ // The handback turn also gets the turn-long typing loop it never had —
369
+ // whether or not a card was painted (the debounce may not have fired) —
370
+ // stopped by the canonical turn-end (`purgeReactionTracking →
371
+ // stopTurnTypingLoop`).
372
+ if (HANDBACK_PRETURN_ENABLED) {
373
+ const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
374
+ if (handbackAdoption != null) {
375
+ if (handbackAdoption.activityMessageId != null) {
376
+ next.activityMessageId = handbackAdoption.activityMessageId
377
+ next.activityEverOpened = true
378
+ }
379
+ startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
380
+ }
381
+ }
382
+ // PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
383
+ // the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
384
+ // per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
385
+ // the SAME statusKey the ctor's façade was constructed with just above.
386
+ setCurrentTurn(next, statusKey(ev.chatId, enqThreadIdNum))
387
+ // (turn start already stamped the idle clock at the top of
388
+ // handleSessionEvent, along with every other session event — see the
389
+ // idle-clear block there.)
390
+ // Early-open the "Working…" liveness card at turn start so narration /
391
+ // thinking emitted BEFORE the first tool surfaces within ~a second
392
+ // instead of after the old 12 s threshold (the dead-air gap). Fires the
393
+ // SAME `openLivenessFeedIfDue` the 6 s heartbeat uses — a no-op if a
394
+ // tool/narrative already opened the card, and gated by `mayOpenActivityCard`
395
+ // (lever 1/4) so it never opens below a delivered answer. Scoped to real
396
+ // turns by construction: only the `enqueue` lifecycle event reaches here,
397
+ // and anonymous one-shot hook clients (recall.py) never emit it.
398
+ scheduleEarlyLivenessOpen(next)
399
+ // Status-surface observability: one line at every turn SET so a later
400
+ // dark card is traceable to which turn/topic key it belonged to.
401
+ process.stderr.write(
402
+ `telegram gateway: ${formatTurnLifecycle('set', 'enqueue', next, startedAt)}\n`,
403
+ )
404
+ // Component 3 — retain in the bounded recently-ended registry so a
405
+ // LATE reply (landing after currentTurn flips to a successor) can
406
+ // still resolve THIS turn's origin thread by its turnId.
407
+ rememberRecentTurn(next)
408
+ // Component 5 (Hook B) — this turn's topic had a queued placeholder
409
+ // from Hook A; promote it to "On it — replying now." (deleted later
410
+ // when the answer lands). No-op when there's no placeholder / DM.
411
+ promoteQueuedStatus(ev.chatId, enqThreadIdNum)
412
+ // Ack inbound delivery (the marko drop-wedge): claude actually started
413
+ // this turn, so its delivered inbound landed — stop tracking it for
414
+ // re-delivery. `enqueue` carries the same chat/thread the inbound was
415
+ // keyed on, so the key matches.
416
+ if (DELIVERY_CONFIRM_ENABLED) {
417
+ // Match on the source message id: `enqueue` fires for EVERY turn
418
+ // start (cron / subagent-handback / vault-resume / restart-marker
419
+ // too — see comment below), so a key-only ack would let a synthetic
420
+ // turn clear a real user message still waiting under the same key.
421
+ ackDelivery(
422
+ deliveryQueue,
423
+ chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
424
+ ev.messageId,
425
+ // #2786 — pass the raw enqueue envelope so the ack survives the
426
+ // composer merging/reordering inbound wrappers (the single
427
+ // re-parsed `ev.messageId` can then belong to a sibling, not our
428
+ // tracked message). The tolerant match scans all ids in this
429
+ // content; a synthetic-source turn still lacks the user id, so the
430
+ // cross-source false-ack guard holds.
431
+ ev.rawContent,
432
+ )
433
+ }
434
+ // PR3b-cutover: feed the authoritative turn-start to the delivery
435
+ // machine. `enqueue` fires for EVERY turn atom regardless of
436
+ // source — inbound, cron, subagent-handback, vault-resume,
437
+ // restart-marker — so it is the single chokepoint that captures
438
+ // the non-inbound turns the machine's own `inbound` event never
439
+ // sees (those bypass handleInbound). Without it the machine reads
440
+ // idle during a cron/handback turn and the gate would mis-deliver
441
+ // a concurrent inbound mid-turn (the #1556 composer wedge).
442
+ // Idempotent when already in_turn (turnStart only sets perKey).
443
+ shadowEmit({
444
+ kind: 'turnStart',
445
+ key: statusKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : undefined) as _ChatKey,
446
+ at: startedAt,
447
+ })
448
+ // #549 fix — fresh turn, reset preamble-suppression state.
449
+ preambleSuppressor.reset()
450
+ // Reset the silent-end retry budget for this chat. The stored
451
+ // turnKey is `chat:thread` shape (no per-instance suffix), so
452
+ // without an explicit per-turn clear, `writeSilentEndState`
453
+ // (silent-end.ts:114) inherits `retryCount` across turns
454
+ // whenever a prior turn for the same chat hit retryCount=1.
455
+ // The Stop hook then sees `retryCount >= MAX_RETRIES=1` on the
456
+ // very first silent-end of every subsequent turn and bails
457
+ // without re-prompting. finn hit this on 2026-05-25 with a
458
+ // stuck retryCount=1 file. A new turn invalidates any prior
459
+ // turn's retry budget by definition; clear it eagerly here.
460
+ // ev.threadId is `string | null` (Telegram's wire shape);
461
+ // statusKey wants `number | null` — same conversion as the
462
+ // registry-key branch a few lines down.
463
+ clearSilentEndState(statusKey(
464
+ ev.chatId,
465
+ ev.threadId != null ? Number(ev.threadId) : null,
466
+ ))
467
+ // Stage 3b: stamp turn-start in the registry. turn_key is
468
+ // chat:thread:startTs — unique per turn, distinct from the
469
+ // progress-card-driver's per-chat sequence number (these are two
470
+ // independent identifier schemes and don't need to align).
471
+ if (turnsDb != null) {
472
+ // ev.threadId is `string | null` (Telegram emits as string); convert
473
+ // to number for chatKeyWithSuffix. Number(null) = 0 which canonicalizes
474
+ // to '_' — same as the explicit `null` branch below.
475
+ const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null
476
+ const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt))
477
+ next.registryKey = turnKey
478
+ // Phase 1 of #332: capture first ~200 chars of the user's message.
479
+ const userPromptPreview = extractUserPromptPreview(ev.rawContent)
480
+ // Closes #472 finding #11. Pre-fix: this write was scheduled
481
+ // via setImmediate to "avoid stalling the turn handler" — but
482
+ // SQLite local writes are sub-millisecond, and the deferral
483
+ // opened a SIGTERM race window: a kill landing in the gap
484
+ // between scheduling and firing left a turn with no start
485
+ // row, invisible to the resume protocol (the user sent a
486
+ // message, the gateway lost it, no SWITCHROOM_PENDING_TURN
487
+ // env on next boot). Sibling writeTurnActiveMarker has always
488
+ // been synchronous here; this matches it.
489
+ try {
490
+ recordTurnStart(turnsDb, {
491
+ turnKey,
492
+ chatId: String(ev.chatId),
493
+ threadId: ev.threadId != null ? String(ev.threadId) : null,
494
+ lastUserMsgId: ev.messageId != null ? String(ev.messageId) : null,
495
+ userPromptPreview,
496
+ })
497
+ } catch (err) {
498
+ process.stderr.write(`telegram gateway: recordTurnStart failed turnKey=${turnKey}: ${(err as Error).message}\n`)
499
+ }
500
+ // #412: turn-active marker for the bridge-watchdog. File exists
501
+ // for the duration of the in-flight turn; mtime advances on
502
+ // every tool_use; deleted on turn_complete. The watchdog
503
+ // distinguishes wedged-mid-turn from healthy-idle by checking
504
+ // for this file's presence + mtime staleness.
505
+ writeTurnActiveMarker(STATE_DIR, {
506
+ turnKey,
507
+ chatId: String(ev.chatId),
508
+ threadId: ev.threadId != null ? String(ev.threadId) : null,
509
+ startedAt,
510
+ })
511
+ }
512
+ // (accessor-narrowing spelling: the pre-move body guarded the
513
+ // `pendingPtyPartial` variable then re-read it into `pending`; the
514
+ // injected accessor is a call expression TS can't narrow across, so
515
+ // capture once then guard the local — equivalent, no await between.)
516
+ const pending = getPendingPtyPartial()
517
+ if (pending != null) {
518
+ setPendingPtyPartial(null)
519
+ handlePtyPartial(pending)
520
+ }
521
+ }
522
+ return
523
+ }
524
+ case 'dequeue': return
525
+ case 'model': {
526
+ // Live model capture for the main turn. The session-tail projection
527
+ // already filtered sentinels (`<synthetic>` compaction lines), so any
528
+ // value reaching here is a real resolved model id. Record it on the turn
529
+ // (update-on-change) so the activity/liveness card header and /status
530
+ // render the model actually serving this turn's API calls — transcript-
531
+ // sourced, never config. Also note it on the freshness-aware session-model
532
+ // source so a /status query between turns still reflects the last model
533
+ // (and a fresh assistant line reclaims the source from a /model override).
534
+ const turn = getCurrentTurn()
535
+ if (turn != null) {
536
+ turn.currentModel = ev.model
537
+ }
538
+ sessionModelSource.noteTranscriptModel(ev.model)
539
+ return
540
+ }
541
+ case 'usage': {
542
+ // Fold the parent agent's OWN per-message token usage into the turn's
543
+ // running total, deduped by message.id (one logical assistant message can
544
+ // land as several JSONL lines sharing one id + usage block). Rendered on
545
+ // the 🤖 turn-activity card's metrics line. Sub-agent tokens are NOT
546
+ // folded here — they surface on their own worker-feed rows; summing them
547
+ // would double-count. A null messageId is un-dedupable → always counted.
548
+ const turn = getCurrentTurn()
549
+ if (turn == null) return
550
+ if (ev.messageId != null) {
551
+ if (turn.seenUsageMessageIds.has(ev.messageId)) return
552
+ turn.seenUsageMessageIds.add(ev.messageId)
553
+ }
554
+ turn.totalTokens += ev.totalTokens
555
+ return
556
+ }
557
+ case 'thinking': {
558
+ // #1067: snapshot the turn atom at handler entry. Even though this
559
+ // handler is sync, the principle is uniform across all event arms
560
+ // — read `turn` once, don't re-read currentTurn after any await.
561
+ const turn = getCurrentTurn()
562
+ if (turn == null) return
563
+ // S2 fix (fable red-team 2026-07-17) — a thinking block means the model
564
+ // is still working, not quiescent. Without this, "prose → >1s thinking
565
+ // pause → trailing NO_REPLY" let the answer-ready quiescence timer fire
566
+ // mid-pause and deliver a turn the model was about to mark silent
567
+ // (#2053 in miniature). Re-arm (not just clear): `reset()` re-verifies
568
+ // via `decideTurnFlush` and pushes the debounce out by a fresh window,
569
+ // so the trailing sentinel gets to land before any fire; if no further
570
+ // text arrives, the flush still fires one window after the LAST
571
+ // thinking event — the fast path is deferred, never lost.
572
+ resetAnswerReadyFlushTimeout()
573
+ const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
574
+ if (ctrl) ctrl.setThinking()
575
+ return
576
+ }
577
+ case 'tool_use': {
578
+ const turn = getCurrentTurn()
579
+ if (turn == null) return
580
+ // PR A — the model resumed work (surface or otherwise). Cancel any pending
581
+ // answer-ready quiescence flush: the turn is no longer quiescent. (Fire-time
582
+ // re-verification would also catch this, but disarming here avoids a wasted
583
+ // wakeup and matches the design's disarm-on-tool requirement.)
584
+ clearAnswerReadyFlushTimeout(turn)
585
+ // Narrative-dedup gate step 2 (JSONL-text-narrative primitive): a
586
+ // narrative block was pending; this tool_use is the lookahead event
587
+ // that decides it. reply/stream_reply with near-identical text ⇒
588
+ // draft-then-send ⇒ SUPPRESS (the reply prints the canonical answer);
589
+ // anything else ⇒ SHOW as a transient liveness step. Runs BEFORE the
590
+ // normal tool handling so a working preamble surfaces just ahead of
591
+ // its tool step.
592
+ resolvePendingNarrativeOnTool(turn, ev.toolName, ev.input)
593
+ // Phase 1 of #332: count every tool_use in the current turn.
594
+ turn.toolCallCount++
595
+ // #412: bump turn-active marker mtime so the watchdog sees this
596
+ // turn is making forward progress. Stop-hook deadlocks (the
597
+ // failure mode #116 originally tracked) emit no more tool_use
598
+ // events, so the marker mtime stops advancing → watchdog acts.
599
+ touchTurnActiveMarker(STATE_DIR)
600
+ // #549 fix: a tool_use immediately following text events makes
601
+ // those texts "preamble" — the progress card already captured
602
+ // them as a narrative for this tool. Drop the pending answer-
603
+ // stream buffer so the same text doesn't also land in chat as a
604
+ // standalone message. Telegram-surface tools (reply / stream_reply)
605
+ // are EXCEPTIONS: their text IS the answer, so we flush instead
606
+ // of dropping. The answer-stream's own dedup handles overlap
607
+ // with the reply tool's payload.
608
+ preambleSuppressor.onTool({ isReplyTool: isTelegramSurfaceTool(ev.toolName) })
609
+ // #2849 Phase 4 — sparse chat-legible memory. Surface ONE terse line in
610
+ // the originating chat/topic when this tool call materially changes what
611
+ // the agent remembers (create_directive / invalidate / demote). Fires
612
+ // BEFORE the `if (!ctrl) return` status-reaction gate below so it works
613
+ // on turns with no active status-reaction controller. Deterministic
614
+ // tool-call observation — no model call, no polling; ordinary recall and
615
+ // routine consolidation never reach here (they aren't material tools).
616
+ surfaceMemoryLegibility(turn, ev.toolName, ev.toolUseId, ev.input)
617
+ const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
618
+ const name = ev.toolName
619
+ // Phase tracking removed in #553 PR 5 — phases only fed the
620
+ // placeholder-heartbeat label, which has been retired.
621
+ if (isTelegramReplyTool(name)) {
622
+ turn.replyCalled = true
623
+ // NIT 2 (reply-proxy precision): capture the ACTUAL delivered reply
624
+ // text so flushPendingNarrativeAtTurnEnd compares a trailing
625
+ // narrative block against the real answer surface, not
626
+ // capturedText.join('') (which mis-suppresses when the model emits
627
+ // the same short string twice in a turn). Reply tools ('reply',
628
+ // 'stream_reply') carry the answer in input.text; only those count.
629
+ // Prefix-aware: prod jsonl carries the mcp__…__stream_reply form.
630
+ if (isReplyTool(name) && typeof ev.input?.text === 'string') {
631
+ turn.lastReplyText = ev.input.text as string
632
+ }
633
+ if (turn.orphanedReplyTimeoutId != null) {
634
+ clearTimeout(turn.orphanedReplyTimeoutId)
635
+ turn.orphanedReplyTimeoutId = null
636
+ }
637
+ // Delete the activity feed only when the FINAL answer has landed —
638
+ // NOT on an ack-first interim reply ("On it"). Gating on the first
639
+ // reply deleted the feed on the ack, so the post-ack work
640
+ // (sub-agents/tools) rendered into nothing — the "agent went silent
641
+ // after On it" gap. `finalAnswerDelivered` is set by executeReply
642
+ // (isFinalAnswerReply) before this tool_use event fires; turn_end
643
+ // (below) clears unconditionally as the idempotent no-reply / race net.
644
+ if (turn.finalAnswerDelivered) {
645
+ clearActivitySummary(turn)
646
+ }
647
+ }
648
+ // The live activity feed is driven by the real-time `tool_label`
649
+ // event (PreToolUse sidecar) rather than this flush-gated tool_use
650
+ // path — see `case 'tool_label'`. The sidecar fires at tool-call
651
+ // time regardless of when claude flushes the transcript, which is
652
+ // the determinism fix: on a fast/clustered-tool turn the JSONL
653
+ // tool_use rows aren't on disk until ~turn-end, so sourcing the
654
+ // feed here would lose them.
655
+ if (!ctrl) return
656
+ if (isTelegramSurfaceTool(name)) return
657
+ ctrl.setTool(name)
658
+ if (ev.toolUseId) {
659
+ typingWrapper.onToolUse(ev.toolUseId, turn.sessionChatId, name, turn.sessionThreadId ?? null)
660
+ }
661
+ return
662
+ }
663
+ case 'tool_label': {
664
+ // Real-time activity-feed driver. The PreToolUse hook wrote this
665
+ // label synchronously at tool-call time; the sidecar surfaced it
666
+ // here (~250ms) independent of the transcript flush. Accumulate it
667
+ // into the live feed and edit the activity message in place — this
668
+ // is what makes the feed deterministic on fast/clustered-tool turns
669
+ // where the JSONL tool_use rows arrive too late.
670
+ const turn = getCurrentTurn()
671
+ if (turn == null) return
672
+ // PR A — a tool_label (real-time, ~250 ms) means the model is producing
673
+ // work right now: cancel any pending answer-ready quiescence flush (the
674
+ // turn is not quiescent). Fires ahead of the JSONL tool_use, so it disarms
675
+ // the timer at the earliest deterministic point.
676
+ clearAnswerReadyFlushTimeout(turn)
677
+ // SECONDARY FIX: an active tool_label means the model is producing work
678
+ // right now — re-arm the orphaned-reply fuse so a multi-phase tool turn
679
+ // (write → compile → test → fix) that regularly emits labels doesn't let
680
+ // the 30 s timer run down between labels. Mirrors how `case 'text':` calls
681
+ // resetOrphanedReplyTimeout() at ~line 10786.
682
+ resetOrphanedReplyTimeout()
683
+ // Surface tools (reply/stream_reply/react) are the conversation, not
684
+ // activity — the hook labels them ("Replying"), so filter by name.
685
+ if (isTelegramSurfaceTool(ev.toolName)) return
686
+ // Stop feeding once the FINAL answer has landed — the hand-off where
687
+ // `clearActivitySummary` deletes the feed so the answer is the
688
+ // authoritative surface. Gating on `replyCalled` (any reply) killed the
689
+ // feed on an ack-first interim "On it", so the post-ack work had no live
690
+ // surface; gate on `finalAnswerDelivered` so the feed keeps narrating
691
+ // between the ack and the real answer. Without this a tool called after
692
+ // the FINAL answer would re-`sendMessage` a fresh feed below it (flicker).
693
+ // Safe ordering: `tool_label` is real-time (PreToolUse, ~250ms) while
694
+ // `finalAnswerDelivered` is set from executeReply on the final answer.
695
+ //
696
+ // Feed-reopen-after-ack: a tool label here means the model is STILL
697
+ // working. If the turn was already marked finalAnswerDelivered, the
698
+ // "final" reply MIGHT have been an interim ACK ("on it, checking
699
+ // Brevo…" pings, classified final by isFinalAnswerReply), so the
700
+ // post-ack work had no live feed — the gate above dropped every label.
701
+ //
702
+ // ACK-ONLY refinement: finalAnswerDelivered latches true for BOTH a
703
+ // short pinging ack AND a substantive answer. Reopening unconditionally
704
+ // is harmful after a GENUINE final answer — routine post-answer
705
+ // housekeeping (memory write / TodoWrite / Bash; non-surface tools that
706
+ // reach here) would reset finalAnswerDelivered=false and trip the
707
+ // silent-end re-prompt (NOT zero-outbound gated) → duplicate answer. So
708
+ // reopen ONLY when the prior final was a short ack
709
+ // (finalAnswerSubstantive=false). When it was substantive, drop the
710
+ // label (legacy gate) so the genuine final stays delivered.
711
+ //
712
+ // On reopen: reclassify the interim ack — the turn has NOT delivered its
713
+ // final answer while still doing tool work. Reset the flag and clear
714
+ // activityMessageId so a FRESH feed message opens below the ack, then
715
+ // proceed normally. When the model's REAL final answer lands,
716
+ // executeReply / stream_reply re-set finalAnswerDelivered=true (and
717
+ // finalAnswerSubstantive) and the feed gates off again. The reset keeps
718
+ // the #2137 serialize gate HOLDING the next topic mid-work (next-topic
719
+ // liveness is the bounded no-reply timer's job) and lets the silent-end
720
+ // re-prompt fire if the turn ends on only an ack.
721
+ // Kill switch SWITCHROOM_FEED_REOPEN_AFTER_ACK=0 → legacy `return`.
722
+ if (turn.finalAnswerDelivered) {
723
+ // decideFeedReopen returns dropLabel (legacy return) or the reset
724
+ // deltas: finalAnswerDelivered→false (the turn has NOT delivered its
725
+ // final answer while still doing tool work), activityMessageId→null
726
+ // (a FRESH feed message opens below the ack), activityLastSentRender
727
+ // →null (so the drain loop's `pending !== lastSent` guard never
728
+ // mistakes the fresh render for the ack's finalized one and skips it).
729
+ const reopen = decideFeedReopen({
730
+ finalAnswerDelivered: turn.finalAnswerDelivered,
731
+ // ACK-ONLY: reopen only when the prior final was a short ack, not a
732
+ // substantive answer — otherwise post-answer housekeeping would
733
+ // reset finalAnswerDelivered and trip the silent-end re-prompt.
734
+ finalAnswerSubstantive: turn.finalAnswerSubstantive,
735
+ enabled: FEED_REOPEN_AFTER_ACK_ENABLED,
736
+ })
737
+ if (reopen.dropLabel) return
738
+ turn.finalAnswerDelivered = reopen.reset!.finalAnswerDelivered
739
+ turn.activityMessageId = reopen.reset!.activityMessageId
740
+ turn.activityLastSentRender = reopen.reset!.activityLastSentRender
741
+ }
742
+ const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
743
+ if (rendered != null) {
744
+ // Count surfaced tool steps — the single source of truth for the `tools=`
745
+ // lifecycle field and the `✓ N steps` total. Incremented HERE (not at the
746
+ // top of the case) so the count stays consistent with what the feed
747
+ // actually surfaces: an empty label (appendActivityLabel → null) or a
748
+ // label dropped by the post-final-answer reopen guard never inflates it.
749
+ // Surface tools (reply/react) returned earlier; send_typing/sync_retain
750
+ // are suppressed at the hook (computeLabel → null) so they never arrive.
751
+ turn.labeledToolCount++
752
+ // A new tool label = a new live step → re-anchor the heartbeat clock so
753
+ // the " · Ns" elapsed restarts from this step (and the feed itself just
754
+ // advanced, so it isn't stale).
755
+ turn.lastToolLabelAt = Date.now()
756
+ // Production-liveness: a NEW model-driven activity label is genuine
757
+ // liveness (the model emitted a new step), so reset the silence-poke
758
+ // clock — this is the safe site, NOT drainActivitySummary, because the
759
+ // framework feedHeartbeatTick also drains (climbing-elapsed re-renders)
760
+ // and would falsely reset the clock forever on a hung-mid-tool turn,
761
+ // reintroducing the #1556 dangling-turn wedge. Only the model emitting a
762
+ // fresh label reaches here.
763
+ // PR-4e — keyed liveness under the flag. Flag-OFF keeps the literal
764
+ // `currentTurn === turn` (a late tool-label for topic A must reset A's
765
+ // silence clock, not topic B's); flag-ON resolves A by ITS OWN key so a
766
+ // flip to B doesn't falsify A's liveness here.
767
+ if (
768
+ SILENCE_LIVENESS_PRODUCTION &&
769
+ (EMISSION_AUTHORITY_ENABLED ? turnLiveForItsTopic(turn) : getCurrentTurn() === turn)
770
+ ) {
771
+ silencePoke.noteProduction(statusKey(turn.sessionChatId, turn.sessionThreadId), Date.now())
772
+ }
773
+ // Recompose so any active foreground sub-agent's nested block (Model A)
774
+ // is preserved when the parent appends its own step. composeTurnActivity
775
+ // == the flat render when no foreground sub-agent is active.
776
+ turn.activityPendingRender = composeTurnActivity(turn) ?? rendered
777
+ const ea = emissionAuthorityFor(turn)
778
+ // PR-4d: route through the centralized chatLock-serialized card-drain gate.
779
+ cardDrainGate(turn, ea, () => {
780
+ if (ea.mayDrain(turn)) {
781
+ // Producer B (tool label): always OPEN-eligible (labeledToolCount was
782
+ // incremented just above). A turn that started conversational and now
783
+ // dispatches a tool opens here, rendering any narration accumulated
784
+ // by the suppressed narrative-SHOW drains (design §9 lever 5 / R4).
785
+ // PR-4a: routed through the emission-authority façade (no-op delegate).
786
+ ea.openOrEditCard('tool', () => {
787
+ turn.activityInFlight = drainActivitySummary(turn, 'tool')
788
+ })
789
+ }
790
+ })
791
+ }
792
+ return
793
+ }
794
+ case 'text': {
795
+ // #1067: snapshot at entry. The answer-stream creation closures
796
+ // below also read `turn` instead of currentTurn so they pin to
797
+ // this turn's chat for the stream's lifetime.
798
+ //
799
+ // #1664 ordering note: a `text` event can arrive AFTER turn_end has
800
+ // nulled currentTurn (the issue observed `answer_lane_update
801
+ // transport:"draft"` firing post-turn_end). Such a late event is
802
+ // dropped here by the `turn != null` guard — it is NOT folded back
803
+ // into the just-ended turn. That is deliberate and safe: by the
804
+ // time this fires, the turn atom has been handed to
805
+ // endCurrentTurnAtomic and turn_end has already run its flush /
806
+ // silent-end decision; re-opening a closed turn (re-creating an
807
+ // answer stream, re-evaluating decideTurnFlush) would be a large,
808
+ // race-prone change. The #1664 safety net does not depend on
809
+ // catching the late text: a turn whose real answer lost the race
810
+ // ends with finalAnswerDelivered=false, so recordUndeliveredTurnEnd
811
+ // engages the Stop-hook re-prompt and the model re-delivers the
812
+ // answer through the reply tool. The dropped draft text is
813
+ // recovered by re-prompt, not by post-hoc materialization.
814
+ const turn = getCurrentTurn()
815
+ if (turn != null) {
816
+ turn.capturedText.push(ev.text)
817
+ // #3237 — accumulate the block's structural provenance in LOCKSTEP with
818
+ // the text (same push, same index). `ev.lastInMessage` is true when NO
819
+ // tool_use follows this text block in its assistant message; its
820
+ // negation is the draft-then-send narration signal the turn-flush strip
821
+ // uses (`selectFlushDeliveryText`) to keep a real answer intact instead
822
+ // of truncating a paragraph that merely opens with "Let me explain…".
823
+ turn.capturedBlockMeta.push(!ev.lastInMessage)
824
+ // Narrative-dedup gate step 1 (JSONL-text-narrative primitive):
825
+ // stage this text block for one lookahead step. If a previous block
826
+ // was pending with nothing reply-shaped after it, it flushes here as
827
+ // a SHOWN transient liveness step. The eventual SHOW/SUPPRESS of THIS
828
+ // block is decided by the next tool_use / turn_end. Invariant
829
+ // `chat-is-the-single-source-of-truth` (reference/invariants.md): a
830
+ // SHOWN line rides the same renderStepFeed path as a tool step —
831
+ // transient + clipped, never a persisted parallel mirror. This is a
832
+ // separate lane from the answer-stream wiring below (which owns the
833
+ // canonical reply), so the two never fight over the same text.
834
+ stagePendingNarrative(turn, ev.text)
835
+ // Issue #195: feed the answer-lane stream. The stream itself
836
+ // gates on minInitialChars and throttles edits — short replies
837
+ // stay below the threshold and never spawn a message.
838
+ if (turn.answerStream == null) {
839
+ turn.answerStream = createAnswerStream({
840
+ chatId: turn.sessionChatId,
841
+ threadId: turn.sessionThreadId,
842
+ // VISIBLE on (opt-in, SWITCHROOM_VISIBLE_ANSWER_STREAM=1) →
843
+ // minInitialChars:1 opens a user-visible edit-in-place preview on the
844
+ // first text chunk. At turn_end the preview is materialized as a pinged
845
+ // final answer (materialize()) when the model never called reply.
846
+ // VISIBLE off (default) → minInitialChars:MAX so NO visible preview ever
847
+ // opens; the reply tool is the single canonical formatted message
848
+ // (no flash). The draft transport is permanently retired — both modes
849
+ // use sendMessage + editMessageText for any message that does open.
850
+ minInitialChars: ANSWER_LANE.minInitialChars,
851
+ // #2669: the answer-stream ships the RAW transcript markdown; the
852
+ // sendMessage/editMessageText wrappers below send it via the
853
+ // rich-message path (`sendRichMessage` / `editMessageText({ markdown })`),
854
+ // matching every other outbound lane. No render step needed.
855
+ // #1075: route through robustApiCall so flood-wait,
856
+ // benign-400, and THREAD_NOT_FOUND are handled uniformly
857
+ // instead of crashing the answer-stream loop on a deleted
858
+ // forum topic. answer-stream's own try/catch already
859
+ // tolerates undefined returns from editMessageText.
860
+ //
861
+ // disable_notification gating by purpose (2026-05-25):
862
+ //
863
+ // - purpose='stream' (the live edit-in-place preview): SILENT.
864
+ // Without disable_notification, the first text chunk that
865
+ // opens the visible message device-pings, and then when the
866
+ // model later calls the reply MCP tool, that reply pings
867
+ // AGAIN (the over-ping safety net at gateway.ts:~4452 only
868
+ // sees executeReply paths, not this direct sendMessage). Two
869
+ // device pings per multi-step turn — the original Bug A.
870
+ // Edits in place don't notify regardless (Telegram semantics).
871
+ //
872
+ // - purpose='materialize' (turn-end final-answer fresh send,
873
+ // only fires for text-only turns where the stream IS the
874
+ // answer): PING. The user reached for the agent and the
875
+ // model produced an answer; per beat 5 of
876
+ // `reference/rfcs/conversational-pacing.md` the final answer MUST
877
+ // ping the device exactly once. Without this carve-out, a
878
+ // short text-only turn ("on it" being the whole response)
879
+ // lands silently and the user has no notification to know
880
+ // the answer arrived — the original over-correction.
881
+ //
882
+ // - purpose unset (defensive default): SILENT. Treat as
883
+ // stream-purpose so we never accidentally fire a stray ping
884
+ // from an unrecognised sendMessage callsite.
885
+ sendMessage: async (chatId, text, params) => {
886
+ const tid = params?.message_thread_id
887
+ const silent = params?.purpose !== 'materialize'
888
+ const msg = await robustApiCall(
889
+ () =>
890
+ // allow-raw-bot-api: sendRichMessage is not in the THREAD_NOT_FOUND blast pattern; answer-stream tolerates failures via its own try/catch
891
+ // sendRichMessage doesn't accept link_preview_options — omit it.
892
+ bot.api.sendRichMessage(chatId, richMessage(text), {
893
+ disable_notification: silent,
894
+ ...(tid != null ? { message_thread_id: tid } : {}),
895
+ ...(params?.reply_parameters != null
896
+ ? { reply_parameters: params.reply_parameters }
897
+ : {}),
898
+ }),
899
+ {
900
+ chat_id: chatId,
901
+ verb: `answer-stream.sendMessage(${params?.purpose ?? 'stream'})`,
902
+ ...(tid != null ? { threadId: tid } : {}),
903
+ },
904
+ )
905
+ return { message_id: msg.message_id }
906
+ },
907
+ editMessageText: (chatId, messageId, text, params) => {
908
+ const tid = params?.message_thread_id
909
+ return robustApiCall(
910
+ () =>
911
+ bot.api.editMessageText(chatId, messageId, richMessage(text), {
912
+ ...(tid != null ? { message_thread_id: tid } : {}),
913
+ ...(params?.link_preview_options != null
914
+ ? { link_preview_options: params.link_preview_options }
915
+ : {}),
916
+ }),
917
+ {
918
+ chat_id: chatId,
919
+ verb: 'answer-stream.editMessageText',
920
+ ...(tid != null ? { threadId: tid } : {}),
921
+ // #3084 PR 2 / L1: answer-stream edits are COSMETIC — a
922
+ // dropped stream tick costs nothing (the next carries full
923
+ // text). messageId/editPayload engage the per-message floor +
924
+ // coalescing + no-op skip so rapid stream edits don't storm
925
+ // the same message (the top ban trigger).
926
+ priorityClass: 'cosmetic',
927
+ messageId,
928
+ editPayload: richMessage(text),
929
+ },
930
+ )
931
+ },
932
+ deleteMessage: (chatId, messageId) =>
933
+ robustApiCall(
934
+ () => bot.api.deleteMessage(chatId, messageId),
935
+ { chat_id: chatId, verb: 'answer-stream.deleteMessage' },
936
+ ),
937
+ log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
938
+ warn: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
939
+ // Issue #203: route answer-lane events through the streaming
940
+ // metrics sink. Each successful update/edit/draft and the final
941
+ // materialize emit one event. Also tick the silent-gap tracker
942
+ // so answer-lane activity doesn't count as silent.
943
+ //
944
+ // #1067: the closure captures `turn` so the signal tracker
945
+ // ticks against THIS turn's chat key. If a new turn took over,
946
+ // the captured `turn` no longer matches `currentTurn` and we
947
+ // skip the tick (the new turn has its own answer stream).
948
+ onMetric: (metricEv) => {
949
+ logStreamingEvent(metricEv)
950
+ // PR-4e — keyed liveness under the flag. Flag-OFF keeps the literal
951
+ // `currentTurn === turn` (a draft-update metric for topic A's stream
952
+ // must tick A's signal/silence clock); flag-ON resolves A by its own
953
+ // key so a flip to B doesn't skip A's tick.
954
+ if (EMISSION_AUTHORITY_ENABLED ? turnLiveForItsTopic(turn) : getCurrentTurn() === turn) {
955
+ signalTracker.noteSignal(
956
+ statusKey(turn.sessionChatId, turn.sessionThreadId),
957
+ Date.now(),
958
+ )
959
+ // Production-liveness: a draft update is the agent visibly
960
+ // composing — reset the silence-poke clock so a long
961
+ // compose-only turn (no tools, no reply yet) isn't torn down.
962
+ if (SILENCE_LIVENESS_PRODUCTION) {
963
+ silencePoke.noteProduction(
964
+ statusKey(turn.sessionChatId, turn.sessionThreadId),
965
+ Date.now(),
966
+ )
967
+ }
968
+ }
969
+ },
970
+ // #646 — wire the shared outboundDedup into the answer-stream
971
+ // materialize path so it participates in the same dedup window
972
+ // as turn-flush and reply/stream_reply. Closured chatId /
973
+ // threadId come from the captured `turn` snapshot, stable for
974
+ // the lifetime of the stream.
975
+ checkDedup: (text: string) => {
976
+ return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null) != null
977
+ },
978
+ recordDedup: (text: string) => {
979
+ outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null)
980
+ },
981
+ // #648 — write answer-stream materializations into the SQLite
982
+ // history buffer so get_recent_messages can surface them. Guard
983
+ // with HISTORY_ENABLED, matching the turn-flush pattern at ~3783.
984
+ recordOutbound: ({ messageId, text }: { messageId: number; text: string }) => {
985
+ if (!HISTORY_ENABLED) return
986
+ try {
987
+ recordOutbound({
988
+ chat_id: turn.sessionChatId,
989
+ thread_id: turn.sessionThreadId ?? null,
990
+ message_ids: [messageId],
991
+ texts: [text],
992
+ })
993
+ } catch {}
994
+ },
995
+ })
996
+ }
997
+ // #549 fix: route the chunk through the preamble suppressor
998
+ // instead of immediately updating the answer stream. If a
999
+ // tool_use arrives within the buffer window, the suppressor
1000
+ // drops the chunk (the card owns it). Otherwise it flushes as
1001
+ // answer text. `turn.capturedText` is unchanged — it remains
1002
+ // the safety-net source for turn-flush prose recovery.
1003
+ preambleSuppressor.onText(ev.text)
1004
+ }
1005
+ resetOrphanedReplyTimeout()
1006
+ // PR A — (re)arm the deterministic answer-ready quiescence flush. Each
1007
+ // text chunk debounces the timer; it fires only after ~1 s of no new
1008
+ // stream events, delivering a composed toolless answer without waiting on
1009
+ // the unreliable turn_duration signal or the ~150 s orphaned backstop.
1010
+ resetAnswerReadyFlushTimeout()
1011
+
1012
+ if (isContextExhaustionText(ev.text) && turn != null) {
1013
+ const chatId = turn.sessionChatId
1014
+ const threadId = turn.sessionThreadId
1015
+ const now = Date.now()
1016
+ if (now - getLastContextExhaustionWarningAt() < CONTEXT_EXHAUSTION_COOLDOWN_MS) return
1017
+ setLastContextExhaustionWarningAt(now)
1018
+ process.stderr.write(`telegram gateway: context exhaustion detected — notifying user\n`)
1019
+ const warnOpts = {
1020
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
1021
+ }
1022
+ // #1075: thread-id-bearing, fire-and-forget — swallow on
1023
+ // THREAD_NOT_FOUND so a deleted topic doesn't crash the gateway.
1024
+ void swallowingApiCall(
1025
+ () =>
1026
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
1027
+ bot.api.sendRichMessage(
1028
+ chatId,
1029
+ richMessage('⚠️ **Context window full** — send \`/restart\` to start a fresh session.'),
1030
+ warnOpts,
1031
+ ),
1032
+ {
1033
+ chat_id: chatId,
1034
+ verb: 'context-exhaust-warning',
1035
+ ...(threadId != null ? { threadId } : {}),
1036
+ },
1037
+ )
1038
+ // #1713: context-exhaustion is a terminal failure path — paint 😱
1039
+ // and finalize the controller. `setError` alone is non-terminal
1040
+ // (recovery permitted); since this turn is genuinely ending, route
1041
+ // through `finalize('error')` so the emoji lands and the controller
1042
+ // stops accepting further transitions.
1043
+ finalizeStatusReaction(chatId, threadId, 'error')
1044
+ // Surfaced during CC-5 investigation (`docs/status-ask-cause-classes.md`):
1045
+ // the context-exhaust bail path teardown was missing
1046
+ // `silencePoke.endTurn(key)`. Without it, the silence-poke state for
1047
+ // this turn lingers in the Map. Once 300s of clock-time passes from
1048
+ // the turn's original start, the framework fallback fires and the
1049
+ // gateway sends a user-visible "still working… (no update from agent
1050
+ // in 5 min)" message — for a turn the gateway internally considers
1051
+ // dead and has already told the user is over (the ⚠️ Context window
1052
+ // full message above). Match the pattern used at the regular
1053
+ // turn-end path (line ~5039) and the wedged-turn path (~5290).
1054
+ const ceKey = statusKey(chatId, threadId)
1055
+ silencePoke.endTurn(ceKey)
1056
+ pendingProgress.noteTurnEnd(ceKey)
1057
+ // Issue #195: tear down the answer-lane stream on context-exhaustion
1058
+ // bail-out. The user is being told the session needs /restart, so any
1059
+ // partially-streamed answer would be misleading.
1060
+ if (turn.answerStream != null) {
1061
+ turn.answerStream.stop()
1062
+ turn.answerStream = null
1063
+ }
1064
+ // Null the atom — this turn is being abandoned.
1065
+ endCurrentTurnAtomic(turn)
1066
+ // #549 fix — context-exhaustion teardown also resets preamble state.
1067
+ preambleSuppressor.reset()
1068
+ }
1069
+ return
1070
+ }
1071
+ case 'tool_result': {
1072
+ if (ev.toolUseId) typingWrapper.onToolResult(ev.toolUseId)
1073
+ // Fix 1.3 (#2903): flush a staged 📌/✂️ memory-legibility line only on a
1074
+ // CONFIRMED-successful write; a failed write (ev.isError) drops it.
1075
+ confirmMemoryLegibility(ev.toolUseId, ev.isError)
1076
+ return
1077
+ }
1078
+ case 'sub_agent_tool_use': {
1079
+ const turn = getCurrentTurn()
1080
+ if (turn == null) return
1081
+ if (!ev.toolUseId) return
1082
+ typingWrapper.onToolUse(ev.toolUseId, turn.sessionChatId, ev.toolName, turn.sessionThreadId ?? null)
1083
+ return
1084
+ }
1085
+ case 'sub_agent_tool_result': {
1086
+ if (ev.toolUseId) typingWrapper.onToolResult(ev.toolUseId)
1087
+ return
1088
+ }
1089
+ case 'turn_end': {
1090
+ // DEFENSIVE FIX: belt-and-braces guard against the synthetic backstop
1091
+ // (`durationMs: -1`) racing live work. durationMs >= 0 is the
1092
+ // authoritative signal from system/turn_duration; -1 is ONLY ever set
1093
+ // by the orphaned-reply backstop. Reject the synthetic event here so that
1094
+ // even if the PRIMARY fix's re-arm logic is bypassed (e.g. a very fast
1095
+ // fire before isLegitimatelyWorking() is sampled) we still don't tear
1096
+ // down a live feed mid-work. Extended from the original isMidToolCall()
1097
+ // check to the full isLegitimatelyWorking predicate so detached background
1098
+ // work and human-wait tools (ask_user) are also protected.
1099
+ // INVARIANT: a REAL turn_end (durationMs >= 0) is NEVER suppressed.
1100
+ // PR A carve-out: the answer-ready quiescence flush also uses
1101
+ // `durationMs:-1`, but it is a POSITIVE "streaming has settled" signal
1102
+ // fired only after ~1 s of no stream events AND no in-flight tool — the
1103
+ // exact opposite of a hung turn. It must NOT be suppressed by
1104
+ // recentlyStreaming (the terminal answer text itself stamps that window,
1105
+ // which is the whole bug). Its own arm/fire predicate already re-verified
1106
+ // quiescence, so let it through to deliver.
1107
+ if (ev.durationMs === -1 && ev.reason !== 'answer-ready-quiescence') {
1108
+ const turn = getCurrentTurn()
1109
+ const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
1110
+ // Widened to also suppress while the turn is RECENTLY STREAMING — a
1111
+ // model reasoning pause emits no tool/text events (so
1112
+ // isLegitimatelyWorking is false) but a genuine stream landed within
1113
+ // the window, so the turn is alive and must not be torn down.
1114
+ // ACCEPTED TRADE-OFF (F3): the context-exhaustion recovery latency via
1115
+ // THIS backstop path grows from ~30 s to ~120-150 s, because the
1116
+ // "Prompt is too long" marker is itself a genuine `text` event that
1117
+ // stamps recentlyStreaming. This is acceptable — the primary
1118
+ // context-exhaustion teardown is the immediate `endCurrentTurnAtomic`
1119
+ // in `case 'text'` (isContextExhaustionText) above; this backstop only
1120
+ // matters if that path is missed, and it still COMPLETES once the
1121
+ // window lapses. Recovery is delayed, never suppressed forever.
1122
+ const recentlyStreaming =
1123
+ turn != null && turn.liveness.recentlyStreaming(Date.now(), ORPHANED_REPLY_STREAM_WINDOW_MS)
1124
+ if (isLegitimatelyWorking(key) || recentlyStreaming) {
1125
+ process.stderr.write(
1126
+ `telegram gateway: synthetic turn_end suppressed — legitimately working` +
1127
+ ` (in_flight=${toolFlightTracker.inFlightCount()},` +
1128
+ ` recently_streaming=${recentlyStreaming},` +
1129
+ ` bg_work=${turn != null ? pendingProgress.hasPendingAsyncDispatch(key) : false})\n`,
1130
+ )
1131
+ return
1132
+ }
1133
+ }
1134
+ // #2094 finding 1 — turn_end gate-wedge backstop. Capture the turn
1135
+ // BEFORE the body runs (the body re-reads currentTurn as `turn`, then
1136
+ // nulls it via endCurrentTurnAtomic on every clean branch). The guarded
1137
+ // finally in withTurnEndGateBackstop forces the canonical purge iff a
1138
+ // throw in a pre-purge op (redactOutboundText, progressDriver?.
1139
+ // takeOverCard, narrative dedup, answer-stream finalize, …) skipped
1140
+ // endCurrentTurnAtomic → purgeReactionTracking, which would otherwise
1141
+ // leave activeTurnStartedAt populated (and the machine un-turnEnded)
1142
+ // and wedge the #1556 inbound gate closed until the TTL tick. No-op on
1143
+ // the happy path (key already gone).
1144
+ const turnEndBackstopTurn = getCurrentTurn()
1145
+ const turnEndBackstopKey =
1146
+ turnEndBackstopTurn != null
1147
+ ? statusKey(turnEndBackstopTurn.sessionChatId, turnEndBackstopTurn.sessionThreadId)
1148
+ : null
1149
+ withTurnEndGateBackstop(
1150
+ turnEndBackstopKey,
1151
+ turnEndBackstopTurn,
1152
+ () => {
1153
+ // Drain any still-pending tool dispatch typing entries — covers
1154
+ // transcript truncation or a Claude Code crash mid-tool.
1155
+ typingWrapper.drainAll()
1156
+ // Forum-topic placeholder cleanup removed in #553 PR 5 — the
1157
+ // forum-topic placeholder send is also gone, so there is
1158
+ // nothing to clear at turn_end.
1159
+ //
1160
+ // #1067: capture the turn atom at handler entry. The IIFE that
1161
+ // runs turn-flush below is async, so every read after the first
1162
+ // await would be subject to the reattribution race we're fixing.
1163
+ // All downstream code reads `turn.*` or its captured locals.
1164
+ const turn = getCurrentTurn()
1165
+ if (turn?.orphanedReplyTimeoutId != null) {
1166
+ clearTimeout(turn.orphanedReplyTimeoutId)
1167
+ turn.orphanedReplyTimeoutId = null
1168
+ }
1169
+ // Narrative-dedup gate step 3 (JSONL-text-narrative primitive): a
1170
+ // trailing narrative block with nothing after it. When the turn
1171
+ // delivered its answer via reply (replyCalled) the trailing text is
1172
+ // almost always a draft of that answer — compare against the ACTUAL
1173
+ // delivered reply text and SUPPRESS the duplicate; otherwise SHOW
1174
+ // genuine trailing narration ("Done — all green."). Must run BEFORE
1175
+ // clearActivitySummary so a SHOWN line lands in the feed's final
1176
+ // render. Always clears the gate's parked block (and disarms its
1177
+ // early-paint timer) so nothing can leak across turns.
1178
+ //
1179
+ // NIT 2 (reply-proxy precision): use `turn.lastReplyText` (the
1180
+ // most-recent reply/stream_reply input.text) rather than
1181
+ // `capturedText.join('')`. The old proxy concatenated every captured
1182
+ // text block, so a turn that emitted the same short string twice
1183
+ // (e.g. "Done." as working narration, then "Done." as the reply) would
1184
+ // compare the trailing narration against a doubled "DoneDone" — still
1185
+ // a high-prefix match — and wrongly suppress genuine trailing
1186
+ // narration. Comparing against the actual reply text is exact. When
1187
+ // the turn delivered WITHOUT a reply tool (turn-flush emits
1188
+ // capturedText as the answer), fall back to capturedText.join('') so
1189
+ // that path's trailing-draft suppression is preserved.
1190
+ if (turn != null) {
1191
+ const deliveredText = turn.lastReplyText.length > 0
1192
+ ? turn.lastReplyText
1193
+ : (turn.replyCalled ? turn.capturedText.join('') : '')
1194
+ flushPendingNarrativeAtTurnEnd(turn, deliveredText)
1195
+ }
1196
+ // Clear the activity feed at the real end of the turn. This is the
1197
+ // no-reply safety net — a turn that ends without ever calling reply
1198
+ // (the answer is delivered by turn-flush / silent-end) still has its
1199
+ // feed removed. On a normal turn the feed was already cleared at the
1200
+ // first reply (the hand-off); clearActivitySummary is idempotent, so
1201
+ // the second call is a no-op.
1202
+ if (turn != null) {
1203
+ clearActivitySummary(turn)
1204
+ }
1205
+ // #549 fix — flush any pending preamble BEFORE the answer stream is
1206
+ // nulled below. Text emitted immediately before turn_end (no tool
1207
+ // followed) is the answer; the suppressor's emitAnswer callback
1208
+ // would no-op against a nulled stream, silently dropping the text
1209
+ // (regression for short no-tool replies). Order matters here: this
1210
+ // call must come before the retract/null block.
1211
+ preambleSuppressor.flushNow()
1212
+ // #656: by default we ALWAYS retract the answer-lane stream at
1213
+ // turn_end. Turn-flush is the canonical emitter for no-reply
1214
+ // turns; materialising here would race it and post raw model
1215
+ // text (no HTML conv).
1216
+ //
1217
+ // #869-Phase1 override: when `ANSWER_STREAM_VISIBLE_ENABLED` is
1218
+ // on, the stream is rendering a USER-VISIBLE message in the
1219
+ // chat timeline. When the stream is the de-facto final answer
1220
+ // (model never called reply, captured text is substantive), we
1221
+ // need to:
1222
+ // 1. Send a FRESH pinged message via `stream.materialize()`
1223
+ // so the user gets a device notification — beat 5 of the
1224
+ // conversational-pacing contract requires exactly one
1225
+ // ping per turn for the final answer. Without this,
1226
+ // text-only short turns ("on it" being the whole reply)
1227
+ // land silently and the user has no notification to know
1228
+ // the answer arrived (the failure caught by the
1229
+ // midturn-silent-dm UAT, 2026-05-25).
1230
+ // 2. Delete the silent streamed preview message so the user
1231
+ // doesn't see a duplicate (the streamed message in place
1232
+ // + the fresh materialized ping). materialize() handles
1233
+ // the fresh send but leaves the old streamed message_id
1234
+ // orphaned by design — we delete it explicitly here.
1235
+ //
1236
+ // The previous behavior (just `stream.stop()` to freeze the
1237
+ // streamed message in place) avoided the duplicate but also
1238
+ // skipped the ping. Materialize-and-delete trades a brief
1239
+ // visual "the streamed message is replaced by a fresh one"
1240
+ // (often imperceptible for short turns where the streaming
1241
+ // barely had time to register; mildly visible for longer
1242
+ // turns) in exchange for an always-correct turn-end ping.
1243
+ let streamFinalizedAsAnswer = false
1244
+ if (turn?.answerStream != null) {
1245
+ const stream = turn.answerStream
1246
+ const streamedMsgId = stream.messageId()
1247
+ const streamedFinalText = turn.capturedText.join('').trim()
1248
+ if (
1249
+ // Only when a VISIBLE preview actually opened (visible flag on): a
1250
+ // text-only no-reply turn that streamed a visible preview must
1251
+ // materialize a pinged final answer + delete the preview, NOT fall into
1252
+ // the else-branch retract() which would delete the user's only copy of
1253
+ // the answer (a lost-answer bug). Gated on the visible flag alone (the
1254
+ // flash-regression decoupling): with the visible stream OFF (default)
1255
+ // no preview opens (minInitialChars:MAX), so streamedMsgId is null and
1256
+ // this branch is unreachable — the no-reply answer is delivered by the
1257
+ // turn-flush backstop below instead, the pre-v0.14.68 path. The
1258
+ // reply-tool branch hits retract() on a non-opened lane (a no-op), so
1259
+ // there is no preliminary to flash.
1260
+ ANSWER_LANE.opensVisiblePreview
1261
+ && !turn.replyCalled
1262
+ && streamedMsgId != null
1263
+ && streamedFinalText.length > 0
1264
+ ) {
1265
+ turn.answerStream = null
1266
+ streamFinalizedAsAnswer = true
1267
+ turn.finalAnswerDelivered = true
1268
+ // Feed-reopen refinement: the stream is being finalized as the
1269
+ // turn's answer (the model's terminal text), i.e. done=true by
1270
+ // construction → substantive. Post-answer housekeeping must NOT
1271
+ // re-open the feed.
1272
+ turn.finalAnswerSubstantive = true
1273
+ // Capture the old streamed message_id BEFORE materialize so
1274
+ // we can delete it after the fresh ping send. materialize()
1275
+ // overwrites `streamMsgId` internally with the new send's id;
1276
+ // without capturing here we'd lose the reference.
1277
+ const oldStreamedMsgId = streamedMsgId
1278
+ // Fire-and-forget materialize-and-delete sequence.
1279
+ //
1280
+ // Bookkeeping (dedup + history): handled inside
1281
+ // `materialize()` itself — see `answer-stream.ts:~548-549`
1282
+ // which calls the injected `recordDedup` + `recordOutbound`
1283
+ // callbacks with the NEW (fresh-send) message_id only after a
1284
+ // successful send. We deliberately do NOT pre-record here —
1285
+ // doing so populates the same `outboundDedup` store that
1286
+ // materialize's internal `checkDedup` consults at
1287
+ // `answer-stream.ts:~510`, causing materialize to dedup-
1288
+ // suppress its own send (return undefined, no ping fires) —
1289
+ // the exact failure mode this PR exists to fix. Let
1290
+ // materialize own the bookkeeping; gateway only sequences the
1291
+ // operations.
1292
+ //
1293
+ // Delete gating: only run the cleanup `deleteMessage` if
1294
+ // materialize actually sent (returned a numeric sentId). If
1295
+ // it dedup-suppressed or threw, the streamed preview is the
1296
+ // user's only copy of the answer and MUST be preserved.
1297
+ void (async () => {
1298
+ let materializedId: number | undefined
1299
+ try {
1300
+ materializedId = await stream.materialize()
1301
+ } catch (err) {
1302
+ process.stderr.write(
1303
+ `telegram gateway: answer-stream materialize failed: ${
1304
+ err instanceof Error ? err.message : String(err)
1305
+ }\n`,
1306
+ )
1307
+ return
1308
+ }
1309
+ if (typeof materializedId !== 'number' || !Number.isFinite(materializedId)) {
1310
+ // materialize() returned undefined — either pendingText
1311
+ // was empty, the body was a silent marker (NO_REPLY /
1312
+ // HEARTBEAT_OK), or `checkDedup` suppressed it. In every
1313
+ // such case the streamed preview is the user's only copy
1314
+ // of the content; don't delete it.
1315
+ process.stderr.write(
1316
+ `telegram gateway: answer-stream materialize returned no msgId ` +
1317
+ `chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} — ` +
1318
+ `preserving silent preview as the user's only copy\n`,
1319
+ )
1320
+ return
1321
+ }
1322
+ // Materialize sent a fresh pinged message at materializedId.
1323
+ // Delete the silent streamed preview so the chat shows one
1324
+ // canonical message (the fresh pinged one) and not two with
1325
+ // duplicate content. Best-effort; failures (already gone,
1326
+ // permission denied) leave a brief visible duplicate which
1327
+ // we accept rather than retry-storming.
1328
+ try {
1329
+ // allow-raw-bot-api: cleanup delete of silent streamed preview
1330
+ await bot.api.deleteMessage(turn.sessionChatId, oldStreamedMsgId)
1331
+ } catch (delErr) {
1332
+ process.stderr.write(
1333
+ `telegram gateway: answer-stream materialize-cleanup ` +
1334
+ `delete failed for msgId=${oldStreamedMsgId}: ${
1335
+ delErr instanceof Error ? delErr.message : String(delErr)
1336
+ }\n`,
1337
+ )
1338
+ }
1339
+ process.stderr.write(
1340
+ `telegram gateway: answer-stream materialized as answer ` +
1341
+ `chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} ` +
1342
+ `newMsg=${materializedId} chars=${streamedFinalText.length}\n`,
1343
+ )
1344
+ })()
1345
+ } else {
1346
+ turn.answerStream = null
1347
+ void stream.retract().catch((err) => {
1348
+ process.stderr.write(
1349
+ `telegram gateway: answer-stream retract failed: ${
1350
+ err instanceof Error ? err.message : String(err)
1351
+ }\n`,
1352
+ )
1353
+ })
1354
+ }
1355
+ }
1356
+ if (turn == null) return
1357
+ const chatId = turn.sessionChatId
1358
+ const threadId = turn.sessionThreadId
1359
+ const ctrl = activeStatusReactions.get(statusKey(chatId, threadId))
1360
+
1361
+ // #1122 PR3: #51 prose-as-step recovery removed with the
1362
+ // progress card. Without the card there's no narrative-steps
1363
+ // surface to recover from. The decideTurnFlush 'empty-text'
1364
+ // path now relies on capturedText alone.
1365
+
1366
+ // #869-Phase1: when the answer-stream finalised as the answer
1367
+ // above, skip the turn-flush IIFE entirely — its job (deliver
1368
+ // captured text) is already done by the visible stream, and
1369
+ // running it would race a duplicate fresh-sendMessage against
1370
+ // the user-visible edited message.
1371
+ const flushDecision = streamFinalizedAsAnswer
1372
+ ? ({ kind: 'skip', reason: 'reply-called' } as ReturnType<typeof decideTurnFlush>)
1373
+ : decideTurnFlush({
1374
+ chatId: turn.sessionChatId,
1375
+ replyCalled: turn.replyCalled,
1376
+ capturedText: turn.capturedText,
1377
+ capturedBlockMeta: turn.capturedBlockMeta,
1378
+ flushEnabled: TURN_FLUSH_SAFETY_ENABLED,
1379
+ })
1380
+ // #1667 — resolve the turn_end answer-delivery gate once, here, via the
1381
+ // pure decision core. The three dispositions below (silent-marker,
1382
+ // turn-flush, #1664 re-prompt) delegate to this so the gateway runs the
1383
+ // exact code the regression test exercises. `finalAnswerDelivered` is read
1384
+ // at its tail value: the answer-stream materialize branch above has
1385
+ // already run (and may have set it true); the flush branch, which also
1386
+ // sets it, is not yet entered and does not affect the gate's own outcome.
1387
+ const turnEndDecision = decideTurnEndGate({
1388
+ flushDecision,
1389
+ finalAnswerDelivered: turn.finalAnswerDelivered,
1390
+ })
1391
+ if (flushDecision.kind === 'skip' && flushDecision.reason !== 'reply-called') {
1392
+ process.stderr.write(
1393
+ `telegram gateway: turn-flush skipped — reason=${flushDecision.reason}\n`,
1394
+ )
1395
+ // Ghost-reply detection (#45): the model ended a Telegram-inbound turn
1396
+ // without calling reply/stream_reply AND without emitting any assistant
1397
+ // text that the turn-flush could forward. The user will see only the
1398
+ // progress card disappear — no visible output. Log a prominent warning
1399
+ // so this silent-drop pattern is immediately visible in the logs.
1400
+ if (
1401
+ flushDecision.reason === 'empty-text' &&
1402
+ !turn.replyCalled
1403
+ ) {
1404
+ process.stderr.write(
1405
+ `telegram gateway: WARN ghost-reply detected — turn ended with zero outbound messages` +
1406
+ ` chat=${chatId} turnStartedAt=${turn.startedAt} replyCalled=false capturedText=empty` +
1407
+ ` — the progress card steps were the only thing the user saw (#45)\n`,
1408
+ )
1409
+ // #2527: emit structured WARN so the reaction-only failure mode is
1410
+ // machine-readable in the streaming-metrics channel.
1411
+ const tKey = statusKey(chatId, threadId)
1412
+ logStreamingEvent({
1413
+ kind: 'turn_no_reply_warn',
1414
+ chatId,
1415
+ threadId,
1416
+ turnId: turn.turnId,
1417
+ turnDurationMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
1418
+ reactionCount: reactionTransitionCounts.get(tKey) ?? 0,
1419
+ })
1420
+ }
1421
+ }
1422
+
1423
+ // ── Sentinel suppression (NO_REPLY / HEARTBEAT_OK) ──────────────────
1424
+ // When the model's only output is a silent-turn sentinel we must:
1425
+ // 1. NOT finalise the progress card (that would push a "Done" edit).
1426
+ // 2. NOT send any reply message to the user.
1427
+ // 3. Unpin the progress card so no orphaned ⚙️ Working… lingers.
1428
+ // 4. Log at debug level and fall through to normal state cleanup.
1429
+ if (turnEndDecision === 'silent_end') {
1430
+ // Don't try to distinguish NO_REPLY vs HEARTBEAT_OK in the log line:
1431
+ // `isSilentFlushMarker` accepts trailing punctuation (e.g. "NO_REPLY.")
1432
+ // and case variants, so a strict equality check would print the wrong
1433
+ // reason. The flushDecision.reason is the source of truth.
1434
+ process.stderr.write(
1435
+ `telegram gateway: silent-turn-suppression: chat=${chatId} turnKey=${turn.startedAt} reason=silent-marker\n`,
1436
+ )
1437
+ // Drop progress-card streams without finalising — the normal
1438
+ // closeProgressLane call below would call stream.finalize() which
1439
+ // sends a final "Done" edit to Telegram. Skip that for silent turns.
1440
+ const suppressPrefix = chatKeyWithSuffix(chatId, threadId, 'progress')
1441
+ for (const [key] of activeDraftStreams) {
1442
+ if (key.startsWith(suppressPrefix)) {
1443
+ activeDraftStreams.delete(key)
1444
+ }
1445
+ }
1446
+ // Unpin without editing the message so no orphaned card lingers.
1447
+ unpinProgressCardForChat?.(chatId, threadId)
1448
+ // Fall through to normal state cleanup (finalize, purge, etc.)
1449
+ // but skip the regular closeProgressLane so we don't re-finalize.
1450
+ // #1713: silent-marker turns still finalize to 👍 — turn_end is
1451
+ // the terminal trigger regardless of whether a reply landed.
1452
+ finalizeStatusReaction(chatId, threadId, 'done')
1453
+ // Match the normal turn_end path's telemetry so silent-marker turns
1454
+ // still appear in turn-duration graphs.
1455
+ {
1456
+ const sKey = streamKey(chatId, threadId)
1457
+ const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
1458
+ logStreamingEvent({
1459
+ kind: 'turn_end',
1460
+ chatId,
1461
+ durationMs: turnDurationMs,
1462
+ suppressClearedCount: suppressPtyPreview.has(sKey) ? 1 : 0,
1463
+ })
1464
+ // #203: compute trailing gap (last signal → turn_end) then emit.
1465
+ const tKey = statusKey(chatId, threadId)
1466
+ signalTracker.noteSignal(tKey, Date.now())
1467
+ logStreamingEvent({ kind: 'turn_signal_gap', chatId, longestGapMs: signalTracker.getLongestGap(tKey), turnDurationMs })
1468
+ // #1122 KPI: emit turn_ended (silent-marker path) with TTFO +
1469
+ // outbound-gap metrics for the conversational-pacing dashboard.
1470
+ const outboundMetrics = signalTracker.getOutboundMetrics(tKey)
1471
+ emitRuntimeMetric({
1472
+ kind: 'turn_ended',
1473
+ chat_id: chatId,
1474
+ thread_id: threadId ?? null,
1475
+ duration_ms: turnDurationMs,
1476
+ ttfo_ms: outboundMetrics.ttfoMs,
1477
+ outbound_count: outboundMetrics.outboundCount,
1478
+ longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
1479
+ ended_via: 'silent',
1480
+ })
1481
+ // #1122 PR4 fix: deterministic silent-end detection for the
1482
+ // Stop hook. PR3 deleted the writer with the progress card;
1483
+ // this restores it. If the user-message turn ended without
1484
+ // any outbound, write the state file so silent-end-interrupt
1485
+ // -stop.mjs blocks the stop and re-prompts the agent.
1486
+ if (outboundMetrics.outboundCount === 0) {
1487
+ writeSilentEndState({
1488
+ chatId,
1489
+ threadId: threadId ?? null,
1490
+ turnKey: tKey,
1491
+ })
1492
+ }
1493
+ // #1122 PR4 fix: PR3 removed the progressDriver.onTurnComplete
1494
+ // callback that cleared the turn-active marker on silent-marker
1495
+ // turns. The main turn-end path at ~line 5180 has its own
1496
+ // cleanup (#550 defence-in-depth) but the silent-marker path
1497
+ // relied solely on the driver callback. Without this the
1498
+ // bridge-watchdog (#412) reads a stale marker and could
1499
+ // false-positive wedge-detection across silent turns.
1500
+ try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
1501
+ signalTracker.clear(tKey)
1502
+ silencePoke.endTurn(tKey)
1503
+ pendingProgress.noteTurnEnd(tKey)
1504
+ }
1505
+ lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
1506
+ setPendingPtyPartial(null)
1507
+ closeActivityLane(chatId, threadId)
1508
+ // NOTE: closeProgressLane intentionally skipped — streams already dropped above.
1509
+ // #1067: null the atom so any late-arriving event for THIS turn
1510
+ // returns early at handler entry. A new `enqueue` swaps in a
1511
+ // fresh atom; the silent-turn teardown doesn't need to preserve
1512
+ // any of the prior turn's state.
1513
+ endCurrentTurnAtomic(turn)
1514
+ // #549 fix — silent-marker teardown drops any pending preamble.
1515
+ preambleSuppressor.dropNow()
1516
+ return
1517
+ }
1518
+
1519
+ if (turnEndDecision === 'flush' && flushDecision.kind === 'flush') {
1520
+ let capturedText = flushDecision.text
1521
+ // #2798 — turn-flush delivers the model's terminal prose when it
1522
+ // skipped reply/stream_reply, but historically bypassed the reply
1523
+ // path's markdown normalization entirely. Mirror executeReply's front
1524
+ // of pipeline here so the backstop renders identically: repair LLM
1525
+ // JSON-escape bungles (literal `\n`), then promote lone prose paragraph
1526
+ // breaks into GFM hard breaks so the Bot API 10.1 rich path doesn't
1527
+ // collapse them (lists/tables/code left untouched). Runs BEFORE the
1528
+ // redact/scrub below, exactly as reply orders it (repair → normalize →
1529
+ // redact → scrub), so masking sees the repaired text. Paragraph gaps
1530
+ // are the plain `\n\n` normalizeParagraphBreaks guarantees — no spacer
1531
+ // pass runs on the send side any more (removed in the #2669 follow-up).
1532
+ capturedText = normalizeParagraphBreaks(repairEscapedWhitespace(capturedText))
1533
+ // Component 3 — origin-thread backstop. `chatId`/`threadId` are
1534
+ // captured from the turn atom (turn.sessionChatId/sessionThreadId)
1535
+ // at the top of this turn_end handler, NOT from the live
1536
+ // currentTurn and NEVER from chatThreadMap. So the turn-flush
1537
+ // answer always lands in the thread the turn originated from, even
1538
+ // if currentTurn has flipped — the same guarantee the reply path
1539
+ // gets via origin_turn_id.
1540
+ const backstopChatId = chatId
1541
+ const backstopThreadId = threadId
1542
+ const backstopCtrl = ctrl
1543
+
1544
+ // Outbound secret scrub (#2044). Turn-flush delivers the model's
1545
+ // terminal answer prose when it skipped reply/stream_reply — that
1546
+ // is arbitrary agent free-text, sent via sendMessage/editMessageText
1547
+ // and previewed to stderr below, so it needs the same mask as the
1548
+ // three reply tools. Mirror the voice scrub: mask before the send,
1549
+ // the preview, and recordOutbound.
1550
+ capturedText = redactOutboundText(capturedText, 'turn_flush')
1551
+
1552
+ // #2798 reply-parity — normalize dashes/bullets and trip the over-bold
1553
+ // guard deterministically, on code-masked text. This is the SAME chain
1554
+ // the reply path applies (`stripExcessBold(normalizePunctuation(text))`)
1555
+ // in the SAME order: after redact, before scrubVoice. Without it the
1556
+ // turn-flush backstop rendered punctuation/bold differently from an
1557
+ // identical reply. Kept inline to mirror reply exactly — a shared helper
1558
+ // is a deliberate future refactor, not this change.
1559
+ capturedText = stripExcessBold(normalizePunctuation(capturedText))
1560
+
1561
+ // Voice scrub (PR #1683 follow-up). Turn-flush is the path
1562
+ // that fires when the model emits raw transcript text WITHOUT
1563
+ // calling reply / stream_reply. That captured text bypasses
1564
+ // PR #1683's executeReply scrub site entirely and is delivered
1565
+ // via the rich-message path directly. Scrub the capturedText on the
1566
+ // raw markdown so em-dashes never reach the wire. Kill switch:
1567
+ // SWITCHROOM_DISABLE_VOICE_SCRUB.
1568
+ {
1569
+ const scrub = scrubVoice(capturedText)
1570
+ if (scrub.replaced > 0) {
1571
+ capturedText = scrub.scrubbed
1572
+ emitRuntimeMetric({
1573
+ kind: 'voice_scrub_applied',
1574
+ chatKey: statusKey(backstopChatId, backstopThreadId),
1575
+ replaced: scrub.replaced,
1576
+ site: 'turn_flush',
1577
+ })
1578
+ }
1579
+ }
1580
+
1581
+ // #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
1582
+ // returns 'reply-called' otherwise). It legitimately delivers the
1583
+ // model's terminal text as the answer, so the turn IS answered.
1584
+ // Mark it now so the early-return below skips the silent-end
1585
+ // re-prompt for a turn whose answer is genuinely on its way out.
1586
+ // (The IIFE that actually sends runs after this branch's `return`;
1587
+ // since the silent-end block is on the sibling reply-called path
1588
+ // that this branch never reaches, this set is belt-and-braces —
1589
+ // it keeps the captured `turn` atom internally consistent for any
1590
+ // future reader.)
1591
+ // PR B (Fix 4 — intentional record/ledger inconsistency, out of scope).
1592
+ // We keep setting finalAnswerDelivered=true HERE (before the async send)
1593
+ // so endCurrentTurnAtomic's obligation CLOSE (decideObligationTurnEnd,
1594
+ // ~4818) fires unchanged at turn end. PR B only makes the turns.jsonl
1595
+ // *record* honest (status send_failed when the send later throws) — it
1596
+ // deliberately does NOT change obligation behavior. Consequence: a
1597
+ // send_failed turn still CLOSES its obligation, so a flood-dropped
1598
+ // answer is NOT re-presented — an honest record without honest recovery.
1599
+ // Re-delivery on send_failed (drive obligation close from real send
1600
+ // success, leave it open on failure) is a separate change — see the
1601
+ // PR-B handback FOLLOW-UP note; do NOT touch the ledger in this PR.
1602
+ turn.finalAnswerDelivered = true
1603
+ // Feed-reopen refinement: turn-flush delivers the model's terminal
1604
+ // transcript text as the genuine answer (not an ack). Default to
1605
+ // substantive so a late tool label does NOT re-open the feed / trip
1606
+ // the silent-end re-prompt. (Belt-and-braces, like the set above —
1607
+ // this branch returns before any further tool_label can arrive.)
1608
+ turn.finalAnswerSubstantive = true
1609
+ // 2026-07 double-reply-on-DM fix (Part 2) — arm the answer-delivered
1610
+ // race latch NOW, synchronously, BEFORE the ~500 ms async send below and
1611
+ // BEFORE `flushedTurnSupersede.record`. A late reply that lands in the
1612
+ // post-fire pre-record window resolves this turn (via the unified owner
1613
+ // resolver, reading the atom preserved in `recentTurnsById`) and
1614
+ // suppresses itself against this latch — closing the residual race Part
1615
+ // 1's supersede cannot reach. `capturedText` here is the selected,
1616
+ // normalized flush delivery text.
1617
+ //
1618
+ // #3276 guard 2/5 — the arm is now UNCONDITIONAL (dropped the former
1619
+ // ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` gate). A short terminal answer ("yes,
1620
+ // done") is a genuine answer this backstop is about to deliver, so a
1621
+ // late reply carrying the same short answer MUST supersede/suppress
1622
+ // rather than post a duplicate — the real-id supersede recorded below
1623
+ // corrects it in place.
1624
+ //
1625
+ // TWO distinct arbiters set synchronously here, before any `await`:
1626
+ // (a) `turn.answerDelivered` — the backstop-vs-LATE-REPLY signal the
1627
+ // reply path already reads (`decideAnswerLatchSuppression` +
1628
+ // `flushedTurnSupersede`), exactly as on `main`.
1629
+ // (b) `backstopDeliveryLedger.claim` — the backstop-vs-BACKSTOP
1630
+ // double-fire latch: `claim` returning false means this turn
1631
+ // already fired a backstop (answer-ready quiescence, then the
1632
+ // turn-end backstop), so this fire is a no-op. It does NOT
1633
+ // arbitrate the late reply (that is (a)); it is redundant-but-
1634
+ // cheap with the `currentTurn == null` bail below.
1635
+ turn.answerDelivered = true
1636
+ const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId)
1637
+
1638
+ // #654 deterministic double-message fix. Hand off the pinned
1639
+ // progress card BEFORE state reset so the driver doesn't keep
1640
+ // editing it while turn-flush is rewriting it with the answer.
1641
+ // `wasEmitted` tells us whether the card has already been
1642
+ // published; `turnKey` lets us look up the pinned messageId
1643
+ // via pinMgr below. Idempotent — calling later in the IIFE
1644
+ // would be no-op against the same chatState.
1645
+ const cardTakeover = progressDriver?.takeOverCard({
1646
+ chatId: backstopChatId,
1647
+ threadId: backstopThreadId != null ? String(backstopThreadId) : undefined,
1648
+ }) ?? { wasEmitted: false, turnKey: null }
1649
+ const backstopCardMessageId =
1650
+ cardTakeover.wasEmitted && cardTakeover.turnKey != null
1651
+ ? (getPinnedProgressCardMessageId?.(cardTakeover.turnKey) ?? null)
1652
+ : null
1653
+ const backstopCardTurnKey = cardTakeover.turnKey
1654
+
1655
+ // #1067: null the atom BEFORE the async IIFE starts. Any event
1656
+ // that arrives during the 500ms-suppression window or the
1657
+ // sendMessage await for this turn will see currentTurn == null
1658
+ // and bail; a new enqueue will swap in a fresh atom. The
1659
+ // `backstop*` locals above hold everything the IIFE needs.
1660
+ //
1661
+ // PR B — defer the turns.jsonl record write to the send IIFE below so
1662
+ // the recorded `status` reflects the REAL send outcome, not the
1663
+ // speculative `finalAnswerDelivered=true` set just above. Everything
1664
+ // else in endCurrentTurnAtomic (atom null, gate release, obligation
1665
+ // bookkeeping, purge) still runs synchronously here for the #1067 /
1666
+ // #1556 wedge-safety reasons. `backstopTurnEndedAt` is null iff the
1667
+ // atom was already torn down elsewhere (no record to emit).
1668
+ const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true })
1669
+ // #549 fix — turn-flush takes ownership of the captured-text
1670
+ // backup; reset the preamble buffer (its content is already in
1671
+ // the captured `capturedText`, which turn-flush is about to send).
1672
+ preambleSuppressor.dropNow()
1673
+ // #1289 fix — drain silence-poke + signal-tracker state for this
1674
+ // turn. The three sibling turn_end exit branches (context-exhaust
1675
+ // at ~5098, silent-marker at ~5097-5098, default reply-called tail
1676
+ // at ~5348-5349) all call signalTracker.clear + silencePoke.endTurn.
1677
+ // The flush-backstop branch was retrofitted in #1067 to null
1678
+ // currentTurn early but never had this cleanup added — leaving the
1679
+ // silence-poke state in the Map, so 300s after the original turn
1680
+ // start the framework fallback fires and the user sees
1681
+ // "still working… (no update from agent in 5 min)" on a turn the
1682
+ // gateway already considers over.
1683
+ {
1684
+ const tKey = statusKey(chatId, threadId)
1685
+ signalTracker.clear(tKey)
1686
+ silencePoke.endTurn(tKey)
1687
+ pendingProgress.noteTurnEnd(tKey)
1688
+ }
1689
+
1690
+ void (async () => {
1691
+ await new Promise<void>(resolve => setTimeout(resolve, 500))
1692
+ if (HISTORY_ENABLED) {
1693
+ try {
1694
+ // S1 fix (fable red-team 2026-07-17) — the old predicate here,
1695
+ // `getRecentOutboundCount(chatId, 2) > 0`, counted ANY assistant
1696
+ // row in the WHOLE chat: a worker `progress_update`, a command
1697
+ // ack / restart notice, or a reply in a DIFFERENT forum topic all
1698
+ // suppressed the flush and (worse) CLOSED the obligation below —
1699
+ // silently dropping the user's real answer. The scoped predicate
1700
+ // (same thread, length ≥ min(answerLength, 200)) lives in
1701
+ // `turn-flush-suppression.ts`; `hasOutboundDeliveredSince` is the
1702
+ // durable oracle whose thread/length semantics history tests pin.
1703
+ const { hasOutboundDeliveredSince } = await import('../history.js')
1704
+ const { shouldSuppressTurnFlush } = await import('./turn-flush-suppression.js')
1705
+ const suppress = shouldSuppressTurnFlush(
1706
+ { hasSubstantiveOutbound: hasOutboundDeliveredSince },
1707
+ {
1708
+ chatId: backstopChatId,
1709
+ threadId: backstopThreadId ?? null,
1710
+ answerLength: capturedText.length,
1711
+ nowMs: Date.now(),
1712
+ },
1713
+ )
1714
+ if (suppress) {
1715
+ process.stderr.write(`telegram gateway: turn-flush suppressed — a substantive same-thread outbound landed within 2s\n`)
1716
+ // Do NOT finalize the status reaction here. As of #1713
1717
+ // the reaction is only finalized by the `turn_end` IPC
1718
+ // handler — mid-turn delivery proofs (local history,
1719
+ // stream finalize callbacks, executeReply post-send) no
1720
+ // longer transition the emoji. This branch just returns.
1721
+ // #2094 cosmetic: the per-turn reaction tracking was ALREADY
1722
+ // purged synchronously by endCurrentTurnAtomic (before this
1723
+ // async IIFE ran). The old redundant purgeReactionTracking
1724
+ // here re-fired on an already-cleared key WITHOUT `endingTurn`,
1725
+ // emitting an inconsistent shadow trace. Removed.
1726
+ //
1727
+ // PR B — a substantive same-thread outbound just delivered this
1728
+ // turn's answer, so the flush was legitimately suppressed. Emit
1729
+ // the deferred record as 'suppressed'. Not a failure.
1730
+ if (backstopTurnEndedAt != null) {
1731
+ turn.deliveryOutcome = 'suppressed'
1732
+ // S1 fix — do NOT close the obligation here. When the recent
1733
+ // outbound is genuinely this turn's answer (a raced reply /
1734
+ // stream materialization), the reply path closes its own
1735
+ // obligation idempotently; when the suppression is a false
1736
+ // positive (a long same-thread non-answer inside the 2s
1737
+ // window — the residual the scoped predicate can't
1738
+ // discriminate), closing here made the drop PERMANENT.
1739
+ // Leaving it open lets the obligation sweep arbitrate: it
1740
+ // stands down silently if a substantive outbound answered
1741
+ // the user, and re-presents otherwise. `noteTurnEnded` arms
1742
+ // the liveness floor exactly as the send-failed path does.
1743
+ if (OBLIGATION_LEDGER_ENABLED) obligationLedger.noteTurnEnded(turn.turnId, Date.now())
1744
+ emitTurnRecord(turn, backstopTurnEndedAt)
1745
+ }
1746
+ return
1747
+ }
1748
+ } catch {}
1749
+ }
1750
+
1751
+ // #3276 guard 5 — double-fire guard. If this turn already claimed the
1752
+ // delivery latch (a prior backstop fire — e.g. answer-ready quiescence
1753
+ // followed by the turn-end backstop for the same turn), do NOT deliver
1754
+ // again. The first fire owns delivery; this fire is a cosmetic no-op.
1755
+ if (!backstopLatchClaimed) {
1756
+ process.stderr.write(
1757
+ `telegram gateway: turn-flush skipped — turn ${turn.turnId} already claimed the delivery latch\n`,
1758
+ )
1759
+ return
1760
+ }
1761
+
1762
+ process.stderr.write(
1763
+ `telegram gateway: turn-flush firing — ${capturedText.length} chars without reply tool ` +
1764
+ `(chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? 'none'})\n`,
1765
+ )
1766
+ // PR B (Fix 1) — send accounting declared OUTSIDE the try so the
1767
+ // single-record `finally` below reads it on EVERY in-process exit.
1768
+ // `delivered` is the RECEIPT-gated truth (>=1 fresh non-card id AND
1769
+ // all chunks landed), computed by the retry orchestrator — NOT a
1770
+ // blanket outer-catch flag, so a throw in the post-delivery
1771
+ // bookkeeping below (dedup / supersede record) can never demote a
1772
+ // genuinely delivered turn to `send_failed` (finding 4).
1773
+ let sentIds: number[] = []
1774
+ let chunkCount = 0
1775
+ let delivered = false
1776
+ try {
1777
+ // #3276 — the ONE delivery primitive. deliverAnswer routes through
1778
+ // `sendReplyChunks` (the same send core executeReply uses) and posts
1779
+ // a FRESH chat message; it NEVER edits the progress card, so a
1780
+ // "delivery" can never be a card mutation the marker-sweep GC's
1781
+ // ~60-90s later. It returns the REAL fresh chat message ids and
1782
+ // retries mid-chunk (bounded) before giving up — the per-chunk
1783
+ // ledger resumes at the first unsent chunk, never re-sending chunk 0
1784
+ // (guard 6).
1785
+ const delivery = await deliverAnswer({
1786
+ chatId: backstopChatId,
1787
+ threadId: backstopThreadId,
1788
+ text: capturedText,
1789
+ turnId: turn.turnId,
1790
+ cardMessageId: backstopCardMessageId,
1791
+ // S4 — anchor the flushed answer to the inbound it answers
1792
+ // (null for synthesized turns, which send bare as before).
1793
+ replyToMessageId: turn.sourceMessageId,
1794
+ })
1795
+ sentIds = delivery.sentIds
1796
+ chunkCount = delivery.chunkCount
1797
+ delivered = delivery.delivered
1798
+
1799
+ // #546 dedup: record what turn-flush just sent so a late-arriving
1800
+ // reply / stream_reply with the same content gets suppressed.
1801
+ outboundDedup.record(
1802
+ backstopChatId,
1803
+ backstopThreadId,
1804
+ capturedText,
1805
+ Date.now(),
1806
+ getCurrentTurn()?.registryKey ?? null,
1807
+ )
1808
+ // #3276 guard 3 — feed the REAL fresh chat ids into the supersede
1809
+ // record so a late `reply` for the same turn corrects them in place
1810
+ // (edit / delete+resend) instead of shipping a second bubble. These
1811
+ // are genuine chat message ids now, never a card-edit id.
1812
+ if (sentIds.length > 0) {
1813
+ flushedTurnSupersede.record(
1814
+ backstopChatId,
1815
+ backstopThreadId,
1816
+ { turnId: turn.turnId, messageIds: sentIds, text: capturedText },
1817
+ Date.now(),
1818
+ )
1819
+ }
1820
+ // #3276 guard 4 — collapse the taken-over card to a NON-answer
1821
+ // state. The answer flowed ONLY through deliverAnswer (a fresh
1822
+ // bubble); here we just unpin/complete the card so no orphaned
1823
+ // ⚙️ Working… lingers. The card carries NO answer text, so a
1824
+ // card-collapse failure and a fresh-send failure can never leave
1825
+ // BOTH an answer-card AND an answer-bubble visible.
1826
+ if (!delivered) {
1827
+ // Retries exhausted with nothing durable delivered — finalize the
1828
+ // reaction as error and reset the latch so a genuine late reply is
1829
+ // NOT suppressed.
1830
+ if (backstopCtrl) backstopCtrl.finalize('error')
1831
+ backstopDeliveryLedger.release(turn.turnId)
1832
+ turn.answerDelivered = false
1833
+ } else if (backstopCtrl) {
1834
+ backstopCtrl.finalize('done')
1835
+ }
1836
+ // Unpin the card either way (cosmetic). completeTurn cleans up
1837
+ // pinMgr's per-turn state and unpins; fall back to the legacy
1838
+ // unpinForChat sweep when we didn't take over a turn.
1839
+ if (backstopCardTurnKey != null) {
1840
+ completeProgressCardTurn?.({
1841
+ chatId: backstopChatId,
1842
+ threadId: backstopThreadId,
1843
+ turnKey: backstopCardTurnKey,
1844
+ })
1845
+ } else {
1846
+ unpinProgressCardForChat?.(backstopChatId, backstopThreadId)
1847
+ }
1848
+ } catch (err) {
1849
+ // Only reachable via a throw in the post-delivery bookkeeping (the
1850
+ // delivery itself is retry-wrapped inside deliverAnswer and never
1851
+ // throws out). `delivered` already reflects the receipt-gated truth;
1852
+ // do NOT flip it here (finding 4). If nothing landed, reset the
1853
+ // latch so a genuine late reply is not suppressed.
1854
+ process.stderr.write(`telegram gateway: turn-flush post-delivery bookkeeping failed: ${(err as Error).message}\n`)
1855
+ if (!delivered) {
1856
+ turn.answerDelivered = false
1857
+ backstopDeliveryLedger.release(turn.turnId)
1858
+ if (backstopCtrl) backstopCtrl.finalize('error')
1859
+ }
1860
+ } finally {
1861
+ // #3276 guard 7 + finding 1 — honest record AND honest recovery.
1862
+ // Status is derived from the RECEIPT gate: `complete` IFF a fresh
1863
+ // non-card id landed for every chunk; otherwise `send_failed`.
1864
+ //
1865
+ // The delivery-obligation close was DEFERRED out of
1866
+ // endCurrentTurnAtomic (deferObligationClose) so it reflects the
1867
+ // REAL send outcome here, not the speculative fire-time flag:
1868
+ // delivered → close the obligation (answered).
1869
+ // NOT deliv. → leave it OPEN + noteTurnEnded, so the ~150s
1870
+ // liveness floor re-presents the answer instead of
1871
+ // the old silent `send_failed` drop.
1872
+ if (backstopTurnEndedAt != null) {
1873
+ finalizeBackstopSendGated(turn, {
1874
+ threw: !delivered,
1875
+ sentIds,
1876
+ chunkCount,
1877
+ cardMessageId: backstopCardMessageId,
1878
+ })
1879
+ if (OBLIGATION_LEDGER_ENABLED) {
1880
+ if (delivered) {
1881
+ obligationLedger.close(turn.turnId)
1882
+ } else {
1883
+ // Terminal fail — do NOT mark the obligation satisfied.
1884
+ turn.finalAnswerDelivered = false
1885
+ obligationLedger.noteTurnEnded(turn.turnId, Date.now())
1886
+ }
1887
+ }
1888
+ emitTurnRecord(turn, backstopTurnEndedAt)
1889
+ }
1890
+ // GC the IN-MEMORY ledger now; on a partial, deliverAnswer already
1891
+ // persisted the snapshot to the durable obligation (#3282) to resume.
1892
+ backstopDeliveryLedger.clear(turn.turnId)
1893
+ }
1894
+ // #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
1895
+ // was removed. endCurrentTurnAtomic already ran the canonical purge
1896
+ // (with the authoritative `endingTurn`) synchronously before this
1897
+ // async IIFE started, so re-purging here only re-fired on an
1898
+ // already-cleared key without `endingTurn` — an inconsistent shadow
1899
+ // trace. The #2094 finding-1 backstop covers any pre-purge throw.
1900
+ })()
1901
+ return
1902
+ }
1903
+
1904
+ // #1713: turn_end is THE terminal trigger. Finalize via the
1905
+ // single terminal path. Any prior intermediate states pending in
1906
+ // the debounce window are flushed by `finalize()` before the
1907
+ // terminal emoji emits.
1908
+ //
1909
+ // #2527 — role-aware terminal honesty: a USER turn that ends without
1910
+ // a delivered answer must NOT paint 👍 (the operator's "thumbs up so
1911
+ // it feels like you're done" report). It finalizes to the gentle
1912
+ // 'undelivered' terminal (😐) instead; the silent-end fallback below
1913
+ // carries the apology text. system/cron turns and NO_REPLY/HEARTBEAT_OK
1914
+ // turns (which return earlier) keep 👍 — their silence is legitimate.
1915
+ let terminalReason = decideTerminalReason({
1916
+ enabled: LIVENESS_TERMINAL_HONESTY,
1917
+ role: turn.role,
1918
+ finalAnswerDelivered: turn.finalAnswerDelivered,
1919
+ })
1920
+ // #2527 review note 1 — worker-hold carve-out: if the turn is STILL
1921
+ // legitimately working at turn_end (a background sub-agent the parent
1922
+ // dispatched is running on), don't prematurely paint 😐. Fall back to
1923
+ // 'done' so the existing deferred-done path holds ✍️ until the worker
1924
+ // completes (then 👍) — the worker-activity feed carries the progress.
1925
+ // Only a turn that genuinely ended undelivered AND is not still working
1926
+ // gets the honest 😐.
1927
+ if (terminalReason === 'undelivered' && isLegitimatelyWorking(statusKey(chatId, threadId))) {
1928
+ terminalReason = 'done'
1929
+ }
1930
+ if (terminalReason === 'undelivered') {
1931
+ process.stderr.write(
1932
+ `telegram gateway: WARN turn_no_reply — user turn ended with an ` +
1933
+ `ambient ack but no delivered answer; painting 😐 not 👍 ` +
1934
+ `chat=${chatId} thread=${threadId ?? '-'} turnId=${turn.turnId} (#2527)\n`,
1935
+ )
1936
+ }
1937
+ finalizeStatusReaction(chatId, threadId, terminalReason)
1938
+ {
1939
+ const sKey = streamKey(chatId, threadId)
1940
+ const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
1941
+ logStreamingEvent({
1942
+ kind: 'turn_end',
1943
+ chatId,
1944
+ durationMs: turnDurationMs,
1945
+ suppressClearedCount: suppressPtyPreview.has(sKey) ? 1 : 0,
1946
+ })
1947
+ // #203: compute trailing gap (last signal → turn_end) then emit.
1948
+ const tKey = statusKey(chatId, threadId)
1949
+ signalTracker.noteSignal(tKey, Date.now())
1950
+ logStreamingEvent({ kind: 'turn_signal_gap', chatId, longestGapMs: signalTracker.getLongestGap(tKey), turnDurationMs })
1951
+ // #1122 KPI: emit turn_ended with TTFO + outbound-gap metrics so
1952
+ // the dashboard can compute outbound silence p95 and TTFO p95
1953
+ // without per-event reconstruction.
1954
+ const outboundMetrics = signalTracker.getOutboundMetrics(tKey)
1955
+ emitRuntimeMetric({
1956
+ kind: 'turn_ended',
1957
+ chat_id: chatId,
1958
+ thread_id: threadId ?? null,
1959
+ duration_ms: turnDurationMs,
1960
+ ttfo_ms: outboundMetrics.ttfoMs,
1961
+ outbound_count: outboundMetrics.outboundCount,
1962
+ longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
1963
+ ended_via: outboundMetrics.outboundCount > 0 ? 'reply' : 'silent',
1964
+ })
1965
+ // #1122 PR4 / #1161 / #1664: deterministic undelivered-turn
1966
+ // handling (see the silent-marker path above for the rationale).
1967
+ // - first undelivered turn-end → recordSilentTurnEnd writes the
1968
+ // state file so the Stop hook (silent-end-interrupt-stop.mjs)
1969
+ // blocks the session-end and re-prompts the agent to deliver.
1970
+ // - the Stop-hook re-prompt is already spent and the agent is
1971
+ // STILL undelivered → recordSilentTurnEnd returns
1972
+ // exhausted:true; deliver a user-facing fallback so the turn
1973
+ // never just vanishes (the user otherwise only sees the card
1974
+ // disappear).
1975
+ //
1976
+ // #1664 — the trigger is "no final answer delivered", not "zero
1977
+ // outbound". `outboundCount === 0` is now just the special case
1978
+ // where nothing landed at all. The added case: the model sent an
1979
+ // interim ack via reply/stream_reply (outboundCount > 0,
1980
+ // replyCalled = true) but ended the turn with its real answer as
1981
+ // plain transcript text — rendered into an ephemeral answer-lane
1982
+ // draft and retracted at turn_end, never finalized. finalAnswer-
1983
+ // Delivered stays false there, so the re-prompt engages and the
1984
+ // model re-delivers the answer through the reply tool. NO_REPLY /
1985
+ // HEARTBEAT_OK silent-marker turns return earlier and never reach
1986
+ // this path. The turn-flush 'flush' branch also returns earlier
1987
+ // (and sets finalAnswerDelivered=true defensively).
1988
+ // #1667 — this is the reply-called tail; `turnEndDecision === 'reprompt'`
1989
+ // is exactly `turn.finalAnswerDelivered === false` here (silent-marker
1990
+ // and flush both returned earlier), delegated to the pure gate core.
1991
+ if (turnEndDecision === 'reprompt') {
1992
+ // Option A transcript-prose bridge (#3227). Before falling through to
1993
+ // the re-prompt / represent safety nets, check whether the Stop hook
1994
+ // already isolated this turn's real answer from the transcript and
1995
+ // persisted it in silent-end-pending.json (`pendingText`). That file
1996
+ // lands BEFORE this turn_end handler runs (the hook fires upstream of
1997
+ // the gateway's own state write — see silent-end-interrupt-stop.mjs).
1998
+ // If a substantive answer is waiting, deliver it directly via the
1999
+ // normal send path NOW, instead of leaning on the (unreliable)
2000
+ // Stop-hook re-prompt or waiting ~2-5 min for the obligation
2001
+ // represent. The delivery closes the obligation + records dedup, so
2002
+ // the represent and a late reply-tool retry are both suppressed —
2003
+ // captured-prose delivery and represent are mutually exclusive.
2004
+ const proseDecision = CAPTURED_PROSE_DELIVERY_ENABLED
2005
+ ? decideCapturedProseDelivery({
2006
+ turnKey: tKey,
2007
+ // Per-turn nonce (#3228 Finding 3) — the persisted record must
2008
+ // belong to THIS turn, not a stale carryover from a prior turn
2009
+ // on the same chat/thread (tKey is not per-turn unique).
2010
+ turnId: turn.turnId,
2011
+ minChars: CAPTURED_PROSE_MIN_CHARS,
2012
+ })
2013
+ : { deliver: false as const, reason: 'no-state' as const }
2014
+ if (proseDecision.deliver && proseDecision.text != null) {
2015
+ // Deliver the recovered answer directly. This runs async and owns
2016
+ // its own bookkeeping — on success it closes the obligation +
2017
+ // records dedup + clears the silent-end state; on send FAILURE it
2018
+ // arms the recovery net itself (see below) instead of leaving the
2019
+ // answer lost.
2020
+ //
2021
+ // We DELIBERATELY skip recordUndeliveredTurnEnd on the HAPPY path
2022
+ // here: re-arming the Stop-hook re-prompt for an answer that just
2023
+ // went out is exactly what this bridge exists to avoid.
2024
+ //
2025
+ // #3228 Finding 1 — the send-failure net can NOT rely on the shared
2026
+ // turn-end teardown "leaving the obligation open". That teardown
2027
+ // (endCurrentTurnAtomic → decideObligationTurnEnd) closes the
2028
+ // obligation whenever `replyCalled === true`, which is EXACTLY the
2029
+ // interim-ack case that reaches this branch. So a thrown send would
2030
+ // otherwise leave the answer permanently lost (obligation already
2031
+ // closed by teardown, recordUndeliveredTurnEnd skipped). The real
2032
+ // net lives INSIDE deliverCapturedProse's catch: it calls
2033
+ // recordUndeliveredTurnEnd, arming the deterministic Stop-hook
2034
+ // re-prompt (parity with the non-captured path below).
2035
+ process.stderr.write(
2036
+ `telegram gateway: captured-prose delivery engaged on first silent-end ` +
2037
+ `chat=${chatId} turnKey=${tKey} (#3227)\n`,
2038
+ )
2039
+ void deliverCapturedProse({
2040
+ chatId,
2041
+ threadId,
2042
+ statusKeyStr: tKey,
2043
+ registryKey: turn.registryKey ?? null,
2044
+ originTurnId: turn.turnId,
2045
+ text: proseDecision.text,
2046
+ // For the honest "(waited Ns)" clause if the exhaustion-boundary
2047
+ // apology fallback fires (#3228).
2048
+ turnDurationMs,
2049
+ })
2050
+ } else {
2051
+ // PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
2052
+ // wire the represent-guard-style staleness
2053
+ // check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
2054
+ // an exhausted-looking record left over from a PRIOR, already-
2055
+ // answered turn on this same chat/thread (statusKey is not a
2056
+ // per-turn nonce) can never be misread as this turn's spent
2057
+ // re-prompt budget. Falls back to the pre-existing turnKey/
2058
+ // retryCount-only check when history is unavailable.
2059
+ const silentEndDeps: SilentEndDeps | undefined = HISTORY_ENABLED
2060
+ ? {
2061
+ hasOutboundDeliveredSince: (cid, sinceMs, tid) =>
2062
+ hasOutboundDeliveredSince(cid, sinceMs, tid, 1),
2063
+ }
2064
+ : undefined
2065
+ const silentEnd = recordUndeliveredTurnEnd(
2066
+ {
2067
+ chatId,
2068
+ threadId: threadId ?? null,
2069
+ turnKey: tKey,
2070
+ },
2071
+ silentEndDeps,
2072
+ )
2073
+ if (silentEnd.exhausted) {
2074
+ process.stderr.write(
2075
+ `telegram gateway: WARN silent-end fallback — agent stayed ` +
2076
+ `silent after the Stop-hook re-prompt; delivering fallback ` +
2077
+ `message chat=${chatId} turnKey=${tKey} (#1161)\n`,
2078
+ )
2079
+ void retryWithThreadFallback(
2080
+ robustApiCall,
2081
+ (tid) =>
2082
+ bot.api.sendMessage(
2083
+ chatId,
2084
+ silentEndFallbackText(turnDurationMs),
2085
+ tid != null ? { message_thread_id: tid } : {},
2086
+ ),
2087
+ { threadId, chat_id: chatId, verb: 'silent-end-fallback.sendMessage' },
2088
+ ).catch((err) => {
2089
+ process.stderr.write(
2090
+ `telegram gateway: silent-end fallback send failed: ${
2091
+ err instanceof Error ? err.message : String(err)
2092
+ }\n`,
2093
+ )
2094
+ })
2095
+ }
2096
+ } // end else (no captured-prose to deliver)
2097
+ }
2098
+ signalTracker.clear(tKey)
2099
+ silencePoke.endTurn(tKey)
2100
+ pendingProgress.noteTurnEnd(tKey)
2101
+ }
2102
+ lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
2103
+ setPendingPtyPartial(null)
2104
+ closeActivityLane(chatId, threadId)
2105
+ closeProgressLane(chatId, threadId)
2106
+ // Pre-allocated draft orphan-cleanup removed in #553 PR 5 — the
2107
+ // gateway no longer pre-allocates drafts on inbound, so there
2108
+ // is nothing to clean up at turn_end.
2109
+ // Stage 3b: stamp turn-end in the registry as endedVia='stop' (clean
2110
+ // turn_end emit). The kill paths (schedule_restart / SIGTERM) handle
2111
+ // the 'restart' / 'sigterm' cases separately in 3c.
2112
+ if (turnsDb != null && turn.registryKey != null) {
2113
+ // Phase 1 of #332: capture first ~200 chars of the assistant's reply.
2114
+ const capturedJoined = turn.capturedText.join('')
2115
+ const assistantReplyPreview = capturedJoined
2116
+ ? capturedJoined.slice(0, TURN_PREVIEW_MAX)
2117
+ : null
2118
+ // Closes #472 finding #11 — same SIGTERM race as recordTurnStart
2119
+ // above. Sub-ms SQLite write; the setImmediate deferral wasn't
2120
+ // saving anything observable but was opening a window where a
2121
+ // SIGTERM between turn_end and the microtask losing the end row
2122
+ // (turn appears in DB as still-running, then 3c relabels it as
2123
+ // 'sigterm' on shutdown — false negative for clean completion).
2124
+ const _turnKey = turn.registryKey
2125
+ try {
2126
+ recordTurnEnd(turnsDb, {
2127
+ turnKey: _turnKey,
2128
+ endedVia: 'stop' as const,
2129
+ lastAssistantMsgId: turn.lastAssistantMsgId,
2130
+ lastAssistantDone: turn.lastAssistantDone,
2131
+ assistantReplyPreview,
2132
+ toolCallCount: turn.toolCallCount,
2133
+ })
2134
+ } catch (err) {
2135
+ process.stderr.write(`telegram gateway: recordTurnEnd(stop) failed turnKey=${_turnKey}: ${(err as Error).message}\n`)
2136
+ }
2137
+ }
2138
+ // #550: symmetric cleanup with the writeTurnActiveMarker call at
2139
+ // the enqueue arm (line ~2810). Pre-fix, removal was single-pathed
2140
+ // through the (now-retired, #1122/#1126) progressDriver.onTurnComplete
2141
+ // callback, which silently no-op'd when forceCompleteTurn found no
2142
+ // active card — leaking the marker across restarts and triggering
2143
+ // watchdog false-positive restarts. That driver callback no longer
2144
+ // exists (progressDriver is permanently null); this explicit
2145
+ // removeTurnActiveMarker is now the sole cleanup path (idempotent —
2146
+ // unlinkSync swallows ENOENT).
2147
+ removeTurnActiveMarker(STATE_DIR)
2148
+ // #1067: null the atom in one assignment, replacing the seven
2149
+ // field clears the pre-refactor version did. Any late-arriving
2150
+ // event for this turn will see currentTurn == null and bail.
2151
+ endCurrentTurnAtomic(turn)
2152
+ // #549 fix — preamble flush already happened at the TOP of this
2153
+ // turn_end handler (before turn.answerStream is nulled). See
2154
+ // comment near line 3431.
2155
+ return
2156
+ }, // end withTurnEndGateBackstop body (#2094 finding 1)
2157
+ {
2158
+ hasActiveTurn: (k) => activeTurnStartedAt.has(k),
2159
+ purge: (k, endingTurn) => purgeReactionTracking(k, endingTurn),
2160
+ log: (m) => process.stderr.write(m + '\n'),
2161
+ },
2162
+ )
2163
+ return
2164
+ }
2165
+ }
2166
+ }