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,440 @@
1
+ /**
2
+ * liveness-wiring.ts — the silence-poke timer options (#2996 P8 PR-C3).
3
+ *
4
+ * Extracted VERBATIM from gateway.ts: the `silencePoke.startTimer({...})`
5
+ * options object — `floorState`, the approval-blocked `onMidTurnFloor`
6
+ * re-ping, and the 300 s `onFrameworkFallback` unwedge (turn teardown, stale
7
+ * sibling purge, keyed clear, buffer self-heal). The gateway builds the deps
8
+ * (`gatewayLivenessWiringDeps()`), calls `buildSilencePokeOptions(deps)` and
9
+ * REGISTERS the timer itself (P0c: modules export the body; the gateway owns
10
+ * the timer / boot side effect).
11
+ *
12
+ * The two user-visible sends cross as the injected `sendSilenceText` closure
13
+ * so the `bot.api` touch stays behind the gateway retry-policy allowlist.
14
+ * Volatile reads (`currentTurn`, `inFlightUpdate`, `turnsDb`, `inboundSpool`,
15
+ * `ipcServer`) cross as getters. Module-level collaborators that are already
16
+ * extracted (`silence-poke`, `turn-signal-tracker`, `pending-work-progress`,
17
+ * `turn-state-purge`, registry, metrics) are imported directly.
18
+ */
19
+ import type { LivenessWiringDeps } from './gateway.js'
20
+ import * as silencePoke from '../silence-poke.js'
21
+ import * as signalTracker from '../turn-signal-tracker.js'
22
+ import * as pendingProgress from '../pending-work-progress.js'
23
+ import { emitRuntimeMetric } from '../runtime-metrics.js'
24
+ import { logStreamingEvent } from '../streaming-metrics.js'
25
+ import { clearSilentEndState } from '../silent-end.js'
26
+ import { purgeStaleTurnsForChat } from './turn-state-purge.js'
27
+ import { removeTurnActiveMarker } from './turn-active-marker.js'
28
+ import { recordTurnEnd } from '../registry/turns-schema.js'
29
+ import { hostdGetStatusOnce } from './hostd-dispatch.js'
30
+ import { formatUpdateStatusLine } from './update-status-line.js'
31
+ import { formatTurnLifecycle } from './status-surface-log.js'
32
+ import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
33
+
34
+ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<typeof silencePoke.startTimer>[0] {
35
+ const {
36
+ SILENCE_FALLBACK_MS,
37
+ SILENCE_FALLBACK_HARD_MS,
38
+ SILENCE_FLOOR_MS,
39
+ SILENCE_DEFER_INFLIGHT_TOOLS,
40
+ TURN_PREVIEW_MAX,
41
+ STATE_DIR,
42
+ isLegitimatelyWorking,
43
+ getCurrentTurn,
44
+ getInFlightUpdate,
45
+ getTurnsDb,
46
+ getInboundSpool,
47
+ sendToAgent,
48
+ sendSilenceText,
49
+ getMyAgentName,
50
+ statusKey,
51
+ activeTurnStartedAt,
52
+ activeStatusReactions,
53
+ lastPtyPreviewByChat,
54
+ preambleSuppressor,
55
+ purgeReactionTracking,
56
+ currentTurnMap,
57
+ turnLiveForItsTopic,
58
+ endCurrentTurnForKey,
59
+ getPendingInboundBuffer,
60
+ trackRedeliveredInbound,
61
+ closeActivityLane,
62
+ closeProgressLane,
63
+ } = deps
64
+
65
+ return {
66
+ thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS, floor: SILENCE_FLOOR_MS },
67
+ deferFallbackWhileToolInFlight: SILENCE_DEFER_INFLIGHT_TOOLS,
68
+ isLegitimatelyWorking: (key) => isLegitimatelyWorking(key),
69
+ emitMetric: (event) => {
70
+ // Re-emit through the unified runtime-metrics fan-out (PostHog + JSONL).
71
+ emitRuntimeMetric(event)
72
+ },
73
+ // #2527 — the gateway-owned half of the mid-turn-floor decision: only the
74
+ // live turn knows its loop role + whether a substantive answer has landed.
75
+ // Keyed on statusKey so a DM (threadId null) and a forum topic are identical.
76
+ floorState: (key) => {
77
+ const turn = getCurrentTurn()
78
+ if (turn == null) return null
79
+ if (statusKey(turn.sessionChatId, turn.sessionThreadId) !== key) return null
80
+ return { role: turn.role, finalAnswerDelivered: turn.finalAnswerDelivered }
81
+ },
82
+ // Phase 3 (reference/rfcs/deterministic-turn-liveness.md): the busy-but-
83
+ // silent TEXT floor is retired. The climbing card (Phase 1 — the 0-label
84
+ // branch of `feedHeartbeatTick` / `runSilentTurnHeartbeatTick` above) IS
85
+ // the mid-turn floor now: it edits the already-open activity card's
86
+ // elapsed clock every `FEED_HEARTBEAT_MIN_STALE_MS`, model-independent, no
87
+ // ping. This handler still decides/fires via `decideMidTurnFloor` (the
88
+ // timing + role + fire-once machinery in `silence-poke.ts` /
89
+ // `turn-liveness-floor.ts` is unchanged and still load-bearing — see
90
+ // below), but its ONLY surviving delivery is the approval-blocked re-ping.
91
+ // Do not re-add a generic "still working…" text send here believing this
92
+ // path covers the silent-tool case; it does not, and re-adding one would
93
+ // reintroduce the exact banned cadence ping `conversational-pacing.md`
94
+ // retired in #2667.
95
+ onMidTurnFloor: async (ctx) => {
96
+ // Late-fire guard, mirroring the fallback: a clean turn-end can race the
97
+ // tick. If the turn is gone, stay silent.
98
+ if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurn() == null) return
99
+ const blockedOnApproval = activeStatusReactions
100
+ .get(statusKey(ctx.chatId, ctx.threadId))
101
+ ?.isAwaiting() ?? false
102
+ // The one surviving text case: the turn is parked on an approval card
103
+ // waiting for YOUR tap. That's not a stall — it names the real blocker —
104
+ // so it keeps this quiet re-ping (`conversational-pacing.md` § Silence-
105
+ // poke fallback names this one of the two surviving honest cases). The
106
+ // busy-but-silent (non-approval) case sends nothing here; the card climb
107
+ // (Phase 1) is its only signal now.
108
+ if (!blockedOnApproval) return
109
+ const text = silencePoke.formatFrameworkFallbackText(
110
+ 'working',
111
+ ctx.silenceMs,
112
+ ctx.inFlightTools,
113
+ blockedOnApproval,
114
+ )
115
+ if (text == null) return
116
+ try {
117
+ // The quiet beat: visible in-thread, no device buzz. (The 300s
118
+ // fallback pings; the floor must not train the user to mute.) The send
119
+ // stays in gateway.ts behind the bot-api retry policy (sendSilenceText).
120
+ await sendSilenceText(ctx.chatId, ctx.threadId ?? null, text, true)
121
+ // Count it as production so the silence clock resets — the user just
122
+ // saw a real message, so the 300s loud fallback is measured from here.
123
+ silencePoke.noteProduction(ctx.key, Date.now())
124
+ } catch (err) {
125
+ process.stderr.write(
126
+ `silence-poke mid-turn floor sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
127
+ )
128
+ }
129
+ },
130
+ onFrameworkFallback: async (ctx) => {
131
+ // Late-fire short-circuit (2026-05-23 audit finding). The fallback
132
+ // can race a clean turn-end: the model's actual reply lands inside
133
+ // the silence window's final ~50ms, the canonical turn-end path
134
+ // clears `activeTurnStartedAt` and nulls `currentTurn`, and then
135
+ // this handler fires anyway. Without this check we emit a noisy
136
+ // "still working…" ping to the user (right after they got their
137
+ // real reply) AND a misleading "ended wedged turn ... currentTurn_
138
+ // nulled=false drained_buffered=0/0" log line. The 7-day audit
139
+ // showed this race accounts for ~90% of all framework_fallback log
140
+ // events (124 of 138 `currentTurn_nulled=false` cases). Distinct
141
+ // log line so observability still tracks the fact that the silence
142
+ // crossed threshold; the wedge counter is no longer polluted.
143
+ if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurn() == null) {
144
+ process.stderr.write(
145
+ `telegram gateway: silence-poke framework-fallback late-fire skipped — ` +
146
+ `turn ended cleanly during silence window ` +
147
+ `chat=${ctx.chatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs}\n`,
148
+ )
149
+ // #2527: structured skip event so the late-fire race is machine-readable.
150
+ logStreamingEvent({
151
+ kind: 'silence_poke_skip',
152
+ chatId: ctx.chatId,
153
+ threadId: ctx.threadId ?? undefined,
154
+ silenceMs: ctx.silenceMs,
155
+ skipReason: 'turn_ended_cleanly_during_window',
156
+ })
157
+ // Tell silence-poke this chat-thread is finished so the next
158
+ // arming doesn't carry stale state.
159
+ silencePoke.endTurn(ctx.key)
160
+ return
161
+ }
162
+
163
+ // Deterministic in-flight update status (klanker incident). If this
164
+ // gateway dispatched an update_apply that's still running, the
165
+ // recurring framework fallback carries hostd's REAL phase + elapsed
166
+ // instead of the content-free "still working…". No model: pure
167
+ // get_status snapshot → pure formatter. Any hostd unavailability
168
+ // degrades silently to the existing generic text (zero regression).
169
+ let text: string | null = null
170
+ // Hoisted out of the generic-fallback branch below because the send site
171
+ // gates `disable_notification` on it: when the turn is parked on an
172
+ // approval card, the fallback TEXT is a user-gating re-ping ("waiting for
173
+ // your approval — tap Approve or Deny …"), and that must stay LOUD so the
174
+ // user knows the ball is in their court. The reaction controller tracks the
175
+ // park via setAwaiting on the permission-request.
176
+ const blockedOnApproval = activeStatusReactions
177
+ .get(statusKey(ctx.chatId, ctx.threadId))
178
+ ?.isAwaiting() ?? false
179
+ const upd = getInFlightUpdate()
180
+ if (upd != null) {
181
+ try {
182
+ const st = await hostdGetStatusOnce(getMyAgentName(), upd.requestId)
183
+ if (st !== 'not-configured' && st !== 'unavailable') {
184
+ text = formatUpdateStatusLine(st, upd.startedAt, Date.now())
185
+ }
186
+ } catch {
187
+ /* degrade to generic fallback below */
188
+ }
189
+ }
190
+ if (text == null) {
191
+ // Wording is load-bearing — extracted to `formatFrameworkFallbackText`
192
+ // in `silence-poke.ts` so it can be snapshot-tested in isolation
193
+ // (CC-4 in `docs/status-ask-cause-classes.md`). Derives "N min" suffix
194
+ // from `ctx.silenceMs` so the wording stays honest if the 300s
195
+ // threshold is tuned.
196
+ // Honesty: if the turn is parked on an approval card (the dominant
197
+ // benign "wedge" class — claude is alive, waiting on the operator's
198
+ // tap), say so instead of "still working…". The reaction controller
199
+ // already tracks this (setAwaiting on the permission-request park).
200
+ text = silencePoke.formatFrameworkFallbackText(
201
+ ctx.fallbackKind,
202
+ ctx.silenceMs,
203
+ ctx.inFlightTools,
204
+ blockedOnApproval,
205
+ )
206
+ }
207
+ // #2527: log the actual poke fire with structured data before sending,
208
+ // so the event is visible even if the send fails. The stall-notice
209
+ // stop-gap is retired (the user-visible send below is now gated), but the
210
+ // fire event is still logged so observability + the unwedge accounting are
211
+ // unchanged — mirrors PR #2641 (drop the chat badge, keep the log).
212
+ logStreamingEvent({
213
+ kind: 'silence_poke_fire',
214
+ chatId: ctx.chatId,
215
+ threadId: ctx.threadId ?? undefined,
216
+ silenceMs: ctx.silenceMs,
217
+ fallbackKind: ctx.fallbackKind,
218
+ })
219
+ // Send a user-visible message ONLY when there's something honest to say:
220
+ // - a deterministic in-flight update_apply phase (`text` set above), or
221
+ // - the approval-blocked re-ping (`formatFrameworkFallbackText` returns a
222
+ // string only for that case).
223
+ // The generic "still working… (no update from agent in N min)" stall notice
224
+ // is retired — `formatFrameworkFallbackText` returns null for it, so `text`
225
+ // stays null and we skip the send. The turn-teardown below (the unwedge —
226
+ // the one job the live draft can't do, per the conversational-pacing RFC)
227
+ // still runs unconditionally.
228
+ if (text != null) {
229
+ try {
230
+ // Conditional: when the turn is parked on an approval card, this
231
+ // fallback carries a user-gating re-ping ("waiting for your
232
+ // approval — tap Approve or Deny …") — that must PING, because the
233
+ // user is the one being waited on. The deterministic update-status
234
+ // line stays SILENT (a status surface). Gate on `blockedOnApproval`.
235
+ // The send stays in gateway.ts behind the bot-api retry policy.
236
+ await sendSilenceText(ctx.chatId, ctx.threadId ?? null, text, blockedOnApproval ? false : true)
237
+ } catch (err) {
238
+ process.stderr.write(
239
+ `silence-poke fallback sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
240
+ )
241
+ }
242
+ }
243
+ // #1122 follow-up: end the wedged turn AFTER the fallback fires.
244
+ // Without this, `activeTurnStartedAt` stays set, every subsequent
245
+ // inbound is routed as `queued="true" prior_turn_in_progress="true"`
246
+ // (see handleInbound mid-turn branch ~line 6160), and the user's
247
+ // conversation is permanently stuck behind a dead turn no signal
248
+ // will ever close. Caught by the human-style fuzz on 2026-05-13:
249
+ // 23/23 inbounds queued behind msg 599 that never got a turn_ended.
250
+ //
251
+ // Cleanup mirrors the regular reply / silent-marker turn-end paths
252
+ // (lines 4985 / 5274) so a late `turn_end` from claude — once it
253
+ // finally exits the wedge — finds nulled state and bails at the
254
+ // handler-entry `const turn = currentTurn` check (lines 4441,
255
+ // 4593, etc), avoiding a double-emit / stale-turn-close on the
256
+ // next inbound's fresh turn.
257
+ const fbKey = ctx.key
258
+ const fbChatId = ctx.chatId
259
+ const fbThreadId = ctx.threadId ?? undefined
260
+ const wedgedTurn = getCurrentTurn()
261
+ // Match by sessionChatId + sessionThreadId before mutating
262
+ // `currentTurn` — multi-chat agents shouldn't have their CURRENT
263
+ // (different-chat) turn nulled by this fallback.
264
+ const turnMatchesFallback =
265
+ wedgedTurn != null &&
266
+ wedgedTurn.sessionChatId === fbChatId &&
267
+ wedgedTurn.sessionThreadId === fbThreadId
268
+ const turnStartedAt = activeTurnStartedAt.get(fbKey)
269
+ if (turnStartedAt != null) {
270
+ const turnDurationMs = Date.now() - turnStartedAt
271
+ const outboundMetrics = signalTracker.getOutboundMetrics(fbKey)
272
+ emitRuntimeMetric({
273
+ kind: 'turn_ended',
274
+ chat_id: fbChatId,
275
+ thread_id: ctx.threadId,
276
+ duration_ms: turnDurationMs,
277
+ ttfo_ms: outboundMetrics.ttfoMs,
278
+ outbound_count: outboundMetrics.outboundCount,
279
+ longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
280
+ ended_via: 'framework_fallback',
281
+ })
282
+ // #1892-follow-up: do NOT clear signalTracker state here. When the
283
+ // model recovers post-fallback (the framework's user-visible
284
+ // "still working" message is the load-bearing unwedge primitive —
285
+ // see `project_silence_poke_broken_and_cross_turn_fix`), its late
286
+ // reply calls fire `signalTracker.noteOutbound(fbKey, ...)`. If
287
+ // state is already cleared, that's a silent no-op and the late
288
+ // reply is invisible to outbound_count + ttfo metrics — the
289
+ // canonical session-end path (silent-marker line 7407 or normal
290
+ // turn-end line 7502) emits turn_ended a second time, reading
291
+ // the empty state and reporting outbound_count=0 even though
292
+ // replies landed. Defer clear to the canonical paths so
293
+ // post-fallback recoveries are correctly counted.
294
+ }
295
+ // Stamp the turn-DB end row as `timeout` so the wedged turn doesn't
296
+ // stay open until a SIGTERM/restart relabels it (false-negative for
297
+ // clean completion). Mirrors the canonical stop-path at line 5250.
298
+ const fbTurnsDb = getTurnsDb()
299
+ if (fbTurnsDb != null && turnMatchesFallback && wedgedTurn?.registryKey != null) {
300
+ const _turnKey = wedgedTurn.registryKey
301
+ const capturedJoined = wedgedTurn.capturedText.join('')
302
+ const assistantReplyPreview = capturedJoined
303
+ ? capturedJoined.slice(0, TURN_PREVIEW_MAX)
304
+ : null
305
+ try {
306
+ recordTurnEnd(fbTurnsDb, {
307
+ turnKey: _turnKey,
308
+ endedVia: 'timeout' as const,
309
+ lastAssistantMsgId: wedgedTurn.lastAssistantMsgId,
310
+ lastAssistantDone: wedgedTurn.lastAssistantDone,
311
+ assistantReplyPreview,
312
+ toolCallCount: wedgedTurn.toolCallCount,
313
+ })
314
+ } catch (err) {
315
+ process.stderr.write(
316
+ `telegram gateway: recordTurnEnd(timeout) failed turnKey=${_turnKey}: ${(err as Error).message}\n`,
317
+ )
318
+ }
319
+ }
320
+ // Bridge-watchdog (#412): clear the on-disk turn-active marker so
321
+ // the watchdog doesn't read a stale "turn active" file after a
322
+ // wedge-recovery. Same defence the canonical paths run at 4971,
323
+ // 5270.
324
+ try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
325
+ // Stream/lane teardown — a pinned progress card or buffered
326
+ // preamble survives the wedge otherwise and lands on the next
327
+ // turn. Mirror the canonical stop-path at 5228-5229; skip
328
+ // closeProgressLane analogue from the silent-marker path because
329
+ // we have no streams to finalize here.
330
+ closeActivityLane(fbChatId, fbThreadId)
331
+ closeProgressLane(fbChatId, fbThreadId)
332
+ lastPtyPreviewByChat.delete(fbKey)
333
+ preambleSuppressor.dropNow()
334
+ // Drop silence-poke state and clear turn-active so the next inbound
335
+ // for this chat starts a fresh turn instead of queueing forever.
336
+ silencePoke.endTurn(fbKey)
337
+ pendingProgress.noteTurnEnd(fbKey)
338
+ purgeReactionTracking(fbKey)
339
+ // Defense-in-depth: the fallback's purgeReactionTracking above
340
+ // clears the canonical statusKey(chatId, threadId) for fbKey
341
+ // only. activeTurnStartedAt can hold sibling entries for the
342
+ // SAME chat (different threads, or a `null` vs `undefined`-thread
343
+ // variant left over from a normal turn-end path that nulled
344
+ // currentTurn without invoking purgeReactionTracking — the
345
+ // gymbro/klanker held-mid-turn symptom, 2026-05-20); sweep them via
346
+ // the same purger. Multi-chat-safe — only touches keys for fbChatId, so
347
+ // #1546's intentional cross-chat safety guard is preserved.
348
+ //
349
+ // BUT a sibling is NOT "by definition stale": in one-agent-owns-supergroup
350
+ // every forum topic shares fbChatId, so a chatId-only sweep would purge a
351
+ // LIVE sibling topic's reaction controller + typing loop when THIS topic's
352
+ // poke fires. Gate each sibling on its OWN silence clock — purge only those
353
+ // also silent ≥ the fallback threshold (their own poke would fire too),
354
+ // sparing topics that are actively mid-turn. Use silence, not turn-start
355
+ // age, so a long-but-narrating turn isn't mistaken for stale.
356
+ // See turn-state-purge.ts.
357
+ const fbNow = Date.now()
358
+ const fbExtraPurge = purgeStaleTurnsForChat(
359
+ fbChatId,
360
+ activeTurnStartedAt.keys(),
361
+ purgeReactionTracking,
362
+ (siblingKey) => {
363
+ if (siblingKey === fbKey) return true // the firing key is genuinely stale
364
+ const sib = silencePoke.silenceMsForKey(siblingKey, fbNow)
365
+ // No silence-poke state → dangling (turn ended, key not purged) → stale.
366
+ return sib == null || sib >= silencePoke.DEFAULT_THRESHOLDS.fallback
367
+ },
368
+ )
369
+ // PR-4e self-heal backstop: drop any per-topic `currentTurnByKey` entries
370
+ // for fbChatId whose turn is stale-by-the-same-silence-gate the sibling
371
+ // sweep above used — the same precedent as `purgeStaleTurnsForChat`'s
372
+ // activeTurnStartedAt sweep, so a leaked map entry (a turn whose keyed
373
+ // delete somehow never ran) can never outlive its chat. Gated identically:
374
+ // the firing key is always stale; a sibling is stale iff it's silent ≥ the
375
+ // fallback threshold (or has no silence state). No-op under the flag OFF
376
+ // (the map is empty).
377
+ currentTurnMap.purgeChatStale(fbChatId, (siblingKey) => {
378
+ if (siblingKey === fbKey) return true
379
+ const sib = silencePoke.silenceMsForKey(siblingKey, fbNow)
380
+ return sib == null || sib >= silencePoke.DEFAULT_THRESHOLDS.fallback
381
+ })
382
+ // Null `currentTurn` if it's still pointing at the wedged turn —
383
+ // when claude eventually fires a late `turn_end` for this session
384
+ // (or never does), the handler's `const turn = currentTurn` snapshot
385
+ // returns null and the regular teardown short-circuits. Without
386
+ // this, the late event would re-emit `turn_ended` AND clobber
387
+ // whatever fresh turn the next inbound started.
388
+ // PR-4e — keyed liveness for the guard. Flag-OFF: `turnLiveForItsTopic`
389
+ // reduces to `currentTurn === wedgedTurn` (singleton mirror), verbatim.
390
+ // Flag-ON: `byKey.get(fbKey) === wedgedTurn`, so the keyed delete still
391
+ // fires when the LIVE mirror has already flipped to another topic B (a bare
392
+ // `currentTurn === wedgedTurn` would falsely skip and leak A's byKey entry).
393
+ if (turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)) {
394
+ // Status-surface observability: emit the lifecycle CLEAR for the
395
+ // silence-poke teardown so a fallback-nulled turn has a turn-lifecycle
396
+ // line like every other clear path (the framework-fallback line below is
397
+ // its own format — this makes the dark-out greppable in the same shape).
398
+ process.stderr.write(
399
+ `telegram gateway: ${formatTurnLifecycle('clear', 'silence_fallback', wedgedTurn, Date.now())}\n`,
400
+ )
401
+ // PR-4e — keyed delete for the wedged turn's OWN key (fbKey == the
402
+ // statusKey this fallback fired for, == the wedgedTurn's key since
403
+ // turnMatchesFallback gated chat+thread equality). Flag-OFF nulls the
404
+ // singleton, verbatim; flag-ON deletes only this topic's entry and clears
405
+ // the mirror iff it still points here — a live sibling topic is untouched.
406
+ endCurrentTurnForKey(wedgedTurn, fbKey)
407
+ }
408
+ // Best-effort: clear any pending silent-end marker so the Stop hook
409
+ // doesn't double-block when claude eventually exits the wedged turn.
410
+ try {
411
+ clearSilentEndState(fbKey)
412
+ } catch { /* best-effort */ }
413
+ // Self-heal the inbound buffer. pendingInboundBuffer otherwise
414
+ // drains ONLY on bridge re-register (onClientRegistered). After a
415
+ // network storm that settles with the bridge STILL connected, user
416
+ // messages buffered during the flap sit forever — until a manual
417
+ // restart forces a re-register (the fleet-update thundering-herd
418
+ // incident, 2026-05-19: agents "not responding", logs show
419
+ // pending-inbound-buffer depth>0 with no drain). Flushing on
420
+ // wedge-clear makes the agent self-heal. selfAgent-keyed; a miss
421
+ // re-buffers so nothing is lost if the bridge is genuinely offline.
422
+ const fbSelfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
423
+ const fbRedeliver = redeliverBufferedInbound(
424
+ getPendingInboundBuffer(),
425
+ fbSelfAgent,
426
+ (m) => sendToAgent(fbSelfAgent, m),
427
+ getInboundSpool(),
428
+ trackRedeliveredInbound,
429
+ )
430
+ process.stderr.write(
431
+ `telegram gateway: silence-poke framework-fallback ended wedged turn ` +
432
+ `chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
433
+ `currentTurn_nulled=${turnMatchesFallback} ` +
434
+ `drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
435
+ `${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
436
+ `${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
437
+ )
438
+ },
439
+ }
440
+ }