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,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
@@ -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
@@ -46,7 +46,7 @@ import { homedir } from 'os'
46
46
  import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
47
47
  import { sanitiseToolArg } from './fleet-state.js'
48
48
  import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
49
- import { REPLY_TOOLS } from './narrative-dedup.js'
49
+ import { isReplyTool } from './narrative-dedup.js'
50
50
  import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from './narrative-flush.js'
51
51
  import { truncate } from './card-format.js'
52
52
  import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents, recordNestedSubagentDispatch, recordSubagentModel } from './registry/subagents-schema.js'
@@ -551,6 +551,31 @@ export interface SubagentWatcherConfig {
551
551
  * that the chain is bounded, not that a lost worker gets a fresh card.
552
552
  */
553
553
  onWorkerLost?: (agentId: string, description: string) => void
554
+ /**
555
+ * Issue #3373 (SendMessage-resume feed re-surface). Fires when a worker that
556
+ * had a GENUINE terminal completion (a real `sub_agent_turn_end`, then swept
557
+ * by `cleanupTerminalAgent`) is RESUMED via SendMessage — its
558
+ * `agent-<id>.jsonl` grows PAST the byte size recorded at terminal cleanup,
559
+ * and `scanSubagentsDir` re-registers it as a fresh live entry (#3315).
560
+ *
561
+ * This is DISTINCT from `onResurrect` (#3023), which reverses a FALSE finish
562
+ * from silent-stall synthesis and carries bounded-chain semantics. A
563
+ * SendMessage resume is a real, legitimately-unbounded lifecycle event (a
564
+ * worker may be stopped and resumed many times), so it does NOT route through
565
+ * the resurrection budget — it fires here every time growth-past-terminal is
566
+ * observed.
567
+ *
568
+ * Why it exists: the live worker-activity feed latches a terminal `finalized`
569
+ * gate per agentId (worker-activity-feed.ts) so a late zombie tick can't
570
+ * repaint a done worker. The #3315 re-registration re-arms the watcher and
571
+ * re-fires `onProgress`, but those cues are SWALLOWED by that latch — so a
572
+ * resumed long-running worker reads as silence with no card. The gateway
573
+ * wires this to `feed.resurrect(agentId)` (clear the latch) so the subsequent
574
+ * `onProgress` cue repaints a fresh live row. Deterministic: gated solely on
575
+ * the file growing past the recorded terminal boundary, never on model
576
+ * discipline. Best-effort; the callback is expected to be idempotent.
577
+ */
578
+ onResume?: (agentId: string, description: string) => void
554
579
  /**
555
580
  * Called exactly once per sub-agent when its watcher observes a terminal
556
581
  * transition (`done` or `failed`). Mirrors the existing `sub_agent_started`
@@ -846,6 +871,17 @@ const DEFAULT_REAPER_INTERVAL_MS = 15 * 60_000 // 15 minutes
846
871
  */
847
872
  const TERMINAL_CLEANUP_GRACE_MS = 30_000
848
873
 
874
+ /**
875
+ * Bounded tail window (bytes) the boot-reconcile fast-path reads to decide
876
+ * whether a stale historical worker already reached `turn_end` before this
877
+ * boot (see `probeReachedTurnEndAtBoot`). Large enough to contain any realistic
878
+ * final line (a `turn_duration` line is ~100 bytes; an `end_turn` assistant
879
+ * answer line is well under this), yet a tiny fraction of the multi-MB
880
+ * transcripts a long-running worker accumulates — so the boot read is O(window)
881
+ * per record instead of O(filesize). 512 KiB.
882
+ */
883
+ const BOOT_TERMINAL_PROBE_WINDOW_BYTES = 512 * 1024
884
+
849
885
  /**
850
886
  * Throttle for the liveness-path retry of `backfillJsonlAgentId` (see
851
887
  * WorkerEntry.lastBackfillAttemptAt). Cheap (one meta.json read + two
@@ -1551,8 +1587,8 @@ export function readSubTail(
1551
1587
  const narrativeJustFired = resolvePendingSubNarrative(ev.toolName, ev.input)
1552
1588
  // NIT 3: capture a foreground sub-agent's actual reply text so the
1553
1589
  // turn_end path can suppress a trailing draft of it (see
1554
- // resolvePendingSubNarrative). Only REPLY_TOOLS carry the answer.
1555
- if (REPLY_TOOLS.has(ev.toolName) && typeof ev.input?.text === 'string') {
1590
+ // resolvePendingSubNarrative). Only reply tools carry the answer.
1591
+ if (isReplyTool(ev.toolName) && typeof ev.input?.text === 'string') {
1556
1592
  entry.lastReplyText = ev.input.text as string
1557
1593
  }
1558
1594
  entry.toolCount++
@@ -1923,6 +1959,58 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1923
1959
 
1924
1960
  let stopped = false
1925
1961
 
1962
+ /**
1963
+ * Cheap boot-terminal probe. Reads only a BOUNDED TAIL of a JSONL (not the
1964
+ * whole multi-MB transcript) and runs the canonical `projectSubagentLine`
1965
+ * projector over its complete lines to decide whether the worker had already
1966
+ * reached `sub_agent_turn_end` before this boot. Used by the boot-reconcile
1967
+ * fast-path to tell a done-at-boot worker (which still needs terminal
1968
+ * cleanup — feed-row sweep, card collapse/unpin) apart from a running-at-boot
1969
+ * dead prior-session worker (which needs nothing) WITHOUT the expensive full
1970
+ * read. `done` is the only terminal boot state (`WorkerState` never flips to
1971
+ * `'failed'` at boot — see its doc), and its signal (`system/turn_duration`
1972
+ * or an `end_turn` assistant line) is always the tail of the file, so a
1973
+ * bounded window catches it. Conservative on error: an unreadable/oversized
1974
+ * final line returns `false`, so the entry falls through to the running-stale
1975
+ * skip rather than being mis-swept.
1976
+ */
1977
+ function probeReachedTurnEndAtBoot(filePath: string, size: number): boolean {
1978
+ if (size <= 0) return false
1979
+ const start = Math.max(0, size - BOOT_TERMINAL_PROBE_WINDOW_BYTES)
1980
+ let raw: string
1981
+ try {
1982
+ const len = size - start
1983
+ const buf = Buffer.alloc(len)
1984
+ const fd = fs.openSync(filePath, 'r')
1985
+ try {
1986
+ fs.readSync(fd, buf, 0, len, start)
1987
+ } finally {
1988
+ fs.closeSync(fd)
1989
+ }
1990
+ raw = buf.toString('utf-8')
1991
+ } catch {
1992
+ return false
1993
+ }
1994
+ const lines = raw.split('\n')
1995
+ // If we started mid-file, the first fragment is a partial line — drop it
1996
+ // (projectSubagentLine would JSON-parse-fail on it anyway, but be explicit).
1997
+ if (start > 0) lines.shift()
1998
+ const probeState = { hasEmittedStart: true } // suppress start events; irrelevant here
1999
+ for (const line of lines) {
2000
+ if (!line) continue
2001
+ let events
2002
+ try {
2003
+ events = projectSubagentLine(line, 'probe', probeState)
2004
+ } catch {
2005
+ continue
2006
+ }
2007
+ for (const ev of events) {
2008
+ if (ev.kind === 'sub_agent_turn_end') return true
2009
+ }
2010
+ }
2011
+ return false
2012
+ }
2013
+
1926
2014
  // ─── Per-agent registration ─────────────────────────────────────────────
1927
2015
 
1928
2016
  /**
@@ -1969,6 +2057,85 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1969
2057
  }
1970
2058
  registry.set(agentId, entry)
1971
2059
 
2060
+ // Boot reconcile fast-path (perf): a historical boot entry whose file's
2061
+ // LAST WRITE already exceeds the in-flight promotion freshness window is a
2062
+ // provably-dead prior-session worker. The freshness gate below reaches the
2063
+ // exact same verdict ("stale — leaving historical, no live transition to
2064
+ // observe"), but only AFTER the full initial `readSubTail` (a whole-
2065
+ // transcript parse plus per-record sqlite liveness/backfill round-trips).
2066
+ // On a busy 24/7 agent, hundreds of these dead JSONLs accumulate, and doing
2067
+ // that O(n) work synchronously at boot delayed the gateway answering
2068
+ // Telegram by minutes after every restart — the agent looked dead. Since
2069
+ // the age check ALONE decides "dead prior-session worker" (identical
2070
+ // threshold `inflightPromoteMaxAgeMs`), compute it up front and short-
2071
+ // circuit: register a minimal historical entry, skip the read, the
2072
+ // promotion attempt, and the FSWatcher. A resume re-registration (#3315)
2073
+ // passes isHistorical=false (historicalFiles.delete precedes it) and a
2074
+ // genuinely fresh in-flight-at-boot worker fails the age check, so both
2075
+ // still take the full path below.
2076
+ if (isHistorical) {
2077
+ let mtimeMs: number | null = null
2078
+ let fileSize = 0
2079
+ try {
2080
+ const st = fs.statSync(filePath)
2081
+ if (typeof st.size === 'number') fileSize = st.size
2082
+ if (typeof st.mtimeMs === 'number') mtimeMs = st.mtimeMs
2083
+ } catch {
2084
+ /* unreadable → mtimeMs stays null → conservative full path below */
2085
+ }
2086
+ // Only fast-path when the mtime is KNOWN and already past the freshness
2087
+ // window — a confidently-dead prior-session worker. A missing/unreadable
2088
+ // mtime is NOT treated as dead here (unlike the freshness gate's
2089
+ // Infinity-is-stale rule): we conservatively fall through to the full
2090
+ // path so the read still happens, since the whole optimisation targets
2091
+ // real on-disk files (which always carry an mtime) and the boot cost only
2092
+ // bites at fleet scale where mtimes are present.
2093
+ if (mtimeMs != null && n - mtimeMs > inflightPromoteMaxAgeMs) {
2094
+ const fileAgeMs = n - mtimeMs
2095
+ // The expensive boot work was the full initial `readSubTail` (whole-
2096
+ // transcript parse + per-record sqlite liveness/backfill round-trips)
2097
+ // done for EVERY historical `running` JSONL, including the hundreds a
2098
+ // busy 24/7 agent hoards that were last written days-to-weeks ago. It
2099
+ // produced nothing actionable for a worker the freshness gate was about
2100
+ // to leave historical anyway, yet its O(filesize) disk reads (×~1177 on
2101
+ // the incident agent) delayed the gateway answering Telegram by minutes
2102
+ // after every restart. Replace it with a BOUNDED tail probe.
2103
+ //
2104
+ // The probe distinguishes the two stale-at-boot populations WITHOUT the
2105
+ // full read, so we don't over-skip: a *done*-at-boot worker (its JSONL
2106
+ // already reached `turn_end`) must still reach `scheduleTerminalCleanup`
2107
+ // — otherwise its worker-feed row leaks as a ghost (card never
2108
+ // collapsed/unpinned; #<worker-feed ghost-leak>). A *running*-at-boot
2109
+ // dead prior-session worker (never wrote a terminal `turn_end` — the
2110
+ // incident population) needs nothing: no read, no backfill, no watcher,
2111
+ // no cleanup.
2112
+ const doneAtBoot = probeReachedTurnEndAtBoot(filePath, fileSize)
2113
+ // Park the tail cursor at the boot-time EOF either way. A historical
2114
+ // entry whose cursor sits at EOF is a cheap no-op in the poll loop
2115
+ // (readSubTail statSyncs, sees `size === cursor`, returns before any
2116
+ // openSync/sqlite — the skeleton-cue path is gated on `!historical`), so
2117
+ // no poll-loop special-casing is needed; a genuine post-boot resume that
2118
+ // grows the file PAST this EOF is still read on the next poll. No
2119
+ // FSWatcher: a dead prior-session worker has no live transition to
2120
+ // observe. And a stale worker's pre-existing `turn_end` sits BEFORE the
2121
+ // parked cursor, so it is never replayed as a spurious completion (the
2122
+ // v0.14.23 stale-handback regression stays fixed).
2123
+ tails.set(agentId, { cursor: fileSize, pendingPartial: '', hasEmittedStart: false, watcher: null })
2124
+ if (doneAtBoot) {
2125
+ // Mirror the done-at-boot branch of the full path (below), minus the
2126
+ // whole-transcript parse: suppress a spurious completion notification
2127
+ // and schedule the terminal cleanup so the feed row is swept.
2128
+ entry.state = 'done'
2129
+ entry.completionNotified = true
2130
+ log?.(`subagent-watcher: ${agentId} done at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — historical; scheduling terminal cleanup (bounded tail probe, no whole-transcript parse, no watcher)`)
2131
+ scheduleTerminalCleanup(agentId)
2132
+ } else {
2133
+ log?.(`subagent-watcher: ${agentId} running at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — leaving historical, skipping reconcile read (dead prior-session worker, not in-flight; no whole-transcript parse, no backfill, no watcher)`)
2134
+ }
2135
+ return
2136
+ }
2137
+ }
2138
+
1972
2139
  // Backfill jsonl_agent_id linkage. The PreToolUse hook inserts the row
1973
2140
  // keyed on tool_use_id and doesn't know the JSONL stem yet (the JSONL
1974
2141
  // doesn't exist when PreToolUse fires). We bridge that gap here: read
@@ -2950,6 +3117,22 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2950
3117
  terminatedAgentIds.delete(agentId)
2951
3118
  historicalFiles.delete(filePath)
2952
3119
  knownFiles.add(filePath)
3120
+ // Issue #3373: the worker-activity feed latched a terminal `finalized`
3121
+ // gate for this agentId at its genuine completion, so the `onProgress`
3122
+ // cues the re-registration is about to re-fire would be SWALLOWED and no
3123
+ // card would re-surface. Fire onResume FIRST (before registerAgent, whose
3124
+ // initial read can synchronously re-emit onProgress) so the gateway clears
3125
+ // that gate; the subsequent live cue then repaints a fresh row. Fired only
3126
+ // on growth-past-terminal — never for a static leftover (the
3127
+ // `currentSize <= terminalSize` continue above), so the anti-zombie latch
3128
+ // still blocks genuinely-finished workers.
3129
+ if (config.onResume != null) {
3130
+ try {
3131
+ config.onResume(agentId, registry.get(agentId)?.description ?? 'sub-agent')
3132
+ } catch (cbErr) {
3133
+ log?.(`subagent-watcher: onResume callback error ${agentId}: ${(cbErr as Error).message}`)
3134
+ }
3135
+ }
2953
3136
  registerAgent(filePath, agentId, terminalSize)
2954
3137
  continue
2955
3138
  }
@@ -20,6 +20,11 @@ import { readFileSync } from 'node:fs'
20
20
  import { resolve } from 'node:path'
21
21
 
22
22
  const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
23
+ // #2996 P4-B: drainActivitySummary (the card-OPEN persist site) and
24
+ // clearActivitySummary (the normal-CLOSE clear site) moved VERBATIM into
25
+ // narrative-lane.ts (bodies indented +2 inside the factory — multi-line
26
+ // markers below use the lane spelling).
27
+ const laneSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'), 'utf-8')
23
28
 
24
29
  function between(src: string, startMarker: string, endMarker: string): string {
25
30
  const after = src.split(startMarker)[1] ?? ''
@@ -30,7 +35,7 @@ describe('activity-card durability wiring', () => {
30
35
  it('the card-OPEN path persists the durable handle (writeActivityCardRecord)', () => {
31
36
  // The open branch runs when activityMessageId transitions null → set.
32
37
  const openBranch = between(
33
- gatewaySrc,
38
+ laneSrc,
34
39
  'if (turn.activityMessageId == null) {',
35
40
  'turn.activityLastSentRender = target',
36
41
  )
@@ -55,8 +60,8 @@ describe('activity-card durability wiring', () => {
55
60
 
56
61
  it('the normal-CLOSE path clears the durable handle, id-scoped (reap-race guard)', () => {
57
62
  const closeBody = between(
58
- gatewaySrc,
59
- 'const id = turn.activityMessageId\n turn.activityMessageId = null',
63
+ laneSrc,
64
+ 'const id = turn.activityMessageId\n turn.activityMessageId = null',
60
65
  'if (CLEAR_STATUS_ON_COMPLETION)',
61
66
  )
62
67
  expect(closeBody.length).toBeGreaterThan(50)