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,865 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Narrative / activity lane — `composeTurnActivity`, the narrative gate
3
+ // (`makeNarrativeGate` + SHOW/RETRACT/stage/resolve effects,
4
+ // `flushPendingNarrativeAtTurnEnd`), and the activity-feed drain/liveness/
5
+ // heartbeat/clear cluster, relocated VERBATIM from gateway.ts
6
+ // (switchroom#2996 P4-B, plan Amendments 1/5/9/10).
7
+ //
8
+ // SHAPE
9
+ // -----
10
+ // One factory, `createNarrativeLane(deps)`, destructures the injected gateway
11
+ // deps ONCE and returns the 15 lane functions with their original signatures.
12
+ // Intra-lane calls (e.g. the gate's SHOW effect -> `showNarrativeStep` ->
13
+ // `drainActivitySummary`) resolve inside the factory scope, so every body is
14
+ // byte-identical to the pre-move gateway.ts inline body except the enumerated
15
+ // spelling below. gateway.ts holds thin hoisted wrappers that build the lane
16
+ // LAZILY on first call (post-boot, so the captured `bot` handle is the live
17
+ // one) and keep every existing call site — including the stream-render (P4-A)
18
+ // deps builder — unchanged.
19
+ //
20
+ // DI CONTRACT (read before editing)
21
+ // ---------------------------------
22
+ // - Every injected dep except `getCurrentTurn` is a STABLE gateway const /
23
+ // function-decl / singleton instance (verified at extraction: `bot` is
24
+ // assigned exactly once inside the isGatewayMain boot, before any lane
25
+ // call can occur — the lazy first-call capture is therefore live).
26
+ // `earlyLivenessOpenTimers` and the config consts stay declared in
27
+ // gateway.ts (they have non-lane readers: the disconnect teardown drains
28
+ // the timer map).
29
+ // - TURN HANDLE (Amendment 9 / #1664): the lane NEVER reads the
30
+ // `currentTurn` module global — the single live re-read (the heartbeat's
31
+ // chat-root fallback) routes through the injected `getCurrentTurn()`
32
+ // accessor. Per-topic resolution injects the live `currentTurnMap`
33
+ // instance. This is the ONLY body deviation from byte-identical.
34
+ // - SHARED SINGLETONS (Amendment 1): this lane has ZERO
35
+ // `outboundDedup` / `backstopDeliveryLedger` sites (verified: the
36
+ // answer-stream dedup sites are P4-A's stream-render surface, already
37
+ // extracted + golden-tested). The module constructs NO OutboundDedupCache
38
+ // — pinned structurally by stream-render-golden alongside the P2/P4-A
39
+ // modules.
40
+ //
41
+ // ORACLE (Amendment 10): extracted-module golden harness
42
+ // (tests/narrative-lane-golden.test.ts) drives these functions directly with
43
+ // a fake bot recorder + real leaf modules, per the outbound-send-path test
44
+ // precedent; the cross-surface stream-then-reply dedup proof spans the P2 +
45
+ // P4 modules in stream-render-golden.test.ts with the REAL lane flush wired
46
+ // into the stream deps.
47
+ // ─────────────────────────────────────────────────────────────────────────
48
+
49
+ import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
50
+ import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
51
+ import { richMessage } from '../rich-send.js'
52
+ import {
53
+ appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
54
+ } from '../tool-activity-summary.js'
55
+ import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
56
+ import { clearActivityCardRecord, writeActivityCardRecord } from './activity-card-store.js'
57
+ import { chatKeyWithSuffix } from './chat-key.js'
58
+ import {
59
+ computeCrossTurnAnswerDelivered, mayOpenActivityCard, shouldEarlyOpenLiveness,
60
+ type FeedOpenProducer,
61
+ } from './feed-open-gate.js'
62
+ import type { SessionActivityHeader } from '../tool-activity-summary.js'
63
+ import type { CurrentTurn, NarrativeLaneDeps } from './gateway.js'
64
+
65
+ /**
66
+ * Build the narrative/activity lane over the injected gateway deps. Called
67
+ * lazily by gateway.ts on first use (see the section comment for why the
68
+ * one-shot capture is safe) and directly by the golden harness with fakes.
69
+ */
70
+ export function createNarrativeLane(deps: NarrativeLaneDeps) {
71
+ const {
72
+ ACTIVITY_CARD_STORE_PATH, CLEAR_STATUS_ON_COMPLETION, FEED_HEARTBEAT_ENABLED,
73
+ FEED_HEARTBEAT_MIN_STALE_MS, FEED_LIVENESS_OPEN_ENABLED, FEED_LIVENESS_OPEN_MS,
74
+ PIN_STATUS_WHILE_WORKING, POST_ANSWER_LIVENESS_STALE_MS, STATIC,
75
+ activeDraftStreams, activityCardPersistEnabled, activityCardStoreFs, bot,
76
+ cardDrainGate, currentTurnMap, earlyLivenessOpenTimers, emissionAuthorityFor,
77
+ feedOpenGateDeps, getCurrentTurn, reconcileStatusPin, robustApiCall, statusKey,
78
+ } = deps
79
+
80
+ function closeActivityLane(chatId: string, threadId: number | undefined): void {
81
+ const key = chatKeyWithSuffix(chatId, threadId, 'activity')
82
+ const stream = activeDraftStreams.get(key)
83
+ if (stream == null) return
84
+ activeDraftStreams.delete(key)
85
+ void stream.finalize().catch(() => {})
86
+ }
87
+
88
+ function closeProgressLane(chatId: string, threadId: number | undefined): void {
89
+ // Progress-card streams include a turnKey suffix in their key
90
+ // (e.g. "chatId:_:progress:chatId:1"). Iterate and match by prefix
91
+ // so the backstop actually finds the stream.
92
+ const prefix = chatKeyWithSuffix(chatId, threadId, 'progress')
93
+ for (const [key, stream] of activeDraftStreams) {
94
+ if (key.startsWith(prefix)) {
95
+ activeDraftStreams.delete(key)
96
+ void stream.finalize().catch(() => {})
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Render this turn's activity feed, nesting any active foreground sub-agent's
103
+ * narrative beneath the parent's own steps (Model A). With no active
104
+ * foreground sub-agent this is exactly the flat feed. Multiple concurrent
105
+ * foreground sub-agents (rare — parallel Task dispatch) flatten in insertion
106
+ * order; the single-sub-agent common case nests precisely under its
107
+ * Delegating line.
108
+ *
109
+ * The header (elapsed + tool count) is now threaded into the render so the
110
+ * main-session card matches the worker card's two-line header style. This
111
+ * fixes the missing header regression where the worker card showed elapsed/
112
+ * tool-count metadata but the main-session card rendered step-lines only.
113
+ */
114
+ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''): string | null {
115
+ const childLines: string[] = []
116
+ for (const narrative of turn.foregroundSubAgents.values()) {
117
+ childLines.push(...narrative)
118
+ }
119
+ // Pass labeledToolCount as stepCount only on the terminal (final) render so
120
+ // the persisted feed record shows a `✓ N steps` total. The live in-progress
121
+ // feed omits it (stepCount undefined) to stay clean and minimal.
122
+ const stepCount = final ? turn.labeledToolCount : undefined
123
+ // Build the session header so the main-session card renders the same two-line
124
+ // elapsed/tool-count header as the worker card.
125
+ const header: SessionActivityHeader = {
126
+ label: 'Agent',
127
+ elapsedMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
128
+ toolCount: turn.labeledToolCount,
129
+ state: final ? 'done' : 'running',
130
+ model: turn.currentModel,
131
+ // The parent's OWN running token total → `· N tok` on the metrics line.
132
+ // 0 → tokenSegment omits it (clean, same as the worker feed).
133
+ totalTokens: turn.totalTokens,
134
+ }
135
+ return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
136
+ }
137
+
138
+ // PENDING_NARRATIVE_FLUSH_MS is now defined in and imported from
139
+ // `narrative-flush.ts` (the kernel's home) so the main-agent gateway path and
140
+ // the worker/sub-agent watcher share ONE source of truth for the time-box.
141
+ // The main path paints a parked block via the SAME `showNarrativeStep` path a
142
+ // lookahead would, and a timer-painted block that later proves to be the reply
143
+ // is deterministically retracted (the RETRACT effect wired into the controller
144
+ // below).
145
+
146
+ /**
147
+ * Retract a narration step the flush timer painted EARLY that turned out to draft
148
+ * the outgoing reply — the effect half of the anti-double-print guarantee's timer
149
+ * path. Splice the line out of `mirrorLines` and re-render, so neither the live
150
+ * nor the finalized card surfaces the answer as a narration step. The splice runs
151
+ * synchronously BEFORE the reply's `clearActivitySummary` finalize reads
152
+ * `mirrorLines`, so the persisted card is clean regardless of the re-render.
153
+ */
154
+ function retractNarrativeLine(turn: CurrentTurn, text: string): void {
155
+ const clipped = clipNarrative(text)
156
+ const idx = turn.mirrorLines.lastIndexOf(clipped)
157
+ if (idx === -1) return // rolled out of the window already — nothing to retract
158
+ turn.mirrorLines.splice(idx, 1)
159
+ // Live re-render without the retracted line (the finalize path reads the same
160
+ // spliced array; this only matters for the interim-ack case where finalize
161
+ // isn't called on this reply). Guarded on a non-null render so an emptied feed
162
+ // doesn't blank the card.
163
+ const rerender = composeTurnActivity(turn)
164
+ if (rerender == null) return
165
+ turn.activityPendingRender = rerender
166
+ const ea = emissionAuthorityFor(turn)
167
+ cardDrainGate(turn, ea, () => {
168
+ if (ea.mayDrain(turn)) {
169
+ ea.openOrEditCard('narrative', () => {
170
+ turn.activityInFlight = drainActivitySummary(turn, 'narrative')
171
+ })
172
+ }
173
+ })
174
+ }
175
+
176
+ /**
177
+ * Build a per-turn narrative gate: the pure park/timer/retract state machine
178
+ * (`narrative-flush.ts`) wired to THIS turn's SHOW effect (`showNarrativeStep`),
179
+ * RETRACT effect (`retractNarrativeLine`), and a real `unref`'d `setTimeout`
180
+ * scheduler. The scheduler captures the turn's own timer handle so a turn swap
181
+ * can't mis-target it — mirrors the `noReplyDrainTimer` discipline.
182
+ */
183
+ function makeNarrativeGate(turn: CurrentTurn): NarrativeFlushController {
184
+ let handle: ReturnType<typeof setTimeout> | null = null
185
+ return new NarrativeFlushController(
186
+ {
187
+ show: (text) => showNarrativeStep(turn, text),
188
+ retractShown: (text) => retractNarrativeLine(turn, text),
189
+ },
190
+ {
191
+ arm: (fn, ms) => {
192
+ if (handle != null) clearTimeout(handle)
193
+ handle = setTimeout(fn, ms)
194
+ handle.unref?.()
195
+ },
196
+ disarm: () => {
197
+ if (handle != null) {
198
+ clearTimeout(handle)
199
+ handle = null
200
+ }
201
+ },
202
+ },
203
+ PENDING_NARRATIVE_FLUSH_MS,
204
+ )
205
+ }
206
+
207
+ /**
208
+ * Render a SHOWN narrative text block as a transient liveness step — the
209
+ * same path a tool label takes (appendActivityLabel → renderStepFeed), so
210
+ * the narrative line is rolling-window-clipped and replaced by the next
211
+ * event exactly like a tool step. NOT a new message, NOT persisted as a
212
+ * parallel mirror (invariant `chat-is-the-single-source-of-truth`,
213
+ * reference/invariants.md). Clipped to a single 120-char line via
214
+ * clipNarrative so it reads as a step, not a paragraph.
215
+ */
216
+ function showNarrativeStep(turn: CurrentTurn, text: string): void {
217
+ const rendered = appendActivityLabel(turn.mirrorLines, clipNarrative(text))
218
+ if (rendered == null) return
219
+ turn.activityPendingRender = composeTurnActivity(turn) ?? rendered
220
+ const ea = emissionAuthorityFor(turn)
221
+ // PR-4d: route the deliver-before-drain decision through the centralized
222
+ // card-drain gate (chatLock-serialized under the flag; verbatim block OFF).
223
+ cardDrainGate(turn, ea, () => {
224
+ if (ea.mayDrain(turn)) {
225
+ // Producer A (narrative SHOW): pre-answer narrative may now OPEN a card,
226
+ // not just EDIT one — lever 5 is INERT (see feed-open-gate.ts), and
227
+ // Lever 2 / clearActivitySummary guarantees reply-is-last ordering instead.
228
+ // Accumulation into mirrorLines still happens, so any narration staged
229
+ // before the card opened renders on the first OPEN (whichever producer
230
+ // wins the race — narrative here, or the enqueue/liveness timer).
231
+ // PR-4a: routed through the emission-authority façade (no-op delegate).
232
+ ea.openOrEditCard('narrative', () => {
233
+ turn.activityInFlight = drainActivitySummary(turn, 'narrative')
234
+ })
235
+ }
236
+ })
237
+ }
238
+
239
+ /**
240
+ * Narrative-dedup gate, step 2 (reducer-side): a tool_use just arrived while
241
+ * a narrative block was pending. Decide SHOW vs SUPPRESS and clear the
242
+ * pending slot. SUPPRESS only when the tool is reply/stream_reply AND the
243
+ * pending text is a draft-then-send of that reply's `input.text`. Everything
244
+ * else (a working tool, or a reply whose text differs — post-action
245
+ * narration) is SHOWN. See narrative-dedup.ts §2b.
246
+ */
247
+ function resolvePendingNarrativeOnTool(
248
+ turn: CurrentTurn,
249
+ toolName: string,
250
+ input: Record<string, unknown> | undefined,
251
+ ): void {
252
+ // Delegate to the pure park/timer/retract kernel: it cancels the early-paint
253
+ // timer, retracts a timer-painted block that THIS reply drafts (anti-double-
254
+ // print), then SHOWs / SUPPRESSes the parked block. See narrative-flush.ts §2.
255
+ turn.narrativeGate.resolveOnTool(toolName, input)
256
+ }
257
+
258
+ /**
259
+ * Narrative-dedup gate, step 1 (reducer-side): a new narrative block
260
+ * arrived. A previously-pending block had nothing reply-shaped immediately
261
+ * after it (pure narration) → flush it as SHOWN, then stage the new one for
262
+ * one lookahead step AND arm the time-boxed early paint. See narrative-flush.ts.
263
+ */
264
+ function stagePendingNarrative(turn: CurrentTurn, text: string): void {
265
+ turn.narrativeGate.stage(text)
266
+ }
267
+
268
+ /**
269
+ * Narrative-dedup gate, step 3 (reducer-side): the turn is ending with a
270
+ * trailing narrative block and nothing after it. SUPPRESS only when the turn
271
+ * already delivered its answer via reply/stream_reply and the trailing text
272
+ * is a draft of that answer; otherwise SHOW (genuine trailing narration like
273
+ * "Done — all green."). Also cancels the early-paint timer and retracts a
274
+ * timer-painted draft of the delivered answer. See narrative-flush.ts §3.
275
+ */
276
+ function flushPendingNarrativeAtTurnEnd(turn: CurrentTurn, lastReplyText: string): void {
277
+ turn.narrativeGate.flushAtTurnEnd(lastReplyText)
278
+ }
279
+
280
+ /**
281
+ * Drain the tool-activity summary's pending render queue. Single-flight
282
+ * by construction (caller assigns the returned promise to
283
+ * `turn.activityInFlight`; while set, new tool_uses only update
284
+ * `turn.activityPendingRender` and return).
285
+ *
286
+ * Transport: a single in-place edited message. The first render does
287
+ * `sendMessage` (capturing `turn.activityMessageId`); subsequent renders
288
+ * `editMessageText` that id, so the summary accumulates in place without
289
+ * retyping the whole block. `clearActivitySummary` deletes the message
290
+ * when the reply tool takes over. Works in DMs, groups, and forum topics
291
+ * alike (forum topics pass message_thread_id).
292
+ *
293
+ * The drain holds a reference to `turn`, so a turn-swap mid-drain
294
+ * doesn't corrupt the next turn's atom — late writes land on the
295
+ * captured `turn` (already-completed turn, harmless).
296
+ */
297
+ async function drainActivitySummary(
298
+ turn: CurrentTurn,
299
+ // Which producer triggered this drain (design §9 levers 1 + 5). Gates the
300
+ // OPEN (first sendMessage) branch via `mayOpenActivityCard`; EDITs of an
301
+ // already-open card are never gated. Defaults to 'tool' — the historically
302
+ // unconditional OPEN behaviour — so any caller that does not opt into the
303
+ // gate is unaffected. Narrative-SHOW and liveness callers pass their producer
304
+ // explicitly.
305
+ producer: FeedOpenProducer = 'tool',
306
+ // Optional flags forwarded to `mayOpenActivityCard`.
307
+ openFlags?: { postAnswerSubagentActivity?: boolean },
308
+ ): Promise<void> {
309
+ try {
310
+ while (turn.activityPendingRender !== turn.activityLastSentRender) {
311
+ const target = turn.activityPendingRender
312
+ if (target == null) break
313
+ // OPEN gate (design §9 levers 1 + 5): when this drain would OPEN a fresh
314
+ // card (activityMessageId == null), consult the pure gate. Refusing an
315
+ // OPEN must NOT advance activityLastSentRender — the accumulated render
316
+ // stays pending so a later OPEN-eligible producer (a tool label, or
317
+ // liveness) renders it. An EDIT (activityMessageId != null) is never
318
+ // gated. Enforced HERE so it covers BOTH the inline producers AND the
319
+ // detached heartbeat setInterval drain (R7/concurrency). The gate guards
320
+ // gate EVALUATION, not an in-flight send: it is not a hard mutex — a send
321
+ // already PAST this check and suspended at its `await robustApiCall(
322
+ // sendMessage)` when a substantive final lands still completes and opens a
323
+ // card; that residual is reconciled by lever-2's `clearActivitySummary`
324
+ // chaining its finalize onto `turn.activityInFlight` (the suspended drain)
325
+ // and editing the card in place, not by this gate blocking it.
326
+ // Lever 4 (cross-turn / race C/D): a synthetic represent/owed-reply turn
327
+ // (and the liveness/heartbeat timer firing on it) starts with a CLEARED
328
+ // per-turn `finalAnswerEverDelivered` latch even when a substantive answer
329
+ // already reached the user in an EARLIER turn — so without this its first
330
+ // drain opens a card BELOW that prior reply. Only such a turn carries
331
+ // `crossTurnGate`; reuse the represent guard's delivered-since check
332
+ // (`hasOutboundDeliveredSince`) with the obligation's `openedAt` cutoff and
333
+ // the SUBSTANTIVE 200-char threshold (so an ack never trips it → #2141
334
+ // stays green). Computed ONLY when about to OPEN (activityMessageId ==
335
+ // null) AND only for a turn with a cross-turn gate — no history query on
336
+ // the common foreground path. Scoped to the synthetic surface by the
337
+ // presence of `crossTurnGate`, so it can never fire on a foreground turn.
338
+ // PR-4b: the cross-turn predicate is now the PURE, shared helper extracted
339
+ // into feed-open-gate.ts (body lifted verbatim) — the SAME function the
340
+ // emission-authority façade calls in its enabled branch, so flag-ON and
341
+ // flag-OFF compute an identical verdict. History deps injected (the module
342
+ // stays sqlite-free). The pure-gate consult + the `break` below stay
343
+ // LITERALLY in the drain (disabled-path byte-identity).
344
+ const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered(
345
+ turn,
346
+ feedOpenGateDeps(),
347
+ )
348
+ if (
349
+ turn.activityMessageId == null
350
+ && !mayOpenActivityCard({
351
+ producer,
352
+ finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
353
+ labeledToolCount: turn.labeledToolCount,
354
+ crossTurnAnswerDelivered,
355
+ postAnswerSubagentActivity: openFlags?.postAnswerSubagentActivity,
356
+ })
357
+ ) {
358
+ break
359
+ }
360
+ // `renderActivityFeed` already emitted ready Telegram HTML with per-line
361
+ // markup (**→ current** / _✓ done_) and escaped each label's
362
+ // <,>,& itself (#1942 class) — send verbatim, do NOT re-escape or
363
+ // re-wrap (double-escaping would surface literal tags).
364
+ const html = target
365
+ const chat = turn.sessionChatId
366
+ const thread = turn.sessionThreadId
367
+ // Native reply-quote: anchor the feed message to the user's question so
368
+ // it renders as a quoted header (reply_parameters renders on a real
369
+ // message; edits preserve it). allow_sending_without_reply so a deleted
370
+ // source can't drop the send.
371
+ const replyAnchor = turn.sourceMessageId != null
372
+ ? { reply_parameters: { message_id: turn.sourceMessageId, allow_sending_without_reply: true } }
373
+ : {}
374
+ try {
375
+ if (turn.activityMessageId == null) {
376
+ const sent = await robustApiCall(
377
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
378
+ () => bot.api.sendRichMessage(chat, richMessage(html), {
379
+ ...(thread != null ? { message_thread_id: thread } : {}),
380
+ disable_notification: true,
381
+ ...replyAnchor,
382
+ }),
383
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.send' },
384
+ )
385
+ turn.activityMessageId = sent.message_id
386
+ turn.activityEverOpened = true
387
+ // Known Gap 1 (deterministic-turn-liveness.md) — persist the
388
+ // minimal card handle the moment it opens, so a gateway restart
389
+ // mid-turn has something to finalize on next boot instead of
390
+ // leaving this card frozen forever. Fire-and-forget/best-effort:
391
+ // a failed persist degrades to the pre-fix (in-memory-only)
392
+ // behaviour, never blocks the card opening.
393
+ if (activityCardPersistEnabled) {
394
+ writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, {
395
+ turnKey: statusKey(chat, thread),
396
+ chatId: chat,
397
+ threadId: thread ?? null,
398
+ activityMessageId: sent.message_id,
399
+ startedAt: turn.startedAt,
400
+ // Mirror the ACTUAL pin decision, not an unconditional `true`:
401
+ // the OPEN below silently-pins the fresh card only when
402
+ // `PIN_STATUS_WHILE_WORKING` is on (`reconcileStatusPin` no-ops
403
+ // when it's off, and can also fail on missing supergroup
404
+ // rights). Persisting `pinned: true` regardless would make the
405
+ // boot reaper attempt an unpin on a card that was never pinned.
406
+ // The reaper's unpin is defense-in-depth anyway
407
+ // (`statusPinBootCleanup` owns the primary unpin), so tracking
408
+ // the flag honestly is what matters here.
409
+ pinned: PIN_STATUS_WHILE_WORKING,
410
+ })
411
+ }
412
+ // Status-pin: the per-turn status message just opened — it's the
413
+ // in-flight "what it's doing" surface. Silently pin it so the turn
414
+ // stays in view when the feed scrolls past. Keyed to the same
415
+ // status-key the canonical turn-end (purgeReactionTracking) unpins.
416
+ // Fire-and-forget; the single-owner reconcile keeps state consistent.
417
+ void reconcileStatusPin(
418
+ `fg:${statusKey(chat, thread)}`,
419
+ chat,
420
+ { pinned: true, messageId: sent.message_id },
421
+ )
422
+ } else {
423
+ const id = turn.activityMessageId
424
+ await robustApiCall(
425
+ () => bot.api.editMessageText(chat, id, richMessage(html), {}),
426
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
427
+ )
428
+ }
429
+ turn.activityLastSentRender = target
430
+ } catch (err) {
431
+ const msg = err instanceof Error ? err.message : String(err)
432
+ const low = msg.toLowerCase()
433
+ // Transport-class failures (429, message gone, "not modified") must
434
+ // NOT inflate `activityDrainFailures` — that counter flags a turn as
435
+ // DEGRADED on turn-end, and a transient rate-limit or an
436
+ // already-deleted message is not a logic defect. Counting them would
437
+ // false-flag a healthy turn. "not modified" is success; gone/429 are
438
+ // retried or harmless. Only a genuine send/open failure counts.
439
+ const isTransport =
440
+ low.includes('not modified') ||
441
+ low.includes('not found') ||
442
+ low.includes("can't be edited") ||
443
+ low.includes('cannot be edited') ||
444
+ low.includes('not enough rights') ||
445
+ low.includes('429') ||
446
+ low.includes('retry after')
447
+ if (!isTransport) {
448
+ turn.activityDrainFailures += 1
449
+ // Surface the failing anchor + topic: the resume-400 bug fed a
450
+ // fabricated 13-digit message_id as the reply anchor here, so every
451
+ // send 400'd and the feed never opened. Logging the anchor +
452
+ // everOpened makes a feed-blanking send self-explanatory (and the
453
+ // turn-end DEGRADED line aggregates it).
454
+ process.stderr.write(
455
+ `telegram gateway: activity-summary drain failed: ${msg} ` +
456
+ `(chat=${chat} thread=${thread ?? '-'} ` +
457
+ `replyAnchor=${turn.sourceMessageId ?? 'none'} ` +
458
+ `everOpened=${turn.activityEverOpened} failures=${turn.activityDrainFailures})\n`,
459
+ )
460
+ }
461
+ // Mark as sent so we don't infinite-loop on a stuck render.
462
+ turn.activityLastSentRender = target
463
+ }
464
+ }
465
+ } finally {
466
+ turn.activityInFlight = null
467
+ }
468
+ }
469
+
470
+ /**
471
+ * OPEN the minimal "Working…" liveness card for a 0-label turn once it has been
472
+ * alive >= FEED_LIVENESS_OPEN_MS. The ONE place the liveness card may OPEN — both
473
+ * the enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and the 6 s
474
+ * heartbeat call through here, so a card opened by one caller is a clean no-op
475
+ * for the other. This function OPENS only; it does NOT climb an already-open card
476
+ * (its WHEN-gate `shouldEarlyOpenLiveness` returns false once `activityMessageId`
477
+ * is set). The 0-label CLIMB of an already-open card lives at the heartbeat call
478
+ * site via `silentTurnClimbRender` (deterministic-turn-liveness.md Phase 1) — so
479
+ * BOTH the labelled and the 0-label branches now keep the card visibly climbing.
480
+ * - `drainActivitySummary` OPENs when `activityMessageId == null` and EDITs
481
+ * once it is set, so a second call after an open just maintains the card;
482
+ * - the `mirrorLines.length === 0` guard at the heartbeat call site (and the
483
+ * drain's own gate) means once a real tool label lands this path is skipped
484
+ * and the labelled-feed heartbeat takes over;
485
+ * - the OPEN itself is still gated by `mayOpenActivityCard` (lever 1 / 4) via
486
+ * `ea.openOrEditCard('liveness', …)`, so a card never opens below a
487
+ * delivered answer or on a cross-turn synthetic surface.
488
+ *
489
+ * Renders the turn's accumulated narration when present (the §3 case: narration
490
+ * staged before the first tool via `mirrorLines`) so the early open is not a
491
+ * bare placeholder when there is real text to show; falls back to "Working…"
492
+ * for a genuinely silent thinking turn.
493
+ */
494
+ function openLivenessFeedIfDue(turn: CurrentTurn): void {
495
+ const age = Date.now() - turn.startedAt
496
+ // The WHEN decision (pure, `feed-open-gate.ts`): feature on, target chat,
497
+ // past threshold, no card already open. Returns false once a card is open
498
+ // (the drain EDITs instead) so the two callers can never double-open.
499
+ if (!shouldEarlyOpenLiveness({
500
+ enabled: FEED_LIVENESS_OPEN_ENABLED,
501
+ ageMs: age,
502
+ thresholdMs: FEED_LIVENESS_OPEN_MS,
503
+ mirrorLineCount: turn.mirrorLines.length,
504
+ activityMessageId: turn.activityMessageId,
505
+ sessionChatId: turn.sessionChatId,
506
+ })) return
507
+ const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working…']
508
+ const livenessHeader: SessionActivityHeader = {
509
+ label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
510
+ model: turn.currentModel,
511
+ }
512
+ // Liveness card is a single "step" whose start is the turn start, so `age`
513
+ // IS the step's own elapsed. formatStepSuffix keeps the `→` line timer-free
514
+ // until the step has run ≥ STEP_TIMER_MIN_MS (header total still shows).
515
+ const rendered = renderActivityFeedWithNested(lines, [], false, formatStepSuffix(age), undefined, livenessHeader)
516
+ if (rendered == null) return
517
+ turn.activityPendingRender = rendered
518
+ const ea = emissionAuthorityFor(turn)
519
+ // PR-4d: route through the centralized chatLock-serialized card-drain gate.
520
+ cardDrainGate(turn, ea, () => {
521
+ if (ea.mayDrain(turn)) {
522
+ // Producer C (liveness timer): the thinking-gap / early-open. Now that
523
+ // Lever 5 is inert (narrative may open pre-answer — #2588), liveness
524
+ // remains the natural open for 0-tool pre-answer turns that are silent.
525
+ // The sticky-latch (lever 1) still gates it in the drain.
526
+ // PR-4a: routed through the emission-authority façade (no-op delegate).
527
+ ea.openOrEditCard('liveness', () => {
528
+ turn.activityInFlight = drainActivitySummary(turn, 'liveness')
529
+ })
530
+ }
531
+ })
532
+ }
533
+
534
+ /**
535
+ * Schedule the enqueue-time early-open of the "Working…" liveness card. Called
536
+ * once per fresh turn at the `enqueue` lifecycle event (the single chokepoint
537
+ * every real turn atom passes through — inbound, cron, subagent-handback,
538
+ * vault-resume, restart-marker; anonymous one-shot hook clients never emit
539
+ * `enqueue`, so they are excluded by construction). Fires `openLivenessFeedIfDue`
540
+ * once at `FEED_LIVENESS_OPEN_MS` after turn start so narration / thinking that
541
+ * happens BEFORE the first tool surfaces a card within ~a second — no more dead
542
+ * air until a tool label or the old 12 s threshold. A no-op if a tool/narrative
543
+ * already opened the card (the helper's own guards). The 6 s heartbeat remains
544
+ * the backstop + the climb.
545
+ */
546
+ function scheduleEarlyLivenessOpen(turn: CurrentTurn): void {
547
+ if (STATIC || !FEED_HEARTBEAT_ENABLED || !FEED_LIVENESS_OPEN_ENABLED) return
548
+ if (turn.sessionChatId == null) return
549
+ const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
550
+ stopEarlyLivenessOpen(key)
551
+ const t = setTimeout(() => {
552
+ earlyLivenessOpenTimers.delete(key)
553
+ // Re-resolve the live turn for this key: only open if THIS turn is still the
554
+ // live one for its topic (a successor turn would carry its own timer). Under
555
+ // flag OFF `get(key)` returns the singleton — same turn unless a successor
556
+ // already replaced it, which the turnId match below also guards.
557
+ const live = currentTurnMap.get(key)
558
+ if (live == null || live.turnId !== turn.turnId) return
559
+ openLivenessFeedIfDue(live)
560
+ }, FEED_LIVENESS_OPEN_MS)
561
+ t.unref?.()
562
+ earlyLivenessOpenTimers.set(key, t)
563
+ }
564
+
565
+ /** Cancel the enqueue-time early-open timer for a status-key (turn-end teardown
566
+ * + re-arm guard). Idempotent. */
567
+ function stopEarlyLivenessOpen(key: string): void {
568
+ const t = earlyLivenessOpenTimers.get(key)
569
+ if (t != null) { clearTimeout(t); earlyLivenessOpenTimers.delete(key) }
570
+ }
571
+
572
+ /**
573
+ * Heartbeat tick (PR1): keep the live activity feed visibly advancing during a
574
+ * long single step that emits no new tool_label. Re-renders the feed with a
575
+ * climbing " · Ns" elapsed on the in-progress line through the SAME single-flight
576
+ * drain path the tool_label handler uses (no separate transport, no race). Pure
577
+ * no-op unless there is a live in-flight feed whose newest step has gone stale.
578
+ * Skips once the final answer landed (the feed is handing off) and after the
579
+ * turn ends (activityMessageId nulled by clearActivitySummary). Deterministic +
580
+ * framework-owned — never depends on the model.
581
+ */
582
+ function feedHeartbeatTick(): void {
583
+ const turn = getCurrentTurn()
584
+ if (turn == null) return
585
+ if (turn.finalAnswerDelivered) {
586
+ // Fix 2: post-answer background-agent liveness. When the sub-agent/workflow
587
+ // watcher has surfaced a new step AFTER the substantive final answer, drive
588
+ // a liveness card so the operator can see "background agent still working".
589
+ //
590
+ // Gate: `turn.subagentActivityAt` must be set (watcher fired) AND it must
591
+ // exceed `turn.finalAnswerDeliveredAt` (the watcher advanced AFTER the answer
592
+ // was delivered — not just any pre-answer label). This is the key fix:
593
+ // #2587 read `lastToolLabelAt`, which is frozen by the drop-guard after a
594
+ // substantive answer and therefore never crosses the threshold. `subagentActivityAt`
595
+ // is written by the watcher's onProgress callback INDEPENDENTLY of the
596
+ // tool_label / drop-guard path, so it correctly advances post-answer.
597
+ //
598
+ // Idle-gap suppression + staleness cap (concern 3) — the single pure decision
599
+ // `evaluatePostAnswerLiveness`:
600
+ // - 'idle' → no watcher activity after the answer (`subagentActivityAt`
601
+ // undefined or ≤ finalAnswerDeliveredAt). Stay silent; the
602
+ // reply-is-last invariant is fully preserved for idle turns.
603
+ // - 'stale' → the worker's last advance is older than POST_ANSWER_LIVENESS_STALE_MS
604
+ // (its `onFinish` froze `subagentActivityAt` and no new step has
605
+ // arrived). STOP re-rendering so the card doesn't climb `running`
606
+ // forever — mirrors the pre-answer FEED_LIVENESS_OPEN_MS cap. The
607
+ // worker's own terminal card (workerActivityFeed.finish) is the
608
+ // durable record once it completes.
609
+ // - 'emit' → genuine in-flight post-answer activity; render the card below.
610
+ const subagentAt = turn.subagentActivityAt
611
+ // Fix 3 (sub-agent-delegation freeze): a foreground `Task`/`Agent` still
612
+ // tracked in `turn.foregroundSubAgents` is POSITIVE evidence the worker
613
+ // has not reported finished — see the doc comment on
614
+ // `PostAnswerLivenessInput.stillDispatched` in turn-liveness-floor.ts for
615
+ // why this must bypass the staleness cap rather than let a single long
616
+ // silent step freeze the card mid-delegation.
617
+ const stillDispatched = turn.foregroundSubAgents.size > 0
618
+ const livenessVerdict = evaluatePostAnswerLiveness({
619
+ subagentActivityAt: subagentAt,
620
+ finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
621
+ now: Date.now(),
622
+ staleCapMs: POST_ANSWER_LIVENESS_STALE_MS,
623
+ stillDispatched,
624
+ })
625
+ if (livenessVerdict !== 'emit' || subagentAt == null) return // idle gap or stale worker → stay silent (the `== null` also narrows subagentAt for the elapsed below)
626
+ // A background worker is genuinely active after the answer. Open or maintain
627
+ // a liveness card below the reply. Route through `mayOpenActivityCard` with
628
+ // `postAnswerSubagentActivity:true` so Lever 1 is lifted for 'tool' producer
629
+ // (Fix 2's Lever 1 exception in feed-open-gate.ts). The card renders the
630
+ // turn's accumulated mirrorLines (which may be empty — in that case the drain
631
+ // opens a "Working…" placeholder matching the pre-answer liveness path).
632
+ if (turn.sessionChatId == null) return
633
+ const age = Date.now() - turn.startedAt
634
+ const livenessHeader: SessionActivityHeader = {
635
+ label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
636
+ model: turn.currentModel,
637
+ }
638
+ const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working in background…']
639
+ // `subagentAt` is the worker's last ADVANCE — the current step's start —
640
+ // so this suffix is already per-step. formatStepSuffix adds the 10 s gate.
641
+ const elapsed = Date.now() - subagentAt
642
+ const rendered = renderActivityFeedWithNested(lines, [], false, formatStepSuffix(elapsed), undefined, livenessHeader)
643
+ if (rendered == null) return
644
+ turn.activityPendingRender = rendered
645
+ const ea = emissionAuthorityFor(turn)
646
+ cardDrainGate(turn, ea, () => {
647
+ if (ea.mayDrain(turn)) {
648
+ // Producer 'tool' with postAnswerSubagentActivity=true: the Lever 1
649
+ // exception allows this OPEN. Lever 4 (cross-turn) and idle-liveness
650
+ // blocks are still respected by the drain. The card surfaces BELOW the
651
+ // reply showing the background agent's live activity.
652
+ ea.openOrEditCard('tool', () => {
653
+ turn.activityInFlight = drainActivitySummary(turn, 'tool', { postAnswerSubagentActivity: true })
654
+ })
655
+ }
656
+ })
657
+ return
658
+ }
659
+
660
+ // Liveness feed (open + maintain). `mirrorLines.length === 0` means no tool
661
+ // has ever produced a label this turn — pure thinking, or only suppressed
662
+ // tools. Open a minimal "Working…" feed once the turn passes the threshold,
663
+ // and keep its elapsed climbing until a real label arrives. The first label
664
+ // makes mirrorLines non-empty, so the labelled-feed heartbeat below takes
665
+ // over and its edit cleanly replaces the placeholder. drainActivitySummary
666
+ // sends (opens) when activityMessageId is null and edits (maintains) once set
667
+ // — so this one branch handles both the open and the climb.
668
+ //
669
+ // The OPEN logic lives in ONE place (`openLivenessFeedIfDue`) so the
670
+ // enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and this 6 s
671
+ // heartbeat both reach the same drain — there is exactly one path that can
672
+ // OPEN the liveness card, so the two callers can never double-open or race.
673
+ //
674
+ // Phase 1 climb (deterministic-turn-liveness.md): once the card IS open, the
675
+ // OPEN path no-ops (its WHEN-gate `shouldEarlyOpenLiveness` returns false for
676
+ // an already-open card) — which is exactly how the 0-label card used to FREEZE
677
+ // during a long silent tool. So split the two cases: OPEN when no card exists,
678
+ // and otherwise re-render the "Working…" card with a fresh wall-clock elapsed
679
+ // through the SAME cardDrainGate / mayDrain / liveness EDIT path the labelled
680
+ // branch below uses. Model-independent (reads only `now - startedAt`), so a
681
+ // blocked tool call can't starve it; edit-only, so it never push-notifies.
682
+ //
683
+ // The tick BODY lives in feed-heartbeat-climb.ts (`runSilentTurnHeartbeatTick`)
684
+ // so the shipped decision logic is directly under the outcome-based regression
685
+ // test (tests/silent-turn-climb-transport.test.ts) — this gateway IIFE cannot
686
+ // be imported in-process. This call site only wires the REAL deps; the wiring
687
+ // shape is pinned structurally by tests/feed-heartbeat-liveness-open.test.ts.
688
+ {
689
+ const ea = emissionAuthorityFor(turn)
690
+ const handled = runSilentTurnHeartbeatTick(
691
+ {
692
+ mirrorLineCount: turn.mirrorLines.length,
693
+ activityMessageId: turn.activityMessageId,
694
+ labeledToolCount: turn.labeledToolCount,
695
+ ageMs: Date.now() - turn.startedAt,
696
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
697
+ },
698
+ {
699
+ openLivenessFeedIfDue: () => openLivenessFeedIfDue(turn),
700
+ setPendingRender: (rendered) => { turn.activityPendingRender = rendered },
701
+ cardDrainGate: (run) => cardDrainGate(turn, ea, run),
702
+ mayDrain: () => ea.mayDrain(turn),
703
+ openOrEditCard: (apply) => ea.openOrEditCard('liveness', apply),
704
+ drain: () => { turn.activityInFlight = drainActivitySummary(turn, 'liveness') },
705
+ },
706
+ )
707
+ if (handled) return
708
+ }
709
+
710
+ // Labelled-feed heartbeat: keep a stale in-progress step visibly advancing.
711
+ if (turn.activityMessageId == null) return // no live feed yet / already cleared
712
+ if (turn.lastToolLabelAt == null) return // feed not driven by a labelled step
713
+ const elapsed = Date.now() - turn.lastToolLabelAt
714
+ if (elapsed < FEED_HEARTBEAT_MIN_STALE_MS) return // step is fresh; feed advancing normally
715
+ // `lastToolLabelAt` resets on every new tool label, so `elapsed` is the
716
+ // CURRENT step's own run time. formatStepSuffix holds the timer back until
717
+ // the step passes STEP_TIMER_MIN_MS (10 s) — header total is unaffected.
718
+ const rendered = composeTurnActivity(turn, false, formatStepSuffix(elapsed))
719
+ if (rendered == null) return
720
+ turn.activityPendingRender = rendered
721
+ const ea = emissionAuthorityFor(turn)
722
+ // PR-4d: route through the centralized chatLock-serialized card-drain gate.
723
+ cardDrainGate(turn, ea, () => {
724
+ if (ea.mayDrain(turn)) {
725
+ // Maintains an already-open card (guarded above on activityMessageId !=
726
+ // null) → only ever EDITs. 'liveness' is correct either way.
727
+ // PR-4a: routed through the emission-authority façade (no-op delegate).
728
+ ea.openOrEditCard('liveness', () => {
729
+ turn.activityInFlight = drainActivitySummary(turn, 'liveness')
730
+ })
731
+ }
732
+ })
733
+ }
734
+
735
+ /**
736
+ * Reconcile the activity summary when the model's reply tool takes over as the
737
+ * authoritative surface. Awaits any in-flight render so we don't race a stale
738
+ * write, then EITHER:
739
+ * - FINALIZE (default, CLEAR_STATUS_ON_COMPLETION=false): edit the message to
740
+ * a terminal all-done render (no "→ in-progress" line) and stop tracking it
741
+ * — the status stays in the chat as a record beside the reply. No delete.
742
+ * - DELETE (CLEAR_STATUS_ON_COMPLETION=true, opt-in via
743
+ * channels.telegram.clear_status_on_completion): remove the message so only
744
+ * the reply remains (the pre-2026-06 behaviour).
745
+ * Idempotent + best-effort — failures stderr-log but don't block.
746
+ *
747
+ * Called on the first reply (hand-off) and again at turn_end (no-reply safety
748
+ * net); finalize edits are idempotent (a 'message is not modified' on the
749
+ * second call is swallowed).
750
+ *
751
+ * `finalHtmlOverride` (finalize path only): a render captured by the caller
752
+ * BEFORE it tore down turn state the finalize render depends on. The
753
+ * foreground handoff-clear path passes this — it deletes the just-finished
754
+ * sub-agent's narrative right after this call, so the async
755
+ * `composeTurnActivity(turn, true)` below would see an emptied feed (and, on
756
+ * ack-first turns, empty `mirrorLines`), render null, and skip the finalize —
757
+ * freezing the last live "→ in-progress" line. The captured render keeps the
758
+ * persisted record reading done (✓). Omitted → compute it here (the common
759
+ * reply/turn_end callers, where state is stable).
760
+ */
761
+ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | null): void {
762
+ const chat = turn.sessionChatId
763
+ const thread = turn.sessionThreadId
764
+ const inFlight = turn.activityInFlight ?? Promise.resolve()
765
+ void inFlight.then(async () => {
766
+ if (turn.activityMessageId == null) return
767
+ const id = turn.activityMessageId
768
+ turn.activityMessageId = null
769
+ // Known Gap 1 (deterministic-turn-liveness.md) — the card is closing
770
+ // normally (about to be deleted or finalized below), so drop its durable
771
+ // handle too: a normal close must never leave a stale record for the
772
+ // boot reaper to "finalize" a message that's already been handled.
773
+ if (activityCardPersistEnabled) {
774
+ clearActivityCardRecord(
775
+ ACTIVITY_CARD_STORE_PATH,
776
+ activityCardStoreFs,
777
+ statusKey(chat, thread),
778
+ // Scope to this card's exact id (reap-race guard): only drop the row
779
+ // for the card THIS turn is closing, never a fresher card another
780
+ // turn may have already upserted under the same topic key.
781
+ id,
782
+ )
783
+ }
784
+ if (CLEAR_STATUS_ON_COMPLETION) {
785
+ try {
786
+ await robustApiCall(
787
+ () => bot.api.deleteMessage(chat, id),
788
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
789
+ )
790
+ } catch (err) {
791
+ // Best-effort teardown of a status card. A transport-class failure
792
+ // (message already deleted, chat gone, 429) is not a liveness-logic
793
+ // error — "message to delete not found" is the desired end state
794
+ // here, and a 429 on a teardown delete is retried by robustApiCall.
795
+ // Stay silent on those; warn only on a genuinely unexpected error.
796
+ const msg = err instanceof Error ? err.message : String(err)
797
+ const low = msg.toLowerCase()
798
+ if (
799
+ low.includes('not modified') ||
800
+ low.includes('not found') ||
801
+ low.includes('not enough rights') ||
802
+ low.includes('429') ||
803
+ low.includes('retry after')
804
+ ) {
805
+ return
806
+ }
807
+ process.stderr.write(`telegram gateway: activity-summary delete failed: ${msg}\n`)
808
+ }
809
+ return
810
+ }
811
+ // Default: leave the status message as a record, edited to a terminal
812
+ // all-done state so it doesn't freeze on a misleading "→ in-progress" line.
813
+ let finalHtml =
814
+ finalHtmlOverride !== undefined ? finalHtmlOverride : composeTurnActivity(turn, true)
815
+ // Liveness-only feed: opened on the timer for a turn that never labelled a
816
+ // tool (pure thinking / suppressed tools), so mirrorLines is empty and the
817
+ // terminal render is null. Finalize to a done "✓ Working…" record instead
818
+ // of leaving the message frozen on the live "→ Working…" line.
819
+ if (finalHtml == null && turn.mirrorLines.length === 0 && turn.activityEverOpened) {
820
+ const livenessElapsed = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
821
+ const livenessHeader: SessionActivityHeader = {
822
+ label: 'Agent', elapsedMs: livenessElapsed, toolCount: turn.labeledToolCount, state: 'done',
823
+ model: turn.currentModel,
824
+ }
825
+ finalHtml = renderActivityFeedWithNested(['Working…'], [], true, '', undefined, livenessHeader)
826
+ }
827
+ if (finalHtml == null) return
828
+ try {
829
+ await robustApiCall(
830
+ () => bot.api.editMessageText(chat, id, richMessage(finalHtml), {}),
831
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
832
+ )
833
+ } catch (err) {
834
+ // Same transport-class discipline as the delete path: the card
835
+ // finalize is a best-effort liveness edit. "not modified" = the card
836
+ // already shows the finalized body (success); "not found" / "not
837
+ // enough rights" = the message is gone (nothing to finalize); 429 is
838
+ // retried by robustApiCall. None of those are liveness-logic errors
839
+ // and none warrant a stderr warning. Warn only on the unexpected.
840
+ const msg = err instanceof Error ? err.message : String(err)
841
+ const low = msg.toLowerCase()
842
+ if (
843
+ low.includes('not modified') ||
844
+ low.includes('not found') ||
845
+ low.includes("can't be edited") ||
846
+ low.includes('cannot be edited') ||
847
+ low.includes('not enough rights') ||
848
+ low.includes('429') ||
849
+ low.includes('retry after')
850
+ ) {
851
+ return
852
+ }
853
+ process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
854
+ }
855
+ })
856
+ }
857
+
858
+ return {
859
+ closeActivityLane, closeProgressLane, composeTurnActivity, retractNarrativeLine,
860
+ makeNarrativeGate, showNarrativeStep, resolvePendingNarrativeOnTool,
861
+ stagePendingNarrative, flushPendingNarrativeAtTurnEnd, drainActivitySummary,
862
+ openLivenessFeedIfDue, scheduleEarlyLivenessOpen, stopEarlyLivenessOpen,
863
+ feedHeartbeatTick, clearActivitySummary,
864
+ }
865
+ }