switchroom 0.16.38 → 0.16.47

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 (59) hide show
  1. package/dist/agent-scheduler/index.js +8 -2
  2. package/dist/auth-broker/index.js +7 -1
  3. package/dist/cli/notion-write-pretool.mjs +7 -1
  4. package/dist/cli/switchroom.js +1259 -375
  5. package/dist/cli/ui/index.html +877 -214
  6. package/dist/host-control/main.js +116 -84
  7. package/dist/vault/approvals/kernel-server.js +8 -2
  8. package/dist/vault/broker/server.js +8 -2
  9. package/examples/minimal.yaml +1 -1
  10. package/examples/switchroom.yaml +1 -1
  11. package/package.json +2 -2
  12. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  13. package/skills/switchroom-status/SKILL.md +1 -1
  14. package/telegram-plugin/bridge/bridge.ts +2 -1
  15. package/telegram-plugin/card-format.ts +7 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +20 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +2197 -964
  18. package/telegram-plugin/dist/server.js +20 -2
  19. package/telegram-plugin/format.ts +305 -31
  20. package/telegram-plugin/gateway/gateway.ts +310 -70
  21. package/telegram-plugin/gateway/model-command.ts +173 -19
  22. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  23. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  24. package/telegram-plugin/package.json +1 -1
  25. package/telegram-plugin/session-tail.ts +47 -1
  26. package/telegram-plugin/stream-reply-handler.ts +19 -1
  27. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  28. package/telegram-plugin/tests/card-format.test.ts +28 -0
  29. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  30. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  31. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  32. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  33. package/telegram-plugin/tests/model-command.test.ts +213 -47
  34. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  35. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  36. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  37. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  38. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  39. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  40. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  41. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  42. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  43. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  44. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  45. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  46. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  47. package/telegram-plugin/text-voice-scrub.ts +68 -18
  48. package/telegram-plugin/tool-activity-summary.ts +20 -108
  49. package/telegram-plugin/tts-normalize.ts +377 -0
  50. package/telegram-plugin/uat/driver.ts +472 -22
  51. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  52. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  53. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  56. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  57. package/telegram-plugin/voice-ondemand.ts +161 -10
  58. package/telegram-plugin/voice-presynth.ts +242 -0
  59. package/telegram-plugin/worker-activity-feed.ts +9 -1
@@ -62,11 +62,19 @@ import {
62
62
  buildListenKeyboard,
63
63
  mayInjectListenButton,
64
64
  } from '../voice-ondemand.js'
65
+ import {
66
+ PreSynthQueue,
67
+ sweepVoiceCacheDir,
68
+ writeVoiceCacheFile,
69
+ eagerVoiceEnabled,
70
+ VOICE_SWEEP_INTERVAL_MS,
71
+ } from '../voice-presynth.js'
65
72
 
66
73
  /** Fleet default TTS speed when voice_out is enabled but speed is unset —
67
74
  * slightly brisker than neutral (Ken's chosen default). */
68
75
  const VOICE_OUT_DEFAULT_SPEED = 1.1
69
76
  import { normalizeForSpeech } from '../voice-normalize-text.js'
77
+ import { normalizeForTts } from '../tts-normalize.js'
70
78
  import { synthesizeViaOpenAi } from '../voice-synthesize.js'
71
79
  import {
72
80
  createTelegraphAccount,
@@ -225,7 +233,7 @@ const REPLY_TO_TEXT_MAX = 200
225
233
  const SILENT_END_FALLBACK_TEXT =
226
234
  '⚠️ The agent finished working but didn’t send a reply — your last ' +
227
235
  'message may not have been answered. Please try asking again.'
228
- import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
236
+ import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
229
237
  import { richMessage } from '../rich-send.js'
230
238
  import { scrubVoice } from '../text-voice-scrub.js'
231
239
  import {
@@ -317,6 +325,8 @@ import {
317
325
  MODEL_CALLBACK_PREFIX,
318
326
  MODEL_CALLBACK_HEADER,
319
327
  MODEL_CALLBACK_SR,
328
+ MODEL_CALLBACK_PAGE_EXTERNAL,
329
+ MODEL_CALLBACK_PAGE_MAIN,
320
330
  srFriendlyLabel,
321
331
  type ModelMenuDeps,
322
332
  type ModelCommandDeps,
@@ -8623,7 +8633,79 @@ const VOICE_OUT_HARD_CHUNK_CAP = 4096
8623
8633
  * Bounded LRU with a TTL so a stale button (tapped an hour later) degrades to
8624
8634
  * a graceful "expired" toast rather than pinning reply text in memory forever.
8625
8635
  */
8626
- const voiceOnDemandCache = new VoiceOnDemandCache()
8636
+ // Persist the Listen-token cache under STATE_DIR (= /state/agent/telegram in
8637
+ // prod, a volume that survives container recreation). Without this the Map was
8638
+ // wiped on every gateway restart, so a 🔊 Listen button tapped on a message
8639
+ // sent before the restart degraded to "Voice expired". The same TTL + LRU cap
8640
+ // still applies; expired entries are dropped on load.
8641
+ const voiceOnDemandCache = new VoiceOnDemandCache({
8642
+ persistPath: join(STATE_DIR, 'voice-ondemand.json'),
8643
+ })
8644
+
8645
+ // ─── Eager voice pre-synthesis (#2763) ─────────────────────────────────────
8646
+ // Whenever a reply becomes Listen-eligible (a voiceOnDemandCache entry is
8647
+ // persisted alongside the Listen button), ALSO synthesize the audio in the
8648
+ // background via the LOCAL sidecar and park it on disk, so the 🔊 Listen tap
8649
+ // attaches the pre-made file instantly instead of waiting on the GPU. Local
8650
+ // engine only (kokoro — zero token/dollar cost); the openai cloud engine
8651
+ // never pre-synthesizes. Strictly off the reply critical path: jobs drain
8652
+ // through a bounded FIFO (concurrency 1, drop-oldest past the cap) and every
8653
+ // failure is swallowed — the tap just falls back to the lazy synth path.
8654
+ // Kill switch: SWITCHROOM_DISABLE_EAGER_VOICE=1 (the sweep still runs so old
8655
+ // files age out).
8656
+ const VOICE_CACHE_DIR = join(STATE_DIR, 'voice-cache')
8657
+
8658
+ const voicePreSynthQueue = new PreSynthQueue({
8659
+ runJob: async (job) => {
8660
+ const sidecarToken = await materializeSidecarToken()
8661
+ if (!sidecarToken) return // sidecar unavailable — lazy path will retry on tap
8662
+ const result = await synthesizeViaSidecar({
8663
+ token: sidecarToken,
8664
+ // Same deterministic normalization the lazy Listen path applies —
8665
+ // job.text is normalizeForSpeech(reply); normalizeForTts is the #2760
8666
+ // L1 pass on top.
8667
+ text: normalizeForTts(job.text),
8668
+ voice: job.voice,
8669
+ speed: job.speed,
8670
+ })
8671
+ if (!result.ok) {
8672
+ process.stderr.write(
8673
+ `telegram gateway: voice-presynth: sidecar synthesis failed token=${job.token} reason=${result.reason}\n`,
8674
+ )
8675
+ return
8676
+ }
8677
+ const filePath = writeVoiceCacheFile(VOICE_CACHE_DIR, job.token, result.audio)
8678
+ voiceOnDemandCache.setFilePath(job.token, filePath)
8679
+ process.stderr.write(
8680
+ `telegram gateway: voice-presynth: cached ${result.audio.length} bytes at ${filePath} ` +
8681
+ `(${result.durationMs}ms, backlog=${voicePreSynthQueue.size})\n`,
8682
+ )
8683
+ },
8684
+ })
8685
+
8686
+ /** Rolling 7-day TTL + 500MB size-budget sweep over VOICE_CACHE_DIR. Runs at
8687
+ * boot and hourly; ALWAYS runs (even under the eager-voice kill switch) so
8688
+ * previously written files age out. Crash-safe: missing dir/files tolerated. */
8689
+ function sweepVoiceCache(): void {
8690
+ try {
8691
+ const result = sweepVoiceCacheDir({ dir: VOICE_CACHE_DIR })
8692
+ if (result.deletedTokens.length > 0) {
8693
+ voiceOnDemandCache.prune(result.deletedTokens)
8694
+ process.stderr.write(
8695
+ `telegram gateway: voice-presynth: sweep removed ${result.deletedTokens.length} file(s), ` +
8696
+ `${result.remainingBytes} bytes remain\n`,
8697
+ )
8698
+ }
8699
+ } catch (err) {
8700
+ // Defence in depth — sweepVoiceCacheDir is already per-file crash-safe.
8701
+ process.stderr.write(`telegram gateway: voice-presynth: sweep failed: ${String(err)}\n`)
8702
+ }
8703
+ }
8704
+
8705
+ if (!STATIC) {
8706
+ sweepVoiceCache() // boot sweep — covers a died timer / long downtime
8707
+ setInterval(sweepVoiceCache, VOICE_SWEEP_INTERVAL_MS).unref()
8708
+ }
8627
8709
 
8628
8710
  type VoiceOutAccess = NonNullable<Access['voice_out']>
8629
8711
 
@@ -8735,12 +8817,16 @@ async function synthesizeVoiceOut(plan: {
8735
8817
  ttsText: string
8736
8818
  }): Promise<Uint8Array | null> {
8737
8819
  try {
8820
+ // #2760 Phase 1: deterministic L1 TTS normalization, applied at the
8821
+ // single choke point where every engine's request body is built.
8822
+ // On by default; kill switch: SWITCHROOM_DISABLE_TTS_NORMALIZE=1.
8823
+ const ttsText = normalizeForTts(plan.ttsText)
8738
8824
  if (plan.engine === 'kokoro') {
8739
8825
  const token = await materializeSidecarToken()
8740
8826
  if (!token) return null // materializeSidecarToken already logged why.
8741
8827
  const result = await synthesizeViaSidecar({
8742
8828
  token,
8743
- text: plan.ttsText,
8829
+ text: ttsText,
8744
8830
  voice: plan.voice,
8745
8831
  speed: plan.speed,
8746
8832
  })
@@ -8764,7 +8850,7 @@ async function synthesizeVoiceOut(plan: {
8764
8850
  if (!apiKey) return null // materializeVoiceKey already logged why.
8765
8851
  const result = await synthesizeViaOpenAi({
8766
8852
  apiKey,
8767
- text: plan.ttsText,
8853
+ text: ttsText,
8768
8854
  voice: plan.voice,
8769
8855
  })
8770
8856
  if (!result.ok) {
@@ -8834,8 +8920,16 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8834
8920
  // Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
8835
8921
  // the stderr preview below, the dedup key, the send, and the history
8836
8922
  // record. Mutates `text` so every downstream consumer sees the masked
8837
- // value, exactly like the voice scrub that follows.
8923
+ // value, exactly like the voice scrub that follows. Runs BEFORE the
8924
+ // punctuation/bold normalizers so a secret containing an em-dash or `**`
8925
+ // is matched literally by the redactor before any mutation.
8838
8926
  text = redactOutboundText(text, 'reply')
8927
+ // Fleet-wide consistent formatting: normalize dashes/bullets and trip the
8928
+ // over-bold guard deterministically, on code-masked text (same contract on
8929
+ // reply / edit / stream paths). Runs before scrubVoice below so dashes get
8930
+ // the comma treatment on every path; scrubVoice's dash telemetry
8931
+ // (voice_scrub_applied) drops to ~zero here as a result — deliberate.
8932
+ text = stripExcessBold(normalizePunctuation(text))
8839
8933
  // Voice scrub (#1683): replace em / en dashes with commas / periods.
8840
8934
  // Runs BEFORE outboundDedup so retries see the scrubbed key, and on the
8841
8935
  // raw markdown text (the scrubber does its own code-region parking so
@@ -9258,6 +9352,22 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
9258
9352
  // replayed. The gate above guarantees this is the ONLY button on the
9259
9353
  // message, so keeping it is safe (no agent buttons to protect).
9260
9354
  replyMarkup = buildListenKeyboard(token)
9355
+ // #2763 eager pre-synthesis: kick a background synth of the same
9356
+ // payload so the Listen tap attaches the pre-made file instantly.
9357
+ // Local-engine only (useOnDemandButton already gates engine==='kokoro'
9358
+ // — the cloud engine never eager-synthesizes). enqueue() is a
9359
+ // synchronous array push; the drain is deferred + async and never
9360
+ // awaited here, so the text sends below are never delayed and a synth
9361
+ // failure can never affect message delivery (the tap just falls back
9362
+ // to the lazy path).
9363
+ if (eagerVoiceEnabled()) {
9364
+ voicePreSynthQueue.enqueue({
9365
+ token,
9366
+ text: voiceOutPlan.ttsChunks[0]!,
9367
+ ...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
9368
+ speed: voiceOutPlan.speed,
9369
+ })
9370
+ }
9261
9371
  }
9262
9372
  }
9263
9373
 
@@ -10094,6 +10204,16 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
10094
10204
  // controller, dedup record, history record) see the scrubbed
10095
10205
  // version. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
10096
10206
  {
10207
+ // Cross-path consistency (#2755): normalizePunctuation runs BEFORE
10208
+ // scrubVoice here, matching the reply/edit paths, so a spaced em-dash
10209
+ // gets the same comma treatment on every outbound path (scrubVoice
10210
+ // would otherwise see the raw dash first on this path only and apply
10211
+ // its period substitution). handleStreamReply's own deps run
10212
+ // normalizePunctuation again downstream — it is idempotent, so the
10213
+ // second pass is a no-op. Side effect: scrubVoice's dash telemetry
10214
+ // (voice_scrub_applied) drops to ~zero on this path since the dashes
10215
+ // are consumed here first — deliberate.
10216
+ args.text = normalizePunctuation(args.text as string)
10097
10217
  const scrub = scrubVoice(args.text as string)
10098
10218
  if (scrub.replaced > 0) {
10099
10219
  args.text = scrub.scrubbed
@@ -10251,6 +10371,8 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
10251
10371
  retry: robustApiCall,
10252
10372
  repairEscapedWhitespace,
10253
10373
  normalizeParagraphBreaks,
10374
+ normalizePunctuation,
10375
+ stripExcessBold,
10254
10376
  addParagraphSpacers,
10255
10377
  assertAllowedChat,
10256
10378
  resolveThreadId,
@@ -11319,7 +11441,12 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
11319
11441
  if (scopeRaw !== 'read' && scopeRaw !== 'write') {
11320
11442
  throw new Error('vault_request_access: scope must be "read" or "write"')
11321
11443
  }
11322
- const reason = typeof args.reason === 'string' ? args.reason : undefined
11444
+ // Accept `why` as an alias for `reason`: the sibling tool
11445
+ // vault_request_save uses `why`, and agents cross-contaminate the two
11446
+ // schemas — without the alias the rationale silently drops off the
11447
+ // approval card and the operator sees "why: not provided".
11448
+ const reason =
11449
+ typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
11323
11450
  // Duration: accept a "30d" / "12h" string from the agent OR default
11324
11451
  // to 30 days. Cap at 90 days — beyond that the operator should use
11325
11452
  // the host CLI and pick the lifetime explicitly. Refuse "never"
@@ -11480,10 +11607,14 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
11480
11607
  // paragraph breaks for the rich path. A literal-text edit (`format:'text'`)
11481
11608
  // skips paragraph normalization — it must edit byte-for-byte as given.
11482
11609
  let editRawText = repairEscapedWhitespace(args.text as string)
11483
- if (!editLiteralText) editRawText = addParagraphSpacers(normalizeParagraphBreaks(editRawText))
11610
+ if (!editLiteralText) editRawText = normalizeParagraphBreaks(editRawText)
11484
11611
  // Outbound secret scrub (#2044): an edit must not re-introduce a raw
11485
11612
  // secret into a live bubble or the history row. Mask before scrub/send.
11486
11613
  editRawText = redactOutboundText(editRawText, 'edit_message')
11614
+ // Fleet-wide consistent formatting (same order as the reply path: redact
11615
+ // first so secrets are matched literally, then normalize, then spacers on
11616
+ // the rich path only).
11617
+ if (!editLiteralText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
11487
11618
  // Voice scrub (#1683): same em-dash scrub as the reply path. Edits
11488
11619
  // are how silent-anchor and progress-update mutate already-sent
11489
11620
  // bubbles, so without this an edit can re-introduce dashes the
@@ -20029,20 +20160,61 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
20029
20160
  // cards open at once.
20030
20161
  const joiningBatch = items.length > 1
20031
20162
  await ctx.answerCallbackQuery({ text: joiningBatch ? `🔐 Queued — one passphrase covers ${items.length} cards` : '🔐 Send your passphrase…' }).catch(() => {})
20163
+
20164
+ // Strip the buttons on the ORIGINAL card and mark it "waiting" so it
20165
+ // can't be re-tapped, but do NOT overload it as the passphrase prompt.
20166
+ // An in-place edit fires no notification and stays pinned to the card's
20167
+ // old position in the chat, so a busy topic buries it and the operator
20168
+ // never sees the passphrase ask — the exact admin-key miss this fixes
20169
+ // (v0.16.45: the prompt scrolled off, the passphrase never arrived, the
20170
+ // grant was never minted). The prompt goes out as a fresh message below.
20032
20171
  await ctx.api
20033
20172
  .editMessageText(
20034
20173
  pending.chat_id,
20035
20174
  pending.card_message_id,
20036
- joiningBatch
20037
- ? `🔐 **Queued behind an earlier card.** Type your passphrase as your next message — it covers **${items.length}** pending approvals in this chat (one entry mints all grants, no re-type per card).`
20038
- : isAdminOnly
20039
- ? `🔒 **Admin-only credential.** \`${pending.key}\` requires your vault passphrase to grant — reply with it as your next message and we'll mint the grant for **${escapeHtmlForTg(pending.agent)}**, then delete the passphrase message.\n\n` +
20040
- `_The passphrase is what proves it's you: an agent can never mint this key on its own._`
20041
- : `🔐 **Vault is locked.** Reply with your passphrase as your next message — we'll unlock, mint the grant for **${escapeHtmlForTg(pending.agent)}**, and delete the passphrase message in one step.\n\n` +
20042
- richMessage(`_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches._`),
20175
+ richMessage(`🔐 _Approved — waiting for your vault passphrase. See the prompt below._`),
20043
20176
  { reply_markup: { inline_keyboard: [] } },
20044
20177
  )
20045
20178
  .catch(() => {})
20179
+
20180
+ // The passphrase prompt as a NEW rich message. Three fixes vs. the old
20181
+ // in-place edit, all of which the reported bug needed:
20182
+ // 1. Real bold/italic — rendered through the sanctioned `richMessage`
20183
+ // GFM path (`sendRichMessage`), never a raw string. The old admin
20184
+ // and joining-batch branches passed raw markdown to editMessageText
20185
+ // (parse_mode=none), so `**`/`_` rendered as literal characters;
20186
+ // the "locked" branch even concatenated a string with a
20187
+ // `richMessage()` object (→ `[object Object]`). All three are gone.
20188
+ // 2. It lands at the BOTTOM of the chat, not stapled to an old card
20189
+ // that later messages bury.
20190
+ // 3. It fires a notification — `disable_notification` is deliberately
20191
+ // NOT set — so the operator is actually pinged to act.
20192
+ // Attention-grabbing header, short lines, key in code formatting.
20193
+ const promptText = joiningBatch
20194
+ ? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
20195
+ `Type your vault passphrase as your **next message**.\n` +
20196
+ `One entry covers **${items.length}** pending approvals in this chat, no re-type per card.\n\n` +
20197
+ `_We delete the passphrase message the moment we read it._`
20198
+ : isAdminOnly
20199
+ ? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
20200
+ `\`${pending.key}\` is an **admin-only credential**.\n` +
20201
+ `Type your vault passphrase as your **next message** to mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
20202
+ `_The passphrase is what proves it's you. An agent can never mint this key on its own. We delete the passphrase message the moment we read it._`
20203
+ : `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
20204
+ `Your vault is locked.\n` +
20205
+ `Reply with your passphrase as your **next message** to unlock and mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
20206
+ `_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches. We delete the passphrase message the moment we read it._`
20207
+
20208
+ // #1075: deleted-topic safe — fall back to the main chat. Wrapped
20209
+ // through robustApiCall for flood-wait retries, mirroring the card send.
20210
+ await retryWithThreadFallback<{ message_id: number }>(
20211
+ robustApiCall,
20212
+ (tid) =>
20213
+ lockedBot.api.sendRichMessage(pending.chat_id, richMessage(promptText), {
20214
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
20215
+ }),
20216
+ { threadId: pending.threadId, chat_id: pending.chat_id, verb: 'vault_request_access.passphrase_prompt' },
20217
+ ).catch(() => {})
20046
20218
  return
20047
20219
  }
20048
20220
 
@@ -22076,7 +22248,11 @@ bot.on('callback_query:data', async ctx => {
22076
22248
  await ctx.answerCallbackQuery({ text: 'Tap a model in this section to switch' }).catch(() => {})
22077
22249
  return
22078
22250
  }
22079
- await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
22251
+ // Page navigation (External / ◂ Back) just re-renders the menu with the
22252
+ // other keyboard page — it never drives the picker, so ack "Loading…"
22253
+ // rather than the switch-oriented "Switching…".
22254
+ const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN
22255
+ await ctx.answerCallbackQuery({ text: isPageNav ? 'Loading…' : 'Switching…' }).catch(() => {})
22080
22256
  // sr-* inject waits for claude to respond (can take 10-30s). Edit the
22081
22257
  // menu immediately to show a "working on it" state so the operator isn't
22082
22258
  // left looking at a stale menu with no feedback. The final edit (✅/❌)
@@ -22453,7 +22629,6 @@ bot.on('callback_query:data', async ctx => {
22453
22629
  .catch(() => {})
22454
22630
  return
22455
22631
  }
22456
- await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
22457
22632
  const cbChatId = String(ctx.chat?.id ?? ctx.from.id)
22458
22633
  const cbMessageId = ctx.callbackQuery?.message?.message_id
22459
22634
  const cbThreadId = (() => {
@@ -22464,31 +22639,57 @@ bot.on('callback_query:data', async ctx => {
22464
22639
  }
22465
22640
  return undefined
22466
22641
  })()
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
22642
+ // #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written
22643
+ // by the pre-synth queue at reply time). If it's on disk, attach it
22644
+ // immediately no GPU wait. Missing/unreadable file (expired + swept,
22645
+ // crash, pre-feature entry, kill-switched gateway) falls back
22646
+ // transparently to the lazy synth path below.
22647
+ let audio: Uint8Array | null = null
22648
+ if (entry.filePath != null) {
22649
+ try {
22650
+ audio = readFileSync(entry.filePath)
22651
+ } catch {
22652
+ audio = null // swept/missing — lazy fallback
22653
+ }
22476
22654
  }
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
22655
+ if (audio != null) {
22656
+ await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
22657
+ } else {
22658
+ await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
22659
+ // Local sidecar (kokoro) synthesis — same helper the immediate voice-out
22660
+ // path uses. On-demand is a local-engine feature; the cache is only
22661
+ // populated when resolveVoiceOutPlan gated the local verdict.
22662
+ const sidecarToken = await materializeSidecarToken()
22663
+ if (!sidecarToken) {
22664
+ await ctx
22665
+ .answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
22666
+ .catch(() => {})
22667
+ return
22668
+ }
22669
+ const result = await synthesizeViaSidecar({
22670
+ token: sidecarToken,
22671
+ // #2760 Phase 1: same deterministic normalization as the immediate
22672
+ // voice-out path — the Listen lazy path builds its own /tts body from
22673
+ // the persisted cache, so it must normalize independently (cache
22674
+ // entries may predate the flag flip).
22675
+ text: normalizeForTts(entry.text),
22676
+ voice: entry.voice,
22677
+ speed: entry.speed,
22678
+ })
22679
+ if (!result.ok) {
22680
+ process.stderr.write(
22681
+ `telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
22682
+ )
22683
+ await ctx
22684
+ .answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
22685
+ .catch(() => {})
22686
+ return
22687
+ }
22688
+ audio = result.audio
22491
22689
  }
22690
+ // Rebind as const so the closure below narrows to non-null (TS doesn't
22691
+ // narrow a captured `let` inside an arrow function).
22692
+ const audioOut: Uint8Array = audio
22492
22693
  try {
22493
22694
  // Native voice note (NOT a document), quote-replying the button's
22494
22695
  // message.
@@ -22497,7 +22698,7 @@ bot.on('callback_query:data', async ctx => {
22497
22698
  bot.api.sendVoice(
22498
22699
  cbChatId,
22499
22700
  // allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
22500
- new InputFile(Buffer.from(result.audio)),
22701
+ new InputFile(Buffer.from(audioOut)),
22501
22702
  {
22502
22703
  ...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
22503
22704
  ...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
@@ -22759,9 +22960,48 @@ bot.on('callback_query:data', async ctx => {
22759
22960
  action: naturalAction(details.tool_name, details.input_preview),
22760
22961
  })
22761
22962
 
22762
- // (3) Decide the persistence path. tryHostdDispatch returns
22763
- // "not-configured" when host_control is disabled or the per-agent
22764
- // socket is absent legacy fallback.
22963
+ // (3) Ack the tap IMMEDIATELY with an honest interim status. The
22964
+ // durable persist below goes through hostd `config_propose_edit`
22965
+ // (validate→approve→apply→reconcile), which can take 5-10 minutes on
22966
+ // a busy host — the old code awaited only 60s and then reported
22967
+ // "did NOT save" even when the edit later landed. Instead: ack now,
22968
+ // run the persistence in a background continuation with a 12-min
22969
+ // dispatch window, then edit the card with the REAL outcome.
22970
+ // HTML-escape baseText — `ctx.callbackQuery.message.text` returns
22971
+ // entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
22972
+ // expanded permission card's `description` or `input_preview`
22973
+ // (claude-generated tool descriptions routinely carry these)
22974
+ // would break the HTML re-parse. PR #1158 review caught the same
22975
+ // issue on the operator-event card.
22976
+ const sourceMsg = ctx.callbackQuery?.message
22977
+ const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
22978
+ ? escapeHtmlForTg(sourceMsg.text)
22979
+ : ''
22980
+ const interimLabel =
22981
+ `⏳ **Rule applied for this session** — ${escapeHtmlForTg(agentName)} can ${escapeHtmlForTg(grantPhrase)} ` +
22982
+ `without asking for now; saving durably in background…`
22983
+ // #1150 audit: route through finalizeCallback so the keyboard
22984
+ // strips alongside the status-line edit. The in-flight verdict was
22985
+ // ALREADY dispatched above (independently of this host round-trip)
22986
+ // so the turn never blocked — finalizeCallback here only edits the
22987
+ // card; no synthInbound (would double-fire the verdict).
22988
+ await finalizeCallback(ctx, {
22989
+ ackText: 'Rule applied for this session; saving durably in background…'.slice(0, 200),
22990
+ newText: baseText ? `${baseText}\n\n${interimLabel}` : interimLabel,
22991
+ })
22992
+
22993
+ // Background continuation: decide the persistence path.
22994
+ // tryHostdDispatch returns "not-configured" when host_control is
22995
+ // disabled or the per-agent socket is absent → legacy fallback.
22996
+ // The whole body is wrapped in try/catch: a throw inside a void
22997
+ // IIFE (scheduleGrantRestart's sync fs writes, richMessage, etc.)
22998
+ // would surface as an unhandledRejection — which this gateway
22999
+ // routes through shutdown(). Pre-background, those throws were
23000
+ // contained by grammY's handler wrapper; keep that containment.
23001
+ // Known accepted gap: if the gateway restarts mid-persist, the
23002
+ // interim "saving durably in background…" card stays stale.
23003
+ void (async () => {
23004
+ try {
22765
23005
  let durable = false
22766
23006
  let legacy = false
22767
23007
  let failReason = ''
@@ -22803,8 +23043,10 @@ bot.on('callback_query:data', async ctx => {
22803
23043
  },
22804
23044
  }
22805
23045
  // config_propose_edit blocks on validate→approve→apply→reconcile,
22806
- // so allow ~60s (well past the default 5s).
22807
- const resp = await tryHostdDispatch(agentName, req, 60_000)
23046
+ // which can legitimately take 5-10 minutes allow 12 min. Safe
23047
+ // to wait this long because we're in a background continuation:
23048
+ // the tap was already acked and the verdict already dispatched.
23049
+ const resp = await tryHostdDispatch(agentName, req, 720_000)
22808
23050
  if (resp === 'not-configured') {
22809
23051
  warnLegacySpawnIfHostdDisabled('always-allow')
22810
23052
  legacy = true
@@ -22888,23 +23130,6 @@ bot.on('callback_query:data', async ctx => {
22888
23130
  `always-allow: ${grantPhrase}`,
22889
23131
  ) !== "disabled"
22890
23132
  const liveSuffix = restartScheduled ? " — applying now (restarting to take effect)" : ""
22891
- const ackText = ok
22892
- ? (legacyNote
22893
- ? `✅ Saved. ${agentName} can now ${grantPhrase} without asking (legacy path).`
22894
- : `✅ Saved. ${agentName} can now ${grantPhrase} without asking.${liveSuffix}`)
22895
- : (editLockHint
22896
- ? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
22897
- : `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
22898
- // HTML-escape baseText — `ctx.callbackQuery.message.text` returns
22899
- // entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
22900
- // expanded permission card's `description` or `input_preview`
22901
- // (claude-generated tool descriptions routinely carry these)
22902
- // would break the HTML re-parse. PR #1158 review caught the same
22903
- // issue on the operator-event card.
22904
- const sourceMsg = ctx.callbackQuery?.message
22905
- const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
22906
- ? escapeHtmlForTg(sourceMsg.text)
22907
- : ''
22908
23133
  const editLabel = ok
22909
23134
  ? (legacyNote
22910
23135
  ? `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking (legacy path); restart agent for full effect`
@@ -22914,15 +23139,30 @@ bot.on('callback_query:data', async ctx => {
22914
23139
  : (editLockHint
22915
23140
  ? `⚠️ **Allowed for now — "always" did NOT save.** Config edits are locked; enable \`hostd.config_edit_enabled\`.`
22916
23141
  : `⚠️ **Allowed for now — "always" did NOT save.** It will ask again after restart. Check gateway log.`)
22917
- // #1150 audit: route through finalizeCallback so the keyboard
22918
- // strips alongside the status-line edit. The in-flight verdict was
22919
- // ALREADY dispatched above (independently of this host round-trip)
22920
- // so the turn never blocked finalizeCallback here only edits the
22921
- // card; no synthInbound (would double-fire the verdict).
22922
- await finalizeCallback(ctx, {
22923
- ackText: ackText.slice(0, 200),
22924
- newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
23142
+ // Edit the card with the REAL outcome. The keyboard was already
23143
+ // stripped by the interim finalizeCallback ack above, and the
23144
+ // callback query was already answered this is a plain edit
23145
+ // replacing the "saving durably in background…" interim line.
23146
+ await ctx.editMessageText(
23147
+ richMessage(baseText ? `${baseText}\n\n${editLabel}` : editLabel),
23148
+ {
23149
+ reply_markup: { inline_keyboard: [] },
23150
+ link_preview_options: { is_disabled: true },
23151
+ },
23152
+ ).catch((err: unknown) => {
23153
+ process.stderr.write(
23154
+ `telegram gateway: always-allow outcome card edit failed: ${(err as Error).message} (request_id=${request_id})\n`,
23155
+ )
22925
23156
  })
23157
+ } catch (err) {
23158
+ // Never let the background persist take the gateway down — an
23159
+ // unhandledRejection here becomes shutdown(). Log and move on;
23160
+ // the in-session rule was already applied at tap time.
23161
+ process.stderr.write(
23162
+ `telegram gateway: always-allow background persist threw: ${(err as Error).message} (request_id=${request_id})\n`,
23163
+ )
23164
+ }
23165
+ })()
22926
23166
  return
22927
23167
  }
22928
23168