switchroom 0.16.38 → 0.16.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/agent-scheduler/index.js +88 -82
  2. package/dist/auth-broker/index.js +87 -81
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +89 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1621 -737
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +271 -239
  10. package/dist/vault/approvals/kernel-server.js +90 -84
  11. package/dist/vault/broker/server.js +91 -85
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +2 -2
  15. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  16. package/skills/switchroom-status/SKILL.md +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -1
  18. package/telegram-plugin/card-format.ts +7 -1
  19. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  20. package/telegram-plugin/dist/gateway/gateway.js +2090 -1046
  21. package/telegram-plugin/dist/server.js +180 -162
  22. package/telegram-plugin/format.ts +305 -31
  23. package/telegram-plugin/gateway/gateway.ts +262 -63
  24. package/telegram-plugin/gateway/model-command.ts +173 -19
  25. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  26. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  27. package/telegram-plugin/package.json +1 -1
  28. package/telegram-plugin/session-tail.ts +47 -1
  29. package/telegram-plugin/stream-reply-handler.ts +19 -1
  30. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  31. package/telegram-plugin/tests/card-format.test.ts +28 -0
  32. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  33. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  34. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  35. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  36. package/telegram-plugin/tests/model-command.test.ts +213 -47
  37. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  38. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  40. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  41. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  42. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  43. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  44. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  45. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  46. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  47. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  48. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  49. package/telegram-plugin/text-voice-scrub.ts +68 -18
  50. package/telegram-plugin/tool-activity-summary.ts +20 -108
  51. package/telegram-plugin/tts-normalize.ts +377 -0
  52. package/telegram-plugin/uat/driver.ts +472 -22
  53. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  54. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  56. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  57. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  58. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  59. package/telegram-plugin/voice-ondemand.ts +161 -10
  60. package/telegram-plugin/voice-presynth.ts +242 -0
  61. 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
@@ -22076,7 +22207,11 @@ bot.on('callback_query:data', async ctx => {
22076
22207
  await ctx.answerCallbackQuery({ text: 'Tap a model in this section to switch' }).catch(() => {})
22077
22208
  return
22078
22209
  }
22079
- await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
22210
+ // Page navigation (External / ◂ Back) just re-renders the menu with the
22211
+ // other keyboard page — it never drives the picker, so ack "Loading…"
22212
+ // rather than the switch-oriented "Switching…".
22213
+ const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN
22214
+ await ctx.answerCallbackQuery({ text: isPageNav ? 'Loading…' : 'Switching…' }).catch(() => {})
22080
22215
  // sr-* inject waits for claude to respond (can take 10-30s). Edit the
22081
22216
  // menu immediately to show a "working on it" state so the operator isn't
22082
22217
  // left looking at a stale menu with no feedback. The final edit (✅/❌)
@@ -22453,7 +22588,6 @@ bot.on('callback_query:data', async ctx => {
22453
22588
  .catch(() => {})
22454
22589
  return
22455
22590
  }
22456
- await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
22457
22591
  const cbChatId = String(ctx.chat?.id ?? ctx.from.id)
22458
22592
  const cbMessageId = ctx.callbackQuery?.message?.message_id
22459
22593
  const cbThreadId = (() => {
@@ -22464,31 +22598,57 @@ bot.on('callback_query:data', async ctx => {
22464
22598
  }
22465
22599
  return undefined
22466
22600
  })()
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
22601
+ // #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written
22602
+ // by the pre-synth queue at reply time). If it's on disk, attach it
22603
+ // immediately no GPU wait. Missing/unreadable file (expired + swept,
22604
+ // crash, pre-feature entry, kill-switched gateway) falls back
22605
+ // transparently to the lazy synth path below.
22606
+ let audio: Uint8Array | null = null
22607
+ if (entry.filePath != null) {
22608
+ try {
22609
+ audio = readFileSync(entry.filePath)
22610
+ } catch {
22611
+ audio = null // swept/missing — lazy fallback
22612
+ }
22476
22613
  }
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
22614
+ if (audio != null) {
22615
+ await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
22616
+ } else {
22617
+ await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
22618
+ // Local sidecar (kokoro) synthesis — same helper the immediate voice-out
22619
+ // path uses. On-demand is a local-engine feature; the cache is only
22620
+ // populated when resolveVoiceOutPlan gated the local verdict.
22621
+ const sidecarToken = await materializeSidecarToken()
22622
+ if (!sidecarToken) {
22623
+ await ctx
22624
+ .answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
22625
+ .catch(() => {})
22626
+ return
22627
+ }
22628
+ const result = await synthesizeViaSidecar({
22629
+ token: sidecarToken,
22630
+ // #2760 Phase 1: same deterministic normalization as the immediate
22631
+ // voice-out path — the Listen lazy path builds its own /tts body from
22632
+ // the persisted cache, so it must normalize independently (cache
22633
+ // entries may predate the flag flip).
22634
+ text: normalizeForTts(entry.text),
22635
+ voice: entry.voice,
22636
+ speed: entry.speed,
22637
+ })
22638
+ if (!result.ok) {
22639
+ process.stderr.write(
22640
+ `telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
22641
+ )
22642
+ await ctx
22643
+ .answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
22644
+ .catch(() => {})
22645
+ return
22646
+ }
22647
+ audio = result.audio
22491
22648
  }
22649
+ // Rebind as const so the closure below narrows to non-null (TS doesn't
22650
+ // narrow a captured `let` inside an arrow function).
22651
+ const audioOut: Uint8Array = audio
22492
22652
  try {
22493
22653
  // Native voice note (NOT a document), quote-replying the button's
22494
22654
  // message.
@@ -22497,7 +22657,7 @@ bot.on('callback_query:data', async ctx => {
22497
22657
  bot.api.sendVoice(
22498
22658
  cbChatId,
22499
22659
  // allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
22500
- new InputFile(Buffer.from(result.audio)),
22660
+ new InputFile(Buffer.from(audioOut)),
22501
22661
  {
22502
22662
  ...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
22503
22663
  ...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
@@ -22759,9 +22919,48 @@ bot.on('callback_query:data', async ctx => {
22759
22919
  action: naturalAction(details.tool_name, details.input_preview),
22760
22920
  })
22761
22921
 
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.
22922
+ // (3) Ack the tap IMMEDIATELY with an honest interim status. The
22923
+ // durable persist below goes through hostd `config_propose_edit`
22924
+ // (validate→approve→apply→reconcile), which can take 5-10 minutes on
22925
+ // a busy host — the old code awaited only 60s and then reported
22926
+ // "did NOT save" even when the edit later landed. Instead: ack now,
22927
+ // run the persistence in a background continuation with a 12-min
22928
+ // dispatch window, then edit the card with the REAL outcome.
22929
+ // HTML-escape baseText — `ctx.callbackQuery.message.text` returns
22930
+ // entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
22931
+ // expanded permission card's `description` or `input_preview`
22932
+ // (claude-generated tool descriptions routinely carry these)
22933
+ // would break the HTML re-parse. PR #1158 review caught the same
22934
+ // issue on the operator-event card.
22935
+ const sourceMsg = ctx.callbackQuery?.message
22936
+ const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
22937
+ ? escapeHtmlForTg(sourceMsg.text)
22938
+ : ''
22939
+ const interimLabel =
22940
+ `⏳ **Rule applied for this session** — ${escapeHtmlForTg(agentName)} can ${escapeHtmlForTg(grantPhrase)} ` +
22941
+ `without asking for now; saving durably in background…`
22942
+ // #1150 audit: route through finalizeCallback so the keyboard
22943
+ // strips alongside the status-line edit. The in-flight verdict was
22944
+ // ALREADY dispatched above (independently of this host round-trip)
22945
+ // so the turn never blocked — finalizeCallback here only edits the
22946
+ // card; no synthInbound (would double-fire the verdict).
22947
+ await finalizeCallback(ctx, {
22948
+ ackText: 'Rule applied for this session; saving durably in background…'.slice(0, 200),
22949
+ newText: baseText ? `${baseText}\n\n${interimLabel}` : interimLabel,
22950
+ })
22951
+
22952
+ // Background continuation: decide the persistence path.
22953
+ // tryHostdDispatch returns "not-configured" when host_control is
22954
+ // disabled or the per-agent socket is absent → legacy fallback.
22955
+ // The whole body is wrapped in try/catch: a throw inside a void
22956
+ // IIFE (scheduleGrantRestart's sync fs writes, richMessage, etc.)
22957
+ // would surface as an unhandledRejection — which this gateway
22958
+ // routes through shutdown(). Pre-background, those throws were
22959
+ // contained by grammY's handler wrapper; keep that containment.
22960
+ // Known accepted gap: if the gateway restarts mid-persist, the
22961
+ // interim "saving durably in background…" card stays stale.
22962
+ void (async () => {
22963
+ try {
22765
22964
  let durable = false
22766
22965
  let legacy = false
22767
22966
  let failReason = ''
@@ -22803,8 +23002,10 @@ bot.on('callback_query:data', async ctx => {
22803
23002
  },
22804
23003
  }
22805
23004
  // 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)
23005
+ // which can legitimately take 5-10 minutes allow 12 min. Safe
23006
+ // to wait this long because we're in a background continuation:
23007
+ // the tap was already acked and the verdict already dispatched.
23008
+ const resp = await tryHostdDispatch(agentName, req, 720_000)
22808
23009
  if (resp === 'not-configured') {
22809
23010
  warnLegacySpawnIfHostdDisabled('always-allow')
22810
23011
  legacy = true
@@ -22888,23 +23089,6 @@ bot.on('callback_query:data', async ctx => {
22888
23089
  `always-allow: ${grantPhrase}`,
22889
23090
  ) !== "disabled"
22890
23091
  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
23092
  const editLabel = ok
22909
23093
  ? (legacyNote
22910
23094
  ? `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking (legacy path); restart agent for full effect`
@@ -22914,15 +23098,30 @@ bot.on('callback_query:data', async ctx => {
22914
23098
  : (editLockHint
22915
23099
  ? `⚠️ **Allowed for now — "always" did NOT save.** Config edits are locked; enable \`hostd.config_edit_enabled\`.`
22916
23100
  : `⚠️ **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,
23101
+ // Edit the card with the REAL outcome. The keyboard was already
23102
+ // stripped by the interim finalizeCallback ack above, and the
23103
+ // callback query was already answered this is a plain edit
23104
+ // replacing the "saving durably in background…" interim line.
23105
+ await ctx.editMessageText(
23106
+ richMessage(baseText ? `${baseText}\n\n${editLabel}` : editLabel),
23107
+ {
23108
+ reply_markup: { inline_keyboard: [] },
23109
+ link_preview_options: { is_disabled: true },
23110
+ },
23111
+ ).catch((err: unknown) => {
23112
+ process.stderr.write(
23113
+ `telegram gateway: always-allow outcome card edit failed: ${(err as Error).message} (request_id=${request_id})\n`,
23114
+ )
22925
23115
  })
23116
+ } catch (err) {
23117
+ // Never let the background persist take the gateway down — an
23118
+ // unhandledRejection here becomes shutdown(). Log and move on;
23119
+ // the in-session rule was already applied at tap time.
23120
+ process.stderr.write(
23121
+ `telegram gateway: always-allow background persist threw: ${(err as Error).message} (request_id=${request_id})\n`,
23122
+ )
23123
+ }
23124
+ })()
22926
23125
  return
22927
23126
  }
22928
23127