switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,204 @@
1
+ /**
2
+ * On-demand voice ('🔊 Listen') callback family (`voice:<token>`) extracted
3
+ * verbatim from the gateway's `callback_query:data` dispatcher
4
+ * (switchroom#2996 P6 dispatcher drain).
5
+ *
6
+ * Handles the tap INTERNALLY — synthesizes the cached reply text and sends it
7
+ * as a native voice note. MUST run before the agent-callback routing in the
8
+ * dispatcher so a tap never reaches the agent as an inbound message.
9
+ *
10
+ * Returns true when the callback was a voice-on-demand tap (handled —
11
+ * dispatcher must `return`), false otherwise (dispatcher falls through).
12
+ *
13
+ * DI note (check-bot-api-wrapping, cluster-F pattern): the raw
14
+ * `bot.api.sendVoice` / `bot.api.editMessageReplyMarkup` calls are NOT made
15
+ * here — they are injected as `sendVoiceByFileId` / `sendVoiceByUpload` /
16
+ * `stripListenKeyboard`, bound in gateway.ts inside `robustApiCall(...)` with
17
+ * their existing allow-raw-bot-api markers. This module has no raw Bot API
18
+ * site.
19
+ */
20
+
21
+ import { readFileSync } from 'node:fs'
22
+ import type { Context } from 'grammy'
23
+ import {
24
+ isVoiceOnDemandCallback,
25
+ parseVoiceOnDemandToken,
26
+ type VoiceOnDemandPayload,
27
+ } from '../voice-ondemand.js'
28
+ import { sendVoiceReusingFileId, type SentVoiceMessage } from '../voice-send.js'
29
+ import { synthesizeViaSidecar } from '../voice-synthesize-sidecar.js'
30
+ import { normalizeForTts } from '../tts-normalize.js'
31
+ import { materializeSidecarToken } from '../../src/telegram/materialize-sidecar-token.js'
32
+
33
+ export interface VoiceSendOpts {
34
+ reply_parameters?: { message_id: number }
35
+ message_thread_id?: number
36
+ }
37
+
38
+ export interface VoiceOnDemandCallbackDeps {
39
+ loadAccess: () => { allowFrom: string[] }
40
+ voiceOnDemandCache: {
41
+ get: (token: string) => VoiceOnDemandPayload | null
42
+ setTelegramFileId: (token: string, fileId: string) => void
43
+ }
44
+ /** robustApiCall(() => bot.api.sendVoice(chatId, fileId, opts), verbOpts) — bound in gateway.ts. */
45
+ sendVoiceByFileId: (
46
+ chatId: string,
47
+ fileId: string,
48
+ sendOpts: VoiceSendOpts,
49
+ threadId: number | undefined,
50
+ ) => Promise<SentVoiceMessage>
51
+ /** robustApiCall(() => bot.api.sendVoice(chatId, new InputFile(audio), opts), verbOpts) — bound in gateway.ts. */
52
+ sendVoiceByUpload: (
53
+ chatId: string,
54
+ audio: Uint8Array,
55
+ sendOpts: VoiceSendOpts,
56
+ threadId: number | undefined,
57
+ ) => Promise<SentVoiceMessage>
58
+ /** robustApiCall(() => bot.api.editMessageReplyMarkup(...)) — bound in gateway.ts; swallows failure. */
59
+ stripListenKeyboard: (
60
+ chatId: string,
61
+ messageId: number,
62
+ threadId: number | undefined,
63
+ ) => Promise<void>
64
+ /** stderr log sink. */
65
+ log: (line: string) => void
66
+ }
67
+
68
+ /** Handle a `voice:` callback. Returns false if `data` is not one. */
69
+ export async function handleVoiceOnDemandCallback(
70
+ ctx: Context,
71
+ data: string,
72
+ deps: VoiceOnDemandCallbackDeps,
73
+ ): Promise<boolean> {
74
+ if (!isVoiceOnDemandCallback(data)) return false
75
+ const access = deps.loadAccess()
76
+ const senderId = String(ctx.from!.id)
77
+ if (!access.allowFrom.includes(senderId)) {
78
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
79
+ return true
80
+ }
81
+ const token = parseVoiceOnDemandToken(data)
82
+ const entry = token != null ? deps.voiceOnDemandCache.get(token) : null
83
+ if (entry == null) {
84
+ await ctx
85
+ .answerCallbackQuery({ text: 'Voice expired — send again to hear it.' })
86
+ .catch(() => {})
87
+ return true
88
+ }
89
+ const cbChatId = String(ctx.chat?.id ?? ctx.from!.id)
90
+ const cbMessageId = ctx.callbackQuery?.message?.message_id
91
+ const cbThreadId = (() => {
92
+ const msg = ctx.callbackQuery?.message
93
+ if (msg && 'is_topic_message' in msg && msg.is_topic_message && 'message_thread_id' in msg) {
94
+ const tid = (msg as { message_thread_id?: number }).message_thread_id
95
+ return typeof tid === 'number' ? tid : undefined
96
+ }
97
+ return undefined
98
+ })()
99
+ const tok = token as string
100
+ const sendOpts: VoiceSendOpts = {
101
+ ...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
102
+ ...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
103
+ }
104
+
105
+ // #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written by
106
+ // the pre-synth queue at reply time). If it's on disk, attach it
107
+ // immediately — no GPU wait. Missing/unreadable file (expired + swept,
108
+ // crash, pre-feature entry, kill-switched gateway) falls back transparently
109
+ // to the lazy synth path. Only invoked when there's no reusable file_id (or
110
+ // a stored id was rejected as stale) — see sendVoiceReusingFileId.
111
+ const loadAudio = async (): Promise<Uint8Array | null> => {
112
+ let audio: Uint8Array | null = null
113
+ if (entry.filePath != null) {
114
+ try {
115
+ audio = readFileSync(entry.filePath)
116
+ } catch {
117
+ audio = null // swept/missing — lazy fallback
118
+ }
119
+ }
120
+ if (audio != null) {
121
+ await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
122
+ return audio
123
+ }
124
+ await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
125
+ // Local sidecar (kokoro) synthesis — same helper the immediate voice-out
126
+ // path uses. On-demand is a local-engine feature; the cache is only
127
+ // populated when resolveVoiceOutPlan gated the local verdict.
128
+ const sidecarToken = await materializeSidecarToken()
129
+ if (!sidecarToken) {
130
+ await ctx
131
+ .answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
132
+ .catch(() => {})
133
+ return null
134
+ }
135
+ const result = await synthesizeViaSidecar({
136
+ token: sidecarToken,
137
+ // #2760 Phase 1: same deterministic normalization as the immediate
138
+ // voice-out path — the Listen lazy path builds its own /tts body from
139
+ // the persisted cache, so it must normalize independently (cache
140
+ // entries may predate the flag flip).
141
+ text: normalizeForTts(entry.text),
142
+ voice: entry.voice,
143
+ speed: entry.speed,
144
+ })
145
+ if (!result.ok) {
146
+ deps.log(
147
+ `telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
148
+ )
149
+ await ctx
150
+ .answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
151
+ .catch(() => {})
152
+ return null
153
+ }
154
+ return result.audio
155
+ }
156
+
157
+ // Fast path: if we already captured a reusable file_id, ack instantly and
158
+ // send BY the id — no disk read, no re-upload. First tap (no id yet) and a
159
+ // stale-id fallback both go through loadAudio + InputFile below and refresh
160
+ // the stored id from the returned message.
161
+ if (entry.telegramFileId != null) {
162
+ await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
163
+ }
164
+ const sendResult = await sendVoiceReusingFileId({
165
+ fileId: entry.telegramFileId ?? null,
166
+ sendByFileId: (fid) => deps.sendVoiceByFileId(cbChatId, fid, sendOpts, cbThreadId),
167
+ loadAudio,
168
+ sendByUpload: (audioOut) => deps.sendVoiceByUpload(cbChatId, audioOut, sendOpts, cbThreadId),
169
+ onFileId: (fid) => deps.voiceOnDemandCache.setTelegramFileId(tok, fid),
170
+ log: (line) => deps.log(line),
171
+ })
172
+
173
+ if (!sendResult.ok) {
174
+ // no-audio already surfaced a toast inside loadAudio; send-failed is
175
+ // logged non-fatally — the '🔊 Listen' keyboard is left intact so the
176
+ // user can retry (only a successful send strips it below).
177
+ if (sendResult.reason === 'send-failed') {
178
+ const err = sendResult.error
179
+ const msg = err instanceof Error ? err.message : String(err)
180
+ deps.log(
181
+ `telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
182
+ )
183
+ }
184
+ return true
185
+ }
186
+ try {
187
+ // Single-use on SUCCESS: strip the '🔊 Listen' keyboard so the button
188
+ // can't be re-tapped now that the audio has been delivered. Mirrors the
189
+ // agent-button single_use strip (keyboardIsSingleUse) house style.
190
+ // Best-effort + non-fatal — a failed strip only leaves a replayable
191
+ // button, never drops the delivered audio. Only reached on a successful
192
+ // sendVoice; expiry / synth-failure / sidecar-unavailable all return
193
+ // earlier WITHOUT stripping, so the user can retry those.
194
+ if (cbMessageId != null) {
195
+ await deps.stripListenKeyboard(cbChatId, cbMessageId, cbThreadId).catch(() => {})
196
+ }
197
+ } catch (err) {
198
+ const msg = err instanceof Error ? err.message : String(err)
199
+ deps.log(
200
+ `telegram gateway: voice-out on-demand: strip-listen-keyboard failed (non-fatal): ${msg}\n`,
201
+ )
202
+ }
203
+ return true
204
+ }
@@ -1,5 +1,45 @@
1
1
  import type { Subagent } from '../registry/subagents-schema.js'
2
2
 
3
+ /**
4
+ * The narrow slice of the worker-activity feed `handleWorkerResume` needs —
5
+ * structurally satisfied by `WorkerActivityFeed` (worker-activity-feed.ts)
6
+ * without importing it (keeps this module dependency-light and the seam
7
+ * trivially mockable in tests).
8
+ */
9
+ export interface WorkerFeedForResume {
10
+ resurrect(agentId: string): void
11
+ }
12
+
13
+ /**
14
+ * Issue #3373 (SendMessage-resume feed re-surface) — the gateway's `onResume`
15
+ * handler body, extracted so the seam is unit-testable and gateway.ts keeps
16
+ * only a thin delegation (the line-ratchet drains inline bodies).
17
+ *
18
+ * A worker with a GENUINE terminal completion that is resumed via SendMessage
19
+ * grows its jsonl past the terminal boundary and is re-registered live by the
20
+ * watcher (#3315). This clears the feed's terminal `finalized` latch
21
+ * (`feed.resurrect`) so the resumed worker's next onProgress cue repaints a
22
+ * fresh live row — without it the latch swallows every resumed cue and the
23
+ * worker reads as silence. Distinct from the #3023 `onResurrect` path: no
24
+ * bounded-chain budget (a worker may be stopped/resumed any number of times).
25
+ *
26
+ * Best-effort: a resurrect failure is logged, never thrown back into the
27
+ * watcher's poll loop. `feed` may be null/undefined (feed disabled) — the
28
+ * re-surface log line is still emitted for the audit trail.
29
+ */
30
+ export function handleWorkerResume(
31
+ feed: WorkerFeedForResume | null | undefined,
32
+ agentId: string,
33
+ log: (msg: string) => void,
34
+ ): void {
35
+ try {
36
+ feed?.resurrect(agentId)
37
+ } catch (err) {
38
+ log(`telegram gateway: worker resume feed re-surface error agent=${agentId}: ${(err as Error).message}`)
39
+ }
40
+ log(`telegram gateway: worker ${agentId} card RE-SURFACED — resumed via SendMessage after a genuine terminal (issue #3373)`)
41
+ }
42
+
3
43
  export interface WorkerFeedDispatch {
4
44
  /** True when the sub-agent was dispatched with `run_in_background: true`. */
5
45
  isBackground: boolean
@@ -25,9 +25,32 @@
25
25
  * message.
26
26
  */
27
27
 
28
- /** Tools whose `input.text` IS the canonical answer surface. */
28
+ /** Tool suffixes whose `input.text` IS the canonical answer surface. */
29
29
  export const REPLY_TOOLS = new Set(['reply', 'stream_reply'])
30
30
 
31
+ /**
32
+ * Strip this plugin's `mcp__<server-key>__telegram__` prefix, matched by the
33
+ * same key-agnostic regex `computeLabel` / `isTelegramReplyTool` use so renames
34
+ * and forks (`clerk-telegram`, `switchroom-telegram`, …) all resolve. A bare
35
+ * name (no prefix) is returned unchanged.
36
+ */
37
+ const TELEGRAM_TOOL_PREFIX_RE = /^mcp__[^_].*?telegram__/
38
+
39
+ /**
40
+ * Prefix-aware membership test for {@link REPLY_TOOLS}.
41
+ *
42
+ * Production jsonl carries the fully-qualified MCP tool name
43
+ * (`mcp__switchroom-telegram__stream_reply`), so a bare `REPLY_TOOLS.has(name)`
44
+ * is always false in prod and the draft-then-send suppression path silently
45
+ * goes inert (#3231). Bare names ('reply' / 'stream_reply') can still appear
46
+ * from non-MCP sources, so both wire shapes must match: strip the telegram MCP
47
+ * prefix (a no-op for bare names) then test the suffix against REPLY_TOOLS.
48
+ */
49
+ export function isReplyTool(toolName: string | null | undefined): boolean {
50
+ if (typeof toolName !== 'string') return false
51
+ return REPLY_TOOLS.has(toolName.replace(TELEGRAM_TOOL_PREFIX_RE, ''))
52
+ }
53
+
31
54
  /**
32
55
  * Normalize for prefix comparison: strip markdown/HTML-ish emphasis,
33
56
  * heading and quote marks, collapse whitespace, lowercase. Mirrors the
@@ -39,7 +39,7 @@
39
39
  * effects and the per-turn lifetime.
40
40
  */
41
41
 
42
- import { REPLY_TOOLS, isDraftOfReply } from './narrative-dedup.js'
42
+ import { isReplyTool, isDraftOfReply } from './narrative-dedup.js'
43
43
 
44
44
  /**
45
45
  * Time-box for the parked-narrative early-paint (the kernel's home for the
@@ -139,7 +139,7 @@ export class NarrativeFlushController {
139
139
  resolveOnTool(toolName: string, input: Record<string, unknown> | undefined): void {
140
140
  this.scheduler.disarm()
141
141
  const replyText =
142
- REPLY_TOOLS.has(toolName) && typeof input?.text === 'string' ? (input.text as string) : null
142
+ isReplyTool(toolName) && typeof input?.text === 'string' ? (input.text as string) : null
143
143
  if (replyText != null) this.maybeRetract(replyText)
144
144
  const pending = this.pending
145
145
  if (pending == null) return
@@ -36,6 +36,25 @@ export interface PendingUserNotice {
36
36
  kind: string
37
37
  /** When the notice was scheduled (ms epoch). */
38
38
  atMs: number
39
+ /**
40
+ * TOPIC KEY (#3294) — `statusKey(chatId, threadId)` of the turn that was live
41
+ * when the error surfaced, or `undefined` when no turn was attributable (the
42
+ * error arrived between turns, or after a silence poke nulled the live turn).
43
+ *
44
+ * Why it exists: the api_error operator event is agent-level (its wire
45
+ * `chatId` is always empty), so the gate USED to collapse per-agent and let
46
+ * ANY turn end resolve the notice. Under the PR-4e keyed-liveness flag
47
+ * (concurrent per-topic turns) that is a bug: turn B's reply-less end would
48
+ * flush turn A's pending notice while A may still recover — a FALSE "couldn't
49
+ * complete" claim. Keying the notice by the live turn's topic means only a
50
+ * turn end on the SAME topic resolves it (see {@link
51
+ * PendingUserNoticeGate.resolveTurnEnd}).
52
+ *
53
+ * A notice with an UNDEFINED key keeps the legacy agent-wide resolution (any
54
+ * turn end resolves it), so single-turn gateways — the current norm, where
55
+ * there is only ever one topic — are byte-identical to the pre-#3294 gate.
56
+ */
57
+ key?: string
39
58
  }
40
59
 
41
60
  /** How long a scheduled notice may wait for a resolving turn end. */
@@ -45,28 +64,55 @@ export class PendingUserNoticeGate {
45
64
  private pending: PendingUserNotice[] = []
46
65
 
47
66
  /**
48
- * Schedule a notice for turn-end resolution. Collapses per agent — a burst
49
- * of error lines within one turn holds ONE pending notice, not a stack.
67
+ * Schedule a notice for turn-end resolution. Collapses per (agent, topic key)
68
+ * — a burst of error lines within one turn holds ONE pending notice for that
69
+ * topic, not a stack. Distinct concurrent topics (keyed liveness) each keep
70
+ * their own pending notice, so one topic's burst never overwrites another's.
71
+ * (For a single-turn gateway every notice shares the one topic — or an
72
+ * undefined key — so the collapse is identical to the pre-#3294 per-agent one.)
50
73
  */
51
74
  schedule(notice: PendingUserNotice): void {
52
75
  this.prune(notice.atMs)
53
- this.pending = this.pending.filter((p) => p.agent !== notice.agent)
76
+ this.pending = this.pending.filter(
77
+ (p) => !(p.agent === notice.agent && p.key === notice.key),
78
+ )
54
79
  this.pending.push(notice)
55
80
  }
56
81
 
57
82
  /**
58
- * Resolve at turn end. `turnDeliveredReply` is the turn's outcome signal
59
- * (the gateway passes `finalAnswerDelivered || replyCalled`):
60
- * - true → the turn recovered; every pending notice is dropped, [] returned.
61
- * - false → the turn died without a reply; the un-expired pending notices
62
- * are returned EXACTLY ONCE for the caller to send.
63
- * Either way the ledger is cleared (a notice never survives its turn end).
83
+ * Resolve at the end of the turn whose topic is `turnKey`. `turnDeliveredReply`
84
+ * is the turn's outcome signal (the gateway passes `finalAnswerDelivered ||
85
+ * replyCalled`).
86
+ *
87
+ * A pending notice is RESOLVED by this turn end iff it belongs to the ending
88
+ * turn's topic (`p.key === turnKey`) OR it is unattributed (`p.key ===
89
+ * undefined` — legacy agent-wide resolution, kept so single-turn gateways are
90
+ * unchanged). Notices scheduled for a DIFFERENT live topic stay pending until
91
+ * their own topic's turn ends (or the TTL expires) — this is the #3294 fix:
92
+ * turn B's end no longer flushes turn A's pending notice.
93
+ *
94
+ * Of the resolved notices:
95
+ * - `turnDeliveredReply === true` → the turn recovered; the resolved
96
+ * notices are dropped and `[]` is returned.
97
+ * - `turnDeliveredReply === false` → the turn died reply-less; the resolved,
98
+ * un-expired notices are returned EXACTLY ONCE for the caller to send.
99
+ * Either way the resolved notices leave the ledger (a notice never survives
100
+ * the end of the turn it is keyed to).
64
101
  */
65
- resolveTurnEnd(turnDeliveredReply: boolean, now: number = Date.now()): PendingUserNotice[] {
102
+ resolveTurnEnd(
103
+ turnKey: string | undefined,
104
+ turnDeliveredReply: boolean,
105
+ now: number = Date.now(),
106
+ ): PendingUserNotice[] {
66
107
  this.prune(now)
67
- const out = turnDeliveredReply ? [] : [...this.pending]
68
- this.pending = []
69
- return out
108
+ const resolved: PendingUserNotice[] = []
109
+ const remaining: PendingUserNotice[] = []
110
+ for (const p of this.pending) {
111
+ if (p.key === turnKey || p.key === undefined) resolved.push(p)
112
+ else remaining.push(p)
113
+ }
114
+ this.pending = remaining
115
+ return turnDeliveredReply ? [] : resolved
70
116
  }
71
117
 
72
118
  /** True when at least one un-expired notice is pending (does not mutate). */
@@ -203,10 +203,34 @@ function renderTable(node: TableNode): string {
203
203
  return [headerLine, sepLine, ...bodyLines].join("\n");
204
204
  }
205
205
 
206
+ /**
207
+ * Escape a line-leading `#` in rendered PROSE so Telegram cannot promote the
208
+ * line to a heading (#3304 regression).
209
+ *
210
+ * Per CommonMark/GFM, `#` opens an ATX heading only when followed by a space,
211
+ * another `#`, or end-of-line — so micromark correctly parses `#3304 is
212
+ * merged.` as a plain paragraph, and this renderer re-emits it verbatim
213
+ * (`escapeMarkdown` deliberately leaves `#` alone). But Telegram's Bot API
214
+ * 10.1 server-side rich-markdown parser is NON-spec here: any line-leading
215
+ * `#` run is read as a heading, so an issue reference opening a line renders
216
+ * the whole paragraph as huge heading text. Backslash-escaping the first `#`
217
+ * of each such line keeps it literal on Telegram while remaining a no-op
218
+ * visually (Telegram honours backslash escapes, same mechanism the link-href
219
+ * `)` escape relies on).
220
+ *
221
+ * Applied ONLY to paragraph output (including paragraphs nested in
222
+ * blockquotes / list items via their renderBlock recursion), never to
223
+ * heading, code-block, or table nodes — so a genuine `# Title` heading node
224
+ * still renders as `# Title`.
225
+ */
226
+ function escapeLineLeadingHash(text: string): string {
227
+ return text.replace(/^([ \t]{0,3})#/gm, "$1\\#");
228
+ }
229
+
206
230
  function renderBlock(node: Block): string {
207
231
  switch (node.type) {
208
232
  case "paragraph":
209
- return renderInlineChildren(node.children);
233
+ return escapeLineLeadingHash(renderInlineChildren(node.children));
210
234
  case "heading":
211
235
  return `${"#".repeat(node.level)} ${renderInlineChildren(node.children)}`;
212
236
  case "blockquote":
@@ -28,6 +28,19 @@ import { RICH_MESSAGE_MAX_CHARS } from './format.js'
28
28
  */
29
29
  export const STATUS_ROLLING_LINES = 5
30
30
 
31
+ /**
32
+ * Ceiling on the per-worker recent-step history RETAINED in `row.narrative`
33
+ * (worker-activity-feed.ts) and the deepest trail the worker cards will render.
34
+ *
35
+ * Distinct from `STATUS_ROLLING_LINES` (which governs the 🤖 agent card's
36
+ * window and stays at 5) so raising the worker trail never widens the agent
37
+ * card. Set to 6 because the deterministic per-worker depth curve
38
+ * (`workerHistoryDepth`, tool-activity-summary.ts) peaks at 6 for a lone
39
+ * worker — `max(3, 7 − workerCount)` at `w = 1`. The buffer must retain at
40
+ * least this many lines or the renderer would ask for 6 and only find 5.
41
+ */
42
+ export const WORKER_HISTORY_MAX = 6
43
+
31
44
  /**
32
45
  * Per-line character cap, applied to every step + child step on BOTH
33
46
  * surfaces before HTML-escaping (clip raw → escape last). A line longer