switchroom 0.19.15 → 0.19.16

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 (27) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/dist/bridge/bridge.js +30 -1
  5. package/telegram-plugin/dist/gateway/gateway.js +693 -433
  6. package/telegram-plugin/dist/server.js +30 -1
  7. package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
  8. package/telegram-plugin/gateway/gateway.ts +7 -58
  9. package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
  10. package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
  11. package/telegram-plugin/gateway/outbox-sweep.ts +92 -18
  12. package/telegram-plugin/gateway/rich-message-handler.ts +10 -4
  13. package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
  14. package/telegram-plugin/session-tail.ts +88 -1
  15. package/telegram-plugin/silence-poke.ts +118 -1
  16. package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
  17. package/telegram-plugin/tests/feed-survival.test.ts +7 -1
  18. package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
  19. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
  20. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
  21. package/telegram-plugin/tests/session-tail.test.ts +91 -1
  22. package/telegram-plugin/tests/silence-poke.test.ts +280 -0
  23. package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
  24. package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
  25. package/telegram-plugin/tts-normalize.ts +12 -0
  26. package/telegram-plugin/voice-normalize-text.ts +100 -0
  27. package/telegram-plugin/voice-ondemand.ts +71 -0
@@ -17365,6 +17365,29 @@ function extractToolResultErrorText(content) {
17365
17365
  }
17366
17366
  return "";
17367
17367
  }
17368
+ function parseBackgroundTaskId(obj) {
17369
+ const tur = obj.toolUseResult;
17370
+ if (typeof tur === "object" && tur != null) {
17371
+ const id = tur.backgroundTaskId;
17372
+ if (typeof id === "string" && id.length > 0)
17373
+ return id;
17374
+ }
17375
+ return null;
17376
+ }
17377
+ function parseBackgroundLaunchString(content) {
17378
+ const text = typeof content === "string" ? content : extractToolResultErrorText(content);
17379
+ const m = text.match(/Command running in background with ID: (\w+)/);
17380
+ return m != null ? m[1] : null;
17381
+ }
17382
+ function parseTaskNotification(content) {
17383
+ if (!content.includes("<task-notification>"))
17384
+ return null;
17385
+ const idM = content.match(/<task-id>([^<]+)<\/task-id>/);
17386
+ const stM = content.match(/<status>([^<]+)<\/status>/);
17387
+ if (idM == null || stM == null)
17388
+ return null;
17389
+ return { taskId: idM[1].trim(), status: stM[1].trim() };
17390
+ }
17368
17391
  function projectAssistantTextBlocks(content, make) {
17369
17392
  const out = new Map;
17370
17393
  let lastToolUseIdx = -1;
@@ -17422,6 +17445,10 @@ function projectTranscriptLine(line) {
17422
17445
  const op = obj.operation;
17423
17446
  if (op === "enqueue") {
17424
17447
  const content = obj.content ?? "";
17448
+ const notif = parseTaskNotification(content);
17449
+ if (notif != null) {
17450
+ return [{ kind: "task_notification", taskId: notif.taskId, status: notif.status }];
17451
+ }
17425
17452
  const { chatId, messageId, threadId } = parseChannelMeta(content);
17426
17453
  return [{ kind: "enqueue", chatId, messageId, threadId, rawContent: content }];
17427
17454
  }
@@ -17479,6 +17506,7 @@ function projectTranscriptLine(line) {
17479
17506
  const content = message?.content;
17480
17507
  if (!Array.isArray(content))
17481
17508
  return [];
17509
+ const backgroundTaskId = parseBackgroundTaskId(obj);
17482
17510
  const events = [];
17483
17511
  for (const c of content) {
17484
17512
  if (c.type === "tool_result") {
@@ -17488,7 +17516,8 @@ function projectTranscriptLine(line) {
17488
17516
  toolUseId: c.tool_use_id ?? "",
17489
17517
  toolName: null,
17490
17518
  isError,
17491
- errorText: isError ? extractToolResultErrorText(c.content) : undefined
17519
+ errorText: isError ? extractToolResultErrorText(c.content) : undefined,
17520
+ backgroundTaskId: backgroundTaskId ?? parseBackgroundLaunchString(c.content) ?? undefined
17492
17521
  });
17493
17522
  }
17494
17523
  }
@@ -0,0 +1,65 @@
1
+ // #3519 sharpen — background-shell liveness signal wiring.
2
+ //
3
+ // Extracted from gateway.ts (the file is under a hard line ratchet — see
4
+ // switchroom#2996). Maps parsed session events to the silence-poke
5
+ // background-shell registry so the 300s silence-fallback defers ONLY while a
6
+ // CLI-side background shell is proven alive, and recovers at ~300s once it
7
+ // finishes. Called once per session event from the gateway's event loop.
8
+ //
9
+ // The three real, deterministic markers (all proven from captured transcript
10
+ // data — carrie session a6d2d33a-…, claude v2.1.197; see the fixture at
11
+ // telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl):
12
+ // • ALIVE — a tool_result carrying `backgroundTaskId` (a foreground Bash the
13
+ // CLI auto-moved to the background past its ~120s window, or an explicit
14
+ // run_in_background:true). session-tail resolves it from the structured
15
+ // `toolUseResult.backgroundTaskId` field, with the launch string as a
16
+ // secondary.
17
+ // • DEAD (completed/failed) — the CLI's proactive `<task-notification>`,
18
+ // projected as a `task_notification` event.
19
+ // • DEAD (explicit) — a `KillShell` tool_use naming the shell via
20
+ // `input.shell_id`.
21
+ import type { SessionEvent } from '../session-tail.js'
22
+
23
+ /** The subset of silence-poke this wiring drives (kept narrow for testability). */
24
+ export interface BackgroundShellRegistry {
25
+ noteBackgroundShellAlive(key: string, shellId: string): void
26
+ noteBackgroundShellDead(key: string, shellId: string): void
27
+ }
28
+
29
+ /** Terminal `<task-notification>` statuses that genuinely mean the shell ended. */
30
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed'])
31
+
32
+ /**
33
+ * Apply the background-shell liveness signal carried by `ev` (if any) to the
34
+ * registry for `key`. A no-op for events that carry no marker, so the caller
35
+ * can invoke it unconditionally on every session event.
36
+ */
37
+ export function applyBackgroundShellLiveness(
38
+ registry: BackgroundShellRegistry,
39
+ key: string,
40
+ ev: SessionEvent,
41
+ ): void {
42
+ if (ev.kind === 'tool_result') {
43
+ if (ev.backgroundTaskId != null && ev.backgroundTaskId.length > 0) {
44
+ registry.noteBackgroundShellAlive(key, ev.backgroundTaskId)
45
+ }
46
+ return
47
+ }
48
+ if (ev.kind === 'task_notification') {
49
+ // Defensive terminal-status gate: real captured data proves every genuine
50
+ // parseable <task-notification> carries a terminal status, so today ANY
51
+ // parsed notification is safe to treat as DEAD. This guards a hypothetical
52
+ // FUTURE CLI that emits an interim (non-terminal) notification for a still-
53
+ // running shell — we must not drop such a shell from the alive-set early.
54
+ if (ev.taskId.length > 0 && TERMINAL_STATUSES.has(ev.status)) {
55
+ registry.noteBackgroundShellDead(key, ev.taskId)
56
+ }
57
+ return
58
+ }
59
+ if (ev.kind === 'tool_use' && ev.toolName === 'KillShell') {
60
+ const killId = (ev.input as { shell_id?: string } | undefined)?.shell_id
61
+ if (typeof killId === 'string' && killId.length > 0) {
62
+ registry.noteBackgroundShellDead(key, killId)
63
+ }
64
+ }
65
+ }
@@ -60,6 +60,7 @@ import {
60
60
  import {
61
61
  VoiceOnDemandCache,
62
62
  } from '../voice-ondemand.js'
63
+ import { makeOutboxListenMarkupResolver } from './outbox-listen-markup.js'
63
64
  import {
64
65
  PreSynthQueue,
65
66
  sweepVoiceCacheDir,
@@ -275,7 +276,6 @@ import { createSessionModelSource } from './session-model-source.js'
275
276
  import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
276
277
  import { REPLY_TOOLS } from '../narrative-dedup.js'
277
278
  import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
278
- import { toolLabel } from '../tool-labels.js'
279
279
  import { createTypingWrapper } from '../typing-wrap.js'
280
280
  import { createTurnTypingLoop } from './turn-typing-loop.js'
281
281
  import {
@@ -751,6 +751,7 @@ import { createDeliveryConfirmWiring } from './delivery-confirm-wiring.js'
751
751
  import { createObligationWiring } from './obligation-wiring.js'
752
752
  // #2996 P8 PR-C3 — the extracted silence-poke wiring.
753
753
  import { buildSilencePokeOptions } from './liveness-wiring.js'
754
+ import { applySilencePokeSessionEvent } from './silence-poke-session-event.js'
754
755
  import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
755
756
  import { dispatchEffects } from './inbound-delivery-machine-dispatch.js'
756
757
  import { maybeFireWarmup } from './prefix-warmup.js'
@@ -9850,7 +9851,7 @@ function runDeliveryConfirmSweep(): void {
9850
9851
  const _deliveryConfirmSweep = isGatewayMain ? setInterval(runDeliveryConfirmSweep, DELIVERY_CONFIRM_SWEEP_MS) : undefined
9851
9852
  _deliveryConfirmSweep?.unref?.()
9852
9853
 
9853
- startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook-captured prose (../outbox.ts)
9854
+ startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, resolveReplyMarkup: makeOutboxListenMarkupResolver({ resolveVoiceOutPlan: (t) => resolveVoiceOutPlan(loadAccess().voice_out, t), cachePut: (token, payload) => voiceOnDemandCache.put(token, payload), eagerVoiceEnabled, enqueuePreSynth: (j) => voicePreSynthQueue.enqueue(j) }), log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook prose; resolveReplyMarkup keeps the #3502 Listen button on net-delivered answers (../outbox.ts)
9854
9855
 
9855
9856
  // #1445 cross-turn pending-async ambient. When a turn ends after the
9856
9857
  // model dispatched background async work (Agent / Task / Bash run-in-
@@ -10871,61 +10872,7 @@ if (isGatewayMain) ipcServer = createIpcServer({
10871
10872
  // (thinking vs working, plus the longest-running in-flight tool).
10872
10873
  if (currentTurn != null) {
10873
10874
  const key = statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId)
10874
- if (ev.kind === 'thinking') {
10875
- silencePoke.noteThinking(key, Date.now())
10876
- } else if (ev.kind === 'tool_use') {
10877
- // #1292: track in-flight tool calls so the 300s framework
10878
- // fallback message can name the actual observable (e.g.
10879
- // "running Grep \"foo\" for 4m") instead of the dishonest
10880
- // generic "still working… no update in 5 min" when the agent
10881
- // is clearly busy on tool calls. Telegram-surface tools are
10882
- // excluded — their job IS the outbound message, the silence
10883
- // clock resets via noteOutbound when they fire. Sub-agent
10884
- // tool_use events (kind='sub_agent_tool_use') intentionally
10885
- // NOT tracked: the parent's Task tool_use is already on the
10886
- // map and represents the user-observable wait.
10887
- if (
10888
- ev.toolUseId != null
10889
- && ev.toolUseId.length > 0
10890
- && !isTelegramSurfaceTool(ev.toolName)
10891
- ) {
10892
- const label = toolLabel(
10893
- ev.toolName,
10894
- ev.input,
10895
- /*preamble*/ undefined,
10896
- ev.precomputedLabel,
10897
- )
10898
- silencePoke.noteToolStart(
10899
- key,
10900
- ev.toolUseId,
10901
- ev.toolName,
10902
- label.length > 0 ? label : null,
10903
- Date.now(),
10904
- )
10905
- // #1445 cross-turn pending-async ambient. Mark the chat as
10906
- // having dispatched background work this turn so a turn_end
10907
- // that follows activates the edit-in-place ambient line.
10908
- // Covers `Agent` / `Task` (the harness-managed async path
10909
- // — handback channel turn clears it) and `Bash` with
10910
- // run_in_background:true (model is expected to poll
10911
- // BashOutput; the ambient ticks until next inbound or the
10912
- // 30-min budget cap).
10913
- const evInput = ev.input as { run_in_background?: boolean } | undefined
10914
- if (
10915
- ev.toolName === 'Agent'
10916
- || ev.toolName === 'Task'
10917
- || (ev.toolName === 'Bash' && evInput?.run_in_background === true)
10918
- ) {
10919
- pendingProgress.noteAsyncDispatch(key)
10920
- }
10921
- }
10922
- } else if (ev.kind === 'tool_result') {
10923
- // #1292: drain the in-flight entry. Idempotent on unknown ids
10924
- // (covers Telegram-surface tools we skipped at start time).
10925
- if (ev.toolUseId != null && ev.toolUseId.length > 0) {
10926
- silencePoke.noteToolEnd(key, ev.toolUseId, Date.now())
10927
- }
10928
- }
10875
+ applySilencePokeSessionEvent(silencePoke, pendingProgress, key, ev)
10929
10876
  }
10930
10877
  },
10931
10878
 
@@ -22498,7 +22445,9 @@ bot.on('message:checklist_tasks_added' as Parameters<typeof bot.on>[0], (ctx) =>
22498
22445
  bot.on('message:pinned_message', ctx => handlePinnedMessage(ctx, pinnedMessageHandlerDeps))
22499
22446
  // Bot API 10.1 rich messages (forwarded bot messages carry these with NO
22500
22447
  // text/caption — see rich-message-handler.ts; MUST precede the catch-all).
22501
- bot.on('message:rich_message', ctx => handleRichMessageMessage(ctx, mediaEnvelopeDeps))
22448
+ // Pure forwarded body text, no attachment → coalesce like `message:text`:
22449
+ // bind `handleInbound` to `handleInboundCoalesced`, not the bare one (#3516).
22450
+ bot.on('message:rich_message', ctx => handleRichMessageMessage(ctx, { ...mediaEnvelopeDeps, handleInbound: handleInboundCoalesced }))
22502
22451
  installUnhandledMessageCatchAll(
22503
22452
  bot,
22504
22453
  (ctx, text) => routeInbound(ctx, text, undefined, undefined, inboundRouterDeps),
@@ -65,9 +65,8 @@ import {
65
65
  import { decideAnswerLatchSuppression, type ReplyOwnerTier } from '../reply-owner-resolve.js'
66
66
  import { deriveTelegraphTitle } from '../telegraph.js'
67
67
  import {
68
- mintVoiceOnDemandToken,
69
- buildListenKeyboard,
70
68
  mayInjectListenButton,
69
+ planListenButton,
71
70
  type VoiceOnDemandCache,
72
71
  } from '../voice-ondemand.js'
73
72
  import { eagerVoiceEnabled, type PreSynthQueue } from '../voice-presynth.js'
@@ -1495,31 +1494,34 @@ export async function sendReply(
1495
1494
  // config never reaches here (it synthesized immediately above), so a Listen
1496
1495
  // button is never minted for an engine whose taps would dead-end on the
1497
1496
  // local sidecar.
1498
- if (
1499
- useOnDemandButton &&
1500
- voiceOutPlan!.ttsChunks.length > 0 &&
1501
- voiceOutPlan!.ttsChunks[0]!.length > 0
1502
- ) {
1503
- if (!mayInjectListenButton(rawKeyboard)) {
1504
- process.stderr.write(
1505
- 'telegram gateway: voice-out on-demand: agent supplied inline_keyboard skipping Listen button (single_use collision gate)\n',
1506
- )
1497
+ if (useOnDemandButton) {
1498
+ // planListenButton is the SHARED decision (also used by the durable-outbox
1499
+ // safety net, outbox-sweep.ts) it enforces the empty-TTS guard and the
1500
+ // agent-keyboard collision gate. Null = don't inject.
1501
+ const listenPlan = planListenButton({ voiceOutPlan, rawKeyboard })
1502
+ if (listenPlan == null) {
1503
+ // Log the collision gate ONLY when the agent keyboard is the ACTUAL
1504
+ // reason we skipped i.e. there IS speakable text. When ttsChunks is
1505
+ // empty the empty-TTS guard is what returned null (there was nothing to
1506
+ // speak), so a "skipping — agent supplied inline_keyboard" line would
1507
+ // misattribute the reason.
1508
+ const hasSpeakableText =
1509
+ voiceOutPlan!.ttsChunks.length > 0 && voiceOutPlan!.ttsChunks[0]!.length > 0
1510
+ if (hasSpeakableText && !mayInjectListenButton(rawKeyboard)) {
1511
+ process.stderr.write(
1512
+ 'telegram gateway: voice-out on-demand: agent supplied inline_keyboard — skipping Listen button (single_use collision gate)\n',
1513
+ )
1514
+ }
1507
1515
  } else {
1508
1516
  // Token is intentionally GLOBAL (not chat-keyed): under the single-tenant
1509
1517
  // invariant the operator is the only authorized sender across all chats,
1510
1518
  // and the tap handler re-checks access.allowFrom before synthesizing, so
1511
1519
  // a token needs no per-chat scoping to be safe.
1512
- const token = mintVoiceOnDemandToken()
1513
- voiceOnDemandCache.put(token, {
1514
- // ttsChunks[0] is already normalizeForSpeech(reply) (kokoro path).
1515
- text: voiceOutPlan.ttsChunks[0]!,
1516
- ...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
1517
- speed: voiceOutPlan.speed,
1518
- })
1520
+ voiceOnDemandCache.put(listenPlan.token, listenPlan.payload)
1519
1521
  // The keyboard stays after the tap (never stripped) so it can be
1520
1522
  // replayed. The gate above guarantees this is the ONLY button on the
1521
1523
  // message, so keeping it is safe (no agent buttons to protect).
1522
- replyMarkup = buildListenKeyboard(token)
1524
+ replyMarkup = listenPlan.replyMarkup
1523
1525
  // #2763 eager pre-synthesis: kick a background synth of the same
1524
1526
  // payload so the Listen tap attaches the pre-made file instantly.
1525
1527
  // Local-engine only (useOnDemandButton already gates engine==='kokoro'
@@ -1530,10 +1532,10 @@ export async function sendReply(
1530
1532
  // to the lazy path).
1531
1533
  if (eagerVoiceEnabled()) {
1532
1534
  voicePreSynthQueue.enqueue({
1533
- token,
1534
- text: voiceOutPlan.ttsChunks[0]!,
1535
- ...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
1536
- speed: voiceOutPlan.speed,
1535
+ token: listenPlan.token,
1536
+ text: listenPlan.payload.text,
1537
+ ...(listenPlan.payload.voice != null ? { voice: listenPlan.payload.voice } : {}),
1538
+ speed: listenPlan.payload.speed,
1537
1539
  })
1538
1540
  }
1539
1541
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * outbox-listen-markup.ts — wires the shared `planListenButton` decision into
3
+ * the durable-outbox safety-net delivery (outbox-sweep.ts), so a net-delivered
4
+ * final answer carries the SAME 🔊 Listen button / voice-out keyboard the normal
5
+ * `sendReply` path injects (switchroom #3502 regression: the sweep sent captured
6
+ * prose via a RAW `bot.api.sendMessage` with no voice-out resolution, dropping
7
+ * the button).
8
+ *
9
+ * Extracted from gateway.ts so the wiring lives in a module (the #2996 gateway
10
+ * anti-inflation ratchet) and is independently testable.
11
+ */
12
+
13
+ import {
14
+ planListenButton,
15
+ type ListenButtonVoiceOutPlan,
16
+ type VoiceOnDemandPayload,
17
+ } from '../voice-ondemand.js'
18
+ import type { OutboxDeliveryMarkup } from './outbox-sweep.js'
19
+
20
+ /** The minimal side-effecting deps the resolver needs — mirrors what `sendReply`
21
+ * does on the normal path (cache put + eager pre-synth), kept as an injected
22
+ * surface so gateway.ts passes its own singletons and this stays unit-testable. */
23
+ export interface OutboxListenMarkupDeps {
24
+ /** Resolve the agent's voice-out plan for a reply body (loadAccess().voice_out
25
+ * → resolveVoiceOutPlan). Returns null when voice-out is off / not applicable. */
26
+ resolveVoiceOutPlan: (replyText: string) => ListenButtonVoiceOutPlan | null
27
+ /** Persist a minted token so a Listen tap can resynthesize. A thin closure
28
+ * (not the cache instance) so the gateway can pass it before its module-scope
29
+ * `voiceOnDemandCache` const is initialized (this factory runs at wiring time,
30
+ * earlier in gateway.ts than the cache declaration). */
31
+ cachePut: (token: string, payload: VoiceOnDemandPayload) => void
32
+ /** True when eager pre-synth is enabled (kill switch off). */
33
+ eagerVoiceEnabled: () => boolean
34
+ /** Enqueue an eager pre-synth job (best-effort, off the critical path). */
35
+ enqueuePreSynth: (job: { token: string; text: string; voice?: string; speed: number }) => void
36
+ }
37
+
38
+ /**
39
+ * Build the `resolveReplyMarkup` callback for `startOutboxSweep`. Returns the
40
+ * Listen keyboard (and persists the token + eager-synth job) when kokoro
41
+ * on-demand voice-out is enabled and the empty-TTS / collision gates pass;
42
+ * otherwise undefined → the sweep delivers plain.
43
+ *
44
+ * A net-delivered captured-prose answer never carries an agent keyboard, so
45
+ * `rawKeyboard` is always undefined here; `planListenButton` still enforces the
46
+ * empty-TTS guard and the kokoro-on-demand gate.
47
+ */
48
+ export function makeOutboxListenMarkupResolver(
49
+ deps: OutboxListenMarkupDeps,
50
+ ): (chatId: string, threadId: number | null, text: string) => OutboxDeliveryMarkup | undefined {
51
+ return (_chatId, _threadId, text) => {
52
+ const voiceOutPlan = deps.resolveVoiceOutPlan(text)
53
+ const listenPlan = planListenButton({ voiceOutPlan, rawKeyboard: undefined })
54
+ if (listenPlan == null) return undefined
55
+ const payload: VoiceOnDemandPayload = listenPlan.payload
56
+ deps.cachePut(listenPlan.token, payload)
57
+ if (deps.eagerVoiceEnabled()) {
58
+ deps.enqueuePreSynth({
59
+ token: listenPlan.token,
60
+ text: payload.text,
61
+ ...(payload.voice != null ? { voice: payload.voice } : {}),
62
+ speed: payload.speed,
63
+ })
64
+ }
65
+ return listenPlan.replyMarkup
66
+ }
67
+ }
@@ -244,39 +244,113 @@ export const OUTBOX_SWEEP_INTERVAL_MS = 5_000
244
244
  * (`resolveSubagentOriginTurnKey`) then the last-real-inbound fallback (H3).
245
245
  * Kill switch: `SWITCHROOM_TG_OUTBOX_DELIVERY=0`.
246
246
  */
247
+ /** A Telegram inline keyboard the sweep attaches to the FINAL delivered chunk
248
+ * (the 🔊 Listen button / voice-out keyboard — switchroom #3502 regression fix,
249
+ * so a net-delivered answer keeps the same button a `sendReply` answer gets).
250
+ * Shaped to match `planListenButton().replyMarkup`. */
251
+ export type OutboxDeliveryMarkup = {
252
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
253
+ }
254
+
255
+ /** Minimal bot-api surface the sweep needs to deliver text. */
256
+ type OutboxSendBot = {
257
+ api: {
258
+ sendMessage: (
259
+ chatId: string,
260
+ text: string,
261
+ opts: object,
262
+ ) => Promise<{ message_id?: number }>
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Build the chunked `send` the sweep hands to {@link sweepOutbox}. Extracted +
268
+ * exported so the reply-markup attachment (the #3502 fix) is unit-testable
269
+ * without a live gateway or timer.
270
+ *
271
+ * `resolveReplyMarkup` resolves the voice-out Listen button / keyboard for a
272
+ * delivery (null/undefined → no button). It is applied to the FINAL chunk ONLY
273
+ * so the button lands on the last visible message, exactly like the normal
274
+ * `sendReply` path (buttons attach to the last chunk there too). This makes a
275
+ * safety-net-delivered final answer indistinguishable from a normally-delivered
276
+ * one instead of silently dropping the button.
277
+ */
278
+ export function createOutboxSend(deps: {
279
+ getBot: () => OutboxSendBot | undefined
280
+ retry: Parameters<typeof retryWithThreadFallback>[0]
281
+ resolveReplyMarkup?: (
282
+ chatId: string,
283
+ threadId: number | null,
284
+ text: string,
285
+ ) => OutboxDeliveryMarkup | undefined
286
+ }): OutboxSweepDeps['send'] {
287
+ return async (chatId, threadId, text) => {
288
+ const bot = deps.getBot()
289
+ if (bot == null) throw new Error('outbox-sweep: bot unavailable')
290
+ // Empty text → nothing to deliver. Telegram rejects an empty message body,
291
+ // so sending one chunk of '' would throw every tick and wedge the sweep in
292
+ // a permanent retry (the record never journals → never clears). Return
293
+ // early, matching the pre-refactor loop's zero-chunk behaviour.
294
+ if (text.length === 0) return undefined
295
+ // Resolve the Listen button / keyboard ONCE from the full answer text; it
296
+ // rides only on the final chunk below.
297
+ const replyMarkup = deps.resolveReplyMarkup?.(chatId, threadId, text)
298
+ // Chunk to Telegram's 4096-char ceiling; each chunk goes through the
299
+ // standard retry / flood-wait / thread-fallback wrapper. A thrown send
300
+ // propagates so the sweep releases the claim and retries next tick (the
301
+ // record is never journaled → never lost).
302
+ let lastId: number | undefined
303
+ const chunkCount = Math.ceil(text.length / 4000)
304
+ for (let i = 0, idx = 0; i < text.length; i += 4000, idx++) {
305
+ const chunk = text.slice(i, i + 4000)
306
+ const isLast = idx === chunkCount - 1
307
+ const res = await retryWithThreadFallback(
308
+ deps.retry,
309
+ (tid) => {
310
+ const base = tid != null ? { message_thread_id: tid } : {}
311
+ // Button on the LAST chunk only (final visible message).
312
+ const opts =
313
+ isLast && replyMarkup != null ? { ...base, reply_markup: replyMarkup } : base
314
+ return bot.api.sendMessage(chatId, chunk, opts)
315
+ },
316
+ { threadId: threadId ?? undefined, chat_id: chatId, verb: 'outbox-sweep.sendMessage' },
317
+ )
318
+ lastId = res?.message_id
319
+ }
320
+ return lastId
321
+ }
322
+ }
323
+
247
324
  export function startOutboxSweep(deps: {
248
325
  isGatewayMain: boolean
249
326
  stateDir: string
250
- getBot: () => { api: { sendMessage: (chatId: string, text: string, opts: object) => Promise<{ message_id?: number }> } } | undefined
327
+ getBot: () => OutboxSendBot | undefined
251
328
  getTurnsDb: () => Parameters<typeof resolveSubagentOriginTurnKey>[0] | null
252
329
  dedupCheck: (chatId: string, threadId: number | undefined, text: string) => boolean
330
+ /** Resolve the voice-out Listen button / keyboard for a net delivery. Wired
331
+ * by the gateway (loadAccess → resolveVoiceOutPlan → planListenButton +
332
+ * cache put + eager pre-synth). Absent → deliver plain (legacy behaviour). */
333
+ resolveReplyMarkup?: (
334
+ chatId: string,
335
+ threadId: number | null,
336
+ text: string,
337
+ ) => OutboxDeliveryMarkup | undefined
253
338
  log?: (line: string) => void
254
339
  }): ReturnType<typeof setInterval> | undefined {
255
340
  if (!deps.isGatewayMain || process.env.SWITCHROOM_TG_OUTBOX_DELIVERY === '0') return undefined
256
341
  const retry = createRetryApiCall({ log: deps.log })
342
+ const send = createOutboxSend({
343
+ getBot: deps.getBot,
344
+ retry,
345
+ ...(deps.resolveReplyMarkup != null ? { resolveReplyMarkup: deps.resolveReplyMarkup } : {}),
346
+ })
257
347
  const tick = () => {
258
348
  const bot = deps.getBot()
259
349
  if (bot == null) return
260
350
  void sweepOutbox({
261
351
  stateDir: deps.stateDir,
262
352
  log: deps.log,
263
- send: async (chatId, threadId, text) => {
264
- // Chunk to Telegram's 4096-char ceiling; each chunk goes through the
265
- // standard retry / flood-wait / thread-fallback wrapper. A thrown send
266
- // propagates so the sweep releases the claim and retries next tick (the
267
- // record is never journaled → never lost).
268
- let lastId: number | undefined
269
- for (let i = 0; i < text.length; i += 4000) {
270
- const chunk = text.slice(i, i + 4000)
271
- const res = await retryWithThreadFallback(
272
- retry,
273
- (tid) => bot.api.sendMessage(chatId, chunk, tid != null ? { message_thread_id: tid } : {}),
274
- { threadId: threadId ?? undefined, chat_id: chatId, verb: 'outbox-sweep.sendMessage' },
275
- )
276
- lastId = res?.message_id
277
- }
278
- return lastId
279
- },
353
+ send,
280
354
  textAlreadyDelivered: (chatId, threadId, text) => deps.dedupCheck(chatId, threadId ?? undefined, text),
281
355
  registryChainLookup: (taskId) => {
282
356
  const db = deps.getTurnsDb()
@@ -216,10 +216,16 @@ export function extractRichMessageText(rich: unknown): string | undefined {
216
216
  }
217
217
 
218
218
  /**
219
- * `message:rich_message` handler — same dispatch surface as the cluster-A
220
- * media-envelope handlers: renders the body and hands it to the normal
221
- * coalescing inbound pipeline (access gating + forward-origin parsing
222
- * downstream, identical to `message:text`).
219
+ * `message:rich_message` handler — renders the body and hands it to the
220
+ * normal COALESCING inbound pipeline, identical to `message:text`. It shares
221
+ * the cluster-A `MediaEnvelopeDeps` SHAPE, but gateway.ts binds this handler's
222
+ * `deps.handleInbound` to `handleInboundCoalesced` via an inline spread at the
223
+ * registration site — `{ ...mediaEnvelopeDeps, handleInbound: handleInboundCoalesced }`
224
+ * — NOT the bare `handleInbound` the media-envelope handlers use: a rich
225
+ * message is pure forwarded body text with no attachment, so a forwarded bot
226
+ * message arriving in the same sliding window as another inbound folds into one
227
+ * turn (same coalescing contract as `message:text`). Access gating +
228
+ * forward-origin parsing happen downstream, unchanged.
223
229
  */
224
230
  export async function handleRichMessageMessage(
225
231
  ctx: Filter<Context, 'message:rich_message'>,
@@ -0,0 +1,89 @@
1
+ // #1122 silence-poke session-event wiring.
2
+ //
3
+ // Extracted from gateway.ts (the file is under a hard line ratchet — see
4
+ // switchroom#2996). Maps a parsed session event onto the silence-poke activity
5
+ // registry so the 300s framework-fallback message wording is honest (thinking
6
+ // vs working, plus the longest-running in-flight tool), threads the #1445
7
+ // cross-turn pending-async ambient, and feeds the #3519 background-shell
8
+ // liveness signal. Called once per session event from the gateway's event loop
9
+ // while a turn is live (the caller owns the `currentTurn != null` guard and the
10
+ // `key` derivation).
11
+ import type { SessionEvent } from '../session-tail.js'
12
+ import { isTelegramSurfaceTool } from '../tool-names.js'
13
+ import { toolLabel } from '../tool-labels.js'
14
+ import { applyBackgroundShellLiveness } from './background-shell-liveness.js'
15
+
16
+ /** The silence-poke module namespace (kept as `typeof` so no surface drift). */
17
+ type SilencePoke = typeof import('../silence-poke.js')
18
+ /** The pending-work-progress module namespace. */
19
+ type PendingProgress = typeof import('../pending-work-progress.js')
20
+
21
+ /**
22
+ * Apply the activity signals carried by `ev` to silence-poke / pending-progress
23
+ * for the live turn identified by `key`. Behaviour is identical to the inline
24
+ * block this replaced — a pure extract/move.
25
+ */
26
+ export function applySilencePokeSessionEvent(
27
+ silencePoke: SilencePoke,
28
+ pendingProgress: PendingProgress,
29
+ key: string,
30
+ ev: SessionEvent,
31
+ ): void {
32
+ if (ev.kind === 'thinking') {
33
+ silencePoke.noteThinking(key, Date.now())
34
+ } else if (ev.kind === 'tool_use') {
35
+ // #1292: track in-flight tool calls so the 300s framework
36
+ // fallback message can name the actual observable (e.g.
37
+ // "running Grep \"foo\" for 4m") instead of the dishonest
38
+ // generic "still working… no update in 5 min" when the agent
39
+ // is clearly busy on tool calls. Telegram-surface tools are
40
+ // excluded — their job IS the outbound message, the silence
41
+ // clock resets via noteOutbound when they fire. Sub-agent
42
+ // tool_use events (kind='sub_agent_tool_use') intentionally
43
+ // NOT tracked: the parent's Task tool_use is already on the
44
+ // map and represents the user-observable wait.
45
+ if (
46
+ ev.toolUseId != null
47
+ && ev.toolUseId.length > 0
48
+ && !isTelegramSurfaceTool(ev.toolName)
49
+ ) {
50
+ const label = toolLabel(
51
+ ev.toolName,
52
+ ev.input,
53
+ /*preamble*/ undefined,
54
+ ev.precomputedLabel,
55
+ )
56
+ silencePoke.noteToolStart(
57
+ key,
58
+ ev.toolUseId,
59
+ ev.toolName,
60
+ label.length > 0 ? label : null,
61
+ Date.now(),
62
+ )
63
+ // #1445 cross-turn pending-async ambient. Mark the chat as
64
+ // having dispatched background work this turn so a turn_end
65
+ // that follows activates the edit-in-place ambient line.
66
+ // Covers `Agent` / `Task` (the harness-managed async path
67
+ // — handback channel turn clears it) and `Bash` with
68
+ // run_in_background:true (model is expected to poll
69
+ // BashOutput; the ambient ticks until next inbound or the
70
+ // 30-min budget cap).
71
+ const evInput = ev.input as { run_in_background?: boolean } | undefined
72
+ if (
73
+ ev.toolName === 'Agent'
74
+ || ev.toolName === 'Task'
75
+ || (ev.toolName === 'Bash' && evInput?.run_in_background === true)
76
+ ) {
77
+ pendingProgress.noteAsyncDispatch(key)
78
+ }
79
+ }
80
+ } else if (ev.kind === 'tool_result') {
81
+ // #1292: drain the in-flight entry. Idempotent on unknown ids
82
+ // (covers Telegram-surface tools we skipped at start time).
83
+ if (ev.toolUseId != null && ev.toolUseId.length > 0) {
84
+ silencePoke.noteToolEnd(key, ev.toolUseId, Date.now())
85
+ }
86
+ }
87
+ // #3519 sharpen: feed background-shell liveness (ALIVE/DEAD) to silence-poke — see background-shell-liveness.ts.
88
+ applyBackgroundShellLiveness(silencePoke, key, ev)
89
+ }