switchroom 0.16.29 → 0.16.46

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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -48,6 +48,34 @@ import {
48
48
  type GifSendArgs,
49
49
  } from '../sticker-aliases.js'
50
50
  import { transcribeViaWhisper } from '../voice-transcribe.js'
51
+ import { transcribeViaSidecar } from '../voice-transcribe-sidecar.js'
52
+ import {
53
+ synthesizeViaSidecar,
54
+ chunkTtsText,
55
+ clampTtsSpeed,
56
+ } from '../voice-synthesize-sidecar.js'
57
+ import {
58
+ VoiceOnDemandCache,
59
+ mintVoiceOnDemandToken,
60
+ isVoiceOnDemandCallback,
61
+ parseVoiceOnDemandToken,
62
+ buildListenKeyboard,
63
+ mayInjectListenButton,
64
+ } from '../voice-ondemand.js'
65
+ import {
66
+ PreSynthQueue,
67
+ sweepVoiceCacheDir,
68
+ writeVoiceCacheFile,
69
+ eagerVoiceEnabled,
70
+ VOICE_SWEEP_INTERVAL_MS,
71
+ } from '../voice-presynth.js'
72
+
73
+ /** Fleet default TTS speed when voice_out is enabled but speed is unset —
74
+ * slightly brisker than neutral (Ken's chosen default). */
75
+ const VOICE_OUT_DEFAULT_SPEED = 1.1
76
+ import { normalizeForSpeech } from '../voice-normalize-text.js'
77
+ import { normalizeForTts } from '../tts-normalize.js'
78
+ import { synthesizeViaOpenAi } from '../voice-synthesize.js'
51
79
  import {
52
80
  createTelegraphAccount,
53
81
  createTelegraphPage,
@@ -65,6 +93,9 @@ import {
65
93
  import { StatusReactionController } from '../status-reactions.js'
66
94
  import { DeferredDoneReactions } from '../reaction-defer.js'
67
95
  import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
96
+ import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
97
+ import type { PinState, DesiredPin } from '../status-pin.js'
98
+ import { decidePinAction } from '../status-pin.js'
68
99
  import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
69
100
  import { parseSourceMessageId } from './source-message-id.js'
70
101
  import {
@@ -73,11 +104,14 @@ import {
73
104
  duplicateDenyMessage,
74
105
  isRecentTimeoutDuplicate,
75
106
  PERMISSION_TTL_MS,
107
+ approvalTtlMs,
76
108
  ttlForTool,
77
109
  buildTimedOutCardEdits,
78
110
  STALE_TAP_NOTICE,
79
111
  type PermissionCardRef,
80
112
  } from './permission-timeout.js'
113
+ import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
114
+ import { createPermissionCardStore } from './permission-card-store.js'
81
115
  import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
82
116
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
83
117
  import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, type SessionActivityHeader } from '../tool-activity-summary.js'
@@ -140,6 +174,10 @@ import {
140
174
  runMicrosoftConnectPoll,
141
175
  } from './microsoft-connect-flow.js'
142
176
  import { resolveAuthBrokerSocketPath } from '../../src/auth/broker/client.js'
177
+ import { materializeVoiceKey } from '../../src/telegram/materialize-voice-key.js'
178
+ import { materializeSidecarToken } from '../../src/telegram/materialize-sidecar-token.js'
179
+ import { loadHostCapabilities } from '../../src/setup/host-capabilities.js'
180
+ import type { VoiceEngine } from '../../src/setup/gpu-detect.js'
143
181
  import { createFleetFallbackGate } from '../fleet-fallback-gate.js'
144
182
  import { createFleetFallbackResumeGate } from '../fleet-fallback-resume.js'
145
183
  import { resolveExhaustUntil } from './exhaust-until.js'
@@ -195,7 +233,7 @@ const REPLY_TO_TEXT_MAX = 200
195
233
  const SILENT_END_FALLBACK_TEXT =
196
234
  '⚠️ The agent finished working but didn’t send a reply — your last ' +
197
235
  'message may not have been answered. Please try asking again.'
198
- import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
236
+ import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
199
237
  import { richMessage } from '../rich-send.js'
200
238
  import { scrubVoice } from '../text-voice-scrub.js'
201
239
  import {
@@ -287,6 +325,8 @@ import {
287
325
  MODEL_CALLBACK_PREFIX,
288
326
  MODEL_CALLBACK_HEADER,
289
327
  MODEL_CALLBACK_SR,
328
+ MODEL_CALLBACK_PAGE_EXTERNAL,
329
+ MODEL_CALLBACK_PAGE_MAIN,
290
330
  srFriendlyLabel,
291
331
  type ModelMenuDeps,
292
332
  type ModelCommandDeps,
@@ -341,6 +381,15 @@ import {
341
381
  obligationEscalationText,
342
382
  } from './obligation-ledger.js'
343
383
  import { loadObligations, persistObligations } from './obligation-store.js'
384
+ import {
385
+ loadStatusPins,
386
+ pinnedMessageIsOurs,
387
+ reconcileAndPersistStatusPin,
388
+ runStatusPinBootCleanup,
389
+ type PersistedStatusPin,
390
+ type StatusPinPersistOp,
391
+ type TrackedStatusPin,
392
+ } from './status-pin-store.js'
344
393
  import { driveEscalation } from './escalation-drive.js'
345
394
  import { shouldSuppressRepresent } from './represent-guard.js'
346
395
  import { createInboundSpool } from './inbound-spool.js'
@@ -362,6 +411,9 @@ import {
362
411
  } from './emission-authority.js'
363
412
  import { CurrentTurnMap } from './current-turn-map.js'
364
413
  import { resolveAnswerThreadId } from './answer-thread-resolve.js'
414
+ import { resolveChatIdFallback } from './chat-id-fallback.js'
415
+ import { decideObligationTurnEnd } from './obligation-turn-end.js'
416
+ import { maybeRotate } from './turns-jsonl-rotate.js'
365
417
  import {
366
418
  createDeliveryQueue,
367
419
  trackDelivery,
@@ -385,6 +437,7 @@ import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-d
385
437
  import { maybeFireWarmup } from './prefix-warmup.js'
386
438
  import {
387
439
  buildVaultGrantApprovedInbound,
440
+ buildVaultGrantApprovedCardText,
388
441
  buildVaultGrantDeniedInbound,
389
442
  buildVaultSaveCompletedInbound,
390
443
  buildVaultSaveFailedInbound,
@@ -428,6 +481,8 @@ import type {
428
481
  QuotaWallDetectedMessage,
429
482
  PostSkillProposalMessage,
430
483
  PermissionEvent,
484
+ RolloutStatusPostMessage,
485
+ RolloutStatusEditMessage,
431
486
  } from './ipc-protocol.js'
432
487
  import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
433
488
  import { buildReactionDispatchInbound, evaluateReactionDispatch, resolveReactionDispatchConfig, type ReactionDispatchResolvedConfig } from './reaction-dispatch.js'
@@ -599,6 +654,7 @@ process.on('beforeExit', () => {
599
654
 
600
655
  // ─── Env + state dir ──────────────────────────────────────────────────────
601
656
  const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
657
+ const permCardStore = createPermissionCardStore(STATE_DIR)
602
658
  const ACCESS_FILE = join(STATE_DIR, 'access.json')
603
659
  const APPROVED_DIR = join(STATE_DIR, 'approved')
604
660
  const ENV_FILE = join(STATE_DIR, '.env')
@@ -950,12 +1006,42 @@ type Access = {
950
1006
  /** Voice-in transcription config (#578 spike). When `enabled` is
951
1007
  * true and provider is 'openai', inbound voice/audio messages are
952
1008
  * downloaded and transcribed via Whisper, then surface as the
953
- * user's inbound text. API key read from
954
- * ~/.switchroom/openai-api-key. Off by default. */
1009
+ * user's inbound text. The provider API key is a `vault:` reference
1010
+ * (`api_key`, default `vault:openai/api-key`) resolved through the
1011
+ * vault broker at use-time — never read from a plaintext file. Off by
1012
+ * default. */
955
1013
  voice_in?: {
956
1014
  enabled?: boolean
957
1015
  provider?: 'openai'
958
1016
  language?: string
1017
+ api_key?: string
1018
+ }
1019
+ /** Outbound spoken replies via TTS (PR-C2). When enabled, the agent's
1020
+ * text reply is synthesized into an OGG/Opus voice note and sent
1021
+ * alongside (or instead of) the text, per `reply_mode`. The 'kokoro'
1022
+ * engine uses the local voice sidecar (POST /tts) and is only active
1023
+ * when the host voice verdict is local (SWITCHROOM_VOICE_ENGINE ===
1024
+ * 'local'); 'openai' is an honest-exception cloud path gated on an
1025
+ * `api_key` vault ref. Voice is best-effort and fully non-fatal — any
1026
+ * TTS error falls back to the text reply. ONE voice note per response:
1027
+ * the reply is speech-normalized (markdown/symbols stripped) and, for the
1028
+ * kokoro sidecar, sent in a SINGLE /tts call that returns one concatenated
1029
+ * file. Off by default. */
1030
+ voice_out?: {
1031
+ enabled?: boolean
1032
+ engine?: 'kokoro' | 'openai'
1033
+ voice?: string
1034
+ reply_mode?: 'voice+text' | 'voice-only' | 'on-demand'
1035
+ /** Kokoro-engine playback speed. Clamped to 0.5–2.0; defaults to 1.1
1036
+ * when voice_out is enabled but speed is unset (the fleet default —
1037
+ * slightly brisker than neutral). Ignored by the OpenAI path. */
1038
+ speed?: number
1039
+ /** OpenAI-engine only: per-voice-note chunk size (default 600) used to
1040
+ * split a long reply across sequential notes, since OpenAI's TTS input
1041
+ * has a hard cap. Ignored by the kokoro path, which sends the whole
1042
+ * reply in one call (the sidecar owns length + concatenation). */
1043
+ max_chars?: number
1044
+ api_key?: string
959
1045
  }
960
1046
  /** Telegraph long-reply publishing (#579). When enabled, replies
961
1047
  * above `threshold` chars publish to Telegraph and the agent's
@@ -1049,6 +1135,7 @@ function readAccessFile(): Access {
1049
1135
  // `access.telegraph`, `access.stickers`) silently see undefined.
1050
1136
  stickers: parsed.stickers,
1051
1137
  voice_in: parsed.voice_in,
1138
+ voice_out: parsed.voice_out,
1052
1139
  telegraph: parsed.telegraph,
1053
1140
  }
1054
1141
  } catch (err) {
@@ -3206,6 +3293,19 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
3206
3293
  // successor turn; the timer's own turnId match is the second guard, this is
3207
3294
  // the first. Idempotent — a no-op when no timer is registered.
3208
3295
  stopEarlyLivenessOpen(key as string)
3296
+ // Status-pin: turn end is the canonical unpin point for the foreground
3297
+ // pin. `purgeReactionTracking` is the single turn-end owner (all normal /
3298
+ // abnormal exit branches funnel here), so this is the one place the
3299
+ // foreground status pin is dropped. Idempotent: a no-op when nothing was
3300
+ // pinned (trivial turn that never opened a status message). The
3301
+ // drop-on-unpin contract in reconcilePin guarantees state clears even if
3302
+ // the unpin API throws — a stuck pin can never outlive its turn.
3303
+ {
3304
+ const pinChatId = endingTurn != null
3305
+ ? endingTurn.sessionChatId
3306
+ : chatIdOfChatKey(key as _ChatKey)
3307
+ void reconcileStatusPin(`fg:${key}`, pinChatId, { pinned: false })
3308
+ }
3209
3309
  if (msgInfo) {
3210
3310
  const agentDir = resolveAgentDirFromEnv()
3211
3311
  if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
@@ -3401,7 +3501,20 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
3401
3501
  status: turn.finalAnswerDelivered ? 'complete' : 'no_reply',
3402
3502
  turn_id: turn.turnId,
3403
3503
  }) + '\n'
3404
- appendFileSync('/state/agent/turns.jsonl', rec)
3504
+ const turnsPath = '/state/agent/turns.jsonl'
3505
+ // Size-cap rotation: keep at most one rotated generation so the file can't
3506
+ // grow unbounded on a long-lived agent. Best-effort (never throws).
3507
+ maybeRotate(turnsPath, {
3508
+ statSize: (p) => {
3509
+ try {
3510
+ return statSync(p).size
3511
+ } catch {
3512
+ return undefined
3513
+ }
3514
+ },
3515
+ rename: (from, to) => renameSync(from, to),
3516
+ })
3517
+ appendFileSync(turnsPath, rec)
3405
3518
  } catch {
3406
3519
  // best-effort — never let metrics emission break turn teardown
3407
3520
  }
@@ -3457,7 +3570,7 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
3457
3570
  // At turn_end with replyCalled=true the model explicitly signalled completion
3458
3571
  // AND replied, so the obligation is satisfied regardless of finalAnswerDelivered.
3459
3572
  if (OBLIGATION_LEDGER_ENABLED) {
3460
- if (turn.finalAnswerDelivered || turn.replyCalled) {
3573
+ if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === 'close') {
3461
3574
  obligationLedger.close(turn.turnId)
3462
3575
  } else {
3463
3576
  // Turn ended WITHOUT any reply (no ack, no answer). If this turn was
@@ -4671,7 +4784,10 @@ interface PendingVaultRequestSave {
4671
4784
  staged_at: number
4672
4785
  }
4673
4786
  const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
4674
- const VAULT_REQUEST_SAVE_TTL_MS = 10 * 60 * 1000
4787
+ // Gateway-side reap window for a staged vault-save card. Tracks the operator
4788
+ // approval-card lifetime (config-driven, 60-min default) so the reap never
4789
+ // races ahead of the card the operator is still looking at.
4790
+ const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
4675
4791
  function sweepPendingVaultRequestSaves(): void {
4676
4792
  const cutoff = Date.now() - VAULT_REQUEST_SAVE_TTL_MS
4677
4793
  for (const [k, v] of pendingVaultRequestSaves) {
@@ -4716,7 +4832,9 @@ interface PendingVaultRequestAccess {
4716
4832
  staged_at: number
4717
4833
  }
4718
4834
  const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
4719
- const VAULT_REQUEST_ACCESS_TTL_MS = 10 * 60 * 1000
4835
+ // Gateway-side reap window for a staged vault-access card. Tracks the operator
4836
+ // approval-card lifetime (config-driven, 60-min default) — see approvalTtlMs.
4837
+ const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
4720
4838
  function sweepPendingVaultRequestAccesses(): void {
4721
4839
  const cutoff = Date.now() - VAULT_REQUEST_ACCESS_TTL_MS
4722
4840
  for (const [k, v] of pendingVaultRequestAccesses) {
@@ -5024,6 +5142,7 @@ const pendingStateReaper = setInterval(() => {
5024
5142
  `${timeoutMinutes}m)\n`,
5025
5143
  )
5026
5144
  pendingPermissions.delete(k)
5145
+ permCardStore.remove(k)
5027
5146
  }
5028
5147
  }
5029
5148
  // Drop no-repeat suppression entries past the safety-cap window (the primary
@@ -5481,6 +5600,273 @@ const CLEAR_STATUS_ON_COMPLETION = (() => {
5481
5600
  return v === '1' || v === 'true' || v === 'on' || v === 'yes'
5482
5601
  })()
5483
5602
 
5603
+ // Whether to SILENTLY pin the already-rendered status message while its work
5604
+ // is in-flight (auto-unpinned on completion). Default ON (silent + low-risk):
5605
+ // keeps in-flight work in view when the conversation scrolls past it. This is
5606
+ // the ONE sanctioned pin under `chat-is-the-single-source-of-truth` — it pins
5607
+ // a message the chat ALREADY rendered (the per-turn activity/status message,
5608
+ // the `🛠 Worker` message), never a new parallel surface, never with a device
5609
+ // buzz. Opt OUT per agent via channels.telegram.pin_status_while_working: false
5610
+ // (→ this env). See status-pin.ts / status-pin-driver.ts and
5611
+ // `reference/invariants.md` § chat-is-the-single-source-of-truth.
5612
+ const PIN_STATUS_WHILE_WORKING = (() => {
5613
+ const raw = process.env.SWITCHROOM_TG_PIN_STATUS_WHILE_WORKING
5614
+ if (raw == null) return true // default ON
5615
+ const v = raw.trim().toLowerCase()
5616
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
5617
+ })()
5618
+
5619
+ // ─── Status-pin: single-owner state + reconcile ────────────────────────────
5620
+ // EXACTLY ONE owner of pin state. Every desired-state change for a key routes
5621
+ // through `reconcileStatusPin`, which computes ONE action (pin|unpin|noop) via
5622
+ // the pure `decidePinAction` and executes it through `reconcilePin` (which
5623
+ // drops the claim on unpin even if the API throws — so pin state can never get
5624
+ // stuck). Two desired-state sources feed distinct keys:
5625
+ // - foreground: `fg:<statusKey>` — pinned while the per-turn activity message
5626
+ // is in-flight; unpinned at the canonical turn-end (purgeReactionTracking).
5627
+ // - background: `wk:<agentId>` — pinned while the `🛠 Worker` message runs;
5628
+ // unpinned on that worker's completion (worker-feed finish).
5629
+ // It does NOT touch the reply / stream_reply send handlers (the v1 bug was send
5630
+ // handlers unconditionally unpinning on every send) and runs NO polling
5631
+ // watchdog / getChat().pinned_message reconciler.
5632
+ const statusPinState = new Map<string, PinState>()
5633
+ // Companion registry: pinKey → chatId, so the pre-restart sweep can unpin
5634
+ // owned pins without threading the chat id through every call site. Written on
5635
+ // every desired-pinned reconcile, cleared alongside the state on unpin.
5636
+ const statusPinChatIds = new Map<string, string>()
5637
+
5638
+ // Durable snapshot of the pin claim set on the persistent per-agent volume
5639
+ // (STATE_DIR = /state/agent/telegram in prod). Closes the crash hole: the
5640
+ // in-memory Maps alone empty on restart, so a status pin left dangling by a
5641
+ // crashed session is never unpinned and the service-message-deletion handler
5642
+ // can't recognise it as ours. Every reconcile persists the current claim set;
5643
+ // boot cleanup (statusPinBootCleanup, wired after lockedBot is defined) unpins
5644
+ // each persisted entry and clears the store. STATIC mode and feature-off skip
5645
+ // disk. Mirrors obligation-store.ts.
5646
+ const STATUS_PIN_STORE_PATH = join(STATE_DIR, 'status-pins.json')
5647
+ const statusPinStoreFs = {
5648
+ readFileSync: (p: string) => readFileSync(p, 'utf8'),
5649
+ writeFileSync: (p: string, d: string) => writeFileSync(p, d),
5650
+ renameSync: (a: string, b: string) => renameSync(a, b),
5651
+ existsSync: (p: string) => existsSync(p),
5652
+ }
5653
+ const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
5654
+
5655
+ // The full live claim set as persisted rows (confirmed pins), from the Maps.
5656
+ function snapshotStatusPins(): PersistedStatusPin[] {
5657
+ const snapshot: PersistedStatusPin[] = []
5658
+ for (const [pinKey, state] of statusPinState) {
5659
+ const chatId = statusPinChatIds.get(pinKey)
5660
+ if (chatId == null) continue
5661
+ snapshot.push({ pinKey, chatId, messageId: state.messageId })
5662
+ }
5663
+ return snapshot
5664
+ }
5665
+
5666
+ // The live claim set EXCLUDING one key — used by reconcileAndPersistStatusPin so
5667
+ // it can rewrite the whole set atomically while it flips that one key's record
5668
+ // between pending / confirmed / absent.
5669
+ function snapshotStatusPinsExcept(exceptKey: string): PersistedStatusPin[] {
5670
+ return snapshotStatusPins().filter((p) => p.pinKey !== exceptKey)
5671
+ }
5672
+
5673
+ // The Bot API surface the pin driver needs. `lockedBot` is defined later; wrap
5674
+ // lazily so this helper can be declared alongside the state it owns.
5675
+ function statusPinApi(): PinBotApi {
5676
+ return {
5677
+ pinChatMessage: (chat_id, message_id, opts) =>
5678
+ robustApiCall(
5679
+ () => lockedBot.api.pinChatMessage(chat_id, message_id, opts),
5680
+ { chat_id: String(chat_id), verb: 'status-pin.pin' },
5681
+ ),
5682
+ unpinChatMessage: (chat_id, message_id) =>
5683
+ robustApiCall(
5684
+ () => lockedBot.api.unpinChatMessage(chat_id, message_id),
5685
+ { chat_id: String(chat_id), verb: 'status-pin.unpin' },
5686
+ ),
5687
+ }
5688
+ }
5689
+
5690
+ /**
5691
+ * Boot-time orphan cleanup — thin gateway wrapper over the pure
5692
+ * `runStatusPinBootCleanup` (which owns the load → best-effort-unpin →
5693
+ * empty-store contract). Binds the live fs seam, the robust unpin api, and the
5694
+ * gateway logger.
5695
+ *
5696
+ * MUST run ONLY after this gateway wins the startup mutex (the store is a
5697
+ * shared per-agent file; a losing double-boot would unpin the live holder's
5698
+ * legitimate pins). Runs before any new pin claim is written, so the emptied
5699
+ * store leaves the service-message handler's ownership check with no false
5700
+ * positives.
5701
+ */
5702
+ async function statusPinBootCleanup(): Promise<void> {
5703
+ if (!statusPinPersistEnabled) return
5704
+ const api = statusPinApi()
5705
+ const { cleared, total } = await runStatusPinBootCleanup({
5706
+ path: STATUS_PIN_STORE_PATH,
5707
+ fs: statusPinStoreFs,
5708
+ unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
5709
+ })
5710
+ if (total > 0) {
5711
+ process.stderr.write(
5712
+ `telegram gateway: status-pin: cleared ${cleared}/${total} ` +
5713
+ `orphaned pin(s) from a prior session\n`,
5714
+ )
5715
+ }
5716
+ }
5717
+ // NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
5718
+ // The status-pin store is a SHARED per-agent file, and cleanup issues real
5719
+ // unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
5720
+ // that shared state — its unpins would strip pins the STILL-ALIVE holder
5721
+ // legitimately owns. So the call is gated on winning the startup mutex
5722
+ // (top-level await below); see the acquireStartupLock block.
5723
+
5724
+ /**
5725
+ * The single entry point that mutates pin state for `pinKey`. Serialises
5726
+ * nothing itself — callers fire-and-forget; the pure decision + drop-on-unpin
5727
+ * contract keep state consistent. No-op when the feature is disabled.
5728
+ */
5729
+ async function reconcileStatusPin(
5730
+ pinKey: string,
5731
+ chatId: string,
5732
+ desired: DesiredPin,
5733
+ ): Promise<void> {
5734
+ // Fire-and-forget hard boundary. Most callers invoke this as
5735
+ // `void reconcileStatusPin(...)` (auto status-pin is best-effort — it must
5736
+ // never affect turn flow). reconcilePin already swallows pin/unpin API
5737
+ // errors via onError, but the surrounding persistence + Map bookkeeping
5738
+ // (and any future edge) could still reject the returned promise. A rejected
5739
+ // fire-and-forget promise becomes a process-level `unhandledRejection` which
5740
+ // the gateway's handler crashes on — that is exactly how a benign
5741
+ // "not enough rights to manage pinned messages" 400 in a supergroup took the
5742
+ // whole gateway down (marko, 2026-07-01). Auto status-pin is cosmetic; it
5743
+ // must NEVER be able to crash the gateway. Any throw here is logged and
5744
+ // absorbed. (The `pin_message` MCP tool still surfaces failures to the agent
5745
+ // as a normal tool-error — that path is `executePinMessage`, not this one.)
5746
+ try {
5747
+ await reconcileStatusPinInner(pinKey, chatId, desired)
5748
+ } catch (err) {
5749
+ const msg = err instanceof Error ? err.message : String(err)
5750
+ process.stderr.write(
5751
+ `telegram gateway: status-pin reconcile absorbed error ` +
5752
+ `(key=${pinKey} chat=${chatId}): ${msg}\n`,
5753
+ )
5754
+ }
5755
+ }
5756
+
5757
+ async function reconcileStatusPinInner(
5758
+ pinKey: string,
5759
+ chatId: string,
5760
+ desired: DesiredPin,
5761
+ ): Promise<void> {
5762
+ if (!PIN_STATUS_WHILE_WORKING) return
5763
+ if (chatId.length === 0) return
5764
+ const prev = statusPinState.get(pinKey) ?? null
5765
+
5766
+ const runReconcile = () =>
5767
+ reconcilePin({
5768
+ api: statusPinApi(),
5769
+ chatId,
5770
+ prevState: prev,
5771
+ desired,
5772
+ onError: (phase, err) => {
5773
+ const msg = err instanceof Error ? err.message : String(err)
5774
+ process.stderr.write(
5775
+ `telegram gateway: status-pin ${phase} failed (key=${pinKey} chat=${chatId}): ${msg}\n`,
5776
+ )
5777
+ },
5778
+ })
5779
+
5780
+ // Classify the action so we persist INTENT before the pin API call. Only a
5781
+ // fresh `pin` of a message that isn't already our claim opens the leak window
5782
+ // (the API call actually pins something new); everything else (unpin, noop,
5783
+ // re-pin of the same id) clears / leaves the record and is safe to persist
5784
+ // after. See reconcileAndPersistStatusPin for the ordering rationale.
5785
+ const action = decidePinAction(prev, desired)
5786
+ const op: StatusPinPersistOp =
5787
+ action.kind === 'pin'
5788
+ ? { kind: 'pin', messageId: action.messageId }
5789
+ : { kind: 'clear' }
5790
+
5791
+ if (!statusPinPersistEnabled) {
5792
+ // Persistence off (STATIC / feature-off): just reconcile + update Maps.
5793
+ const next = await runReconcile()
5794
+ if (next == null) {
5795
+ statusPinState.delete(pinKey)
5796
+ statusPinChatIds.delete(pinKey)
5797
+ } else {
5798
+ statusPinState.set(pinKey, next)
5799
+ statusPinChatIds.set(pinKey, chatId)
5800
+ }
5801
+ return
5802
+ }
5803
+
5804
+ // Persist-BEFORE-pin ordering lives in reconcileAndPersistStatusPin: for a
5805
+ // pin it writes a `pending` record first, then confirms it after the API call
5806
+ // lands (or drops it on failure). A crash in the window leaves a pending
5807
+ // record boot cleanup will unpin. In-memory Maps are updated from the result.
5808
+ const next = await reconcileAndPersistStatusPin({
5809
+ path: STATUS_PIN_STORE_PATH,
5810
+ fs: statusPinStoreFs,
5811
+ pinKey,
5812
+ chatId,
5813
+ op,
5814
+ snapshotOthers: () => snapshotStatusPinsExcept(pinKey),
5815
+ applyPin: runReconcile,
5816
+ })
5817
+ if (next == null) {
5818
+ statusPinState.delete(pinKey)
5819
+ statusPinChatIds.delete(pinKey)
5820
+ } else {
5821
+ statusPinState.set(pinKey, next)
5822
+ statusPinChatIds.set(pinKey, chatId)
5823
+ }
5824
+ }
5825
+
5826
+ /**
5827
+ * Background-worker desired-pin, driven off the live `🛠 Worker` message.
5828
+ * Reads the worker feed's current message_id (the EXISTING message — we pin
5829
+ * what the feed already rendered, never a new send) and reconciles a silent
5830
+ * pin while it's running / an unpin on completion. No-op until the feed has
5831
+ * actually painted a message for this worker (trivial sub-second workers stay
5832
+ * silent and are never pinned). Keyed `wk:<agentId>`.
5833
+ */
5834
+ function reconcileWorkerPin(
5835
+ agentId: string,
5836
+ chatId: string | null,
5837
+ running: boolean,
5838
+ ): void {
5839
+ if (!PIN_STATUS_WHILE_WORKING) return
5840
+ const key = `wk:${agentId}`
5841
+ if (!running) {
5842
+ // Unpin: recover the chat we pinned in (caller may not have it at
5843
+ // completion). No-op when nothing was pinned for this worker.
5844
+ const unpinChat = chatId ?? statusPinChatIds.get(key)
5845
+ if (unpinChat == null) return
5846
+ void reconcileStatusPin(key, unpinChat, { pinned: false })
5847
+ return
5848
+ }
5849
+ if (chatId == null) return
5850
+ const messageId = workerActivityFeed?.messageIdOf(agentId) ?? null
5851
+ if (messageId == null) return // no message painted yet — nothing to pin
5852
+ void reconcileStatusPin(key, chatId, { pinned: true, messageId })
5853
+ }
5854
+
5855
+ /** Unpin every owned status pin — used by the pre-restart sweep so a
5856
+ * crash / interrupt never leaves a permanent pin behind. Best-effort;
5857
+ * clears the claim regardless of the unpin outcome (drop-on-unpin). */
5858
+ async function unpinAllStatusPins(): Promise<void> {
5859
+ const keys = [...statusPinState.keys()]
5860
+ for (const key of keys) {
5861
+ const st = statusPinState.get(key)
5862
+ if (st == null) continue
5863
+ // Recover the chat id from the state map's companion key registry.
5864
+ const chatId = statusPinChatIds.get(key)
5865
+ if (chatId == null) { statusPinState.delete(key); continue }
5866
+ await reconcileStatusPin(key, chatId, { pinned: false })
5867
+ }
5868
+ }
5869
+
5484
5870
  // Activity feed. The gateway streams a live "what it's doing" tool-activity
5485
5871
  // feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
5486
5872
  // call (flush-independent, so it stays real-time on fast/clustered-tool
@@ -5628,6 +6014,12 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
5628
6014
  process.stderr.write(
5629
6015
  `telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS}\n`,
5630
6016
  )
6017
+ // We WON the startup mutex — this gateway is the sole live owner of the
6018
+ // shared per-agent status-pin store, so it's now safe to clean up orphaned
6019
+ // pins from a prior (dead) session. Gated here (not at import time) so a
6020
+ // LOSING double-boot never unpins the live holder's legitimate pins.
6021
+ // Fire-and-forget: cleanup is best-effort and must not block boot.
6022
+ void statusPinBootCleanup()
5631
6023
  } catch (err) {
5632
6024
  process.stderr.write(
5633
6025
  `telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
@@ -5639,6 +6031,11 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
5639
6031
  try {
5640
6032
  writePidFile(GATEWAY_PID_PATH, { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS })
5641
6033
  process.stderr.write(`telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS} (mutex-fallback)\n`)
6034
+ // Mutex was unavailable (link() unsupported fs); the legacy pid-file
6035
+ // probe + 409-retry loop is still the liveness guard on this path. A
6036
+ // successful writePidFile here means no live holder was detected, so
6037
+ // running orphan cleanup is consistent with the pre-mutex behaviour.
6038
+ void statusPinBootCleanup()
5642
6039
  } catch (writeErr) {
5643
6040
  process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
5644
6041
  }
@@ -7118,6 +7515,14 @@ const ipcServer: IpcServer = createIpcServer({
7118
7515
  const pend = pendingPermissions.get(requestId)
7119
7516
  if (pend && sent && typeof sent.message_id === 'number') {
7120
7517
  pend.cards.push({ chatId, messageId: sent.message_id })
7518
+ permCardStore.add({
7519
+ requestId,
7520
+ chatId,
7521
+ messageId: sent.message_id,
7522
+ startedAt: pend.startedAt,
7523
+ toolName: pend.tool_name,
7524
+ cardText: pend.card_text,
7525
+ })
7121
7526
  }
7122
7527
  }).catch(e => {
7123
7528
  process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
@@ -7556,6 +7961,81 @@ const ipcServer: IpcServer = createIpcServer({
7556
7961
  })
7557
7962
  },
7558
7963
 
7964
+ // #2726 — hostd-initiated rollout status message. Part 1 uses this only for
7965
+ // the terminal ping; Part 2 uses the same post as the FIRST narration
7966
+ // message, then EDITs it (onRolloutStatusEdit) as later phases arrive. This
7967
+ // is an ORDINARY operator-DM message, NOT a pinned card — the framework
7968
+ // speaking a plain progress line in the chat. We reply with the message_id so
7969
+ // hostd can edit it later; a post failure replies ok:false (hostd then just
7970
+ // won't edit — the durable audit log remains the record).
7971
+ async onRolloutStatusPost(client: IpcClient, msg: RolloutStatusPostMessage) {
7972
+ const self = process.env.SWITCHROOM_AGENT_NAME
7973
+ if (self && msg.agentName !== self) {
7974
+ process.stderr.write(
7975
+ `telegram gateway: rollout_status_post rejected — agent mismatch (${msg.agentName} != ${self})\n`,
7976
+ )
7977
+ try {
7978
+ client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: 'agent mismatch' })
7979
+ } catch { /* best effort */ }
7980
+ return
7981
+ }
7982
+ const operator = loadAccess().allowFrom[0]
7983
+ if (operator === undefined) {
7984
+ process.stderr.write(`telegram gateway: rollout_status_post — no operator chat (allowFrom empty)\n`)
7985
+ try {
7986
+ client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: 'no operator chat' })
7987
+ } catch { /* best effort */ }
7988
+ return
7989
+ }
7990
+ try {
7991
+ const sent = await robustApiCall(
7992
+ () =>
7993
+ // allow-raw-bot-api: rich progress line, routed through robustApiCall.
7994
+ bot.api.sendRichMessage(operator, richMessage(msg.text), {}),
7995
+ { chat_id: String(operator), verb: 'rollout-status-post' },
7996
+ )
7997
+ const messageId = (sent as { message_id: number }).message_id
7998
+ process.stderr.write(
7999
+ `telegram gateway: rollout_status_post agent=${msg.agentName} request=${msg.requestId} message_id=${messageId}\n`,
8000
+ )
8001
+ try {
8002
+ client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: true, messageId })
8003
+ } catch { /* best effort */ }
8004
+ } catch (err) {
8005
+ process.stderr.write(
8006
+ `telegram gateway: rollout_status_post send failed: ${(err as Error).message}\n`,
8007
+ )
8008
+ try {
8009
+ client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: (err as Error).message })
8010
+ } catch { /* best effort */ }
8011
+ }
8012
+ },
8013
+
8014
+ // #2726 Part 2 — edit the previously-posted rollout status message in place as
8015
+ // later phases arrive. Fire-and-forget: an edit failure (incl. Telegram 429)
8016
+ // is swallowed here and NEVER surfaced back toward the roll. hostd owns the
8017
+ // debounce + 429 retry cadence; this handler is a thin edit relay.
8018
+ onRolloutStatusEdit(_client: IpcClient, msg: RolloutStatusEditMessage) {
8019
+ const self = process.env.SWITCHROOM_AGENT_NAME
8020
+ if (self && msg.agentName !== self) {
8021
+ process.stderr.write(
8022
+ `telegram gateway: rollout_status_edit rejected — agent mismatch (${msg.agentName} != ${self})\n`,
8023
+ )
8024
+ return
8025
+ }
8026
+ const operator = loadAccess().allowFrom[0]
8027
+ if (operator === undefined) {
8028
+ process.stderr.write(`telegram gateway: rollout_status_edit — no operator chat (allowFrom empty)\n`)
8029
+ return
8030
+ }
8031
+ void swallowingApiCall(
8032
+ () =>
8033
+ // allow-raw-bot-api: in-place edit of the ordinary status message.
8034
+ bot.api.editMessageText(operator, msg.messageId, richMessage(msg.text), {}),
8035
+ { chat_id: String(operator), verb: 'rollout-status-edit' },
8036
+ )
8037
+ },
8038
+
7559
8039
  onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
7560
8040
  // Cron fires (incl. cheap-cron, whose session events are dropped before
7561
8041
  // currentTurn is set) are real activity — re-arm idle auto-clear so a
@@ -8129,6 +8609,273 @@ function redactOutboundText(text: string, site: string): string {
8129
8609
  return masked
8130
8610
  }
8131
8611
 
8612
+ /** Default per-voice-note chunk size (chars). A long reply is split into
8613
+ * sequential voice notes of roughly this size on sentence/paragraph
8614
+ * boundaries. Only the OpenAI (cloud) engine still uses this — the kokoro
8615
+ * sidecar now takes the whole reply in one call and returns a single note. */
8616
+ const VOICE_OUT_DEFAULT_CHUNK_CHARS = 600
8617
+
8618
+ /** Hard ceiling for a single OpenAI TTS request. OpenAI's TTS input caps at
8619
+ * 4096 chars, so client-side chunks for that engine never exceed this; the
8620
+ * reply is spoken across sequential notes when it's longer. (The kokoro path
8621
+ * no longer chunks — the sidecar owns length and returns one file.) */
8622
+ const VOICE_OUT_HARD_CHUNK_CAP = 4096
8623
+
8624
+ /**
8625
+ * On-demand voice-out (reply_mode='on-demand'). Instead of synthesizing at
8626
+ * reply time, the gateway appends a single "🔊 Listen" inline button carrying
8627
+ * a reserved `voice:<token>` callback_data; audio is synthesized + sent only
8628
+ * when the user taps it. This keeps the voice pipeline subscription-honest and
8629
+ * visible — zero GPU/sidecar work happens behind the user's back.
8630
+ *
8631
+ * We cache the (speech-normalized text, voice, speed) needed to synthesize by
8632
+ * TOKEN, not message_id, because the message_id is not known at send time.
8633
+ * Bounded LRU with a TTL so a stale button (tapped an hour later) degrades to
8634
+ * a graceful "expired" toast rather than pinning reply text in memory forever.
8635
+ */
8636
+ // Persist the Listen-token cache under STATE_DIR (= /state/agent/telegram in
8637
+ // prod, a volume that survives container recreation). Without this the Map was
8638
+ // wiped on every gateway restart, so a 🔊 Listen button tapped on a message
8639
+ // sent before the restart degraded to "Voice expired". The same TTL + LRU cap
8640
+ // still applies; expired entries are dropped on load.
8641
+ const voiceOnDemandCache = new VoiceOnDemandCache({
8642
+ persistPath: join(STATE_DIR, 'voice-ondemand.json'),
8643
+ })
8644
+
8645
+ // ─── Eager voice pre-synthesis (#2763) ─────────────────────────────────────
8646
+ // Whenever a reply becomes Listen-eligible (a voiceOnDemandCache entry is
8647
+ // persisted alongside the Listen button), ALSO synthesize the audio in the
8648
+ // background via the LOCAL sidecar and park it on disk, so the 🔊 Listen tap
8649
+ // attaches the pre-made file instantly instead of waiting on the GPU. Local
8650
+ // engine only (kokoro — zero token/dollar cost); the openai cloud engine
8651
+ // never pre-synthesizes. Strictly off the reply critical path: jobs drain
8652
+ // through a bounded FIFO (concurrency 1, drop-oldest past the cap) and every
8653
+ // failure is swallowed — the tap just falls back to the lazy synth path.
8654
+ // Kill switch: SWITCHROOM_DISABLE_EAGER_VOICE=1 (the sweep still runs so old
8655
+ // files age out).
8656
+ const VOICE_CACHE_DIR = join(STATE_DIR, 'voice-cache')
8657
+
8658
+ const voicePreSynthQueue = new PreSynthQueue({
8659
+ runJob: async (job) => {
8660
+ const sidecarToken = await materializeSidecarToken()
8661
+ if (!sidecarToken) return // sidecar unavailable — lazy path will retry on tap
8662
+ const result = await synthesizeViaSidecar({
8663
+ token: sidecarToken,
8664
+ // Same deterministic normalization the lazy Listen path applies —
8665
+ // job.text is normalizeForSpeech(reply); normalizeForTts is the #2760
8666
+ // L1 pass on top.
8667
+ text: normalizeForTts(job.text),
8668
+ voice: job.voice,
8669
+ speed: job.speed,
8670
+ })
8671
+ if (!result.ok) {
8672
+ process.stderr.write(
8673
+ `telegram gateway: voice-presynth: sidecar synthesis failed token=${job.token} reason=${result.reason}\n`,
8674
+ )
8675
+ return
8676
+ }
8677
+ const filePath = writeVoiceCacheFile(VOICE_CACHE_DIR, job.token, result.audio)
8678
+ voiceOnDemandCache.setFilePath(job.token, filePath)
8679
+ process.stderr.write(
8680
+ `telegram gateway: voice-presynth: cached ${result.audio.length} bytes at ${filePath} ` +
8681
+ `(${result.durationMs}ms, backlog=${voicePreSynthQueue.size})\n`,
8682
+ )
8683
+ },
8684
+ })
8685
+
8686
+ /** Rolling 7-day TTL + 500MB size-budget sweep over VOICE_CACHE_DIR. Runs at
8687
+ * boot and hourly; ALWAYS runs (even under the eager-voice kill switch) so
8688
+ * previously written files age out. Crash-safe: missing dir/files tolerated. */
8689
+ function sweepVoiceCache(): void {
8690
+ try {
8691
+ const result = sweepVoiceCacheDir({ dir: VOICE_CACHE_DIR })
8692
+ if (result.deletedTokens.length > 0) {
8693
+ voiceOnDemandCache.prune(result.deletedTokens)
8694
+ process.stderr.write(
8695
+ `telegram gateway: voice-presynth: sweep removed ${result.deletedTokens.length} file(s), ` +
8696
+ `${result.remainingBytes} bytes remain\n`,
8697
+ )
8698
+ }
8699
+ } catch (err) {
8700
+ // Defence in depth — sweepVoiceCacheDir is already per-file crash-safe.
8701
+ process.stderr.write(`telegram gateway: voice-presynth: sweep failed: ${String(err)}\n`)
8702
+ }
8703
+ }
8704
+
8705
+ if (!STATIC) {
8706
+ sweepVoiceCache() // boot sweep — covers a died timer / long downtime
8707
+ setInterval(sweepVoiceCache, VOICE_SWEEP_INTERVAL_MS).unref()
8708
+ }
8709
+
8710
+ type VoiceOutAccess = NonNullable<Access['voice_out']>
8711
+
8712
+ /**
8713
+ * Resolve whether outbound TTS is active for this reply, and how
8714
+ * (engine + reply_mode + the plain-text TTS chunks). PR-C2.
8715
+ *
8716
+ * Mirrors the voice-IN engine-resolution precedence (message:voice
8717
+ * handler): the compose-injected SWITCHROOM_VOICE_ENGINE verdict gates the
8718
+ * local 'kokoro' engine, exactly like the STT sidecar. The 'openai' engine
8719
+ * is an honest-exception cloud path gated on an api_key vault ref — it does
8720
+ * NOT require a local verdict.
8721
+ *
8722
+ * Returns null when voice-out is off / not applicable; otherwise a plan the
8723
+ * caller acts on. `ttsChunks` is speech-normalized plain text (markdown and
8724
+ * TTS-mispronounced symbols removed — see normalizeForSpeech).
8725
+ *
8726
+ * ONE voice note per response: the kokoro (local sidecar) path now sends the
8727
+ * WHOLE normalized reply to `/tts` in a SINGLE call — the sidecar chunks +
8728
+ * concatenates internally on the GPU and returns a single ogg/opus file — so
8729
+ * `ttsChunks` is a single element for kokoro. `max_chars` is no longer a hard
8730
+ * client-side splitter for kokoro; it's retired for that path (the sidecar
8731
+ * owns length). The openai (cloud) engine keeps client-side chunking because
8732
+ * OpenAI's TTS input has its own hard cap; each chunk is one voice note.
8733
+ */
8734
+ function resolveVoiceOutPlan(
8735
+ voiceOut: VoiceOutAccess | undefined,
8736
+ replyText: string,
8737
+ ): {
8738
+ engine: 'kokoro' | 'openai'
8739
+ voice?: string
8740
+ speed: number
8741
+ apiKeyRef?: string
8742
+ replyMode: 'voice+text' | 'voice-only' | 'on-demand'
8743
+ ttsChunks: string[]
8744
+ } | null {
8745
+ if (voiceOut?.enabled !== true) return null
8746
+
8747
+ const engine = voiceOut.engine ?? 'kokoro'
8748
+
8749
+ // Engine gating mirrors the voice-in handler precedence: the
8750
+ // compose-injected env wins, then the persisted host-capabilities file,
8751
+ // then a fail-safe 'cloud'. The in-container ~/.switchroom is a read-only
8752
+ // view that does NOT carry host-capabilities.json, so the env is
8753
+ // load-bearing in-fleet (see message:voice). Kokoro requires 'local';
8754
+ // openai requires a resolvable api_key.
8755
+ if (engine === 'kokoro') {
8756
+ const envVoiceEngine = process.env.SWITCHROOM_VOICE_ENGINE
8757
+ const voiceEngine: VoiceEngine =
8758
+ envVoiceEngine === 'local' || envVoiceEngine === 'cloud'
8759
+ ? envVoiceEngine
8760
+ : loadHostCapabilities()?.voice.engine ?? 'cloud'
8761
+ if (voiceEngine !== 'local') return null
8762
+ }
8763
+
8764
+ // Speech-normalized TTS input: strip markdown AND translate/drop the
8765
+ // symbols TTS mispronounces (~, backticks, arrows, tables, code fences,
8766
+ // link URLs) REGARDLESS of mode so the engine reads clean prose. Applied
8767
+ // for BOTH engines — the older partial stripMarkdown pass leaked `~`,
8768
+ // code fences and arrows.
8769
+ const ttsText = normalizeForSpeech(replyText)
8770
+ if (ttsText.length === 0) return null
8771
+
8772
+ // ONE voice note for kokoro: the sidecar accepts arbitrarily long text and
8773
+ // returns a single concatenated ogg/opus file, so we send the whole reply
8774
+ // in one call — no client-side splitting, no multi-sendVoice. `max_chars`
8775
+ // is retired as a hard splitter here.
8776
+ //
8777
+ // OpenAI still needs client-side chunking (its TTS input has a hard cap),
8778
+ // so for that engine we split at the engine cap; each chunk is one note.
8779
+ let ttsChunks: string[]
8780
+ if (engine === 'kokoro') {
8781
+ ttsChunks = [ttsText]
8782
+ } else {
8783
+ const chunkChars = Math.min(
8784
+ voiceOut.max_chars ?? VOICE_OUT_DEFAULT_CHUNK_CHARS,
8785
+ VOICE_OUT_HARD_CHUNK_CAP,
8786
+ )
8787
+ ttsChunks = chunkTtsText(ttsText, chunkChars)
8788
+ }
8789
+ if (ttsChunks.length === 0) return null
8790
+
8791
+ // Kokoro playback speed: default 1.1 (fleet default) when unset, clamped to
8792
+ // 0.5–2.0. Threaded into the /tts body by synthesizeVoiceOut. The OpenAI
8793
+ // path ignores it.
8794
+ const speed = clampTtsSpeed(voiceOut.speed, VOICE_OUT_DEFAULT_SPEED)
8795
+
8796
+ return {
8797
+ engine,
8798
+ voice: voiceOut.voice,
8799
+ speed,
8800
+ apiKeyRef: voiceOut.api_key,
8801
+ replyMode: voiceOut.reply_mode ?? 'voice+text',
8802
+ ttsChunks,
8803
+ }
8804
+ }
8805
+
8806
+ /**
8807
+ * Synthesize `ttsText` into OGG/Opus bytes via the configured engine.
8808
+ * Best-effort: returns null on ANY failure (logged to stderr), never
8809
+ * throws. The caller treats null as "voice unavailable, fall back to
8810
+ * text". PR-C2.
8811
+ */
8812
+ async function synthesizeVoiceOut(plan: {
8813
+ engine: 'kokoro' | 'openai'
8814
+ voice?: string
8815
+ speed?: number
8816
+ apiKeyRef?: string
8817
+ ttsText: string
8818
+ }): Promise<Uint8Array | null> {
8819
+ try {
8820
+ // #2760 Phase 1: deterministic L1 TTS normalization, applied at the
8821
+ // single choke point where every engine's request body is built.
8822
+ // On by default; kill switch: SWITCHROOM_DISABLE_TTS_NORMALIZE=1.
8823
+ const ttsText = normalizeForTts(plan.ttsText)
8824
+ if (plan.engine === 'kokoro') {
8825
+ const token = await materializeSidecarToken()
8826
+ if (!token) return null // materializeSidecarToken already logged why.
8827
+ const result = await synthesizeViaSidecar({
8828
+ token,
8829
+ text: ttsText,
8830
+ voice: plan.voice,
8831
+ speed: plan.speed,
8832
+ })
8833
+ if (!result.ok) {
8834
+ process.stderr.write(
8835
+ `telegram gateway: voice-out: local sidecar synthesis failed reason=${result.reason}` +
8836
+ (result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
8837
+ '\n',
8838
+ )
8839
+ return null
8840
+ }
8841
+ process.stderr.write(
8842
+ `telegram gateway: voice-out: local sidecar synthesized ${result.audio.length} bytes in ${result.durationMs}ms ` +
8843
+ `voice=${result.voice ?? '?'} audio_s=${result.audioSeconds ?? '?'} chars=${plan.ttsText.length}\n`,
8844
+ )
8845
+ return result.audio
8846
+ }
8847
+
8848
+ // engine === 'openai' — honest-exception cloud path.
8849
+ const apiKey = await materializeVoiceKey({ apiKeyRef: plan.apiKeyRef })
8850
+ if (!apiKey) return null // materializeVoiceKey already logged why.
8851
+ const result = await synthesizeViaOpenAi({
8852
+ apiKey,
8853
+ text: ttsText,
8854
+ voice: plan.voice,
8855
+ })
8856
+ if (!result.ok) {
8857
+ process.stderr.write(
8858
+ `telegram gateway: voice-out: openai synthesis failed reason=${result.reason}` +
8859
+ (result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
8860
+ '\n',
8861
+ )
8862
+ return null
8863
+ }
8864
+ process.stderr.write(
8865
+ `telegram gateway: voice-out: openai synthesized ${result.audio.length} bytes in ${result.durationMs}ms ` +
8866
+ `voice=${result.voice} chars=${plan.ttsText.length}\n`,
8867
+ )
8868
+ return result.audio
8869
+ } catch (err) {
8870
+ // Defence in depth: the synth helpers never throw, but the vault
8871
+ // materialize calls might. Voice is best-effort — swallow + fall back.
8872
+ process.stderr.write(
8873
+ `telegram gateway: voice-out: synthesis threw (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`,
8874
+ )
8875
+ return null
8876
+ }
8877
+ }
8878
+
8132
8879
  async function executeReply(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
8133
8880
  // #1664 — pin the turn this reply belongs to at entry. The
8134
8881
  // finalAnswerDelivered write near the end of this function runs after
@@ -8149,18 +8896,20 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8149
8896
  // null because clearTurnStarted fired ≥5 min of model silence, but the model
8150
8897
  // finally called reply after the poke).
8151
8898
  const chat_id = (() => {
8152
- const _a = loadAccess()
8153
- if (_a.allowFrom.includes(_rawChatId) || _rawChatId in _a.groups) return _rawChatId
8154
- const fallbackChatId = turn?.sessionChatId ?? lastActiveTurnChatId
8155
- if (fallbackChatId && (_a.allowFrom.includes(fallbackChatId) || fallbackChatId in _a.groups)) {
8156
- const tier = turn == null ? 'last-known' : 'active'
8899
+ const resolved = resolveChatIdFallback(
8900
+ _rawChatId,
8901
+ loadAccess(),
8902
+ turn?.sessionChatId,
8903
+ lastActiveTurnChatId,
8904
+ turn != null,
8905
+ )
8906
+ if (resolved.tier !== 'raw') {
8157
8907
  process.stderr.write(
8158
8908
  `telegram gateway: reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
8159
- `routing to ${tier} turn chat "${fallbackChatId}"\n`,
8909
+ `routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
8160
8910
  )
8161
- return fallbackChatId
8162
8911
  }
8163
- return _rawChatId // let assertAllowedChat below throw the human-readable error
8912
+ return resolved.chatId // raw tier → let assertAllowedChat throw the human-readable error
8164
8913
  })()
8165
8914
  const rawText = args.text as string | undefined
8166
8915
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
@@ -8171,8 +8920,16 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8171
8920
  // Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
8172
8921
  // the stderr preview below, the dedup key, the send, and the history
8173
8922
  // record. Mutates `text` so every downstream consumer sees the masked
8174
- // value, exactly like the voice scrub that follows.
8923
+ // value, exactly like the voice scrub that follows. Runs BEFORE the
8924
+ // punctuation/bold normalizers so a secret containing an em-dash or `**`
8925
+ // is matched literally by the redactor before any mutation.
8175
8926
  text = redactOutboundText(text, 'reply')
8927
+ // Fleet-wide consistent formatting: normalize dashes/bullets and trip the
8928
+ // over-bold guard deterministically, on code-masked text (same contract on
8929
+ // reply / edit / stream paths). Runs before scrubVoice below so dashes get
8930
+ // the comma treatment on every path; scrubVoice's dash telemetry
8931
+ // (voice_scrub_applied) drops to ~zero here as a result — deliberate.
8932
+ text = stripExcessBold(normalizePunctuation(text))
8176
8933
  // Voice scrub (#1683): replace em / en dashes with commas / periods.
8177
8934
  // Runs BEFORE outboundDedup so retries see the scrubbed key, and on the
8178
8935
  // raw markdown text (the scrubber does its own code-region parking so
@@ -8233,6 +8990,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8233
8990
  const protectContent = args.protect_content === true
8234
8991
  const quoteText = args.quote_text as string | undefined
8235
8992
  const access = loadAccess()
8993
+ // Outbound TTS plan (PR-C2). Resolved once here (engine gating + mode +
8994
+ // plain-text TTS input); synthesis happens just before the send so a
8995
+ // voice-only reply can suppress the text chunk loop on success. Voice is
8996
+ // fully best-effort — every failure below falls back to the text reply.
8997
+ const voiceOutPlan = resolveVoiceOutPlan(access.voice_out, text)
8236
8998
  const configParseMode = access.parseMode ?? 'html'
8237
8999
  const format = (args.format as string | undefined) ?? configParseMode
8238
9000
  const disableLinkPreview = args.disable_web_page_preview != null
@@ -8400,7 +9162,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8400
9162
  // ships the raw GFM markdown via `sendRichMessage`. `effectiveText` is the
8401
9163
  // raw text either way (no HTML/MarkdownV2 rendering happens here anymore).
8402
9164
  const literalText = format === 'text'
8403
- const effectiveText: string = text
9165
+ // Paragraph-spacing fix (rich-message regression after #2669). The rich GFM
9166
+ // renderer collapses a `\n\n` gap TIGHT, so multi-paragraph replies render
9167
+ // jammed together — unlike the old HTML path. Inject a visible blank-line
9168
+ // spacer into prose `\n\n` gaps on the rich path only. The literal
9169
+ // (`format:'text'`) path must stay byte-exact, so it is left untouched.
9170
+ const effectiveText: string = literalText ? text : addParagraphSpacers(text)
8404
9171
 
8405
9172
  assertAllowedChat(chat_id)
8406
9173
 
@@ -8469,6 +9236,60 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8469
9236
  : splitMarkdownChunks(effectiveText, limit)
8470
9237
  const sentIds: number[] = []
8471
9238
 
9239
+ // Outbound TTS synthesis (PR-C2). Done BEFORE the text send so a
9240
+ // voice-only reply can suppress the text chunks on success. ONE voice note
9241
+ // per response for the kokoro path (ttsChunks is a single element — the
9242
+ // whole normalized reply — synthesized in one /tts call). The OpenAI path
9243
+ // may still produce several ordered notes (one per chunk) because of its
9244
+ // input cap. Ken is often on a bike/driving and can't read the screen, so
9245
+ // the full answer must be SPOKEN. Best-effort: a chunk that fails to
9246
+ // synthesize is skipped; if NOTHING synthesizes the text path proceeds
9247
+ // unchanged so the answer is never dropped.
9248
+ // on-demand is a LOCAL-engine (kokoro) feature only: the tap handler
9249
+ // synthesizes via the local sidecar, so a Listen button is only meaningful
9250
+ // when the resolved engine is kokoro. resolveVoiceOutPlan already gated the
9251
+ // local host verdict for kokoro, so engine==='kokoro' here implies the
9252
+ // sidecar is available. For engine==='openai' + reply_mode='on-demand' we do
9253
+ // NOT inject a button (its taps would dead-end on the local sidecar) — we
9254
+ // fall through to the normal immediate-synth path so the openai reply behaves
9255
+ // exactly like a normal openai voice reply.
9256
+ const useOnDemandButton =
9257
+ voiceOutPlan != null &&
9258
+ voiceOutPlan.replyMode === 'on-demand' &&
9259
+ voiceOutPlan.engine === 'kokoro'
9260
+
9261
+ const voiceOggs: Uint8Array[] = []
9262
+ // Skip reply-time synthesis ONLY when we're actually deferring to a Listen
9263
+ // button (kokoro on-demand). An openai on-demand config still synthesizes
9264
+ // immediately below.
9265
+ if (voiceOutPlan != null && !useOnDemandButton) {
9266
+ for (const chunkText of voiceOutPlan.ttsChunks) {
9267
+ const ogg = await synthesizeVoiceOut({
9268
+ engine: voiceOutPlan.engine,
9269
+ voice: voiceOutPlan.voice,
9270
+ speed: voiceOutPlan.speed,
9271
+ apiKeyRef: voiceOutPlan.apiKeyRef,
9272
+ ttsText: chunkText,
9273
+ })
9274
+ // Skip a failed chunk but keep going — a partial spoken answer still
9275
+ // beats silence; full-fail (no oggs at all) falls back to text below.
9276
+ if (ogg != null) voiceOggs.push(ogg)
9277
+ }
9278
+ if (voiceOggs.length < voiceOutPlan.ttsChunks.length) {
9279
+ process.stderr.write(
9280
+ `telegram gateway: voice-out: synthesized ${voiceOggs.length}/${voiceOutPlan.ttsChunks.length} voice-note chunk(s)\n`,
9281
+ )
9282
+ }
9283
+ }
9284
+ // Suppress the text body ONLY when voice-only AND we synthesized the FULL
9285
+ // set of chunks (every part of the answer is spoken). A partial or total
9286
+ // synthesis failure leaves the text path running so the answer still
9287
+ // lands in full — never drop the user's answer silently.
9288
+ const suppressText =
9289
+ voiceOutPlan?.replyMode === 'voice-only' &&
9290
+ voiceOutPlan.ttsChunks.length > 0 &&
9291
+ voiceOggs.length === voiceOutPlan.ttsChunks.length
9292
+
8472
9293
  // #271: validate inline_keyboard and namespace any callback_data with
8473
9294
  // the `agent:` prefix so the gateway's callback_query dispatcher can
8474
9295
  // round-trip taps back to this agent without colliding with
@@ -8490,6 +9311,66 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8490
9311
  replyMarkup = { inline_keyboard: wrapAgentCallbacks(rawKeyboard) }
8491
9312
  }
8492
9313
 
9314
+ // on-demand voice: append a single '🔊 Listen' button that synthesizes the
9315
+ // spoken reply only when tapped. The button carries a RAW `voice:<token>`
9316
+ // callback_data (NOT wrapped with the agent: prefix) so the dispatcher
9317
+ // handles it internally and never routes it to the agent as an inbound.
9318
+ //
9319
+ // Collision gate: inject ONLY when the reply carries no agent-authored
9320
+ // buttons. If the agent supplied its own keyboard, the callback dispatcher's
9321
+ // single_use strip (keyboardIsSingleUse) governs that whole message; adding
9322
+ // a foreign single_use:false button would flip that message to a mixed
9323
+ // keyboard and defeat the agent's double-fire protection. Keep it simple —
9324
+ // agent buttons present → skip the Listen button for this message.
9325
+ //
9326
+ // useOnDemandButton already gates on engine==='kokoro': an openai on-demand
9327
+ // config never reaches here (it synthesized immediately above), so a Listen
9328
+ // button is never minted for an engine whose taps would dead-end on the
9329
+ // local sidecar.
9330
+ if (
9331
+ useOnDemandButton &&
9332
+ voiceOutPlan!.ttsChunks.length > 0 &&
9333
+ voiceOutPlan!.ttsChunks[0]!.length > 0
9334
+ ) {
9335
+ if (!mayInjectListenButton(rawKeyboard)) {
9336
+ process.stderr.write(
9337
+ 'telegram gateway: voice-out on-demand: agent supplied inline_keyboard — skipping Listen button (single_use collision gate)\n',
9338
+ )
9339
+ } else {
9340
+ // Token is intentionally GLOBAL (not chat-keyed): under the single-tenant
9341
+ // invariant the operator is the only authorized sender across all chats,
9342
+ // and the tap handler re-checks access.allowFrom before synthesizing, so
9343
+ // a token needs no per-chat scoping to be safe.
9344
+ const token = mintVoiceOnDemandToken()
9345
+ voiceOnDemandCache.put(token, {
9346
+ // ttsChunks[0] is already normalizeForSpeech(reply) (kokoro path).
9347
+ text: voiceOutPlan.ttsChunks[0]!,
9348
+ ...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
9349
+ speed: voiceOutPlan.speed,
9350
+ })
9351
+ // The keyboard stays after the tap (never stripped) so it can be
9352
+ // replayed. The gate above guarantees this is the ONLY button on the
9353
+ // message, so keeping it is safe (no agent buttons to protect).
9354
+ replyMarkup = buildListenKeyboard(token)
9355
+ // #2763 eager pre-synthesis: kick a background synth of the same
9356
+ // payload so the Listen tap attaches the pre-made file instantly.
9357
+ // Local-engine only (useOnDemandButton already gates engine==='kokoro'
9358
+ // — the cloud engine never eager-synthesizes). enqueue() is a
9359
+ // synchronous array push; the drain is deferred + async and never
9360
+ // awaited here, so the text sends below are never delayed and a synth
9361
+ // failure can never affect message delivery (the tap just falls back
9362
+ // to the lazy path).
9363
+ if (eagerVoiceEnabled()) {
9364
+ voicePreSynthQueue.enqueue({
9365
+ token,
9366
+ text: voiceOutPlan.ttsChunks[0]!,
9367
+ ...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
9368
+ speed: voiceOutPlan.speed,
9369
+ })
9370
+ }
9371
+ }
9372
+ }
9373
+
8493
9374
  const replySKey = streamKey(chat_id, threadId)
8494
9375
  suppressPtyPreview.add(replySKey)
8495
9376
  let previewMessageId: number | null = null
@@ -8739,6 +9620,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8739
9620
 
8740
9621
  try {
8741
9622
  for (let i = 0; i < chunks.length; i++) {
9623
+ // PR-C2: voice-only mode with a successful synthesis suppresses the
9624
+ // text body — the spoken voice note IS the reply. Bail before the
9625
+ // first chunk send (sentIds stays empty for text); the voice send
9626
+ // below lands the answer. Any other mode (voice+text, or voice-only
9627
+ // that fell back) sends the text chunks as normal.
9628
+ if (suppressText) break
8742
9629
  const shouldReplyTo =
8743
9630
  reply_to != null && replyMode !== 'off' && (replyMode === 'all' || i === 0)
8744
9631
  const isLastChunk = i === chunks.length - 1
@@ -8894,6 +9781,76 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8894
9781
  stopTypingLoop(chat_id, threadId ?? null)
8895
9782
  }
8896
9783
 
9784
+ // Outbound voice notes (PR-C2). Sent IN ORDER, AFTER the text body
9785
+ // (voice+text) or INSTEAD of it (voice-only, where suppressText skipped
9786
+ // the chunk loop). A long reply produces SEVERAL notes — each spoken in
9787
+ // sequence so the whole answer is heard, never truncated. Best-effort and
9788
+ // fully non-fatal: a sendVoice failure must NEVER break the text reply
9789
+ // path. In voice-only mode a failure would leave the user with an
9790
+ // incomplete spoken answer, so on the FIRST send failure we recover by
9791
+ // sending the full text once and stop sending further notes.
9792
+ for (let v = 0; v < voiceOggs.length; v++) {
9793
+ const oggBytes = voiceOggs[v]!
9794
+ const voiceOpts: Record<string, unknown> = {
9795
+ // Quote the user's message only on the FIRST voice note (mirrors the
9796
+ // text chunk loop's first-chunk reply behaviour).
9797
+ ...(v === 0 && reply_to != null && replyMode !== 'off'
9798
+ ? { reply_parameters: { message_id: reply_to } }
9799
+ : {}),
9800
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
9801
+ ...(disableNotification ? { disable_notification: true } : {}),
9802
+ }
9803
+ try {
9804
+ const sentVoice = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
9805
+ robustApiCall,
9806
+ (tid) => {
9807
+ const opts = { ...voiceOpts }
9808
+ if (tid != null) opts.message_thread_id = tid
9809
+ else delete opts.message_thread_id
9810
+ // allow-raw-bot-api: adapter callback INSIDE retryWithThreadFallback→robustApiCall; the THREAD_NOT_FOUND fallback is handled by the wrapper.
9811
+ return lockedBot.api.sendVoice(chat_id, new InputFile(Buffer.from(oggBytes)), opts as never)
9812
+ },
9813
+ { threadId, chat_id, verb: 'sendVoice' },
9814
+ )
9815
+ sentIds.push(sentVoice.message_id)
9816
+ logOutbound(
9817
+ 'reply',
9818
+ chat_id,
9819
+ sentVoice.message_id,
9820
+ voiceOutPlan?.ttsChunks[v]?.length ?? 0,
9821
+ `voice-note=${v + 1}/${voiceOggs.length}`,
9822
+ )
9823
+ } catch (err) {
9824
+ const msg = err instanceof Error ? err.message : String(err)
9825
+ process.stderr.write(
9826
+ `telegram gateway: voice-out: sendVoice ${v + 1}/${voiceOggs.length} failed (non-fatal): ${msg}\n`,
9827
+ )
9828
+ // voice-only fell over mid-stream with text suppressed → recover by
9829
+ // sending the FULL text body once so the answer still lands, then
9830
+ // stop sending further notes (the text now carries everything).
9831
+ if (suppressText) {
9832
+ try {
9833
+ const opts: Record<string, unknown> = {
9834
+ ...(reply_to != null && replyMode !== 'off'
9835
+ ? { reply_parameters: { message_id: reply_to } }
9836
+ : {}),
9837
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
9838
+ ...(disableNotification ? { disable_notification: true } : {}),
9839
+ }
9840
+ // allow-raw-bot-api: voice-only recovery fallback — the rich path already ran/was skipped; send the source text plainly so the answer is never lost.
9841
+ const sent = await lockedBot.api.sendMessage(chat_id, effectiveText, opts as never)
9842
+ sentIds.push(sent.message_id)
9843
+ logOutbound('reply', chat_id, sent.message_id, effectiveText.length, 'voice-only-text-recovery')
9844
+ } catch (textErr) {
9845
+ process.stderr.write(
9846
+ `telegram gateway: voice-out: voice-only text recovery ALSO failed: ${textErr instanceof Error ? textErr.message : String(textErr)}\n`,
9847
+ )
9848
+ }
9849
+ break
9850
+ }
9851
+ }
9852
+ }
9853
+
8897
9854
  // #710: remember per-button agent meta (ack_text / single_use) keyed
8898
9855
  // by the message that actually carries the keyboard — that's the last
8899
9856
  // text chunk, since the keyboard is attached only on isLastChunk.
@@ -9154,6 +10111,31 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9154
10111
  const turn = currentTurn
9155
10112
  if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
9156
10113
  if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
10114
+ // chat_id allowlist fallback — mirrors executeReply (~8725). stream_reply is
10115
+ // the primary final-answer path, so a wrong/late chat_id (int/string mismatch,
10116
+ // or the model echoing the wrong identifier after a silence poke flipped
10117
+ // currentTurn to null) would otherwise fail assertAllowedChat in the stream
10118
+ // controller → an invisible reply. Rewrite args.chat_id in place so every
10119
+ // downstream consumer (origin resolution, dedup key, the send) sees the
10120
+ // corrected value. Tier 1: live turn's sessionChatId. Tier 2: last-known
10121
+ // turn's chat (survives silence poke — Bug D fix; currentTurn is null).
10122
+ {
10123
+ const _rawChatId = String(args.chat_id ?? '')
10124
+ const resolved = resolveChatIdFallback(
10125
+ _rawChatId,
10126
+ loadAccess(),
10127
+ turn?.sessionChatId,
10128
+ lastActiveTurnChatId,
10129
+ turn != null,
10130
+ )
10131
+ if (resolved.tier !== 'raw') {
10132
+ process.stderr.write(
10133
+ `telegram gateway: stream_reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
10134
+ `routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
10135
+ )
10136
+ args.chat_id = resolved.chatId
10137
+ }
10138
+ }
9157
10139
  // Thread precedence (matches executeReply; component 3 — turn-origin
9158
10140
  // routing): when the model passes no explicit message_thread_id, inject
9159
10141
  // the ORIGIN turn's thread (matched by origin_turn_id) — authoritative
@@ -9222,6 +10204,16 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9222
10204
  // controller, dedup record, history record) see the scrubbed
9223
10205
  // version. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
9224
10206
  {
10207
+ // Cross-path consistency (#2755): normalizePunctuation runs BEFORE
10208
+ // scrubVoice here, matching the reply/edit paths, so a spaced em-dash
10209
+ // gets the same comma treatment on every outbound path (scrubVoice
10210
+ // would otherwise see the raw dash first on this path only and apply
10211
+ // its period substitution). handleStreamReply's own deps run
10212
+ // normalizePunctuation again downstream — it is idempotent, so the
10213
+ // second pass is a no-op. Side effect: scrubVoice's dash telemetry
10214
+ // (voice_scrub_applied) drops to ~zero on this path since the dashes
10215
+ // are consumed here first — deliberate.
10216
+ args.text = normalizePunctuation(args.text as string)
9225
10217
  const scrub = scrubVoice(args.text as string)
9226
10218
  if (scrub.replaced > 0) {
9227
10219
  args.text = scrub.scrubbed
@@ -9379,6 +10371,9 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9379
10371
  retry: robustApiCall,
9380
10372
  repairEscapedWhitespace,
9381
10373
  normalizeParagraphBreaks,
10374
+ normalizePunctuation,
10375
+ stripExcessBold,
10376
+ addParagraphSpacers,
9382
10377
  assertAllowedChat,
9383
10378
  resolveThreadId,
9384
10379
  disableLinkPreview: access.disableLinkPreview !== false,
@@ -10428,31 +11423,6 @@ function buildVaultRequestAccessKeyboard(stageId: string): { inline_keyboard: Ar
10428
11423
  }
10429
11424
  }
10430
11425
 
10431
- function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
10432
- const lines: string[] = []
10433
- const scopeLabel = req.scope === 'write' ? 'write' : 'read'
10434
- const days = Math.round(req.ttl_seconds / 86400)
10435
- const durationLabel = days >= 1 ? `${days}d` : `${Math.round(req.ttl_seconds / 3600)}h`
10436
- lines.push(`🔐 **${escapeHtmlForTg(req.agent)}** wants vault access`)
10437
- lines.push(`key: \`${req.key}\``)
10438
- lines.push(`scope: \`${scopeLabel}\` · duration: \`${durationLabel}\``)
10439
- // #1790 — always render the why-line, even when the agent omitted
10440
- // `reason`. Rendering "not provided" makes a missing rationale
10441
- // visibly an agent-side failure (the tool description nudges the
10442
- // model to supply one — see executeVaultRequestAccess); skipping
10443
- // the line silently used to make the omission look like a card-
10444
- // template choice, which the operator couldn't tell apart from a
10445
- // legitimate "no reason needed" case.
10446
- if (req.reason && req.reason.length > 0) {
10447
- lines.push(`why: _${escapeHtmlForTg(req.reason)}_`)
10448
- } else {
10449
- lines.push(`why: _not provided_`)
10450
- }
10451
- lines.push('')
10452
- lines.push(`_Tap Approve to mint a scoped grant token (same flow as \`switchroom vault grant\`). Tap Deny to refuse — the agent will receive a denial result._`)
10453
- return lines.join('\n')
10454
- }
10455
-
10456
11426
  /**
10457
11427
  * `vault_request_access` tool — agent surfaces an approval card asking
10458
11428
  * the operator to grant a vault ACL it doesn't yet have. See #1012.
@@ -10471,7 +11441,12 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
10471
11441
  if (scopeRaw !== 'read' && scopeRaw !== 'write') {
10472
11442
  throw new Error('vault_request_access: scope must be "read" or "write"')
10473
11443
  }
10474
- const reason = typeof args.reason === 'string' ? args.reason : undefined
11444
+ // Accept `why` as an alias for `reason`: the sibling tool
11445
+ // vault_request_save uses `why`, and agents cross-contaminate the two
11446
+ // schemas — without the alias the rationale silently drops off the
11447
+ // approval card and the operator sees "why: not provided".
11448
+ const reason =
11449
+ typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
10475
11450
  // Duration: accept a "30d" / "12h" string from the agent OR default
10476
11451
  // to 30 days. Cap at 90 days — beyond that the operator should use
10477
11452
  // the host CLI and pick the lifetime explicitly. Refuse "never"
@@ -10636,6 +11611,10 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10636
11611
  // Outbound secret scrub (#2044): an edit must not re-introduce a raw
10637
11612
  // secret into a live bubble or the history row. Mask before scrub/send.
10638
11613
  editRawText = redactOutboundText(editRawText, 'edit_message')
11614
+ // Fleet-wide consistent formatting (same order as the reply path: redact
11615
+ // first so secrets are matched literally, then normalize, then spacers on
11616
+ // the rich path only).
11617
+ if (!editLiteralText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
10639
11618
  // Voice scrub (#1683): same em-dash scrub as the reply path. Edits
10640
11619
  // are how silent-anchor and progress-update mutate already-sent
10641
11620
  // bubbles, so without this an edit can re-introduce dashes the
@@ -11133,6 +12112,16 @@ async function drainActivitySummary(
11133
12112
  )
11134
12113
  turn.activityMessageId = sent.message_id
11135
12114
  turn.activityEverOpened = true
12115
+ // Status-pin: the per-turn status message just opened — it's the
12116
+ // in-flight "what it's doing" surface. Silently pin it so the turn
12117
+ // stays in view when the feed scrolls past. Keyed to the same
12118
+ // status-key the canonical turn-end (purgeReactionTracking) unpins.
12119
+ // Fire-and-forget; the single-owner reconcile keeps state consistent.
12120
+ void reconcileStatusPin(
12121
+ `fg:${statusKey(chat, thread)}`,
12122
+ chat,
12123
+ { pinned: true, messageId: sent.message_id },
12124
+ )
11136
12125
  } else {
11137
12126
  const id = turn.activityMessageId
11138
12127
  await robustApiCall(
@@ -15448,6 +16437,15 @@ async function sweepBeforeSelfRestart(): Promise<void> {
15448
16437
  const agentDir = resolveAgentDirFromEnv()
15449
16438
  if (agentDir == null) return
15450
16439
  // #1122 PR3: pre-restart progress-card pin sweep removed with the card.
16440
+ // Status-pin: unpin every status pin we own before we hand control to the
16441
+ // restart, so a crash / interrupt / config-bounce never leaves a permanent
16442
+ // pin stuck at the top of the chat. Best-effort; the drop-on-unpin contract
16443
+ // clears the claim even if the API throws.
16444
+ try {
16445
+ await unpinAllStatusPins()
16446
+ } catch (err) {
16447
+ process.stderr.write(`telegram gateway: pre-restart status-pin sweep threw: ${(err as Error).message}\n`)
16448
+ }
15451
16449
  try {
15452
16450
  await sweepActiveReactions(
15453
16451
  agentDir,
@@ -17159,6 +18157,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
17159
18157
  action: naturalAction(details.tool_name, details.input_preview),
17160
18158
  })
17161
18159
  pendingPermissions.delete(request_id)
18160
+ permCardStore.remove(request_id)
17162
18161
  process.stderr.write(
17163
18162
  `[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
17164
18163
  )
@@ -18861,9 +19860,16 @@ async function performVaultAccessApproval(
18861
19860
  .editMessageText(
18862
19861
  pending.chat_id,
18863
19862
  pending.card_message_id,
18864
- `✅ Granted **${escapeHtmlForTg(pending.agent)}** ${pending.scope} access to ` +
18865
- `\`${pending.key}\` for ${days}d. ` +
18866
- richMessage(`(grant \`${id}\`)` + footer),
19863
+ richMessage(
19864
+ buildVaultGrantApprovedCardText({
19865
+ agentEscaped: escapeHtmlForTg(pending.agent),
19866
+ scope: pending.scope,
19867
+ key: pending.key,
19868
+ days,
19869
+ grantId: id,
19870
+ footer,
19871
+ }),
19872
+ ),
18867
19873
  { reply_markup: { inline_keyboard: [] } },
18868
19874
  )
18869
19875
  .catch(() => {})
@@ -20855,7 +21861,7 @@ bot.command('usage', async ctx => {
20855
21861
  }
20856
21862
  return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
20857
21863
  })
20858
- const { renderAuthSnapshotFormat2, buildSnapshotsFromState } = await import(
21864
+ const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
20859
21865
  '../auth-snapshot-format.js'
20860
21866
  )
20861
21867
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
@@ -20866,7 +21872,22 @@ bot.command('usage', async ctx => {
20866
21872
  demo,
20867
21873
  ...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: Date.now() }),
20868
21874
  })
20869
- await switchroomReply(ctx, text, { html: true })
21875
+ // Preserve the Switch/Refresh/usage/Add inline keyboard on the
21876
+ // rich-message render — the table card carries the same actions the
21877
+ // /auth snapshot does. switchroomReply routes through the rich path
21878
+ // (replyWithRichMessage), which accepts reply_markup. Build a grammy
21879
+ // InlineKeyboard so the markup type matches switchroomReply's contract.
21880
+ const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date() })
21881
+ const keyboard = new InlineKeyboard()
21882
+ kbRows.forEach((row, ri) => {
21883
+ if (ri > 0) keyboard.row()
21884
+ for (const b of row) {
21885
+ if (b.callbackData) keyboard.text(b.text, b.callbackData)
21886
+ else if (b.insertText) keyboard.switchInlineCurrent(b.text, b.insertText)
21887
+ else keyboard.text(b.text, 'auth:noop')
21888
+ }
21889
+ })
21890
+ await switchroomReply(ctx, text, { html: true, reply_markup: keyboard })
20870
21891
  return
20871
21892
  }
20872
21893
  }
@@ -21186,7 +22207,11 @@ bot.on('callback_query:data', async ctx => {
21186
22207
  await ctx.answerCallbackQuery({ text: 'Tap a model in this section to switch' }).catch(() => {})
21187
22208
  return
21188
22209
  }
21189
- await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
22210
+ // Page navigation (External ▸ / ◂ Back) just re-renders the menu with the
22211
+ // other keyboard page — it never drives the picker, so ack "Loading…"
22212
+ // rather than the switch-oriented "Switching…".
22213
+ const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN
22214
+ await ctx.answerCallbackQuery({ text: isPageNav ? 'Loading…' : 'Switching…' }).catch(() => {})
21190
22215
  // sr-* inject waits for claude to respond (can take 10-30s). Edit the
21191
22216
  // menu immediately to show a "working on it" state so the operator isn't
21192
22217
  // left looking at a stale menu with no feedback. The final edit (✅/❌)
@@ -21544,6 +22569,135 @@ bot.on('callback_query:data', async ctx => {
21544
22569
  return
21545
22570
  }
21546
22571
 
22572
+ // on-demand voice: '🔊 Listen' buttons carry a reserved `voice:<token>`
22573
+ // callback_data. Handle INTERNALLY here — synthesize the cached reply text
22574
+ // and send it as a native voice note. This MUST run before the agent:
22575
+ // routing below so a tap never reaches the agent as an inbound message.
22576
+ if (isVoiceOnDemandCallback(data)) {
22577
+ const access = loadAccess()
22578
+ const senderId = String(ctx.from.id)
22579
+ if (!access.allowFrom.includes(senderId)) {
22580
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
22581
+ return
22582
+ }
22583
+ const token = parseVoiceOnDemandToken(data)
22584
+ const entry = token != null ? voiceOnDemandCache.get(token) : null
22585
+ if (entry == null) {
22586
+ await ctx
22587
+ .answerCallbackQuery({ text: 'Voice expired — send again to hear it.' })
22588
+ .catch(() => {})
22589
+ return
22590
+ }
22591
+ const cbChatId = String(ctx.chat?.id ?? ctx.from.id)
22592
+ const cbMessageId = ctx.callbackQuery?.message?.message_id
22593
+ const cbThreadId = (() => {
22594
+ const msg = ctx.callbackQuery?.message
22595
+ if (msg && 'is_topic_message' in msg && msg.is_topic_message && 'message_thread_id' in msg) {
22596
+ const tid = (msg as { message_thread_id?: number }).message_thread_id
22597
+ return typeof tid === 'number' ? tid : undefined
22598
+ }
22599
+ return undefined
22600
+ })()
22601
+ // #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written
22602
+ // by the pre-synth queue at reply time). If it's on disk, attach it
22603
+ // immediately — no GPU wait. Missing/unreadable file (expired + swept,
22604
+ // crash, pre-feature entry, kill-switched gateway) falls back
22605
+ // transparently to the lazy synth path below.
22606
+ let audio: Uint8Array | null = null
22607
+ if (entry.filePath != null) {
22608
+ try {
22609
+ audio = readFileSync(entry.filePath)
22610
+ } catch {
22611
+ audio = null // swept/missing — lazy fallback
22612
+ }
22613
+ }
22614
+ if (audio != null) {
22615
+ await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
22616
+ } else {
22617
+ await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
22618
+ // Local sidecar (kokoro) synthesis — same helper the immediate voice-out
22619
+ // path uses. On-demand is a local-engine feature; the cache is only
22620
+ // populated when resolveVoiceOutPlan gated the local verdict.
22621
+ const sidecarToken = await materializeSidecarToken()
22622
+ if (!sidecarToken) {
22623
+ await ctx
22624
+ .answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
22625
+ .catch(() => {})
22626
+ return
22627
+ }
22628
+ const result = await synthesizeViaSidecar({
22629
+ token: sidecarToken,
22630
+ // #2760 Phase 1: same deterministic normalization as the immediate
22631
+ // voice-out path — the Listen lazy path builds its own /tts body from
22632
+ // the persisted cache, so it must normalize independently (cache
22633
+ // entries may predate the flag flip).
22634
+ text: normalizeForTts(entry.text),
22635
+ voice: entry.voice,
22636
+ speed: entry.speed,
22637
+ })
22638
+ if (!result.ok) {
22639
+ process.stderr.write(
22640
+ `telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
22641
+ )
22642
+ await ctx
22643
+ .answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
22644
+ .catch(() => {})
22645
+ return
22646
+ }
22647
+ audio = result.audio
22648
+ }
22649
+ // Rebind as const so the closure below narrows to non-null (TS doesn't
22650
+ // narrow a captured `let` inside an arrow function).
22651
+ const audioOut: Uint8Array = audio
22652
+ try {
22653
+ // Native voice note (NOT a document), quote-replying the button's
22654
+ // message.
22655
+ await robustApiCall(
22656
+ () =>
22657
+ bot.api.sendVoice(
22658
+ cbChatId,
22659
+ // allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
22660
+ new InputFile(Buffer.from(audioOut)),
22661
+ {
22662
+ ...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
22663
+ ...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
22664
+ } as never,
22665
+ ),
22666
+ {
22667
+ chat_id: cbChatId,
22668
+ verb: 'voice-ondemand.sendVoice',
22669
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
22670
+ },
22671
+ )
22672
+ // Single-use on SUCCESS: strip the '🔊 Listen' keyboard so the button
22673
+ // can't be re-tapped now that the audio has been delivered. Mirrors the
22674
+ // agent-button single_use strip (keyboardIsSingleUse) house style.
22675
+ // Best-effort + non-fatal — a failed strip only leaves a replayable
22676
+ // button, never drops the delivered audio. Only reached on a successful
22677
+ // sendVoice; expiry / synth-failure / sidecar-unavailable all return
22678
+ // earlier WITHOUT stripping, so the user can retry those.
22679
+ if (cbMessageId != null) {
22680
+ await robustApiCall(
22681
+ () =>
22682
+ bot.api.editMessageReplyMarkup(cbChatId, cbMessageId, {
22683
+ reply_markup: { inline_keyboard: [] },
22684
+ }),
22685
+ {
22686
+ chat_id: cbChatId,
22687
+ verb: 'voice-ondemand.strip-listen-keyboard',
22688
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
22689
+ },
22690
+ ).catch(() => {})
22691
+ }
22692
+ } catch (err) {
22693
+ const msg = err instanceof Error ? err.message : String(err)
22694
+ process.stderr.write(
22695
+ `telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
22696
+ )
22697
+ }
22698
+ return
22699
+ }
22700
+
21547
22701
  // #271: agent-emitted inline_keyboard callbacks. Namespaced with
21548
22702
  // an `agent:` prefix in inline-keyboard-callbacks.ts so they can
21549
22703
  // round-trip without colliding with infrastructure prefixes above.
@@ -21741,6 +22895,7 @@ bot.on('callback_query:data', async ctx => {
21741
22895
  }
21742
22896
 
21743
22897
  pendingPermissions.delete(request_id)
22898
+ permCardStore.remove(request_id)
21744
22899
 
21745
22900
  // (2) Dispatch the in-flight permission verdict IMMEDIATELY — before
21746
22901
  // any host round-trip — so the turn never blocks on persistence.
@@ -21764,9 +22919,48 @@ bot.on('callback_query:data', async ctx => {
21764
22919
  action: naturalAction(details.tool_name, details.input_preview),
21765
22920
  })
21766
22921
 
21767
- // (3) Decide the persistence path. tryHostdDispatch returns
21768
- // "not-configured" when host_control is disabled or the per-agent
21769
- // socket is absent → legacy fallback.
22922
+ // (3) Ack the tap IMMEDIATELY with an honest interim status. The
22923
+ // durable persist below goes through hostd `config_propose_edit`
22924
+ // (validate→approve→apply→reconcile), which can take 5-10 minutes on
22925
+ // a busy host — the old code awaited only 60s and then reported
22926
+ // "did NOT save" even when the edit later landed. Instead: ack now,
22927
+ // run the persistence in a background continuation with a 12-min
22928
+ // dispatch window, then edit the card with the REAL outcome.
22929
+ // HTML-escape baseText — `ctx.callbackQuery.message.text` returns
22930
+ // entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
22931
+ // expanded permission card's `description` or `input_preview`
22932
+ // (claude-generated tool descriptions routinely carry these)
22933
+ // would break the HTML re-parse. PR #1158 review caught the same
22934
+ // issue on the operator-event card.
22935
+ const sourceMsg = ctx.callbackQuery?.message
22936
+ const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
22937
+ ? escapeHtmlForTg(sourceMsg.text)
22938
+ : ''
22939
+ const interimLabel =
22940
+ `⏳ **Rule applied for this session** — ${escapeHtmlForTg(agentName)} can ${escapeHtmlForTg(grantPhrase)} ` +
22941
+ `without asking for now; saving durably in background…`
22942
+ // #1150 audit: route through finalizeCallback so the keyboard
22943
+ // strips alongside the status-line edit. The in-flight verdict was
22944
+ // ALREADY dispatched above (independently of this host round-trip)
22945
+ // so the turn never blocked — finalizeCallback here only edits the
22946
+ // card; no synthInbound (would double-fire the verdict).
22947
+ await finalizeCallback(ctx, {
22948
+ ackText: 'Rule applied for this session; saving durably in background…'.slice(0, 200),
22949
+ newText: baseText ? `${baseText}\n\n${interimLabel}` : interimLabel,
22950
+ })
22951
+
22952
+ // Background continuation: decide the persistence path.
22953
+ // tryHostdDispatch returns "not-configured" when host_control is
22954
+ // disabled or the per-agent socket is absent → legacy fallback.
22955
+ // The whole body is wrapped in try/catch: a throw inside a void
22956
+ // IIFE (scheduleGrantRestart's sync fs writes, richMessage, etc.)
22957
+ // would surface as an unhandledRejection — which this gateway
22958
+ // routes through shutdown(). Pre-background, those throws were
22959
+ // contained by grammY's handler wrapper; keep that containment.
22960
+ // Known accepted gap: if the gateway restarts mid-persist, the
22961
+ // interim "saving durably in background…" card stays stale.
22962
+ void (async () => {
22963
+ try {
21770
22964
  let durable = false
21771
22965
  let legacy = false
21772
22966
  let failReason = ''
@@ -21808,8 +23002,10 @@ bot.on('callback_query:data', async ctx => {
21808
23002
  },
21809
23003
  }
21810
23004
  // config_propose_edit blocks on validate→approve→apply→reconcile,
21811
- // so allow ~60s (well past the default 5s).
21812
- const resp = await tryHostdDispatch(agentName, req, 60_000)
23005
+ // which can legitimately take 5-10 minutes — allow 12 min. Safe
23006
+ // to wait this long because we're in a background continuation:
23007
+ // the tap was already acked and the verdict already dispatched.
23008
+ const resp = await tryHostdDispatch(agentName, req, 720_000)
21813
23009
  if (resp === 'not-configured') {
21814
23010
  warnLegacySpawnIfHostdDisabled('always-allow')
21815
23011
  legacy = true
@@ -21893,23 +23089,6 @@ bot.on('callback_query:data', async ctx => {
21893
23089
  `always-allow: ${grantPhrase}`,
21894
23090
  ) !== "disabled"
21895
23091
  const liveSuffix = restartScheduled ? " — applying now (restarting to take effect)" : ""
21896
- const ackText = ok
21897
- ? (legacyNote
21898
- ? `✅ Saved. ${agentName} can now ${grantPhrase} without asking (legacy path).`
21899
- : `✅ Saved. ${agentName} can now ${grantPhrase} without asking.${liveSuffix}`)
21900
- : (editLockHint
21901
- ? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
21902
- : `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
21903
- // HTML-escape baseText — `ctx.callbackQuery.message.text` returns
21904
- // entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
21905
- // expanded permission card's `description` or `input_preview`
21906
- // (claude-generated tool descriptions routinely carry these)
21907
- // would break the HTML re-parse. PR #1158 review caught the same
21908
- // issue on the operator-event card.
21909
- const sourceMsg = ctx.callbackQuery?.message
21910
- const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
21911
- ? escapeHtmlForTg(sourceMsg.text)
21912
- : ''
21913
23092
  const editLabel = ok
21914
23093
  ? (legacyNote
21915
23094
  ? `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking (legacy path); restart agent for full effect`
@@ -21919,15 +23098,30 @@ bot.on('callback_query:data', async ctx => {
21919
23098
  : (editLockHint
21920
23099
  ? `⚠️ **Allowed for now — "always" did NOT save.** Config edits are locked; enable \`hostd.config_edit_enabled\`.`
21921
23100
  : `⚠️ **Allowed for now — "always" did NOT save.** It will ask again after restart. Check gateway log.`)
21922
- // #1150 audit: route through finalizeCallback so the keyboard
21923
- // strips alongside the status-line edit. The in-flight verdict was
21924
- // ALREADY dispatched above (independently of this host round-trip)
21925
- // so the turn never blocked — finalizeCallback here only edits the
21926
- // card; no synthInbound (would double-fire the verdict).
21927
- await finalizeCallback(ctx, {
21928
- ackText: ackText.slice(0, 200),
21929
- newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
23101
+ // Edit the card with the REAL outcome. The keyboard was already
23102
+ // stripped by the interim finalizeCallback ack above, and the
23103
+ // callback query was already answered — this is a plain edit
23104
+ // replacing the "saving durably in background…" interim line.
23105
+ await ctx.editMessageText(
23106
+ richMessage(baseText ? `${baseText}\n\n${editLabel}` : editLabel),
23107
+ {
23108
+ reply_markup: { inline_keyboard: [] },
23109
+ link_preview_options: { is_disabled: true },
23110
+ },
23111
+ ).catch((err: unknown) => {
23112
+ process.stderr.write(
23113
+ `telegram gateway: always-allow outcome card edit failed: ${(err as Error).message} (request_id=${request_id})\n`,
23114
+ )
21930
23115
  })
23116
+ } catch (err) {
23117
+ // Never let the background persist take the gateway down — an
23118
+ // unhandledRejection here becomes shutdown(). Log and move on;
23119
+ // the in-session rule was already applied at tap time.
23120
+ process.stderr.write(
23121
+ `telegram gateway: always-allow background persist threw: ${(err as Error).message} (request_id=${request_id})\n`,
23122
+ )
23123
+ }
23124
+ })()
21931
23125
  return
21932
23126
  }
21933
23127
 
@@ -21964,6 +23158,7 @@ bot.on('callback_query:data', async ctx => {
21964
23158
  : null
21965
23159
  const grantAgent = selfAgentName()
21966
23160
  pendingPermissions.delete(request_id)
23161
+ permCardStore.remove(request_id)
21967
23162
  if (timeBox && grantAgent) {
21968
23163
  recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
21969
23164
  process.stderr.write(
@@ -22001,6 +23196,25 @@ bot.on('callback_query:data', async ctx => {
22001
23196
  const baseText = msg && 'text' in msg && msg.text
22002
23197
  ? escapeHtmlForTg(msg.text)
22003
23198
  : ''
23199
+ // NO SPLIT (#card-ux): the agent-voiced "got it, continuing: <action>"
23200
+ // continuation folds into THIS card edit — below the verdict label — instead
23201
+ // of spawning a separate message. formatPermissionResumeMessage returns the
23202
+ // same GFM markdown the standalone message used, so the wording is
23203
+ // unchanged; it just rides in the card the operator already tapped. The
23204
+ // separate message went to EVERY resolvePermissionCardTargets() surface; the
23205
+ // in-place edit deliberately touches only the tapped card (a single, legible
23206
+ // card, no fan-out). The turn-resume trigger (dispatchPermissionVerdict) is
23207
+ // untouched below — only the USER-VISIBLE second message collapses. Honours
23208
+ // the SWITCHROOM_RESUME_MSG=0 kill-switch (suppresses the continuation line,
23209
+ // as it suppressed the separate message before).
23210
+ const resumeLine = process.env.SWITCHROOM_RESUME_MSG === '0'
23211
+ ? ''
23212
+ : formatPermissionResumeMessage({
23213
+ agentName: process.env.SWITCHROOM_AGENT_NAME ?? null,
23214
+ behavior: behavior as 'allow' | 'deny',
23215
+ action: resumeAction,
23216
+ })
23217
+ const labelWithResume = resumeLine ? `${htmlLabel}\n\n${resumeLine}` : htmlLabel
22004
23218
  // #1150 audit: P0 fix — was `editMessageText` WITHOUT reply_markup
22005
23219
  // strip, leaving the [Allow][Deny][Always] keyboard live after the
22006
23220
  // decision. Operator could re-tap and flip Deny → Allow after the
@@ -22008,7 +23222,7 @@ bot.on('callback_query:data', async ctx => {
22008
23222
  // strips the keyboard atomically with the status-line edit.
22009
23223
  await finalizeCallback(ctx, {
22010
23224
  ackText: ackText.slice(0, 200),
22011
- newText: baseText ? `${baseText}\n\n${htmlLabel}` : htmlLabel,
23225
+ newText: baseText ? `${baseText}\n\n${labelWithResume}` : labelWithResume,
22012
23226
  synthInbound: () => {
22013
23227
  // No `rule` → the bridge does NOT cache this (truly once on the bridge);
22014
23228
  // any 30-min stickiness lives only in scopedGrants (recorded above).
@@ -22020,10 +23234,11 @@ bot.on('callback_query:data', async ctx => {
22020
23234
  // Un-park the status reaction: 🙏 → working, re-arming the stall
22021
23235
  // watchdog that setAwaiting() suspended.
22022
23236
  resumeReactionAfterVerdict()
22023
- postPermissionResumeMessage({
22024
- behavior: behavior as 'allow' | 'deny',
22025
- action: resumeAction,
22026
- })
23237
+ // card-folded-resume: the visible "continuing…" line is folded into the
23238
+ // card edit above (labelWithResume), so we deliberately do NOT
23239
+ // postPermissionResumeMessage here — that would re-introduce the split
23240
+ // second message this fix (card-ux fix 3) removes. The turn still resumes
23241
+ // via dispatchPermissionVerdict + resumeReactionAfterVerdict above.
22027
23242
  },
22028
23243
  })
22029
23244
  })
@@ -22091,12 +23306,42 @@ bot.on('message:voice', async ctx => {
22091
23306
  // SOMETHING — better than silent drops.
22092
23307
  const access = loadAccess()
22093
23308
  const voiceIn = access.voice_in
22094
- if (voiceIn?.enabled && voiceIn?.provider === 'openai') {
22095
- const transcript = await maybeTranscribeVoice(
22096
- voice.file_id,
22097
- voice.mime_type,
22098
- voiceIn.language,
22099
- )
23309
+ // Engine selection (PR-B2): the persisted host verdict decides HOW we
23310
+ // transcribe. `local` → the in-fleet GPU sidecar (no third-party key,
23311
+ // vision #3 + #4); anything else → the OpenAI cloud provider. The local
23312
+ // path needs no `provider === 'openai'` gate — it has no API key — so we
23313
+ // route to the sidecar whenever voice_in is enabled AND the host verdict
23314
+ // is `local`. The cloud path keeps its existing openai gate.
23315
+ // Source precedence: the compose-injected SWITCHROOM_VOICE_ENGINE env
23316
+ // (set per-agent by compose-gen from the host verdict — PR-B3) wins,
23317
+ // then the persisted host-capabilities file, then a fail-safe `cloud`.
23318
+ // The env is load-bearing in-fleet: the in-container `~/.switchroom`
23319
+ // is a read-only constructed view that does NOT carry the host's
23320
+ // host-capabilities.json, so without the env the file lookup always
23321
+ // misses and every agent silently falls back to `cloud`. Narrow the
23322
+ // env value to the VoiceEngine union so a bogus value can't leak
23323
+ // through — anything but 'local'/'cloud' is ignored and we fall back.
23324
+ const envVoiceEngine = process.env.SWITCHROOM_VOICE_ENGINE
23325
+ const voiceEngine: VoiceEngine =
23326
+ envVoiceEngine === 'local' || envVoiceEngine === 'cloud'
23327
+ ? envVoiceEngine
23328
+ : loadHostCapabilities()?.voice.engine ?? 'cloud'
23329
+ const localEnabled = voiceIn?.enabled === true && voiceEngine === 'local'
23330
+ const cloudEnabled =
23331
+ voiceIn?.enabled === true && voiceEngine !== 'local' && voiceIn?.provider === 'openai'
23332
+ if (localEnabled || cloudEnabled) {
23333
+ const transcript = localEnabled
23334
+ ? await maybeTranscribeVoiceLocal(
23335
+ voice.file_id,
23336
+ voice.mime_type,
23337
+ voiceIn?.language,
23338
+ )
23339
+ : await maybeTranscribeVoice(
23340
+ voice.file_id,
23341
+ voice.mime_type,
23342
+ voiceIn?.language,
23343
+ voiceIn?.api_key,
23344
+ )
22100
23345
  if (transcript != null) {
22101
23346
  const text = ctx.message.caption
22102
23347
  ? `${ctx.message.caption}\n\n[voice transcript] ${transcript}`
@@ -22121,33 +23366,24 @@ bot.on('message:voice', async ctx => {
22121
23366
  * are logged to stderr but never thrown — voice-in is a UX
22122
23367
  * enhancement, not a critical path.
22123
23368
  */
22124
- async function maybeTranscribeVoice(
22125
- fileId: string,
22126
- mimeType: string | undefined,
22127
- language: string | undefined,
22128
- ): Promise<string | null> {
22129
- // Read API key from the operator-managed file. Same pattern as
22130
- // webhook-secrets.json — simpler than vault integration for the
22131
- // spike. Future: resolve through vault once the abstraction
22132
- // matures.
22133
- let apiKey: string | null = null
22134
- try {
22135
- const path = require('path').join(require('os').homedir(), '.switchroom', 'openai-api-key')
22136
- if (existsSync(path)) {
22137
- apiKey = readFileSync(path, 'utf-8').trim()
22138
- }
22139
- } catch (err) {
22140
- process.stderr.write(`telegram gateway: voice-in: failed to read api key: ${(err as Error).message}\n`)
22141
- return null
22142
- }
22143
- if (!apiKey) {
22144
- process.stderr.write(`telegram gateway: voice-in: enabled but no api key at ~/.switchroom/openai-api-key — falling back\n`)
22145
- return null
22146
- }
23369
+ /**
23370
+ * Filename hint for the multipart body. Telegram voice is OGG/Opus; agents
23371
+ * may also attach mp3/m4a/wav which arrive as message:audio. Match the mime
23372
+ * so the decoder (Whisper / faster-whisper) gets a usable extension.
23373
+ */
23374
+ function voiceFilenameExt(mimeType: string | undefined): string {
23375
+ return mimeType?.includes('mp3') ? 'mp3'
23376
+ : mimeType?.includes('m4a') ? 'm4a'
23377
+ : mimeType?.includes('wav') ? 'wav'
23378
+ : 'ogg'
23379
+ }
22147
23380
 
22148
- // Download the audio bytes from Telegram. Same shape as
22149
- // executeDownloadAttachment but in-memory rather than to disk.
22150
- let audioBytes: Uint8Array
23381
+ /**
23382
+ * Download a Telegram voice attachment into memory. Shared by the cloud
23383
+ * (Whisper) and local (sidecar) transcription paths. Returns the bytes on
23384
+ * success, or null on any failure (caller falls back). Never throws.
23385
+ */
23386
+ async function downloadVoiceBytes(fileId: string): Promise<Uint8Array | null> {
22151
23387
  try {
22152
23388
  const file = await bot.api.getFile(fileId)
22153
23389
  if (!file.file_path) {
@@ -22160,7 +23396,7 @@ async function maybeTranscribeVoice(
22160
23396
  process.stderr.write(`telegram gateway: voice-in: telegram download HTTP ${res.status}\n`)
22161
23397
  return null
22162
23398
  }
22163
- audioBytes = new Uint8Array(await res.arrayBuffer())
23399
+ return new Uint8Array(await res.arrayBuffer())
22164
23400
  } catch (err) {
22165
23401
  // Sanitize: never let the bot token leak into log lines via the
22166
23402
  // download URL — strip anything that looks like a token.
@@ -22168,19 +23404,78 @@ async function maybeTranscribeVoice(
22168
23404
  process.stderr.write(`telegram gateway: voice-in: download failed: ${msg}\n`)
22169
23405
  return null
22170
23406
  }
23407
+ }
22171
23408
 
22172
- // Filename hint — Telegram voice is OGG/Opus; agents may also
22173
- // attach mp3/m4a/etc which arrive as message:audio (handled
22174
- // separately). Match the mime to give Whisper a usable extension.
22175
- const ext = mimeType?.includes('mp3') ? 'mp3'
22176
- : mimeType?.includes('m4a') ? 'm4a'
22177
- : mimeType?.includes('wav') ? 'wav'
22178
- : 'ogg'
23409
+ /**
23410
+ * Transcribe a Telegram voice note via the LOCAL GPU STT sidecar (PR-B2).
23411
+ * Used when the host voice verdict is `local`. Resolves the shared-secret
23412
+ * token from the vault (voice/sidecar-token), downloads the audio, and
23413
+ * POSTs it to the sidecar at loopback (the gateway is network_mode: host).
23414
+ * Returns the transcript on success, null on any failure (caller falls
23415
+ * back to the legacy "(voice message)" envelope). Never throws.
23416
+ */
23417
+ async function maybeTranscribeVoiceLocal(
23418
+ fileId: string,
23419
+ mimeType: string | undefined,
23420
+ language: string | undefined,
23421
+ ): Promise<string | null> {
23422
+ const token = await materializeSidecarToken()
23423
+ if (!token) {
23424
+ // materializeSidecarToken already logged the specific reason.
23425
+ return null
23426
+ }
23427
+
23428
+ const audioBytes = await downloadVoiceBytes(fileId)
23429
+ if (!audioBytes) return null
23430
+
23431
+ const result = await transcribeViaSidecar({
23432
+ token,
23433
+ audio: audioBytes,
23434
+ filename: `voice.${voiceFilenameExt(mimeType)}`,
23435
+ language,
23436
+ })
23437
+
23438
+ if (!result.ok) {
23439
+ process.stderr.write(
23440
+ `telegram gateway: voice-in: local sidecar transcription failed reason=${result.reason}` +
23441
+ (result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
23442
+ '\n',
23443
+ )
23444
+ return null
23445
+ }
23446
+
23447
+ process.stderr.write(
23448
+ `telegram gateway: voice-in: local sidecar transcribed ${audioBytes.length} bytes in ${result.durationMs}ms ` +
23449
+ `lang=${result.language ?? '?'} audio_s=${result.audioSeconds ?? '?'} chars=${result.text.length}\n`,
23450
+ )
23451
+ return result.text
23452
+ }
23453
+
23454
+ async function maybeTranscribeVoice(
23455
+ fileId: string,
23456
+ mimeType: string | undefined,
23457
+ language: string | undefined,
23458
+ apiKeyRef: string | undefined,
23459
+ ): Promise<string | null> {
23460
+ // Resolve the STT key through the vault broker at use-time (PR-A:
23461
+ // voice STT vault-unify). The configured `voice_in.api_key` is a
23462
+ // `vault:<key>` reference (default `vault:openai/api-key`); the
23463
+ // resolved value is held in memory only — never written to disk or
23464
+ // surfaced into the agent prompt. On any failure we return null and
23465
+ // the caller falls back to the legacy "(voice message)" envelope.
23466
+ const apiKey = await materializeVoiceKey({ apiKeyRef })
23467
+ if (!apiKey) {
23468
+ // materializeVoiceKey already logged the specific reason.
23469
+ return null
23470
+ }
23471
+
23472
+ const audioBytes = await downloadVoiceBytes(fileId)
23473
+ if (!audioBytes) return null
22179
23474
 
22180
23475
  const result = await transcribeViaWhisper({
22181
23476
  apiKey,
22182
23477
  audio: audioBytes,
22183
- filename: `voice.${ext}`,
23478
+ filename: `voice.${voiceFilenameExt(mimeType)}`,
22184
23479
  language,
22185
23480
  })
22186
23481
 
@@ -22627,6 +23922,66 @@ bot.on('message:checklist_tasks_added' as Parameters<typeof bot.on>[0], (ctx) =>
22627
23922
  handleChecklistUpdate(ctx as unknown as Context, 'checklist_tasks_added')
22628
23923
  })
22629
23924
 
23925
+ // Suppress the "pinned a message" service message Telegram inserts when OUR
23926
+ // silent status-pin fires. The pin call passes `disable_notification: true`,
23927
+ // which kills the PUSH notification but NOT the in-chat service message — so
23928
+ // delete that service message as it lands, but ONLY for pins we own (tracked
23929
+ // in `statusPinState`). Manual/operator pins are never silent and are never
23930
+ // touched. Only silent status pins reach here as an OUR-pin match, so the
23931
+ // ownership check is the guard.
23932
+ //
23933
+ // Race tolerance: the service update can arrive before `reconcileStatusPin`
23934
+ // has stored the new PinState (the pin API call resolves, Telegram emits the
23935
+ // service message, and only then does the reconcile write the Map). A single
23936
+ // short retry covers that window; if it's still not one of ours, we leave the
23937
+ // service message alone.
23938
+ bot.on('message:pinned_message', async ctx => {
23939
+ const pinnedId = ctx.msg.pinned_message?.message_id
23940
+ if (pinnedId == null) return
23941
+ const chatId = String(ctx.chat.id)
23942
+ const serviceMsgId = ctx.msg.message_id
23943
+
23944
+ // Chat-scoped ownership (see pinnedMessageIsOurs): the match requires BOTH
23945
+ // the messageId AND that the tracked entry lives in THIS chat, so a pin id
23946
+ // colliding across chats can't delete a foreign (e.g. operator-manual) pin
23947
+ // notice. statusPinChatIds is the companion pinKey→chatId map written on
23948
+ // every desired-pinned reconcile.
23949
+ const trackedPins = (): TrackedStatusPin[] => {
23950
+ const out: TrackedStatusPin[] = []
23951
+ for (const [pinKey, state] of statusPinState) {
23952
+ const c = statusPinChatIds.get(pinKey)
23953
+ if (c != null) out.push({ chatId: c, messageId: state.messageId })
23954
+ }
23955
+ return out
23956
+ }
23957
+ const isOurs = () => pinnedMessageIsOurs(trackedPins(), chatId, pinnedId)
23958
+
23959
+ if (!isOurs()) {
23960
+ // Tolerate the reconcile-store race: wait briefly, then re-check once.
23961
+ await new Promise(resolve => setTimeout(resolve, 250))
23962
+ if (!isOurs()) return
23963
+ }
23964
+
23965
+ try {
23966
+ await robustApiCall(
23967
+ () => lockedBot.api.deleteMessage(chatId, serviceMsgId),
23968
+ { chat_id: chatId, verb: 'status-pin.delete-service-message' },
23969
+ )
23970
+ } catch (err) {
23971
+ // Best-effort: a failure to delete the service message is cosmetic only —
23972
+ // the "pinned a message" line just stays. The most likely cause in a
23973
+ // supergroup/forum is the bot lacking can_delete_messages admin right, so
23974
+ // surface a concise one-liner (robustApiCall rethrows this case without
23975
+ // logging a reason) rather than swallowing silently — an operator sees WHY.
23976
+ const msg = err instanceof Error ? err.message : String(err)
23977
+ process.stderr.write(
23978
+ `telegram gateway: status-pin: could not delete pin service message ` +
23979
+ `(chat=${chatId} msg=${serviceMsgId}) — likely missing can_delete_messages ` +
23980
+ `admin right in this chat: ${msg}\n`,
23981
+ )
23982
+ }
23983
+ })
23984
+
22630
23985
  // ─── Reaction-trigger runtime state (#1074) ──────────────────────────────
22631
23986
  //
22632
23987
  // Bot-message reactions in the configured allowlist trigger a synthetic
@@ -23562,6 +24917,40 @@ void (async () => {
23562
24917
  // tracks the live turn from there.
23563
24918
  try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
23564
24919
 
24920
+ // Strip stale permission cards from prior gateway session. Any entry
24921
+ // still in the store was never resolved (gateway died before the
24922
+ // operator tapped or the reaper ran). The operator might have seen
24923
+ // those cards and tapped them — if so, they got STALE_TAP_NOTICE
24924
+ // ("already resolved") which is misleading. Edit the messages to
24925
+ // remove the keyboard and show a clear "restarted" notice instead.
24926
+ void (async () => {
24927
+ const stale = permCardStore.loadAll()
24928
+ if (stale.length === 0) return
24929
+ process.stderr.write(
24930
+ `telegram gateway: boot-sweep: stripping ${stale.length} stale permission card(s) from prior gateway session\n`,
24931
+ )
24932
+ for (const card of stale) {
24933
+ const toolLabel = card.toolName ?? 'unknown tool'
24934
+ const notice = `🔒 **${toolLabel}**\n\n⚠️ *Gateway restarted — this request is no longer active. Ask your agent to try again if needed.*`
24935
+ try {
24936
+ // allow-raw-bot-api: targeted by message_id; no thread needed; fire-and-forget boot sweep
24937
+ await bot.api.editMessageText(
24938
+ card.chatId,
24939
+ card.messageId,
24940
+ richMessage(notice),
24941
+ { reply_markup: { inline_keyboard: [] } },
24942
+ )
24943
+ } catch (err) {
24944
+ // Card may already be deleted, edited, or in an inaccessible chat — benign
24945
+ process.stderr.write(
24946
+ `telegram gateway: boot-sweep: stale-card strip failed ` +
24947
+ `${card.chatId}:${card.messageId}: ${(err as Error).message}\n`,
24948
+ )
24949
+ }
24950
+ }
24951
+ permCardStore.clear()
24952
+ })()
24953
+
23565
24954
  // Boot-time pin sweep
23566
24955
  try {
23567
24956
  const bootAccess = loadAccess()
@@ -24147,6 +25536,8 @@ void (async () => {
24147
25536
  elapsedMs: durationMs,
24148
25537
  state: outcome === 'failed' ? 'failed' : 'done',
24149
25538
  })
25539
+ // Status-pin: worker done — drop its pin.
25540
+ reconcileWorkerPin(agentId, null, false)
24150
25541
  }
24151
25542
  return
24152
25543
  }
@@ -24164,6 +25555,8 @@ void (async () => {
24164
25555
  elapsedMs: durationMs,
24165
25556
  state: outcome === 'failed' ? 'failed' : 'done',
24166
25557
  })
25558
+ // Status-pin: worker done — drop its pin.
25559
+ reconcileWorkerPin(agentId, null, false)
24167
25560
  }
24168
25561
 
24169
25562
  const handbackOrigin = resolveSubagentOriginChat(agentId)
@@ -24291,9 +25684,10 @@ void (async () => {
24291
25684
  })
24292
25685
  if (surface === 'worker-feed') {
24293
25686
  const origin = resolveSubagentOriginChat(agentId)
25687
+ const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
24294
25688
  void workerActivityFeed?.update(
24295
25689
  agentId,
24296
- origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
25690
+ wkChat,
24297
25691
  {
24298
25692
  description: dispatch.feedDescription,
24299
25693
  lastTool,
@@ -24303,7 +25697,7 @@ void (async () => {
24303
25697
  state: 'running',
24304
25698
  },
24305
25699
  origin?.threadId,
24306
- )
25700
+ )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
24307
25701
  return
24308
25702
  }
24309
25703
  if (surface !== 'nest') return // 'skip' — orphan-status off
@@ -24425,9 +25819,10 @@ void (async () => {
24425
25819
  // is gone — see resolveSubagentOriginChat).
24426
25820
  if (workerFeedEnabled) {
24427
25821
  const origin = resolveSubagentOriginChat(agentId)
25822
+ const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
24428
25823
  void workerActivityFeed?.update(
24429
25824
  agentId,
24430
- origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
25825
+ wkChat,
24431
25826
  {
24432
25827
  description: dispatch.feedDescription,
24433
25828
  lastTool,
@@ -24437,7 +25832,7 @@ void (async () => {
24437
25832
  state: 'running',
24438
25833
  },
24439
25834
  origin?.threadId,
24440
- )
25835
+ )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
24441
25836
  return
24442
25837
  }
24443
25838