switchroom 0.16.28 → 0.16.38

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 (93) hide show
  1. package/dist/agent-scheduler/index.js +13 -2
  2. package/dist/auth-broker/index.js +13 -2
  3. package/dist/cli/notion-write-pretool.mjs +13 -2
  4. package/dist/cli/switchroom.js +60648 -59800
  5. package/dist/host-control/main.js +730 -18
  6. package/dist/vault/approvals/kernel-server.js +51 -37
  7. package/dist/vault/broker/server.js +72 -58
  8. package/package.json +2 -1
  9. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  10. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  11. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  12. package/telegram-plugin/card-format.ts +52 -2
  13. package/telegram-plugin/credits-watch.ts +4 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
  15. package/telegram-plugin/dist/server.js +0 -1
  16. package/telegram-plugin/format.ts +264 -7
  17. package/telegram-plugin/gateway/approval-card.ts +7 -14
  18. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  19. package/telegram-plugin/gateway/auth-command.ts +35 -38
  20. package/telegram-plugin/gateway/boot-card.ts +7 -2
  21. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  22. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  23. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  24. package/telegram-plugin/gateway/gateway.ts +1293 -97
  25. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  27. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  28. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  29. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  31. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  32. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  33. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  34. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  35. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  36. package/telegram-plugin/history.ts +17 -7
  37. package/telegram-plugin/idle-footer.ts +2 -2
  38. package/telegram-plugin/issues-card.ts +12 -7
  39. package/telegram-plugin/model-unavailable.ts +3 -6
  40. package/telegram-plugin/operator-events.ts +4 -6
  41. package/telegram-plugin/quota-check.ts +2 -2
  42. package/telegram-plugin/quota-watch.ts +7 -10
  43. package/telegram-plugin/server.ts +3 -1
  44. package/telegram-plugin/status-pin-driver.ts +102 -0
  45. package/telegram-plugin/status-pin.ts +76 -0
  46. package/telegram-plugin/stream-reply-handler.ts +14 -1
  47. package/telegram-plugin/subagent-watcher.ts +6 -3
  48. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  49. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  51. package/telegram-plugin/tests/card-format.test.ts +51 -0
  52. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  53. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  54. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  55. package/telegram-plugin/tests/history.test.ts +38 -2
  56. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  57. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  58. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  59. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  60. package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
  61. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  62. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  63. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  64. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  65. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  66. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  68. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  69. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  70. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  71. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  72. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  73. package/telegram-plugin/tests/telegram-format.test.ts +88 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
  75. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  76. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  77. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  78. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  79. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  80. package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
  81. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  82. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  83. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  84. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  85. package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
  86. package/telegram-plugin/tool-activity-summary.ts +11 -8
  87. package/telegram-plugin/uat/driver.ts +8 -1
  88. package/telegram-plugin/voice-normalize-text.ts +340 -0
  89. package/telegram-plugin/voice-ondemand.ts +138 -0
  90. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  91. package/telegram-plugin/voice-synthesize.ts +128 -0
  92. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  93. package/telegram-plugin/worker-activity-feed.ts +13 -4
@@ -48,6 +48,26 @@ 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
+
66
+ /** Fleet default TTS speed when voice_out is enabled but speed is unset —
67
+ * slightly brisker than neutral (Ken's chosen default). */
68
+ const VOICE_OUT_DEFAULT_SPEED = 1.1
69
+ import { normalizeForSpeech } from '../voice-normalize-text.js'
70
+ import { synthesizeViaOpenAi } from '../voice-synthesize.js'
51
71
  import {
52
72
  createTelegraphAccount,
53
73
  createTelegraphPage,
@@ -65,6 +85,9 @@ import {
65
85
  import { StatusReactionController } from '../status-reactions.js'
66
86
  import { DeferredDoneReactions } from '../reaction-defer.js'
67
87
  import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
88
+ import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
89
+ import type { PinState, DesiredPin } from '../status-pin.js'
90
+ import { decidePinAction } from '../status-pin.js'
68
91
  import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
69
92
  import { parseSourceMessageId } from './source-message-id.js'
70
93
  import {
@@ -73,11 +96,14 @@ import {
73
96
  duplicateDenyMessage,
74
97
  isRecentTimeoutDuplicate,
75
98
  PERMISSION_TTL_MS,
99
+ approvalTtlMs,
76
100
  ttlForTool,
77
101
  buildTimedOutCardEdits,
78
102
  STALE_TAP_NOTICE,
79
103
  type PermissionCardRef,
80
104
  } from './permission-timeout.js'
105
+ import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
106
+ import { createPermissionCardStore } from './permission-card-store.js'
81
107
  import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
82
108
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
83
109
  import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, type SessionActivityHeader } from '../tool-activity-summary.js'
@@ -140,6 +166,10 @@ import {
140
166
  runMicrosoftConnectPoll,
141
167
  } from './microsoft-connect-flow.js'
142
168
  import { resolveAuthBrokerSocketPath } from '../../src/auth/broker/client.js'
169
+ import { materializeVoiceKey } from '../../src/telegram/materialize-voice-key.js'
170
+ import { materializeSidecarToken } from '../../src/telegram/materialize-sidecar-token.js'
171
+ import { loadHostCapabilities } from '../../src/setup/host-capabilities.js'
172
+ import type { VoiceEngine } from '../../src/setup/gpu-detect.js'
143
173
  import { createFleetFallbackGate } from '../fleet-fallback-gate.js'
144
174
  import { createFleetFallbackResumeGate } from '../fleet-fallback-resume.js'
145
175
  import { resolveExhaustUntil } from './exhaust-until.js'
@@ -195,7 +225,7 @@ const REPLY_TO_TEXT_MAX = 200
195
225
  const SILENT_END_FALLBACK_TEXT =
196
226
  '⚠️ The agent finished working but didn’t send a reply — your last ' +
197
227
  'message may not have been answered. Please try asking again.'
198
- import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
228
+ import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
199
229
  import { richMessage } from '../rich-send.js'
200
230
  import { scrubVoice } from '../text-voice-scrub.js'
201
231
  import {
@@ -341,6 +371,15 @@ import {
341
371
  obligationEscalationText,
342
372
  } from './obligation-ledger.js'
343
373
  import { loadObligations, persistObligations } from './obligation-store.js'
374
+ import {
375
+ loadStatusPins,
376
+ pinnedMessageIsOurs,
377
+ reconcileAndPersistStatusPin,
378
+ runStatusPinBootCleanup,
379
+ type PersistedStatusPin,
380
+ type StatusPinPersistOp,
381
+ type TrackedStatusPin,
382
+ } from './status-pin-store.js'
344
383
  import { driveEscalation } from './escalation-drive.js'
345
384
  import { shouldSuppressRepresent } from './represent-guard.js'
346
385
  import { createInboundSpool } from './inbound-spool.js'
@@ -362,6 +401,9 @@ import {
362
401
  } from './emission-authority.js'
363
402
  import { CurrentTurnMap } from './current-turn-map.js'
364
403
  import { resolveAnswerThreadId } from './answer-thread-resolve.js'
404
+ import { resolveChatIdFallback } from './chat-id-fallback.js'
405
+ import { decideObligationTurnEnd } from './obligation-turn-end.js'
406
+ import { maybeRotate } from './turns-jsonl-rotate.js'
365
407
  import {
366
408
  createDeliveryQueue,
367
409
  trackDelivery,
@@ -385,6 +427,7 @@ import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-d
385
427
  import { maybeFireWarmup } from './prefix-warmup.js'
386
428
  import {
387
429
  buildVaultGrantApprovedInbound,
430
+ buildVaultGrantApprovedCardText,
388
431
  buildVaultGrantDeniedInbound,
389
432
  buildVaultSaveCompletedInbound,
390
433
  buildVaultSaveFailedInbound,
@@ -428,6 +471,8 @@ import type {
428
471
  QuotaWallDetectedMessage,
429
472
  PostSkillProposalMessage,
430
473
  PermissionEvent,
474
+ RolloutStatusPostMessage,
475
+ RolloutStatusEditMessage,
431
476
  } from './ipc-protocol.js'
432
477
  import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
433
478
  import { buildReactionDispatchInbound, evaluateReactionDispatch, resolveReactionDispatchConfig, type ReactionDispatchResolvedConfig } from './reaction-dispatch.js'
@@ -599,6 +644,7 @@ process.on('beforeExit', () => {
599
644
 
600
645
  // ─── Env + state dir ──────────────────────────────────────────────────────
601
646
  const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
647
+ const permCardStore = createPermissionCardStore(STATE_DIR)
602
648
  const ACCESS_FILE = join(STATE_DIR, 'access.json')
603
649
  const APPROVED_DIR = join(STATE_DIR, 'approved')
604
650
  const ENV_FILE = join(STATE_DIR, '.env')
@@ -950,12 +996,42 @@ type Access = {
950
996
  /** Voice-in transcription config (#578 spike). When `enabled` is
951
997
  * true and provider is 'openai', inbound voice/audio messages are
952
998
  * 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. */
999
+ * user's inbound text. The provider API key is a `vault:` reference
1000
+ * (`api_key`, default `vault:openai/api-key`) resolved through the
1001
+ * vault broker at use-time — never read from a plaintext file. Off by
1002
+ * default. */
955
1003
  voice_in?: {
956
1004
  enabled?: boolean
957
1005
  provider?: 'openai'
958
1006
  language?: string
1007
+ api_key?: string
1008
+ }
1009
+ /** Outbound spoken replies via TTS (PR-C2). When enabled, the agent's
1010
+ * text reply is synthesized into an OGG/Opus voice note and sent
1011
+ * alongside (or instead of) the text, per `reply_mode`. The 'kokoro'
1012
+ * engine uses the local voice sidecar (POST /tts) and is only active
1013
+ * when the host voice verdict is local (SWITCHROOM_VOICE_ENGINE ===
1014
+ * 'local'); 'openai' is an honest-exception cloud path gated on an
1015
+ * `api_key` vault ref. Voice is best-effort and fully non-fatal — any
1016
+ * TTS error falls back to the text reply. ONE voice note per response:
1017
+ * the reply is speech-normalized (markdown/symbols stripped) and, for the
1018
+ * kokoro sidecar, sent in a SINGLE /tts call that returns one concatenated
1019
+ * file. Off by default. */
1020
+ voice_out?: {
1021
+ enabled?: boolean
1022
+ engine?: 'kokoro' | 'openai'
1023
+ voice?: string
1024
+ reply_mode?: 'voice+text' | 'voice-only' | 'on-demand'
1025
+ /** Kokoro-engine playback speed. Clamped to 0.5–2.0; defaults to 1.1
1026
+ * when voice_out is enabled but speed is unset (the fleet default —
1027
+ * slightly brisker than neutral). Ignored by the OpenAI path. */
1028
+ speed?: number
1029
+ /** OpenAI-engine only: per-voice-note chunk size (default 600) used to
1030
+ * split a long reply across sequential notes, since OpenAI's TTS input
1031
+ * has a hard cap. Ignored by the kokoro path, which sends the whole
1032
+ * reply in one call (the sidecar owns length + concatenation). */
1033
+ max_chars?: number
1034
+ api_key?: string
959
1035
  }
960
1036
  /** Telegraph long-reply publishing (#579). When enabled, replies
961
1037
  * above `threshold` chars publish to Telegraph and the agent's
@@ -1049,6 +1125,7 @@ function readAccessFile(): Access {
1049
1125
  // `access.telegraph`, `access.stickers`) silently see undefined.
1050
1126
  stickers: parsed.stickers,
1051
1127
  voice_in: parsed.voice_in,
1128
+ voice_out: parsed.voice_out,
1052
1129
  telegraph: parsed.telegraph,
1053
1130
  }
1054
1131
  } catch (err) {
@@ -3206,6 +3283,19 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
3206
3283
  // successor turn; the timer's own turnId match is the second guard, this is
3207
3284
  // the first. Idempotent — a no-op when no timer is registered.
3208
3285
  stopEarlyLivenessOpen(key as string)
3286
+ // Status-pin: turn end is the canonical unpin point for the foreground
3287
+ // pin. `purgeReactionTracking` is the single turn-end owner (all normal /
3288
+ // abnormal exit branches funnel here), so this is the one place the
3289
+ // foreground status pin is dropped. Idempotent: a no-op when nothing was
3290
+ // pinned (trivial turn that never opened a status message). The
3291
+ // drop-on-unpin contract in reconcilePin guarantees state clears even if
3292
+ // the unpin API throws — a stuck pin can never outlive its turn.
3293
+ {
3294
+ const pinChatId = endingTurn != null
3295
+ ? endingTurn.sessionChatId
3296
+ : chatIdOfChatKey(key as _ChatKey)
3297
+ void reconcileStatusPin(`fg:${key}`, pinChatId, { pinned: false })
3298
+ }
3209
3299
  if (msgInfo) {
3210
3300
  const agentDir = resolveAgentDirFromEnv()
3211
3301
  if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
@@ -3401,7 +3491,20 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
3401
3491
  status: turn.finalAnswerDelivered ? 'complete' : 'no_reply',
3402
3492
  turn_id: turn.turnId,
3403
3493
  }) + '\n'
3404
- appendFileSync('/state/agent/turns.jsonl', rec)
3494
+ const turnsPath = '/state/agent/turns.jsonl'
3495
+ // Size-cap rotation: keep at most one rotated generation so the file can't
3496
+ // grow unbounded on a long-lived agent. Best-effort (never throws).
3497
+ maybeRotate(turnsPath, {
3498
+ statSize: (p) => {
3499
+ try {
3500
+ return statSync(p).size
3501
+ } catch {
3502
+ return undefined
3503
+ }
3504
+ },
3505
+ rename: (from, to) => renameSync(from, to),
3506
+ })
3507
+ appendFileSync(turnsPath, rec)
3405
3508
  } catch {
3406
3509
  // best-effort — never let metrics emission break turn teardown
3407
3510
  }
@@ -3457,7 +3560,7 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
3457
3560
  // At turn_end with replyCalled=true the model explicitly signalled completion
3458
3561
  // AND replied, so the obligation is satisfied regardless of finalAnswerDelivered.
3459
3562
  if (OBLIGATION_LEDGER_ENABLED) {
3460
- if (turn.finalAnswerDelivered || turn.replyCalled) {
3563
+ if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === 'close') {
3461
3564
  obligationLedger.close(turn.turnId)
3462
3565
  } else {
3463
3566
  // Turn ended WITHOUT any reply (no ack, no answer). If this turn was
@@ -4671,7 +4774,10 @@ interface PendingVaultRequestSave {
4671
4774
  staged_at: number
4672
4775
  }
4673
4776
  const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
4674
- const VAULT_REQUEST_SAVE_TTL_MS = 10 * 60 * 1000
4777
+ // Gateway-side reap window for a staged vault-save card. Tracks the operator
4778
+ // approval-card lifetime (config-driven, 60-min default) so the reap never
4779
+ // races ahead of the card the operator is still looking at.
4780
+ const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
4675
4781
  function sweepPendingVaultRequestSaves(): void {
4676
4782
  const cutoff = Date.now() - VAULT_REQUEST_SAVE_TTL_MS
4677
4783
  for (const [k, v] of pendingVaultRequestSaves) {
@@ -4716,7 +4822,9 @@ interface PendingVaultRequestAccess {
4716
4822
  staged_at: number
4717
4823
  }
4718
4824
  const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
4719
- const VAULT_REQUEST_ACCESS_TTL_MS = 10 * 60 * 1000
4825
+ // Gateway-side reap window for a staged vault-access card. Tracks the operator
4826
+ // approval-card lifetime (config-driven, 60-min default) — see approvalTtlMs.
4827
+ const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
4720
4828
  function sweepPendingVaultRequestAccesses(): void {
4721
4829
  const cutoff = Date.now() - VAULT_REQUEST_ACCESS_TTL_MS
4722
4830
  for (const [k, v] of pendingVaultRequestAccesses) {
@@ -5024,6 +5132,7 @@ const pendingStateReaper = setInterval(() => {
5024
5132
  `${timeoutMinutes}m)\n`,
5025
5133
  )
5026
5134
  pendingPermissions.delete(k)
5135
+ permCardStore.remove(k)
5027
5136
  }
5028
5137
  }
5029
5138
  // Drop no-repeat suppression entries past the safety-cap window (the primary
@@ -5481,6 +5590,273 @@ const CLEAR_STATUS_ON_COMPLETION = (() => {
5481
5590
  return v === '1' || v === 'true' || v === 'on' || v === 'yes'
5482
5591
  })()
5483
5592
 
5593
+ // Whether to SILENTLY pin the already-rendered status message while its work
5594
+ // is in-flight (auto-unpinned on completion). Default ON (silent + low-risk):
5595
+ // keeps in-flight work in view when the conversation scrolls past it. This is
5596
+ // the ONE sanctioned pin under `chat-is-the-single-source-of-truth` — it pins
5597
+ // a message the chat ALREADY rendered (the per-turn activity/status message,
5598
+ // the `🛠 Worker` message), never a new parallel surface, never with a device
5599
+ // buzz. Opt OUT per agent via channels.telegram.pin_status_while_working: false
5600
+ // (→ this env). See status-pin.ts / status-pin-driver.ts and
5601
+ // `reference/invariants.md` § chat-is-the-single-source-of-truth.
5602
+ const PIN_STATUS_WHILE_WORKING = (() => {
5603
+ const raw = process.env.SWITCHROOM_TG_PIN_STATUS_WHILE_WORKING
5604
+ if (raw == null) return true // default ON
5605
+ const v = raw.trim().toLowerCase()
5606
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
5607
+ })()
5608
+
5609
+ // ─── Status-pin: single-owner state + reconcile ────────────────────────────
5610
+ // EXACTLY ONE owner of pin state. Every desired-state change for a key routes
5611
+ // through `reconcileStatusPin`, which computes ONE action (pin|unpin|noop) via
5612
+ // the pure `decidePinAction` and executes it through `reconcilePin` (which
5613
+ // drops the claim on unpin even if the API throws — so pin state can never get
5614
+ // stuck). Two desired-state sources feed distinct keys:
5615
+ // - foreground: `fg:<statusKey>` — pinned while the per-turn activity message
5616
+ // is in-flight; unpinned at the canonical turn-end (purgeReactionTracking).
5617
+ // - background: `wk:<agentId>` — pinned while the `🛠 Worker` message runs;
5618
+ // unpinned on that worker's completion (worker-feed finish).
5619
+ // It does NOT touch the reply / stream_reply send handlers (the v1 bug was send
5620
+ // handlers unconditionally unpinning on every send) and runs NO polling
5621
+ // watchdog / getChat().pinned_message reconciler.
5622
+ const statusPinState = new Map<string, PinState>()
5623
+ // Companion registry: pinKey → chatId, so the pre-restart sweep can unpin
5624
+ // owned pins without threading the chat id through every call site. Written on
5625
+ // every desired-pinned reconcile, cleared alongside the state on unpin.
5626
+ const statusPinChatIds = new Map<string, string>()
5627
+
5628
+ // Durable snapshot of the pin claim set on the persistent per-agent volume
5629
+ // (STATE_DIR = /state/agent/telegram in prod). Closes the crash hole: the
5630
+ // in-memory Maps alone empty on restart, so a status pin left dangling by a
5631
+ // crashed session is never unpinned and the service-message-deletion handler
5632
+ // can't recognise it as ours. Every reconcile persists the current claim set;
5633
+ // boot cleanup (statusPinBootCleanup, wired after lockedBot is defined) unpins
5634
+ // each persisted entry and clears the store. STATIC mode and feature-off skip
5635
+ // disk. Mirrors obligation-store.ts.
5636
+ const STATUS_PIN_STORE_PATH = join(STATE_DIR, 'status-pins.json')
5637
+ const statusPinStoreFs = {
5638
+ readFileSync: (p: string) => readFileSync(p, 'utf8'),
5639
+ writeFileSync: (p: string, d: string) => writeFileSync(p, d),
5640
+ renameSync: (a: string, b: string) => renameSync(a, b),
5641
+ existsSync: (p: string) => existsSync(p),
5642
+ }
5643
+ const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
5644
+
5645
+ // The full live claim set as persisted rows (confirmed pins), from the Maps.
5646
+ function snapshotStatusPins(): PersistedStatusPin[] {
5647
+ const snapshot: PersistedStatusPin[] = []
5648
+ for (const [pinKey, state] of statusPinState) {
5649
+ const chatId = statusPinChatIds.get(pinKey)
5650
+ if (chatId == null) continue
5651
+ snapshot.push({ pinKey, chatId, messageId: state.messageId })
5652
+ }
5653
+ return snapshot
5654
+ }
5655
+
5656
+ // The live claim set EXCLUDING one key — used by reconcileAndPersistStatusPin so
5657
+ // it can rewrite the whole set atomically while it flips that one key's record
5658
+ // between pending / confirmed / absent.
5659
+ function snapshotStatusPinsExcept(exceptKey: string): PersistedStatusPin[] {
5660
+ return snapshotStatusPins().filter((p) => p.pinKey !== exceptKey)
5661
+ }
5662
+
5663
+ // The Bot API surface the pin driver needs. `lockedBot` is defined later; wrap
5664
+ // lazily so this helper can be declared alongside the state it owns.
5665
+ function statusPinApi(): PinBotApi {
5666
+ return {
5667
+ pinChatMessage: (chat_id, message_id, opts) =>
5668
+ robustApiCall(
5669
+ () => lockedBot.api.pinChatMessage(chat_id, message_id, opts),
5670
+ { chat_id: String(chat_id), verb: 'status-pin.pin' },
5671
+ ),
5672
+ unpinChatMessage: (chat_id, message_id) =>
5673
+ robustApiCall(
5674
+ () => lockedBot.api.unpinChatMessage(chat_id, message_id),
5675
+ { chat_id: String(chat_id), verb: 'status-pin.unpin' },
5676
+ ),
5677
+ }
5678
+ }
5679
+
5680
+ /**
5681
+ * Boot-time orphan cleanup — thin gateway wrapper over the pure
5682
+ * `runStatusPinBootCleanup` (which owns the load → best-effort-unpin →
5683
+ * empty-store contract). Binds the live fs seam, the robust unpin api, and the
5684
+ * gateway logger.
5685
+ *
5686
+ * MUST run ONLY after this gateway wins the startup mutex (the store is a
5687
+ * shared per-agent file; a losing double-boot would unpin the live holder's
5688
+ * legitimate pins). Runs before any new pin claim is written, so the emptied
5689
+ * store leaves the service-message handler's ownership check with no false
5690
+ * positives.
5691
+ */
5692
+ async function statusPinBootCleanup(): Promise<void> {
5693
+ if (!statusPinPersistEnabled) return
5694
+ const api = statusPinApi()
5695
+ const { cleared, total } = await runStatusPinBootCleanup({
5696
+ path: STATUS_PIN_STORE_PATH,
5697
+ fs: statusPinStoreFs,
5698
+ unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
5699
+ })
5700
+ if (total > 0) {
5701
+ process.stderr.write(
5702
+ `telegram gateway: status-pin: cleared ${cleared}/${total} ` +
5703
+ `orphaned pin(s) from a prior session\n`,
5704
+ )
5705
+ }
5706
+ }
5707
+ // NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
5708
+ // The status-pin store is a SHARED per-agent file, and cleanup issues real
5709
+ // unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
5710
+ // that shared state — its unpins would strip pins the STILL-ALIVE holder
5711
+ // legitimately owns. So the call is gated on winning the startup mutex
5712
+ // (top-level await below); see the acquireStartupLock block.
5713
+
5714
+ /**
5715
+ * The single entry point that mutates pin state for `pinKey`. Serialises
5716
+ * nothing itself — callers fire-and-forget; the pure decision + drop-on-unpin
5717
+ * contract keep state consistent. No-op when the feature is disabled.
5718
+ */
5719
+ async function reconcileStatusPin(
5720
+ pinKey: string,
5721
+ chatId: string,
5722
+ desired: DesiredPin,
5723
+ ): Promise<void> {
5724
+ // Fire-and-forget hard boundary. Most callers invoke this as
5725
+ // `void reconcileStatusPin(...)` (auto status-pin is best-effort — it must
5726
+ // never affect turn flow). reconcilePin already swallows pin/unpin API
5727
+ // errors via onError, but the surrounding persistence + Map bookkeeping
5728
+ // (and any future edge) could still reject the returned promise. A rejected
5729
+ // fire-and-forget promise becomes a process-level `unhandledRejection` which
5730
+ // the gateway's handler crashes on — that is exactly how a benign
5731
+ // "not enough rights to manage pinned messages" 400 in a supergroup took the
5732
+ // whole gateway down (marko, 2026-07-01). Auto status-pin is cosmetic; it
5733
+ // must NEVER be able to crash the gateway. Any throw here is logged and
5734
+ // absorbed. (The `pin_message` MCP tool still surfaces failures to the agent
5735
+ // as a normal tool-error — that path is `executePinMessage`, not this one.)
5736
+ try {
5737
+ await reconcileStatusPinInner(pinKey, chatId, desired)
5738
+ } catch (err) {
5739
+ const msg = err instanceof Error ? err.message : String(err)
5740
+ process.stderr.write(
5741
+ `telegram gateway: status-pin reconcile absorbed error ` +
5742
+ `(key=${pinKey} chat=${chatId}): ${msg}\n`,
5743
+ )
5744
+ }
5745
+ }
5746
+
5747
+ async function reconcileStatusPinInner(
5748
+ pinKey: string,
5749
+ chatId: string,
5750
+ desired: DesiredPin,
5751
+ ): Promise<void> {
5752
+ if (!PIN_STATUS_WHILE_WORKING) return
5753
+ if (chatId.length === 0) return
5754
+ const prev = statusPinState.get(pinKey) ?? null
5755
+
5756
+ const runReconcile = () =>
5757
+ reconcilePin({
5758
+ api: statusPinApi(),
5759
+ chatId,
5760
+ prevState: prev,
5761
+ desired,
5762
+ onError: (phase, err) => {
5763
+ const msg = err instanceof Error ? err.message : String(err)
5764
+ process.stderr.write(
5765
+ `telegram gateway: status-pin ${phase} failed (key=${pinKey} chat=${chatId}): ${msg}\n`,
5766
+ )
5767
+ },
5768
+ })
5769
+
5770
+ // Classify the action so we persist INTENT before the pin API call. Only a
5771
+ // fresh `pin` of a message that isn't already our claim opens the leak window
5772
+ // (the API call actually pins something new); everything else (unpin, noop,
5773
+ // re-pin of the same id) clears / leaves the record and is safe to persist
5774
+ // after. See reconcileAndPersistStatusPin for the ordering rationale.
5775
+ const action = decidePinAction(prev, desired)
5776
+ const op: StatusPinPersistOp =
5777
+ action.kind === 'pin'
5778
+ ? { kind: 'pin', messageId: action.messageId }
5779
+ : { kind: 'clear' }
5780
+
5781
+ if (!statusPinPersistEnabled) {
5782
+ // Persistence off (STATIC / feature-off): just reconcile + update Maps.
5783
+ const next = await runReconcile()
5784
+ if (next == null) {
5785
+ statusPinState.delete(pinKey)
5786
+ statusPinChatIds.delete(pinKey)
5787
+ } else {
5788
+ statusPinState.set(pinKey, next)
5789
+ statusPinChatIds.set(pinKey, chatId)
5790
+ }
5791
+ return
5792
+ }
5793
+
5794
+ // Persist-BEFORE-pin ordering lives in reconcileAndPersistStatusPin: for a
5795
+ // pin it writes a `pending` record first, then confirms it after the API call
5796
+ // lands (or drops it on failure). A crash in the window leaves a pending
5797
+ // record boot cleanup will unpin. In-memory Maps are updated from the result.
5798
+ const next = await reconcileAndPersistStatusPin({
5799
+ path: STATUS_PIN_STORE_PATH,
5800
+ fs: statusPinStoreFs,
5801
+ pinKey,
5802
+ chatId,
5803
+ op,
5804
+ snapshotOthers: () => snapshotStatusPinsExcept(pinKey),
5805
+ applyPin: runReconcile,
5806
+ })
5807
+ if (next == null) {
5808
+ statusPinState.delete(pinKey)
5809
+ statusPinChatIds.delete(pinKey)
5810
+ } else {
5811
+ statusPinState.set(pinKey, next)
5812
+ statusPinChatIds.set(pinKey, chatId)
5813
+ }
5814
+ }
5815
+
5816
+ /**
5817
+ * Background-worker desired-pin, driven off the live `🛠 Worker` message.
5818
+ * Reads the worker feed's current message_id (the EXISTING message — we pin
5819
+ * what the feed already rendered, never a new send) and reconciles a silent
5820
+ * pin while it's running / an unpin on completion. No-op until the feed has
5821
+ * actually painted a message for this worker (trivial sub-second workers stay
5822
+ * silent and are never pinned). Keyed `wk:<agentId>`.
5823
+ */
5824
+ function reconcileWorkerPin(
5825
+ agentId: string,
5826
+ chatId: string | null,
5827
+ running: boolean,
5828
+ ): void {
5829
+ if (!PIN_STATUS_WHILE_WORKING) return
5830
+ const key = `wk:${agentId}`
5831
+ if (!running) {
5832
+ // Unpin: recover the chat we pinned in (caller may not have it at
5833
+ // completion). No-op when nothing was pinned for this worker.
5834
+ const unpinChat = chatId ?? statusPinChatIds.get(key)
5835
+ if (unpinChat == null) return
5836
+ void reconcileStatusPin(key, unpinChat, { pinned: false })
5837
+ return
5838
+ }
5839
+ if (chatId == null) return
5840
+ const messageId = workerActivityFeed?.messageIdOf(agentId) ?? null
5841
+ if (messageId == null) return // no message painted yet — nothing to pin
5842
+ void reconcileStatusPin(key, chatId, { pinned: true, messageId })
5843
+ }
5844
+
5845
+ /** Unpin every owned status pin — used by the pre-restart sweep so a
5846
+ * crash / interrupt never leaves a permanent pin behind. Best-effort;
5847
+ * clears the claim regardless of the unpin outcome (drop-on-unpin). */
5848
+ async function unpinAllStatusPins(): Promise<void> {
5849
+ const keys = [...statusPinState.keys()]
5850
+ for (const key of keys) {
5851
+ const st = statusPinState.get(key)
5852
+ if (st == null) continue
5853
+ // Recover the chat id from the state map's companion key registry.
5854
+ const chatId = statusPinChatIds.get(key)
5855
+ if (chatId == null) { statusPinState.delete(key); continue }
5856
+ await reconcileStatusPin(key, chatId, { pinned: false })
5857
+ }
5858
+ }
5859
+
5484
5860
  // Activity feed. The gateway streams a live "what it's doing" tool-activity
5485
5861
  // feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
5486
5862
  // call (flush-independent, so it stays real-time on fast/clustered-tool
@@ -5628,6 +6004,12 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
5628
6004
  process.stderr.write(
5629
6005
  `telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS}\n`,
5630
6006
  )
6007
+ // We WON the startup mutex — this gateway is the sole live owner of the
6008
+ // shared per-agent status-pin store, so it's now safe to clean up orphaned
6009
+ // pins from a prior (dead) session. Gated here (not at import time) so a
6010
+ // LOSING double-boot never unpins the live holder's legitimate pins.
6011
+ // Fire-and-forget: cleanup is best-effort and must not block boot.
6012
+ void statusPinBootCleanup()
5631
6013
  } catch (err) {
5632
6014
  process.stderr.write(
5633
6015
  `telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
@@ -5639,6 +6021,11 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
5639
6021
  try {
5640
6022
  writePidFile(GATEWAY_PID_PATH, { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS })
5641
6023
  process.stderr.write(`telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS} (mutex-fallback)\n`)
6024
+ // Mutex was unavailable (link() unsupported fs); the legacy pid-file
6025
+ // probe + 409-retry loop is still the liveness guard on this path. A
6026
+ // successful writePidFile here means no live holder was detected, so
6027
+ // running orphan cleanup is consistent with the pre-mutex behaviour.
6028
+ void statusPinBootCleanup()
5642
6029
  } catch (writeErr) {
5643
6030
  process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
5644
6031
  }
@@ -7118,6 +7505,14 @@ const ipcServer: IpcServer = createIpcServer({
7118
7505
  const pend = pendingPermissions.get(requestId)
7119
7506
  if (pend && sent && typeof sent.message_id === 'number') {
7120
7507
  pend.cards.push({ chatId, messageId: sent.message_id })
7508
+ permCardStore.add({
7509
+ requestId,
7510
+ chatId,
7511
+ messageId: sent.message_id,
7512
+ startedAt: pend.startedAt,
7513
+ toolName: pend.tool_name,
7514
+ cardText: pend.card_text,
7515
+ })
7121
7516
  }
7122
7517
  }).catch(e => {
7123
7518
  process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
@@ -7556,6 +7951,81 @@ const ipcServer: IpcServer = createIpcServer({
7556
7951
  })
7557
7952
  },
7558
7953
 
7954
+ // #2726 — hostd-initiated rollout status message. Part 1 uses this only for
7955
+ // the terminal ping; Part 2 uses the same post as the FIRST narration
7956
+ // message, then EDITs it (onRolloutStatusEdit) as later phases arrive. This
7957
+ // is an ORDINARY operator-DM message, NOT a pinned card — the framework
7958
+ // speaking a plain progress line in the chat. We reply with the message_id so
7959
+ // hostd can edit it later; a post failure replies ok:false (hostd then just
7960
+ // won't edit — the durable audit log remains the record).
7961
+ async onRolloutStatusPost(client: IpcClient, msg: RolloutStatusPostMessage) {
7962
+ const self = process.env.SWITCHROOM_AGENT_NAME
7963
+ if (self && msg.agentName !== self) {
7964
+ process.stderr.write(
7965
+ `telegram gateway: rollout_status_post rejected — agent mismatch (${msg.agentName} != ${self})\n`,
7966
+ )
7967
+ try {
7968
+ client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: 'agent mismatch' })
7969
+ } catch { /* best effort */ }
7970
+ return
7971
+ }
7972
+ const operator = loadAccess().allowFrom[0]
7973
+ if (operator === undefined) {
7974
+ process.stderr.write(`telegram gateway: rollout_status_post — no operator chat (allowFrom empty)\n`)
7975
+ try {
7976
+ client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: 'no operator chat' })
7977
+ } catch { /* best effort */ }
7978
+ return
7979
+ }
7980
+ try {
7981
+ const sent = await robustApiCall(
7982
+ () =>
7983
+ // allow-raw-bot-api: rich progress line, routed through robustApiCall.
7984
+ bot.api.sendRichMessage(operator, richMessage(msg.text), {}),
7985
+ { chat_id: String(operator), verb: 'rollout-status-post' },
7986
+ )
7987
+ const messageId = (sent as { message_id: number }).message_id
7988
+ process.stderr.write(
7989
+ `telegram gateway: rollout_status_post agent=${msg.agentName} request=${msg.requestId} message_id=${messageId}\n`,
7990
+ )
7991
+ try {
7992
+ client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: true, messageId })
7993
+ } catch { /* best effort */ }
7994
+ } catch (err) {
7995
+ process.stderr.write(
7996
+ `telegram gateway: rollout_status_post send failed: ${(err as Error).message}\n`,
7997
+ )
7998
+ try {
7999
+ client.send({ type: 'rollout_status_posted', requestId: msg.requestId, ok: false, reason: (err as Error).message })
8000
+ } catch { /* best effort */ }
8001
+ }
8002
+ },
8003
+
8004
+ // #2726 Part 2 — edit the previously-posted rollout status message in place as
8005
+ // later phases arrive. Fire-and-forget: an edit failure (incl. Telegram 429)
8006
+ // is swallowed here and NEVER surfaced back toward the roll. hostd owns the
8007
+ // debounce + 429 retry cadence; this handler is a thin edit relay.
8008
+ onRolloutStatusEdit(_client: IpcClient, msg: RolloutStatusEditMessage) {
8009
+ const self = process.env.SWITCHROOM_AGENT_NAME
8010
+ if (self && msg.agentName !== self) {
8011
+ process.stderr.write(
8012
+ `telegram gateway: rollout_status_edit rejected — agent mismatch (${msg.agentName} != ${self})\n`,
8013
+ )
8014
+ return
8015
+ }
8016
+ const operator = loadAccess().allowFrom[0]
8017
+ if (operator === undefined) {
8018
+ process.stderr.write(`telegram gateway: rollout_status_edit — no operator chat (allowFrom empty)\n`)
8019
+ return
8020
+ }
8021
+ void swallowingApiCall(
8022
+ () =>
8023
+ // allow-raw-bot-api: in-place edit of the ordinary status message.
8024
+ bot.api.editMessageText(operator, msg.messageId, richMessage(msg.text), {}),
8025
+ { chat_id: String(operator), verb: 'rollout-status-edit' },
8026
+ )
8027
+ },
8028
+
7559
8029
  onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
7560
8030
  // Cron fires (incl. cheap-cron, whose session events are dropped before
7561
8031
  // currentTurn is set) are real activity — re-arm idle auto-clear so a
@@ -8129,6 +8599,197 @@ function redactOutboundText(text: string, site: string): string {
8129
8599
  return masked
8130
8600
  }
8131
8601
 
8602
+ /** Default per-voice-note chunk size (chars). A long reply is split into
8603
+ * sequential voice notes of roughly this size on sentence/paragraph
8604
+ * boundaries. Only the OpenAI (cloud) engine still uses this — the kokoro
8605
+ * sidecar now takes the whole reply in one call and returns a single note. */
8606
+ const VOICE_OUT_DEFAULT_CHUNK_CHARS = 600
8607
+
8608
+ /** Hard ceiling for a single OpenAI TTS request. OpenAI's TTS input caps at
8609
+ * 4096 chars, so client-side chunks for that engine never exceed this; the
8610
+ * reply is spoken across sequential notes when it's longer. (The kokoro path
8611
+ * no longer chunks — the sidecar owns length and returns one file.) */
8612
+ const VOICE_OUT_HARD_CHUNK_CAP = 4096
8613
+
8614
+ /**
8615
+ * On-demand voice-out (reply_mode='on-demand'). Instead of synthesizing at
8616
+ * reply time, the gateway appends a single "🔊 Listen" inline button carrying
8617
+ * a reserved `voice:<token>` callback_data; audio is synthesized + sent only
8618
+ * when the user taps it. This keeps the voice pipeline subscription-honest and
8619
+ * visible — zero GPU/sidecar work happens behind the user's back.
8620
+ *
8621
+ * We cache the (speech-normalized text, voice, speed) needed to synthesize by
8622
+ * TOKEN, not message_id, because the message_id is not known at send time.
8623
+ * Bounded LRU with a TTL so a stale button (tapped an hour later) degrades to
8624
+ * a graceful "expired" toast rather than pinning reply text in memory forever.
8625
+ */
8626
+ const voiceOnDemandCache = new VoiceOnDemandCache()
8627
+
8628
+ type VoiceOutAccess = NonNullable<Access['voice_out']>
8629
+
8630
+ /**
8631
+ * Resolve whether outbound TTS is active for this reply, and how
8632
+ * (engine + reply_mode + the plain-text TTS chunks). PR-C2.
8633
+ *
8634
+ * Mirrors the voice-IN engine-resolution precedence (message:voice
8635
+ * handler): the compose-injected SWITCHROOM_VOICE_ENGINE verdict gates the
8636
+ * local 'kokoro' engine, exactly like the STT sidecar. The 'openai' engine
8637
+ * is an honest-exception cloud path gated on an api_key vault ref — it does
8638
+ * NOT require a local verdict.
8639
+ *
8640
+ * Returns null when voice-out is off / not applicable; otherwise a plan the
8641
+ * caller acts on. `ttsChunks` is speech-normalized plain text (markdown and
8642
+ * TTS-mispronounced symbols removed — see normalizeForSpeech).
8643
+ *
8644
+ * ONE voice note per response: the kokoro (local sidecar) path now sends the
8645
+ * WHOLE normalized reply to `/tts` in a SINGLE call — the sidecar chunks +
8646
+ * concatenates internally on the GPU and returns a single ogg/opus file — so
8647
+ * `ttsChunks` is a single element for kokoro. `max_chars` is no longer a hard
8648
+ * client-side splitter for kokoro; it's retired for that path (the sidecar
8649
+ * owns length). The openai (cloud) engine keeps client-side chunking because
8650
+ * OpenAI's TTS input has its own hard cap; each chunk is one voice note.
8651
+ */
8652
+ function resolveVoiceOutPlan(
8653
+ voiceOut: VoiceOutAccess | undefined,
8654
+ replyText: string,
8655
+ ): {
8656
+ engine: 'kokoro' | 'openai'
8657
+ voice?: string
8658
+ speed: number
8659
+ apiKeyRef?: string
8660
+ replyMode: 'voice+text' | 'voice-only' | 'on-demand'
8661
+ ttsChunks: string[]
8662
+ } | null {
8663
+ if (voiceOut?.enabled !== true) return null
8664
+
8665
+ const engine = voiceOut.engine ?? 'kokoro'
8666
+
8667
+ // Engine gating mirrors the voice-in handler precedence: the
8668
+ // compose-injected env wins, then the persisted host-capabilities file,
8669
+ // then a fail-safe 'cloud'. The in-container ~/.switchroom is a read-only
8670
+ // view that does NOT carry host-capabilities.json, so the env is
8671
+ // load-bearing in-fleet (see message:voice). Kokoro requires 'local';
8672
+ // openai requires a resolvable api_key.
8673
+ if (engine === 'kokoro') {
8674
+ const envVoiceEngine = process.env.SWITCHROOM_VOICE_ENGINE
8675
+ const voiceEngine: VoiceEngine =
8676
+ envVoiceEngine === 'local' || envVoiceEngine === 'cloud'
8677
+ ? envVoiceEngine
8678
+ : loadHostCapabilities()?.voice.engine ?? 'cloud'
8679
+ if (voiceEngine !== 'local') return null
8680
+ }
8681
+
8682
+ // Speech-normalized TTS input: strip markdown AND translate/drop the
8683
+ // symbols TTS mispronounces (~, backticks, arrows, tables, code fences,
8684
+ // link URLs) REGARDLESS of mode so the engine reads clean prose. Applied
8685
+ // for BOTH engines — the older partial stripMarkdown pass leaked `~`,
8686
+ // code fences and arrows.
8687
+ const ttsText = normalizeForSpeech(replyText)
8688
+ if (ttsText.length === 0) return null
8689
+
8690
+ // ONE voice note for kokoro: the sidecar accepts arbitrarily long text and
8691
+ // returns a single concatenated ogg/opus file, so we send the whole reply
8692
+ // in one call — no client-side splitting, no multi-sendVoice. `max_chars`
8693
+ // is retired as a hard splitter here.
8694
+ //
8695
+ // OpenAI still needs client-side chunking (its TTS input has a hard cap),
8696
+ // so for that engine we split at the engine cap; each chunk is one note.
8697
+ let ttsChunks: string[]
8698
+ if (engine === 'kokoro') {
8699
+ ttsChunks = [ttsText]
8700
+ } else {
8701
+ const chunkChars = Math.min(
8702
+ voiceOut.max_chars ?? VOICE_OUT_DEFAULT_CHUNK_CHARS,
8703
+ VOICE_OUT_HARD_CHUNK_CAP,
8704
+ )
8705
+ ttsChunks = chunkTtsText(ttsText, chunkChars)
8706
+ }
8707
+ if (ttsChunks.length === 0) return null
8708
+
8709
+ // Kokoro playback speed: default 1.1 (fleet default) when unset, clamped to
8710
+ // 0.5–2.0. Threaded into the /tts body by synthesizeVoiceOut. The OpenAI
8711
+ // path ignores it.
8712
+ const speed = clampTtsSpeed(voiceOut.speed, VOICE_OUT_DEFAULT_SPEED)
8713
+
8714
+ return {
8715
+ engine,
8716
+ voice: voiceOut.voice,
8717
+ speed,
8718
+ apiKeyRef: voiceOut.api_key,
8719
+ replyMode: voiceOut.reply_mode ?? 'voice+text',
8720
+ ttsChunks,
8721
+ }
8722
+ }
8723
+
8724
+ /**
8725
+ * Synthesize `ttsText` into OGG/Opus bytes via the configured engine.
8726
+ * Best-effort: returns null on ANY failure (logged to stderr), never
8727
+ * throws. The caller treats null as "voice unavailable, fall back to
8728
+ * text". PR-C2.
8729
+ */
8730
+ async function synthesizeVoiceOut(plan: {
8731
+ engine: 'kokoro' | 'openai'
8732
+ voice?: string
8733
+ speed?: number
8734
+ apiKeyRef?: string
8735
+ ttsText: string
8736
+ }): Promise<Uint8Array | null> {
8737
+ try {
8738
+ if (plan.engine === 'kokoro') {
8739
+ const token = await materializeSidecarToken()
8740
+ if (!token) return null // materializeSidecarToken already logged why.
8741
+ const result = await synthesizeViaSidecar({
8742
+ token,
8743
+ text: plan.ttsText,
8744
+ voice: plan.voice,
8745
+ speed: plan.speed,
8746
+ })
8747
+ if (!result.ok) {
8748
+ process.stderr.write(
8749
+ `telegram gateway: voice-out: local sidecar synthesis failed reason=${result.reason}` +
8750
+ (result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
8751
+ '\n',
8752
+ )
8753
+ return null
8754
+ }
8755
+ process.stderr.write(
8756
+ `telegram gateway: voice-out: local sidecar synthesized ${result.audio.length} bytes in ${result.durationMs}ms ` +
8757
+ `voice=${result.voice ?? '?'} audio_s=${result.audioSeconds ?? '?'} chars=${plan.ttsText.length}\n`,
8758
+ )
8759
+ return result.audio
8760
+ }
8761
+
8762
+ // engine === 'openai' — honest-exception cloud path.
8763
+ const apiKey = await materializeVoiceKey({ apiKeyRef: plan.apiKeyRef })
8764
+ if (!apiKey) return null // materializeVoiceKey already logged why.
8765
+ const result = await synthesizeViaOpenAi({
8766
+ apiKey,
8767
+ text: plan.ttsText,
8768
+ voice: plan.voice,
8769
+ })
8770
+ if (!result.ok) {
8771
+ process.stderr.write(
8772
+ `telegram gateway: voice-out: openai synthesis failed reason=${result.reason}` +
8773
+ (result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
8774
+ '\n',
8775
+ )
8776
+ return null
8777
+ }
8778
+ process.stderr.write(
8779
+ `telegram gateway: voice-out: openai synthesized ${result.audio.length} bytes in ${result.durationMs}ms ` +
8780
+ `voice=${result.voice} chars=${plan.ttsText.length}\n`,
8781
+ )
8782
+ return result.audio
8783
+ } catch (err) {
8784
+ // Defence in depth: the synth helpers never throw, but the vault
8785
+ // materialize calls might. Voice is best-effort — swallow + fall back.
8786
+ process.stderr.write(
8787
+ `telegram gateway: voice-out: synthesis threw (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`,
8788
+ )
8789
+ return null
8790
+ }
8791
+ }
8792
+
8132
8793
  async function executeReply(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
8133
8794
  // #1664 — pin the turn this reply belongs to at entry. The
8134
8795
  // finalAnswerDelivered write near the end of this function runs after
@@ -8149,18 +8810,20 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8149
8810
  // null because clearTurnStarted fired ≥5 min of model silence, but the model
8150
8811
  // finally called reply after the poke).
8151
8812
  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'
8813
+ const resolved = resolveChatIdFallback(
8814
+ _rawChatId,
8815
+ loadAccess(),
8816
+ turn?.sessionChatId,
8817
+ lastActiveTurnChatId,
8818
+ turn != null,
8819
+ )
8820
+ if (resolved.tier !== 'raw') {
8157
8821
  process.stderr.write(
8158
8822
  `telegram gateway: reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
8159
- `routing to ${tier} turn chat "${fallbackChatId}"\n`,
8823
+ `routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
8160
8824
  )
8161
- return fallbackChatId
8162
8825
  }
8163
- return _rawChatId // let assertAllowedChat below throw the human-readable error
8826
+ return resolved.chatId // raw tier → let assertAllowedChat throw the human-readable error
8164
8827
  })()
8165
8828
  const rawText = args.text as string | undefined
8166
8829
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
@@ -8233,6 +8896,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8233
8896
  const protectContent = args.protect_content === true
8234
8897
  const quoteText = args.quote_text as string | undefined
8235
8898
  const access = loadAccess()
8899
+ // Outbound TTS plan (PR-C2). Resolved once here (engine gating + mode +
8900
+ // plain-text TTS input); synthesis happens just before the send so a
8901
+ // voice-only reply can suppress the text chunk loop on success. Voice is
8902
+ // fully best-effort — every failure below falls back to the text reply.
8903
+ const voiceOutPlan = resolveVoiceOutPlan(access.voice_out, text)
8236
8904
  const configParseMode = access.parseMode ?? 'html'
8237
8905
  const format = (args.format as string | undefined) ?? configParseMode
8238
8906
  const disableLinkPreview = args.disable_web_page_preview != null
@@ -8400,7 +9068,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8400
9068
  // ships the raw GFM markdown via `sendRichMessage`. `effectiveText` is the
8401
9069
  // raw text either way (no HTML/MarkdownV2 rendering happens here anymore).
8402
9070
  const literalText = format === 'text'
8403
- const effectiveText: string = text
9071
+ // Paragraph-spacing fix (rich-message regression after #2669). The rich GFM
9072
+ // renderer collapses a `\n\n` gap TIGHT, so multi-paragraph replies render
9073
+ // jammed together — unlike the old HTML path. Inject a visible blank-line
9074
+ // spacer into prose `\n\n` gaps on the rich path only. The literal
9075
+ // (`format:'text'`) path must stay byte-exact, so it is left untouched.
9076
+ const effectiveText: string = literalText ? text : addParagraphSpacers(text)
8404
9077
 
8405
9078
  assertAllowedChat(chat_id)
8406
9079
 
@@ -8469,6 +9142,60 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8469
9142
  : splitMarkdownChunks(effectiveText, limit)
8470
9143
  const sentIds: number[] = []
8471
9144
 
9145
+ // Outbound TTS synthesis (PR-C2). Done BEFORE the text send so a
9146
+ // voice-only reply can suppress the text chunks on success. ONE voice note
9147
+ // per response for the kokoro path (ttsChunks is a single element — the
9148
+ // whole normalized reply — synthesized in one /tts call). The OpenAI path
9149
+ // may still produce several ordered notes (one per chunk) because of its
9150
+ // input cap. Ken is often on a bike/driving and can't read the screen, so
9151
+ // the full answer must be SPOKEN. Best-effort: a chunk that fails to
9152
+ // synthesize is skipped; if NOTHING synthesizes the text path proceeds
9153
+ // unchanged so the answer is never dropped.
9154
+ // on-demand is a LOCAL-engine (kokoro) feature only: the tap handler
9155
+ // synthesizes via the local sidecar, so a Listen button is only meaningful
9156
+ // when the resolved engine is kokoro. resolveVoiceOutPlan already gated the
9157
+ // local host verdict for kokoro, so engine==='kokoro' here implies the
9158
+ // sidecar is available. For engine==='openai' + reply_mode='on-demand' we do
9159
+ // NOT inject a button (its taps would dead-end on the local sidecar) — we
9160
+ // fall through to the normal immediate-synth path so the openai reply behaves
9161
+ // exactly like a normal openai voice reply.
9162
+ const useOnDemandButton =
9163
+ voiceOutPlan != null &&
9164
+ voiceOutPlan.replyMode === 'on-demand' &&
9165
+ voiceOutPlan.engine === 'kokoro'
9166
+
9167
+ const voiceOggs: Uint8Array[] = []
9168
+ // Skip reply-time synthesis ONLY when we're actually deferring to a Listen
9169
+ // button (kokoro on-demand). An openai on-demand config still synthesizes
9170
+ // immediately below.
9171
+ if (voiceOutPlan != null && !useOnDemandButton) {
9172
+ for (const chunkText of voiceOutPlan.ttsChunks) {
9173
+ const ogg = await synthesizeVoiceOut({
9174
+ engine: voiceOutPlan.engine,
9175
+ voice: voiceOutPlan.voice,
9176
+ speed: voiceOutPlan.speed,
9177
+ apiKeyRef: voiceOutPlan.apiKeyRef,
9178
+ ttsText: chunkText,
9179
+ })
9180
+ // Skip a failed chunk but keep going — a partial spoken answer still
9181
+ // beats silence; full-fail (no oggs at all) falls back to text below.
9182
+ if (ogg != null) voiceOggs.push(ogg)
9183
+ }
9184
+ if (voiceOggs.length < voiceOutPlan.ttsChunks.length) {
9185
+ process.stderr.write(
9186
+ `telegram gateway: voice-out: synthesized ${voiceOggs.length}/${voiceOutPlan.ttsChunks.length} voice-note chunk(s)\n`,
9187
+ )
9188
+ }
9189
+ }
9190
+ // Suppress the text body ONLY when voice-only AND we synthesized the FULL
9191
+ // set of chunks (every part of the answer is spoken). A partial or total
9192
+ // synthesis failure leaves the text path running so the answer still
9193
+ // lands in full — never drop the user's answer silently.
9194
+ const suppressText =
9195
+ voiceOutPlan?.replyMode === 'voice-only' &&
9196
+ voiceOutPlan.ttsChunks.length > 0 &&
9197
+ voiceOggs.length === voiceOutPlan.ttsChunks.length
9198
+
8472
9199
  // #271: validate inline_keyboard and namespace any callback_data with
8473
9200
  // the `agent:` prefix so the gateway's callback_query dispatcher can
8474
9201
  // round-trip taps back to this agent without colliding with
@@ -8490,6 +9217,50 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8490
9217
  replyMarkup = { inline_keyboard: wrapAgentCallbacks(rawKeyboard) }
8491
9218
  }
8492
9219
 
9220
+ // on-demand voice: append a single '🔊 Listen' button that synthesizes the
9221
+ // spoken reply only when tapped. The button carries a RAW `voice:<token>`
9222
+ // callback_data (NOT wrapped with the agent: prefix) so the dispatcher
9223
+ // handles it internally and never routes it to the agent as an inbound.
9224
+ //
9225
+ // Collision gate: inject ONLY when the reply carries no agent-authored
9226
+ // buttons. If the agent supplied its own keyboard, the callback dispatcher's
9227
+ // single_use strip (keyboardIsSingleUse) governs that whole message; adding
9228
+ // a foreign single_use:false button would flip that message to a mixed
9229
+ // keyboard and defeat the agent's double-fire protection. Keep it simple —
9230
+ // agent buttons present → skip the Listen button for this message.
9231
+ //
9232
+ // useOnDemandButton already gates on engine==='kokoro': an openai on-demand
9233
+ // config never reaches here (it synthesized immediately above), so a Listen
9234
+ // button is never minted for an engine whose taps would dead-end on the
9235
+ // local sidecar.
9236
+ if (
9237
+ useOnDemandButton &&
9238
+ voiceOutPlan!.ttsChunks.length > 0 &&
9239
+ voiceOutPlan!.ttsChunks[0]!.length > 0
9240
+ ) {
9241
+ if (!mayInjectListenButton(rawKeyboard)) {
9242
+ process.stderr.write(
9243
+ 'telegram gateway: voice-out on-demand: agent supplied inline_keyboard — skipping Listen button (single_use collision gate)\n',
9244
+ )
9245
+ } else {
9246
+ // Token is intentionally GLOBAL (not chat-keyed): under the single-tenant
9247
+ // invariant the operator is the only authorized sender across all chats,
9248
+ // and the tap handler re-checks access.allowFrom before synthesizing, so
9249
+ // a token needs no per-chat scoping to be safe.
9250
+ const token = mintVoiceOnDemandToken()
9251
+ voiceOnDemandCache.put(token, {
9252
+ // ttsChunks[0] is already normalizeForSpeech(reply) (kokoro path).
9253
+ text: voiceOutPlan.ttsChunks[0]!,
9254
+ ...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
9255
+ speed: voiceOutPlan.speed,
9256
+ })
9257
+ // The keyboard stays after the tap (never stripped) so it can be
9258
+ // replayed. The gate above guarantees this is the ONLY button on the
9259
+ // message, so keeping it is safe (no agent buttons to protect).
9260
+ replyMarkup = buildListenKeyboard(token)
9261
+ }
9262
+ }
9263
+
8493
9264
  const replySKey = streamKey(chat_id, threadId)
8494
9265
  suppressPtyPreview.add(replySKey)
8495
9266
  let previewMessageId: number | null = null
@@ -8739,6 +9510,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8739
9510
 
8740
9511
  try {
8741
9512
  for (let i = 0; i < chunks.length; i++) {
9513
+ // PR-C2: voice-only mode with a successful synthesis suppresses the
9514
+ // text body — the spoken voice note IS the reply. Bail before the
9515
+ // first chunk send (sentIds stays empty for text); the voice send
9516
+ // below lands the answer. Any other mode (voice+text, or voice-only
9517
+ // that fell back) sends the text chunks as normal.
9518
+ if (suppressText) break
8742
9519
  const shouldReplyTo =
8743
9520
  reply_to != null && replyMode !== 'off' && (replyMode === 'all' || i === 0)
8744
9521
  const isLastChunk = i === chunks.length - 1
@@ -8894,6 +9671,76 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8894
9671
  stopTypingLoop(chat_id, threadId ?? null)
8895
9672
  }
8896
9673
 
9674
+ // Outbound voice notes (PR-C2). Sent IN ORDER, AFTER the text body
9675
+ // (voice+text) or INSTEAD of it (voice-only, where suppressText skipped
9676
+ // the chunk loop). A long reply produces SEVERAL notes — each spoken in
9677
+ // sequence so the whole answer is heard, never truncated. Best-effort and
9678
+ // fully non-fatal: a sendVoice failure must NEVER break the text reply
9679
+ // path. In voice-only mode a failure would leave the user with an
9680
+ // incomplete spoken answer, so on the FIRST send failure we recover by
9681
+ // sending the full text once and stop sending further notes.
9682
+ for (let v = 0; v < voiceOggs.length; v++) {
9683
+ const oggBytes = voiceOggs[v]!
9684
+ const voiceOpts: Record<string, unknown> = {
9685
+ // Quote the user's message only on the FIRST voice note (mirrors the
9686
+ // text chunk loop's first-chunk reply behaviour).
9687
+ ...(v === 0 && reply_to != null && replyMode !== 'off'
9688
+ ? { reply_parameters: { message_id: reply_to } }
9689
+ : {}),
9690
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
9691
+ ...(disableNotification ? { disable_notification: true } : {}),
9692
+ }
9693
+ try {
9694
+ const sentVoice = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
9695
+ robustApiCall,
9696
+ (tid) => {
9697
+ const opts = { ...voiceOpts }
9698
+ if (tid != null) opts.message_thread_id = tid
9699
+ else delete opts.message_thread_id
9700
+ // allow-raw-bot-api: adapter callback INSIDE retryWithThreadFallback→robustApiCall; the THREAD_NOT_FOUND fallback is handled by the wrapper.
9701
+ return lockedBot.api.sendVoice(chat_id, new InputFile(Buffer.from(oggBytes)), opts as never)
9702
+ },
9703
+ { threadId, chat_id, verb: 'sendVoice' },
9704
+ )
9705
+ sentIds.push(sentVoice.message_id)
9706
+ logOutbound(
9707
+ 'reply',
9708
+ chat_id,
9709
+ sentVoice.message_id,
9710
+ voiceOutPlan?.ttsChunks[v]?.length ?? 0,
9711
+ `voice-note=${v + 1}/${voiceOggs.length}`,
9712
+ )
9713
+ } catch (err) {
9714
+ const msg = err instanceof Error ? err.message : String(err)
9715
+ process.stderr.write(
9716
+ `telegram gateway: voice-out: sendVoice ${v + 1}/${voiceOggs.length} failed (non-fatal): ${msg}\n`,
9717
+ )
9718
+ // voice-only fell over mid-stream with text suppressed → recover by
9719
+ // sending the FULL text body once so the answer still lands, then
9720
+ // stop sending further notes (the text now carries everything).
9721
+ if (suppressText) {
9722
+ try {
9723
+ const opts: Record<string, unknown> = {
9724
+ ...(reply_to != null && replyMode !== 'off'
9725
+ ? { reply_parameters: { message_id: reply_to } }
9726
+ : {}),
9727
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
9728
+ ...(disableNotification ? { disable_notification: true } : {}),
9729
+ }
9730
+ // 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.
9731
+ const sent = await lockedBot.api.sendMessage(chat_id, effectiveText, opts as never)
9732
+ sentIds.push(sent.message_id)
9733
+ logOutbound('reply', chat_id, sent.message_id, effectiveText.length, 'voice-only-text-recovery')
9734
+ } catch (textErr) {
9735
+ process.stderr.write(
9736
+ `telegram gateway: voice-out: voice-only text recovery ALSO failed: ${textErr instanceof Error ? textErr.message : String(textErr)}\n`,
9737
+ )
9738
+ }
9739
+ break
9740
+ }
9741
+ }
9742
+ }
9743
+
8897
9744
  // #710: remember per-button agent meta (ack_text / single_use) keyed
8898
9745
  // by the message that actually carries the keyboard — that's the last
8899
9746
  // text chunk, since the keyboard is attached only on isLastChunk.
@@ -9154,6 +10001,31 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9154
10001
  const turn = currentTurn
9155
10002
  if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
9156
10003
  if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
10004
+ // chat_id allowlist fallback — mirrors executeReply (~8725). stream_reply is
10005
+ // the primary final-answer path, so a wrong/late chat_id (int/string mismatch,
10006
+ // or the model echoing the wrong identifier after a silence poke flipped
10007
+ // currentTurn to null) would otherwise fail assertAllowedChat in the stream
10008
+ // controller → an invisible reply. Rewrite args.chat_id in place so every
10009
+ // downstream consumer (origin resolution, dedup key, the send) sees the
10010
+ // corrected value. Tier 1: live turn's sessionChatId. Tier 2: last-known
10011
+ // turn's chat (survives silence poke — Bug D fix; currentTurn is null).
10012
+ {
10013
+ const _rawChatId = String(args.chat_id ?? '')
10014
+ const resolved = resolveChatIdFallback(
10015
+ _rawChatId,
10016
+ loadAccess(),
10017
+ turn?.sessionChatId,
10018
+ lastActiveTurnChatId,
10019
+ turn != null,
10020
+ )
10021
+ if (resolved.tier !== 'raw') {
10022
+ process.stderr.write(
10023
+ `telegram gateway: stream_reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
10024
+ `routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
10025
+ )
10026
+ args.chat_id = resolved.chatId
10027
+ }
10028
+ }
9157
10029
  // Thread precedence (matches executeReply; component 3 — turn-origin
9158
10030
  // routing): when the model passes no explicit message_thread_id, inject
9159
10031
  // the ORIGIN turn's thread (matched by origin_turn_id) — authoritative
@@ -9379,6 +10251,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9379
10251
  retry: robustApiCall,
9380
10252
  repairEscapedWhitespace,
9381
10253
  normalizeParagraphBreaks,
10254
+ addParagraphSpacers,
9382
10255
  assertAllowedChat,
9383
10256
  resolveThreadId,
9384
10257
  disableLinkPreview: access.disableLinkPreview !== false,
@@ -10428,31 +11301,6 @@ function buildVaultRequestAccessKeyboard(stageId: string): { inline_keyboard: Ar
10428
11301
  }
10429
11302
  }
10430
11303
 
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
11304
  /**
10457
11305
  * `vault_request_access` tool — agent surfaces an approval card asking
10458
11306
  * the operator to grant a vault ACL it doesn't yet have. See #1012.
@@ -10632,7 +11480,7 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10632
11480
  // paragraph breaks for the rich path. A literal-text edit (`format:'text'`)
10633
11481
  // skips paragraph normalization — it must edit byte-for-byte as given.
10634
11482
  let editRawText = repairEscapedWhitespace(args.text as string)
10635
- if (!editLiteralText) editRawText = normalizeParagraphBreaks(editRawText)
11483
+ if (!editLiteralText) editRawText = addParagraphSpacers(normalizeParagraphBreaks(editRawText))
10636
11484
  // Outbound secret scrub (#2044): an edit must not re-introduce a raw
10637
11485
  // secret into a live bubble or the history row. Mask before scrub/send.
10638
11486
  editRawText = redactOutboundText(editRawText, 'edit_message')
@@ -11133,6 +11981,16 @@ async function drainActivitySummary(
11133
11981
  )
11134
11982
  turn.activityMessageId = sent.message_id
11135
11983
  turn.activityEverOpened = true
11984
+ // Status-pin: the per-turn status message just opened — it's the
11985
+ // in-flight "what it's doing" surface. Silently pin it so the turn
11986
+ // stays in view when the feed scrolls past. Keyed to the same
11987
+ // status-key the canonical turn-end (purgeReactionTracking) unpins.
11988
+ // Fire-and-forget; the single-owner reconcile keeps state consistent.
11989
+ void reconcileStatusPin(
11990
+ `fg:${statusKey(chat, thread)}`,
11991
+ chat,
11992
+ { pinned: true, messageId: sent.message_id },
11993
+ )
11136
11994
  } else {
11137
11995
  const id = turn.activityMessageId
11138
11996
  await robustApiCall(
@@ -15448,6 +16306,15 @@ async function sweepBeforeSelfRestart(): Promise<void> {
15448
16306
  const agentDir = resolveAgentDirFromEnv()
15449
16307
  if (agentDir == null) return
15450
16308
  // #1122 PR3: pre-restart progress-card pin sweep removed with the card.
16309
+ // Status-pin: unpin every status pin we own before we hand control to the
16310
+ // restart, so a crash / interrupt / config-bounce never leaves a permanent
16311
+ // pin stuck at the top of the chat. Best-effort; the drop-on-unpin contract
16312
+ // clears the claim even if the API throws.
16313
+ try {
16314
+ await unpinAllStatusPins()
16315
+ } catch (err) {
16316
+ process.stderr.write(`telegram gateway: pre-restart status-pin sweep threw: ${(err as Error).message}\n`)
16317
+ }
15451
16318
  try {
15452
16319
  await sweepActiveReactions(
15453
16320
  agentDir,
@@ -17159,6 +18026,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
17159
18026
  action: naturalAction(details.tool_name, details.input_preview),
17160
18027
  })
17161
18028
  pendingPermissions.delete(request_id)
18029
+ permCardStore.remove(request_id)
17162
18030
  process.stderr.write(
17163
18031
  `[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
17164
18032
  )
@@ -18861,9 +19729,16 @@ async function performVaultAccessApproval(
18861
19729
  .editMessageText(
18862
19730
  pending.chat_id,
18863
19731
  pending.card_message_id,
18864
- `✅ Granted **${escapeHtmlForTg(pending.agent)}** ${pending.scope} access to ` +
18865
- `\`${pending.key}\` for ${days}d. ` +
18866
- richMessage(`(grant \`${id}\`)` + footer),
19732
+ richMessage(
19733
+ buildVaultGrantApprovedCardText({
19734
+ agentEscaped: escapeHtmlForTg(pending.agent),
19735
+ scope: pending.scope,
19736
+ key: pending.key,
19737
+ days,
19738
+ grantId: id,
19739
+ footer,
19740
+ }),
19741
+ ),
18867
19742
  { reply_markup: { inline_keyboard: [] } },
18868
19743
  )
18869
19744
  .catch(() => {})
@@ -20855,7 +21730,7 @@ bot.command('usage', async ctx => {
20855
21730
  }
20856
21731
  return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
20857
21732
  })
20858
- const { renderAuthSnapshotFormat2, buildSnapshotsFromState } = await import(
21733
+ const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
20859
21734
  '../auth-snapshot-format.js'
20860
21735
  )
20861
21736
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
@@ -20866,7 +21741,22 @@ bot.command('usage', async ctx => {
20866
21741
  demo,
20867
21742
  ...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: Date.now() }),
20868
21743
  })
20869
- await switchroomReply(ctx, text, { html: true })
21744
+ // Preserve the Switch/Refresh/usage/Add inline keyboard on the
21745
+ // rich-message render — the table card carries the same actions the
21746
+ // /auth snapshot does. switchroomReply routes through the rich path
21747
+ // (replyWithRichMessage), which accepts reply_markup. Build a grammy
21748
+ // InlineKeyboard so the markup type matches switchroomReply's contract.
21749
+ const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date() })
21750
+ const keyboard = new InlineKeyboard()
21751
+ kbRows.forEach((row, ri) => {
21752
+ if (ri > 0) keyboard.row()
21753
+ for (const b of row) {
21754
+ if (b.callbackData) keyboard.text(b.text, b.callbackData)
21755
+ else if (b.insertText) keyboard.switchInlineCurrent(b.text, b.insertText)
21756
+ else keyboard.text(b.text, 'auth:noop')
21757
+ }
21758
+ })
21759
+ await switchroomReply(ctx, text, { html: true, reply_markup: keyboard })
20870
21760
  return
20871
21761
  }
20872
21762
  }
@@ -21544,6 +22434,110 @@ bot.on('callback_query:data', async ctx => {
21544
22434
  return
21545
22435
  }
21546
22436
 
22437
+ // on-demand voice: '🔊 Listen' buttons carry a reserved `voice:<token>`
22438
+ // callback_data. Handle INTERNALLY here — synthesize the cached reply text
22439
+ // and send it as a native voice note. This MUST run before the agent:
22440
+ // routing below so a tap never reaches the agent as an inbound message.
22441
+ if (isVoiceOnDemandCallback(data)) {
22442
+ const access = loadAccess()
22443
+ const senderId = String(ctx.from.id)
22444
+ if (!access.allowFrom.includes(senderId)) {
22445
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
22446
+ return
22447
+ }
22448
+ const token = parseVoiceOnDemandToken(data)
22449
+ const entry = token != null ? voiceOnDemandCache.get(token) : null
22450
+ if (entry == null) {
22451
+ await ctx
22452
+ .answerCallbackQuery({ text: 'Voice expired — send again to hear it.' })
22453
+ .catch(() => {})
22454
+ return
22455
+ }
22456
+ await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
22457
+ const cbChatId = String(ctx.chat?.id ?? ctx.from.id)
22458
+ const cbMessageId = ctx.callbackQuery?.message?.message_id
22459
+ const cbThreadId = (() => {
22460
+ const msg = ctx.callbackQuery?.message
22461
+ if (msg && 'is_topic_message' in msg && msg.is_topic_message && 'message_thread_id' in msg) {
22462
+ const tid = (msg as { message_thread_id?: number }).message_thread_id
22463
+ return typeof tid === 'number' ? tid : undefined
22464
+ }
22465
+ return undefined
22466
+ })()
22467
+ // Local sidecar (kokoro) synthesis — same helper the immediate voice-out
22468
+ // path uses. On-demand is a local-engine feature; the cache is only
22469
+ // populated when resolveVoiceOutPlan gated the local verdict.
22470
+ const sidecarToken = await materializeSidecarToken()
22471
+ if (!sidecarToken) {
22472
+ await ctx
22473
+ .answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
22474
+ .catch(() => {})
22475
+ return
22476
+ }
22477
+ const result = await synthesizeViaSidecar({
22478
+ token: sidecarToken,
22479
+ text: entry.text,
22480
+ voice: entry.voice,
22481
+ speed: entry.speed,
22482
+ })
22483
+ if (!result.ok) {
22484
+ process.stderr.write(
22485
+ `telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
22486
+ )
22487
+ await ctx
22488
+ .answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
22489
+ .catch(() => {})
22490
+ return
22491
+ }
22492
+ try {
22493
+ // Native voice note (NOT a document), quote-replying the button's
22494
+ // message.
22495
+ await robustApiCall(
22496
+ () =>
22497
+ bot.api.sendVoice(
22498
+ cbChatId,
22499
+ // allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
22500
+ new InputFile(Buffer.from(result.audio)),
22501
+ {
22502
+ ...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
22503
+ ...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
22504
+ } as never,
22505
+ ),
22506
+ {
22507
+ chat_id: cbChatId,
22508
+ verb: 'voice-ondemand.sendVoice',
22509
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
22510
+ },
22511
+ )
22512
+ // Single-use on SUCCESS: strip the '🔊 Listen' keyboard so the button
22513
+ // can't be re-tapped now that the audio has been delivered. Mirrors the
22514
+ // agent-button single_use strip (keyboardIsSingleUse) house style.
22515
+ // Best-effort + non-fatal — a failed strip only leaves a replayable
22516
+ // button, never drops the delivered audio. Only reached on a successful
22517
+ // sendVoice; expiry / synth-failure / sidecar-unavailable all return
22518
+ // earlier WITHOUT stripping, so the user can retry those.
22519
+ if (cbMessageId != null) {
22520
+ await robustApiCall(
22521
+ () =>
22522
+ bot.api.editMessageReplyMarkup(cbChatId, cbMessageId, {
22523
+ reply_markup: { inline_keyboard: [] },
22524
+ }),
22525
+ {
22526
+ chat_id: cbChatId,
22527
+ verb: 'voice-ondemand.strip-listen-keyboard',
22528
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
22529
+ },
22530
+ ).catch(() => {})
22531
+ }
22532
+ } catch (err) {
22533
+ const msg = err instanceof Error ? err.message : String(err)
22534
+ process.stderr.write(
22535
+ `telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
22536
+ )
22537
+ }
22538
+ return
22539
+ }
22540
+
21547
22541
  // #271: agent-emitted inline_keyboard callbacks. Namespaced with
21548
22542
  // an `agent:` prefix in inline-keyboard-callbacks.ts so they can
21549
22543
  // round-trip without colliding with infrastructure prefixes above.
@@ -21741,6 +22735,7 @@ bot.on('callback_query:data', async ctx => {
21741
22735
  }
21742
22736
 
21743
22737
  pendingPermissions.delete(request_id)
22738
+ permCardStore.remove(request_id)
21744
22739
 
21745
22740
  // (2) Dispatch the in-flight permission verdict IMMEDIATELY — before
21746
22741
  // any host round-trip — so the turn never blocks on persistence.
@@ -21964,6 +22959,7 @@ bot.on('callback_query:data', async ctx => {
21964
22959
  : null
21965
22960
  const grantAgent = selfAgentName()
21966
22961
  pendingPermissions.delete(request_id)
22962
+ permCardStore.remove(request_id)
21967
22963
  if (timeBox && grantAgent) {
21968
22964
  recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
21969
22965
  process.stderr.write(
@@ -22001,6 +22997,25 @@ bot.on('callback_query:data', async ctx => {
22001
22997
  const baseText = msg && 'text' in msg && msg.text
22002
22998
  ? escapeHtmlForTg(msg.text)
22003
22999
  : ''
23000
+ // NO SPLIT (#card-ux): the agent-voiced "got it, continuing: <action>"
23001
+ // continuation folds into THIS card edit — below the verdict label — instead
23002
+ // of spawning a separate message. formatPermissionResumeMessage returns the
23003
+ // same GFM markdown the standalone message used, so the wording is
23004
+ // unchanged; it just rides in the card the operator already tapped. The
23005
+ // separate message went to EVERY resolvePermissionCardTargets() surface; the
23006
+ // in-place edit deliberately touches only the tapped card (a single, legible
23007
+ // card, no fan-out). The turn-resume trigger (dispatchPermissionVerdict) is
23008
+ // untouched below — only the USER-VISIBLE second message collapses. Honours
23009
+ // the SWITCHROOM_RESUME_MSG=0 kill-switch (suppresses the continuation line,
23010
+ // as it suppressed the separate message before).
23011
+ const resumeLine = process.env.SWITCHROOM_RESUME_MSG === '0'
23012
+ ? ''
23013
+ : formatPermissionResumeMessage({
23014
+ agentName: process.env.SWITCHROOM_AGENT_NAME ?? null,
23015
+ behavior: behavior as 'allow' | 'deny',
23016
+ action: resumeAction,
23017
+ })
23018
+ const labelWithResume = resumeLine ? `${htmlLabel}\n\n${resumeLine}` : htmlLabel
22004
23019
  // #1150 audit: P0 fix — was `editMessageText` WITHOUT reply_markup
22005
23020
  // strip, leaving the [Allow][Deny][Always] keyboard live after the
22006
23021
  // decision. Operator could re-tap and flip Deny → Allow after the
@@ -22008,7 +23023,7 @@ bot.on('callback_query:data', async ctx => {
22008
23023
  // strips the keyboard atomically with the status-line edit.
22009
23024
  await finalizeCallback(ctx, {
22010
23025
  ackText: ackText.slice(0, 200),
22011
- newText: baseText ? `${baseText}\n\n${htmlLabel}` : htmlLabel,
23026
+ newText: baseText ? `${baseText}\n\n${labelWithResume}` : labelWithResume,
22012
23027
  synthInbound: () => {
22013
23028
  // No `rule` → the bridge does NOT cache this (truly once on the bridge);
22014
23029
  // any 30-min stickiness lives only in scopedGrants (recorded above).
@@ -22020,10 +23035,11 @@ bot.on('callback_query:data', async ctx => {
22020
23035
  // Un-park the status reaction: 🙏 → working, re-arming the stall
22021
23036
  // watchdog that setAwaiting() suspended.
22022
23037
  resumeReactionAfterVerdict()
22023
- postPermissionResumeMessage({
22024
- behavior: behavior as 'allow' | 'deny',
22025
- action: resumeAction,
22026
- })
23038
+ // card-folded-resume: the visible "continuing…" line is folded into the
23039
+ // card edit above (labelWithResume), so we deliberately do NOT
23040
+ // postPermissionResumeMessage here — that would re-introduce the split
23041
+ // second message this fix (card-ux fix 3) removes. The turn still resumes
23042
+ // via dispatchPermissionVerdict + resumeReactionAfterVerdict above.
22027
23043
  },
22028
23044
  })
22029
23045
  })
@@ -22091,12 +23107,42 @@ bot.on('message:voice', async ctx => {
22091
23107
  // SOMETHING — better than silent drops.
22092
23108
  const access = loadAccess()
22093
23109
  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
- )
23110
+ // Engine selection (PR-B2): the persisted host verdict decides HOW we
23111
+ // transcribe. `local` the in-fleet GPU sidecar (no third-party key,
23112
+ // vision #3 + #4); anything else → the OpenAI cloud provider. The local
23113
+ // path needs no `provider === 'openai'` gate — it has no API key — so we
23114
+ // route to the sidecar whenever voice_in is enabled AND the host verdict
23115
+ // is `local`. The cloud path keeps its existing openai gate.
23116
+ // Source precedence: the compose-injected SWITCHROOM_VOICE_ENGINE env
23117
+ // (set per-agent by compose-gen from the host verdict — PR-B3) wins,
23118
+ // then the persisted host-capabilities file, then a fail-safe `cloud`.
23119
+ // The env is load-bearing in-fleet: the in-container `~/.switchroom`
23120
+ // is a read-only constructed view that does NOT carry the host's
23121
+ // host-capabilities.json, so without the env the file lookup always
23122
+ // misses and every agent silently falls back to `cloud`. Narrow the
23123
+ // env value to the VoiceEngine union so a bogus value can't leak
23124
+ // through — anything but 'local'/'cloud' is ignored and we fall back.
23125
+ const envVoiceEngine = process.env.SWITCHROOM_VOICE_ENGINE
23126
+ const voiceEngine: VoiceEngine =
23127
+ envVoiceEngine === 'local' || envVoiceEngine === 'cloud'
23128
+ ? envVoiceEngine
23129
+ : loadHostCapabilities()?.voice.engine ?? 'cloud'
23130
+ const localEnabled = voiceIn?.enabled === true && voiceEngine === 'local'
23131
+ const cloudEnabled =
23132
+ voiceIn?.enabled === true && voiceEngine !== 'local' && voiceIn?.provider === 'openai'
23133
+ if (localEnabled || cloudEnabled) {
23134
+ const transcript = localEnabled
23135
+ ? await maybeTranscribeVoiceLocal(
23136
+ voice.file_id,
23137
+ voice.mime_type,
23138
+ voiceIn?.language,
23139
+ )
23140
+ : await maybeTranscribeVoice(
23141
+ voice.file_id,
23142
+ voice.mime_type,
23143
+ voiceIn?.language,
23144
+ voiceIn?.api_key,
23145
+ )
22100
23146
  if (transcript != null) {
22101
23147
  const text = ctx.message.caption
22102
23148
  ? `${ctx.message.caption}\n\n[voice transcript] ${transcript}`
@@ -22121,33 +23167,24 @@ bot.on('message:voice', async ctx => {
22121
23167
  * are logged to stderr but never thrown — voice-in is a UX
22122
23168
  * enhancement, not a critical path.
22123
23169
  */
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
- }
23170
+ /**
23171
+ * Filename hint for the multipart body. Telegram voice is OGG/Opus; agents
23172
+ * may also attach mp3/m4a/wav which arrive as message:audio. Match the mime
23173
+ * so the decoder (Whisper / faster-whisper) gets a usable extension.
23174
+ */
23175
+ function voiceFilenameExt(mimeType: string | undefined): string {
23176
+ return mimeType?.includes('mp3') ? 'mp3'
23177
+ : mimeType?.includes('m4a') ? 'm4a'
23178
+ : mimeType?.includes('wav') ? 'wav'
23179
+ : 'ogg'
23180
+ }
22147
23181
 
22148
- // Download the audio bytes from Telegram. Same shape as
22149
- // executeDownloadAttachment but in-memory rather than to disk.
22150
- let audioBytes: Uint8Array
23182
+ /**
23183
+ * Download a Telegram voice attachment into memory. Shared by the cloud
23184
+ * (Whisper) and local (sidecar) transcription paths. Returns the bytes on
23185
+ * success, or null on any failure (caller falls back). Never throws.
23186
+ */
23187
+ async function downloadVoiceBytes(fileId: string): Promise<Uint8Array | null> {
22151
23188
  try {
22152
23189
  const file = await bot.api.getFile(fileId)
22153
23190
  if (!file.file_path) {
@@ -22160,7 +23197,7 @@ async function maybeTranscribeVoice(
22160
23197
  process.stderr.write(`telegram gateway: voice-in: telegram download HTTP ${res.status}\n`)
22161
23198
  return null
22162
23199
  }
22163
- audioBytes = new Uint8Array(await res.arrayBuffer())
23200
+ return new Uint8Array(await res.arrayBuffer())
22164
23201
  } catch (err) {
22165
23202
  // Sanitize: never let the bot token leak into log lines via the
22166
23203
  // download URL — strip anything that looks like a token.
@@ -22168,19 +23205,78 @@ async function maybeTranscribeVoice(
22168
23205
  process.stderr.write(`telegram gateway: voice-in: download failed: ${msg}\n`)
22169
23206
  return null
22170
23207
  }
23208
+ }
22171
23209
 
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'
23210
+ /**
23211
+ * Transcribe a Telegram voice note via the LOCAL GPU STT sidecar (PR-B2).
23212
+ * Used when the host voice verdict is `local`. Resolves the shared-secret
23213
+ * token from the vault (voice/sidecar-token), downloads the audio, and
23214
+ * POSTs it to the sidecar at loopback (the gateway is network_mode: host).
23215
+ * Returns the transcript on success, null on any failure (caller falls
23216
+ * back to the legacy "(voice message)" envelope). Never throws.
23217
+ */
23218
+ async function maybeTranscribeVoiceLocal(
23219
+ fileId: string,
23220
+ mimeType: string | undefined,
23221
+ language: string | undefined,
23222
+ ): Promise<string | null> {
23223
+ const token = await materializeSidecarToken()
23224
+ if (!token) {
23225
+ // materializeSidecarToken already logged the specific reason.
23226
+ return null
23227
+ }
23228
+
23229
+ const audioBytes = await downloadVoiceBytes(fileId)
23230
+ if (!audioBytes) return null
23231
+
23232
+ const result = await transcribeViaSidecar({
23233
+ token,
23234
+ audio: audioBytes,
23235
+ filename: `voice.${voiceFilenameExt(mimeType)}`,
23236
+ language,
23237
+ })
23238
+
23239
+ if (!result.ok) {
23240
+ process.stderr.write(
23241
+ `telegram gateway: voice-in: local sidecar transcription failed reason=${result.reason}` +
23242
+ (result.detail ? ` detail=${JSON.stringify(result.detail).slice(0, 100)}` : '') +
23243
+ '\n',
23244
+ )
23245
+ return null
23246
+ }
23247
+
23248
+ process.stderr.write(
23249
+ `telegram gateway: voice-in: local sidecar transcribed ${audioBytes.length} bytes in ${result.durationMs}ms ` +
23250
+ `lang=${result.language ?? '?'} audio_s=${result.audioSeconds ?? '?'} chars=${result.text.length}\n`,
23251
+ )
23252
+ return result.text
23253
+ }
23254
+
23255
+ async function maybeTranscribeVoice(
23256
+ fileId: string,
23257
+ mimeType: string | undefined,
23258
+ language: string | undefined,
23259
+ apiKeyRef: string | undefined,
23260
+ ): Promise<string | null> {
23261
+ // Resolve the STT key through the vault broker at use-time (PR-A:
23262
+ // voice STT vault-unify). The configured `voice_in.api_key` is a
23263
+ // `vault:<key>` reference (default `vault:openai/api-key`); the
23264
+ // resolved value is held in memory only — never written to disk or
23265
+ // surfaced into the agent prompt. On any failure we return null and
23266
+ // the caller falls back to the legacy "(voice message)" envelope.
23267
+ const apiKey = await materializeVoiceKey({ apiKeyRef })
23268
+ if (!apiKey) {
23269
+ // materializeVoiceKey already logged the specific reason.
23270
+ return null
23271
+ }
23272
+
23273
+ const audioBytes = await downloadVoiceBytes(fileId)
23274
+ if (!audioBytes) return null
22179
23275
 
22180
23276
  const result = await transcribeViaWhisper({
22181
23277
  apiKey,
22182
23278
  audio: audioBytes,
22183
- filename: `voice.${ext}`,
23279
+ filename: `voice.${voiceFilenameExt(mimeType)}`,
22184
23280
  language,
22185
23281
  })
22186
23282
 
@@ -22627,6 +23723,66 @@ bot.on('message:checklist_tasks_added' as Parameters<typeof bot.on>[0], (ctx) =>
22627
23723
  handleChecklistUpdate(ctx as unknown as Context, 'checklist_tasks_added')
22628
23724
  })
22629
23725
 
23726
+ // Suppress the "pinned a message" service message Telegram inserts when OUR
23727
+ // silent status-pin fires. The pin call passes `disable_notification: true`,
23728
+ // which kills the PUSH notification but NOT the in-chat service message — so
23729
+ // delete that service message as it lands, but ONLY for pins we own (tracked
23730
+ // in `statusPinState`). Manual/operator pins are never silent and are never
23731
+ // touched. Only silent status pins reach here as an OUR-pin match, so the
23732
+ // ownership check is the guard.
23733
+ //
23734
+ // Race tolerance: the service update can arrive before `reconcileStatusPin`
23735
+ // has stored the new PinState (the pin API call resolves, Telegram emits the
23736
+ // service message, and only then does the reconcile write the Map). A single
23737
+ // short retry covers that window; if it's still not one of ours, we leave the
23738
+ // service message alone.
23739
+ bot.on('message:pinned_message', async ctx => {
23740
+ const pinnedId = ctx.msg.pinned_message?.message_id
23741
+ if (pinnedId == null) return
23742
+ const chatId = String(ctx.chat.id)
23743
+ const serviceMsgId = ctx.msg.message_id
23744
+
23745
+ // Chat-scoped ownership (see pinnedMessageIsOurs): the match requires BOTH
23746
+ // the messageId AND that the tracked entry lives in THIS chat, so a pin id
23747
+ // colliding across chats can't delete a foreign (e.g. operator-manual) pin
23748
+ // notice. statusPinChatIds is the companion pinKey→chatId map written on
23749
+ // every desired-pinned reconcile.
23750
+ const trackedPins = (): TrackedStatusPin[] => {
23751
+ const out: TrackedStatusPin[] = []
23752
+ for (const [pinKey, state] of statusPinState) {
23753
+ const c = statusPinChatIds.get(pinKey)
23754
+ if (c != null) out.push({ chatId: c, messageId: state.messageId })
23755
+ }
23756
+ return out
23757
+ }
23758
+ const isOurs = () => pinnedMessageIsOurs(trackedPins(), chatId, pinnedId)
23759
+
23760
+ if (!isOurs()) {
23761
+ // Tolerate the reconcile-store race: wait briefly, then re-check once.
23762
+ await new Promise(resolve => setTimeout(resolve, 250))
23763
+ if (!isOurs()) return
23764
+ }
23765
+
23766
+ try {
23767
+ await robustApiCall(
23768
+ () => lockedBot.api.deleteMessage(chatId, serviceMsgId),
23769
+ { chat_id: chatId, verb: 'status-pin.delete-service-message' },
23770
+ )
23771
+ } catch (err) {
23772
+ // Best-effort: a failure to delete the service message is cosmetic only —
23773
+ // the "pinned a message" line just stays. The most likely cause in a
23774
+ // supergroup/forum is the bot lacking can_delete_messages admin right, so
23775
+ // surface a concise one-liner (robustApiCall rethrows this case without
23776
+ // logging a reason) rather than swallowing silently — an operator sees WHY.
23777
+ const msg = err instanceof Error ? err.message : String(err)
23778
+ process.stderr.write(
23779
+ `telegram gateway: status-pin: could not delete pin service message ` +
23780
+ `(chat=${chatId} msg=${serviceMsgId}) — likely missing can_delete_messages ` +
23781
+ `admin right in this chat: ${msg}\n`,
23782
+ )
23783
+ }
23784
+ })
23785
+
22630
23786
  // ─── Reaction-trigger runtime state (#1074) ──────────────────────────────
22631
23787
  //
22632
23788
  // Bot-message reactions in the configured allowlist trigger a synthetic
@@ -23562,6 +24718,40 @@ void (async () => {
23562
24718
  // tracks the live turn from there.
23563
24719
  try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
23564
24720
 
24721
+ // Strip stale permission cards from prior gateway session. Any entry
24722
+ // still in the store was never resolved (gateway died before the
24723
+ // operator tapped or the reaper ran). The operator might have seen
24724
+ // those cards and tapped them — if so, they got STALE_TAP_NOTICE
24725
+ // ("already resolved") which is misleading. Edit the messages to
24726
+ // remove the keyboard and show a clear "restarted" notice instead.
24727
+ void (async () => {
24728
+ const stale = permCardStore.loadAll()
24729
+ if (stale.length === 0) return
24730
+ process.stderr.write(
24731
+ `telegram gateway: boot-sweep: stripping ${stale.length} stale permission card(s) from prior gateway session\n`,
24732
+ )
24733
+ for (const card of stale) {
24734
+ const toolLabel = card.toolName ?? 'unknown tool'
24735
+ const notice = `🔒 **${toolLabel}**\n\n⚠️ *Gateway restarted — this request is no longer active. Ask your agent to try again if needed.*`
24736
+ try {
24737
+ // allow-raw-bot-api: targeted by message_id; no thread needed; fire-and-forget boot sweep
24738
+ await bot.api.editMessageText(
24739
+ card.chatId,
24740
+ card.messageId,
24741
+ richMessage(notice),
24742
+ { reply_markup: { inline_keyboard: [] } },
24743
+ )
24744
+ } catch (err) {
24745
+ // Card may already be deleted, edited, or in an inaccessible chat — benign
24746
+ process.stderr.write(
24747
+ `telegram gateway: boot-sweep: stale-card strip failed ` +
24748
+ `${card.chatId}:${card.messageId}: ${(err as Error).message}\n`,
24749
+ )
24750
+ }
24751
+ }
24752
+ permCardStore.clear()
24753
+ })()
24754
+
23565
24755
  // Boot-time pin sweep
23566
24756
  try {
23567
24757
  const bootAccess = loadAccess()
@@ -24147,6 +25337,8 @@ void (async () => {
24147
25337
  elapsedMs: durationMs,
24148
25338
  state: outcome === 'failed' ? 'failed' : 'done',
24149
25339
  })
25340
+ // Status-pin: worker done — drop its pin.
25341
+ reconcileWorkerPin(agentId, null, false)
24150
25342
  }
24151
25343
  return
24152
25344
  }
@@ -24164,6 +25356,8 @@ void (async () => {
24164
25356
  elapsedMs: durationMs,
24165
25357
  state: outcome === 'failed' ? 'failed' : 'done',
24166
25358
  })
25359
+ // Status-pin: worker done — drop its pin.
25360
+ reconcileWorkerPin(agentId, null, false)
24167
25361
  }
24168
25362
 
24169
25363
  const handbackOrigin = resolveSubagentOriginChat(agentId)
@@ -24291,9 +25485,10 @@ void (async () => {
24291
25485
  })
24292
25486
  if (surface === 'worker-feed') {
24293
25487
  const origin = resolveSubagentOriginChat(agentId)
25488
+ const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
24294
25489
  void workerActivityFeed?.update(
24295
25490
  agentId,
24296
- origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
25491
+ wkChat,
24297
25492
  {
24298
25493
  description: dispatch.feedDescription,
24299
25494
  lastTool,
@@ -24303,7 +25498,7 @@ void (async () => {
24303
25498
  state: 'running',
24304
25499
  },
24305
25500
  origin?.threadId,
24306
- )
25501
+ )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
24307
25502
  return
24308
25503
  }
24309
25504
  if (surface !== 'nest') return // 'skip' — orphan-status off
@@ -24425,9 +25620,10 @@ void (async () => {
24425
25620
  // is gone — see resolveSubagentOriginChat).
24426
25621
  if (workerFeedEnabled) {
24427
25622
  const origin = resolveSubagentOriginChat(agentId)
25623
+ const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
24428
25624
  void workerActivityFeed?.update(
24429
25625
  agentId,
24430
- origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
25626
+ wkChat,
24431
25627
  {
24432
25628
  description: dispatch.feedDescription,
24433
25629
  lastTool,
@@ -24437,7 +25633,7 @@ void (async () => {
24437
25633
  state: 'running',
24438
25634
  },
24439
25635
  origin?.threadId,
24440
- )
25636
+ )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
24441
25637
  return
24442
25638
  }
24443
25639