switchroom 0.16.22 → 0.16.24

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 (118) hide show
  1. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  2. package/dist/cli/skill-validate-pretool.mjs +2865 -48
  3. package/dist/cli/switchroom.js +338 -92
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +5 -5
  6. package/telegram-plugin/answer-stream.ts +13 -43
  7. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  8. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  9. package/telegram-plugin/card-format.ts +28 -25
  10. package/telegram-plugin/credits-watch.ts +5 -10
  11. package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
  12. package/telegram-plugin/draft-stream.ts +4 -4
  13. package/telegram-plugin/format.ts +101 -662
  14. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  15. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  16. package/telegram-plugin/gateway/approval-card.ts +16 -6
  17. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  18. package/telegram-plugin/gateway/auth-command.ts +74 -74
  19. package/telegram-plugin/gateway/auth-line.ts +5 -15
  20. package/telegram-plugin/gateway/boot-card.ts +20 -22
  21. package/telegram-plugin/gateway/boot-version.ts +3 -2
  22. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  23. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  24. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  25. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  26. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  27. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  28. package/telegram-plugin/gateway/effort-command.ts +17 -17
  29. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  30. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  31. package/telegram-plugin/gateway/gateway.ts +751 -624
  32. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  33. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  34. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  35. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  36. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  37. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  38. package/telegram-plugin/gateway/model-command.ts +25 -25
  39. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  40. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  41. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  42. package/telegram-plugin/issues-card.ts +6 -7
  43. package/telegram-plugin/model-unavailable.ts +8 -12
  44. package/telegram-plugin/operator-events-history.ts +1 -1
  45. package/telegram-plugin/operator-events.ts +24 -28
  46. package/telegram-plugin/package.json +1 -1
  47. package/telegram-plugin/pending-work-progress.ts +36 -36
  48. package/telegram-plugin/permission-title.ts +36 -20
  49. package/telegram-plugin/pty-partial-handler.ts +5 -13
  50. package/telegram-plugin/quota-check.ts +5 -5
  51. package/telegram-plugin/quota-watch.ts +13 -18
  52. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  53. package/telegram-plugin/registry/turns-schema.ts +44 -2
  54. package/telegram-plugin/retry-api-call.ts +15 -7
  55. package/telegram-plugin/rich-send.ts +57 -0
  56. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  57. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  58. package/telegram-plugin/shared/bot-runtime.ts +23 -6
  59. package/telegram-plugin/silence-poke.ts +26 -69
  60. package/telegram-plugin/slot-banner-driver.ts +9 -6
  61. package/telegram-plugin/slot-banner.ts +5 -8
  62. package/telegram-plugin/steering.ts +0 -4
  63. package/telegram-plugin/stream-controller.ts +59 -62
  64. package/telegram-plugin/stream-reply-handler.ts +40 -97
  65. package/telegram-plugin/subagent-watcher.ts +2 -2
  66. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  67. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  68. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  69. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  70. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  71. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  72. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  73. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  74. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  75. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  76. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  77. package/telegram-plugin/tests/card-format.test.ts +6 -4
  78. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  79. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  80. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  81. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  82. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  83. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  84. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  85. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  86. package/telegram-plugin/tests/model-command.test.ts +2 -2
  87. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  88. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  89. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  90. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  91. package/telegram-plugin/tests/permission-title.test.ts +45 -41
  92. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  93. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  94. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  95. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  96. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  97. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  98. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  99. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  100. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  101. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  102. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  103. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  104. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  105. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  106. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  107. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  108. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  109. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  110. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  111. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  112. package/telegram-plugin/text-voice-scrub.ts +8 -11
  113. package/telegram-plugin/tool-activity-summary.ts +29 -29
  114. package/telegram-plugin/welcome-text.ts +82 -83
  115. package/telegram-plugin/worker-activity-feed.ts +2 -3
  116. package/telegram-plugin/html-sanitize.ts +0 -244
  117. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  118. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -189,8 +189,8 @@ const REPLY_TO_TEXT_MAX = 200
189
189
  const SILENT_END_FALLBACK_TEXT =
190
190
  'āš ļø The agent finished working but didn’t send a reply — your last ' +
191
191
  'message may not have been answered. Please try asking again.'
192
- import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
193
- import { sanitizeTelegramHtml } from '../html-sanitize.js'
192
+ import { splitMarkdownChunks, repairEscapedWhitespace, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
193
+ import { richMessage } from '../rich-send.js'
194
194
  import { scrubVoice } from '../text-voice-scrub.js'
195
195
  import {
196
196
  validateInlineKeyboard,
@@ -384,6 +384,18 @@ import {
384
384
  buildVaultSaveFailedInbound,
385
385
  buildVaultSaveDiscardedInbound,
386
386
  } from './vault-grant-inbound-builders.js'
387
+ import {
388
+ parseSkillProposalCallback,
389
+ buildSkillProposalApplyInbound,
390
+ renderSkillProposalCard,
391
+ skillProposalKeyboard,
392
+ } from './skill-proposal-card.js'
393
+ import {
394
+ getProposal as getSkillProposal,
395
+ setProposalStatus as setSkillProposalStatus,
396
+ enqueueProposal as enqueueSkillProposal,
397
+ isSuppressed as isSkillProposalSuppressed,
398
+ } from '../../src/self-improve/skill-proposals.js'
387
399
  import { decideSubagentHandback } from './subagent-handback-inbound-builder.js'
388
400
  import {
389
401
  decideSubagentProgress,
@@ -408,6 +420,7 @@ import type {
408
420
  InjectInboundMessage,
409
421
  SendOutboundMessage,
410
422
  QuotaWallDetectedMessage,
423
+ PostSkillProposalMessage,
411
424
  PermissionEvent,
412
425
  } from './ipc-protocol.js'
413
426
  import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
@@ -934,7 +947,8 @@ function defaultAccess(): Access {
934
947
  return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} }
935
948
  }
936
949
 
937
- const MAX_CHUNK_LIMIT = 4096
950
+ // Rich-message wire cap (#2669): the rich path allows 32768 UTF-8 chars.
951
+ const MAX_CHUNK_LIMIT = RICH_MESSAGE_MAX_CHARS
938
952
  const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
939
953
 
940
954
  function assertSendable(f: string): void {
@@ -1922,7 +1936,6 @@ interface OutstandingCompactCard {
1922
1936
  let compactNotifyState: CompactNotifyState = idleCompactNotifyState()
1923
1937
  let outstandingCompactCard: OutstandingCompactCard | null = null
1924
1938
  const activeDraftStreams = new Map<string, DraftStreamHandle>()
1925
- const activeDraftParseModes = new Map<string, 'HTML' | 'MarkdownV2' | undefined>()
1926
1939
  const suppressPtyPreview = new Set<string>()
1927
1940
  const lastPtyPreviewByChat = new Map<string, string>()
1928
1941
  const progressUpdateLastSent = new Map<string, number>()
@@ -3662,15 +3675,14 @@ async function postCompactCard(occ: number, cap: number): Promise<void> {
3662
3675
  supergroupChatId: resolveAgentSupergroupChatId(),
3663
3676
  });
3664
3677
  const text =
3665
- `šŸ—œļø <b>Context compaction</b>\n` +
3678
+ `šŸ—œļø **Context compaction**\n` +
3666
3679
  `Working context hit ~${occ.toLocaleString()} tokens ` +
3667
- `(cap ${cap.toLocaleString()}) — running <code>/compact</code>. ` +
3680
+ `(cap ${cap.toLocaleString()}) — running \`/compact\`. ` +
3668
3681
  `Older detail moves to Hindsight; I'll confirm here once the ` +
3669
3682
  `context has shrunk (may take a turn or two).`;
3670
3683
  const sent = await swallowingApiCall(
3671
3684
  () =>
3672
- bot.api.sendMessage(chatId, text, {
3673
- parse_mode: 'HTML',
3685
+ bot.api.sendRichMessage(chatId, richMessage(text), {
3674
3686
  ...(threadId != null ? { message_thread_id: threadId } : {}),
3675
3687
  }),
3676
3688
  { chat_id: chatId, verb: 'proactiveCompact.start' },
@@ -3720,7 +3732,7 @@ async function resolveCompactCard(
3720
3732
  let text: string;
3721
3733
  if (kind === 'finished') {
3722
3734
  text =
3723
- `āœ… <b>Context compacted</b>\n` +
3735
+ `āœ… **Context compacted**\n` +
3724
3736
  `Working context reduced` +
3725
3737
  (occNow != null
3726
3738
  ? ` (~${card.occAtStart.toLocaleString()} → ` +
@@ -3729,19 +3741,18 @@ async function resolveCompactCard(
3729
3741
  `. Hindsight retains the detail.`;
3730
3742
  } else if (kind === 'superseded') {
3731
3743
  text =
3732
- `ā†©ļø <b>Context compaction superseded</b>\n` +
3744
+ `ā†©ļø **Context compaction superseded**\n` +
3733
3745
  `A newer compaction started before this one confirmed.`;
3734
3746
  } else {
3735
3747
  text =
3736
- `āš ļø <b>Compaction issued</b>\n` +
3737
- `<code>/compact</code> was requested but the context isn't ` +
3748
+ `āš ļø **Compaction issued**\n` +
3749
+ `\`/compact\` was requested but the context isn't ` +
3738
3750
  `confirmed reduced yet. Native compaction and Hindsight still apply.`;
3739
3751
  }
3740
3752
  try {
3741
3753
  await swallowingApiCall(
3742
3754
  () =>
3743
- bot.api.editMessageText(card.chatId, card.messageId, text, {
3744
- parse_mode: 'HTML',
3755
+ bot.api.editMessageText(card.chatId, card.messageId, richMessage(text), {
3745
3756
  }),
3746
3757
  { chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
3747
3758
  );
@@ -3945,8 +3956,7 @@ function postPermissionResumeMessage(opts: {
3945
3956
  // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
3946
3957
  void swallowingApiCall(
3947
3958
  () =>
3948
- bot.api.sendMessage(chatId, text, {
3949
- parse_mode: 'HTML',
3959
+ bot.api.sendRichMessage(chatId, richMessage(text), {
3950
3960
  ...(threadId != null ? { message_thread_id: threadId } : {}),
3951
3961
  }),
3952
3962
  { chat_id: chatId, verb: 'permission-resume', ...(threadId != null ? { threadId } : {}) },
@@ -4033,21 +4043,6 @@ function chunk(text: string, limit: number, mode: 'length' | 'newline'): string[
4033
4043
  return out
4034
4044
  }
4035
4045
 
4036
- function escapeMarkdownV2(text: string): string {
4037
- const specialChars = /[_*\[\]()~`>#+\-=|{}.!\\]/g
4038
- const parts: string[] = []
4039
- let last = 0
4040
- const codeRe = /(```[\s\S]*?```|`[^`\n]+`)/g
4041
- let m: RegExpExecArray | null
4042
- while ((m = codeRe.exec(text)) !== null) {
4043
- if (m.index > last) parts.push(text.slice(last, m.index).replace(specialChars, '\\$&'))
4044
- parts.push(m[0])
4045
- last = m.index + m[0].length
4046
- }
4047
- if (last < text.length) parts.push(text.slice(last).replace(specialChars, '\\$&'))
4048
- return parts.join('')
4049
- }
4050
-
4051
4046
  // ─── Typing indicator ─────────────────────────────────────────────────────
4052
4047
  // All four state maps re-keyed from `chat_id` to `chatKey(chat, thread)`
4053
4048
  // in PR3 of the supergroup-mode rollout. In supergroup mode one agent
@@ -4193,14 +4188,17 @@ function wrapBootCardApi(
4193
4188
  verb: 'boot-card',
4194
4189
  ...(threadId != null ? { threadId } : {}),
4195
4190
  })
4191
+ // #2669: card bodies are raw GFM markdown — send them via the
4192
+ // rich-message path. The card modules pass plain `opts` (no parse_mode).
4196
4193
  return {
4197
4194
  sendMessage: async (cid, text, sendOpts) => {
4198
4195
  const sent = await robustApiCall(
4199
4196
  () =>
4200
- lockedBot.api.sendMessage(
4197
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
4198
+ lockedBot.api.sendRichMessage(
4201
4199
  cid,
4202
- text,
4203
- sendOpts as Parameters<typeof lockedBot.api.sendMessage>[2],
4200
+ richMessage(text),
4201
+ sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
4204
4202
  ),
4205
4203
  opts(cid),
4206
4204
  )
@@ -4212,7 +4210,7 @@ function wrapBootCardApi(
4212
4210
  lockedBot.api.editMessageText(
4213
4211
  cid,
4214
4212
  mid,
4215
- text,
4213
+ richMessage(text),
4216
4214
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
4217
4215
  ),
4218
4216
  opts(cid),
@@ -4226,7 +4224,7 @@ function wrapBootCardApi(
4226
4224
  type EditOpts = Parameters<Api['editMessageText']>[3]
4227
4225
  try {
4228
4226
  // allow-raw-bot-api: boot-card edit-in-place probe — must detect a deleted target, which the shared retry policy swallows.
4229
- await lockedBot.api.editMessageText(cid, mid, text, editOpts as EditOpts)
4227
+ await lockedBot.api.editMessageText(cid, mid, richMessage(text), editOpts as EditOpts)
4230
4228
  return 'edited'
4231
4229
  } catch (err) {
4232
4230
  const desc =
@@ -4256,14 +4254,16 @@ function wrapIssuesCardApi(
4256
4254
  verb: 'issues-card',
4257
4255
  ...(threadId != null ? { threadId } : {}),
4258
4256
  })
4257
+ // #2669: issues-card body is raw GFM markdown — send via the rich path.
4259
4258
  return {
4260
4259
  sendMessage: async (cid, text, sendOpts) => {
4261
4260
  const sent = await robustApiCall(
4262
4261
  () =>
4263
- lockedBot.api.sendMessage(
4262
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
4263
+ lockedBot.api.sendRichMessage(
4264
4264
  cid,
4265
- text,
4266
- sendOpts as Parameters<typeof lockedBot.api.sendMessage>[2],
4265
+ richMessage(text),
4266
+ sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
4267
4267
  ),
4268
4268
  opts(cid),
4269
4269
  )
@@ -4275,7 +4275,7 @@ function wrapIssuesCardApi(
4275
4275
  lockedBot.api.editMessageText(
4276
4276
  cid,
4277
4277
  mid,
4278
- text,
4278
+ richMessage(text),
4279
4279
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
4280
4280
  ),
4281
4281
  opts(cid),
@@ -5320,7 +5320,6 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
5320
5320
  // grammy's Other<...> opts type is generated and stricter than our
5321
5321
  // call shape; runtime accepts both. Cast through unknown.
5322
5322
  const opts = {
5323
- parse_mode: 'HTML' as const,
5324
5323
  ...(renderedKeyboard ? { reply_markup: renderedKeyboard } : {}),
5325
5324
  ...(opEventThread != null ? { message_thread_id: opEventThread } : {}),
5326
5325
  }
@@ -5328,7 +5327,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
5328
5327
  // very next line is what unlocks the raw bot.api call.
5329
5328
  // Opts now includes message_thread_id when supergroup mode is on.
5330
5329
  // allow-raw-bot-api: operator-event broadcast loop; topic-aware opts
5331
- void bot.api.sendMessage(chat_id, renderedText, opts as never).catch(e => {
5330
+ void bot.api.sendRichMessage(chat_id, richMessage(renderedText), opts as never).catch(e => {
5332
5331
  process.stderr.write(
5333
5332
  `telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
5334
5333
  )
@@ -5356,9 +5355,7 @@ function postLegacyBanner(
5356
5355
  retryWithThreadFallback(
5357
5356
  robustApiCall,
5358
5357
  (tid) =>
5359
- lockedBot.api.sendMessage(chatId, text, {
5360
- parse_mode: 'HTML',
5361
- link_preview_options: { is_disabled: true },
5358
+ lockedBot.api.sendRichMessage(chatId, richMessage(text), {
5362
5359
  ...(tid != null ? { message_thread_id: tid } : {}),
5363
5360
  ...(ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {}),
5364
5361
  }),
@@ -5612,11 +5609,14 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
5612
5609
 
5613
5610
  // #1122: framework safety-net for "model is silent to the user for >5min."
5614
5611
  // Starts a single setInterval poll that walks active turns; at 300s of
5615
- // silence the framework itself sends a user-visible "still working… /
5616
- // still thinking…" message AND unwedges the turn. The model-targeted
5617
- // nudge ladder (ack/soft/firm) and the 60s awareness ping were retired
5618
- // once the live-updating reply/draft took over the pacing job — only
5619
- // this single unwedge fallback remains. Honours
5612
+ // silence the framework unwedges the turn. The generic "still working… /
5613
+ // still thinking…" stall message it used to send was a stop-gap from
5614
+ // before the live draft carried the progress beats and has been retired
5615
+ // (the stall-stop-gap PR) — the surviving user-visible sends are the
5616
+ // deterministic update_apply phase line and the approval-blocked re-ping.
5617
+ // The model-targeted nudge ladder (ack/soft/firm) and the 60s awareness
5618
+ // ping were retired earlier once the live-updating reply/draft took over
5619
+ // the pacing job — only this single unwedge fallback remains. Honours
5620
5620
  // SWITCHROOM_DISABLE_SILENCE_POKE=1 kill switch (no-op if set).
5621
5621
  // Set when this gateway dispatches an `update_apply` to hostd that
5622
5622
  // returns `started`; cleared when the dispatch poll resolves (terminal
@@ -5752,6 +5752,14 @@ silencePoke.startTimer({
5752
5752
  ctx.inFlightTools,
5753
5753
  blockedOnApproval,
5754
5754
  )
5755
+ // The stall-notice stop-gap is retired: `formatFrameworkFallbackText`
5756
+ // returns null for the pure-liveness "still working / running <Tool>"
5757
+ // beat (the only string it still emits is the approval-blocked re-ping).
5758
+ // The early quiet floor beat existed ONLY to surface that stall notice, so
5759
+ // with the notice gone there is nothing to send here unless the turn is
5760
+ // parked on an approval card. Skip silently otherwise; the live draft +
5761
+ // the model's own pacing beats carry progress.
5762
+ if (text == null) return
5755
5763
  try {
5756
5764
  await robustApiCall(
5757
5765
  () => bot.api.sendMessage(ctx.chatId, text, {
@@ -5849,7 +5857,10 @@ silencePoke.startTimer({
5849
5857
  )
5850
5858
  }
5851
5859
  // #2527: log the actual poke fire with structured data before sending,
5852
- // so the event is visible even if the send fails.
5860
+ // so the event is visible even if the send fails. The stall-notice
5861
+ // stop-gap is retired (the user-visible send below is now gated), but the
5862
+ // fire event is still logged so observability + the unwedge accounting are
5863
+ // unchanged — mirrors PR #2641 (drop the chat badge, keep the log).
5853
5864
  logStreamingEvent({
5854
5865
  kind: 'silence_poke_fire',
5855
5866
  chatId: ctx.chatId,
@@ -5857,24 +5868,34 @@ silencePoke.startTimer({
5857
5868
  silenceMs: ctx.silenceMs,
5858
5869
  fallbackKind: ctx.fallbackKind,
5859
5870
  })
5860
- try {
5861
- await robustApiCall(
5862
- () => bot.api.sendMessage(ctx.chatId, text, {
5863
- ...(ctx.threadId != null ? { message_thread_id: ctx.threadId } : {}),
5864
- // Conditional: the pure-liveness "still working…" notice is a status
5865
- // surface and stays SILENT. But when the turn is parked on an
5866
- // approval card, this same fallback carries a user-gating re-ping
5867
- // ("waiting for your approval — tap Approve or Deny …") — that must
5868
- // PING, because the user is the one being waited on. Gate on the same
5869
- // `blockedOnApproval` signal that selects the re-ping text above.
5870
- disable_notification: blockedOnApproval ? false : true,
5871
- }),
5872
- { chat_id: ctx.chatId, ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}) },
5873
- )
5874
- } catch (err) {
5875
- process.stderr.write(
5876
- `silence-poke fallback sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
5877
- )
5871
+ // Send a user-visible message ONLY when there's something honest to say:
5872
+ // - a deterministic in-flight update_apply phase (`text` set above), or
5873
+ // - the approval-blocked re-ping (`formatFrameworkFallbackText` returns a
5874
+ // string only for that case).
5875
+ // The generic "still working… (no update from agent in N min)" stall notice
5876
+ // is retired — `formatFrameworkFallbackText` returns null for it, so `text`
5877
+ // stays null and we skip the send. The turn-teardown below (the unwedge —
5878
+ // the one job the live draft can't do, per the conversational-pacing RFC)
5879
+ // still runs unconditionally.
5880
+ if (text != null) {
5881
+ try {
5882
+ await robustApiCall(
5883
+ () => bot.api.sendMessage(ctx.chatId, text, {
5884
+ ...(ctx.threadId != null ? { message_thread_id: ctx.threadId } : {}),
5885
+ // Conditional: when the turn is parked on an approval card, this
5886
+ // fallback carries a user-gating re-ping ("waiting for your
5887
+ // approval — tap Approve or Deny …") — that must PING, because the
5888
+ // user is the one being waited on. The deterministic update-status
5889
+ // line stays SILENT (a status surface). Gate on `blockedOnApproval`.
5890
+ disable_notification: blockedOnApproval ? false : true,
5891
+ }),
5892
+ { chat_id: ctx.chatId, ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}) },
5893
+ )
5894
+ } catch (err) {
5895
+ process.stderr.write(
5896
+ `silence-poke fallback sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
5897
+ )
5898
+ }
5878
5899
  }
5879
5900
  // #1122 follow-up: end the wedged turn AFTER the fallback fires.
5880
5901
  // Without this, `activeTurnStartedAt` stays set, every subsequent
@@ -6197,19 +6218,18 @@ _deliveryConfirmSweep.unref?.()
6197
6218
  // `SWITCHROOM_DISABLE_PENDING_PROGRESS=1`.
6198
6219
  pendingProgress.startTimer({
6199
6220
  editMessage: async (ctx) => {
6200
- // #1698: preserve the anchor's original parse_mode. Without this
6201
- // the edit goes out as plain text, and any <b>/<code>/<a> tag in
6202
- // anchorOriginalText (the model authored HTML via the reply tool,
6203
- // which defaults to format='html') re-renders as literal text the
6204
- // moment the first "still working (Nm)" tick fires.
6205
- const editOpts = ctx.parseMode != null ? { parse_mode: ctx.parseMode } : undefined
6221
+ // #2669: re-edit via the SAME shape the anchor was sent with. A rich
6222
+ // anchor (the reply tool's default) re-edits via the rich-markdown
6223
+ // path so the body keeps rendering its markdown; a literal
6224
+ // `format:'text'` anchor re-edits as a plain string. This is the
6225
+ // single-rich-path successor to the #1698 parse-mode contract.
6206
6226
  await swallowingApiCall(
6207
6227
  () =>
6208
6228
  lockedBot.api.editMessageText(
6209
6229
  ctx.chatId,
6210
6230
  ctx.messageId,
6211
- ctx.newText,
6212
- editOpts,
6231
+ ctx.literalText ? ctx.newText : richMessage(ctx.newText),
6232
+ undefined,
6213
6233
  ),
6214
6234
  {
6215
6235
  chat_id: ctx.chatId,
@@ -6792,7 +6812,6 @@ const ipcServer: IpcServer = createIpcServer({
6792
6812
  activeTurnStartedAt,
6793
6813
  claudeBusyKeys,
6794
6814
  activeDraftStreams,
6795
- activeDraftParseModes,
6796
6815
  clearActiveReactions: () => {
6797
6816
  const ad = resolveAgentDirFromEnv()
6798
6817
  if (ad) clearActiveReactions(ad)
@@ -6981,8 +7000,8 @@ const ipcServer: IpcServer = createIpcServer({
6981
7000
  // Natural-language card body — a plain sentence ("Gymbro wants to
6982
7001
  // edit: supplement-log.md" + a why-line), never a raw tool id.
6983
7002
  // The operator sees what is being requested and why at a glance.
6984
- // Mirrors the `vault_request_access` card layout. Sent with
6985
- // parse_mode=HTML below.
7003
+ // Mirrors the `vault_request_access` card layout. Sent via the
7004
+ // rich-markdown path below.
6986
7005
  const text = formatPermissionCardBody({
6987
7006
  toolName,
6988
7007
  inputPreview,
@@ -7009,8 +7028,8 @@ const ipcServer: IpcServer = createIpcServer({
7009
7028
  const activeTurn = currentTurn
7010
7029
  const targets = resolvePermissionCardTargets()
7011
7030
  for (const { chatId, threadId } of targets) {
7012
- // parse_mode=HTML pairs with formatPermissionCardBody (#1790) so the
7013
- // <b>/<i> tags render. retryWithThreadFallback: if the topic was
7031
+ // The rich-markdown path pairs with formatPermissionCardBody (#1790)
7032
+ // so its bold/italic render. retryWithThreadFallback: if the topic was
7014
7033
  // deleted/recreated (stale thread id → 400 "message thread not
7015
7034
  // found"), re-send thread-less into the main chat so the card still
7016
7035
  // ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
@@ -7018,8 +7037,7 @@ const ipcServer: IpcServer = createIpcServer({
7018
7037
  void retryWithThreadFallback<{ message_id: number }>(
7019
7038
  robustApiCall,
7020
7039
  (tid) =>
7021
- bot.api.sendMessage(chatId, text, {
7022
- parse_mode: 'HTML',
7040
+ bot.api.sendRichMessage(chatId, richMessage(text), {
7023
7041
  reply_markup: keyboard,
7024
7042
  ...(tid != null ? { message_thread_id: tid } : {}),
7025
7043
  }),
@@ -7198,8 +7216,7 @@ const ipcServer: IpcServer = createIpcServer({
7198
7216
  try {
7199
7217
  const sent = await robustApiCall(
7200
7218
  () =>
7201
- bot.api.sendMessage(args.chatId, args.text, {
7202
- parse_mode: 'HTML',
7219
+ bot.api.sendRichMessage(args.chatId, richMessage(args.text), {
7203
7220
  ...(args.threadId !== undefined
7204
7221
  ? { message_thread_id: args.threadId }
7205
7222
  : {}),
@@ -7349,8 +7366,8 @@ const ipcServer: IpcServer = createIpcServer({
7349
7366
  try {
7350
7367
  const sent = await robustApiCall(
7351
7368
  () =>
7352
- bot.api.sendMessage(args.chatId, args.text, {
7353
- parse_mode: 'HTML',
7369
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
7370
+ bot.api.sendRichMessage(args.chatId, richMessage(args.text), {
7354
7371
  ...(args.threadId !== undefined
7355
7372
  ? { message_thread_id: args.threadId }
7356
7373
  : {}),
@@ -7374,8 +7391,7 @@ const ipcServer: IpcServer = createIpcServer({
7374
7391
  try {
7375
7392
  await robustApiCall(
7376
7393
  () =>
7377
- bot.api.editMessageText(args.chatId, args.messageId, args.text, {
7378
- parse_mode: 'HTML',
7394
+ bot.api.editMessageText(args.chatId, args.messageId, richMessage(args.text), {
7379
7395
  // Strip the inline keyboard on a terminal/interim edit so the
7380
7396
  // [Approve]/[Deny] buttons stop being tappable on a resolved card.
7381
7397
  ...(args.stripKeyboard ? { reply_markup: { inline_keyboard: [] } } : {}),
@@ -7446,8 +7462,7 @@ const ipcServer: IpcServer = createIpcServer({
7446
7462
  try {
7447
7463
  await robustApiCall(
7448
7464
  () =>
7449
- bot.api.editMessageText(args.chatId, args.messageId, args.text, {
7450
- parse_mode: 'HTML',
7465
+ bot.api.editMessageText(args.chatId, args.messageId, richMessage(args.text), {
7451
7466
  // Finalize is terminal — drop the keyboard so buttons are gone.
7452
7467
  ...(args.stripKeyboard ? { reply_markup: { inline_keyboard: [] } } : {}),
7453
7468
  }),
@@ -7544,15 +7559,18 @@ const ipcServer: IpcServer = createIpcServer({
7544
7559
  return
7545
7560
  }
7546
7561
  const threadId = msg.threadId
7547
- const parseMode = msg.parseMode === 'text' ? undefined : 'HTML'
7562
+ // #2669: literal `text` mode → plain send; everything else → rich markdown.
7563
+ const literalText = msg.parseMode === 'text'
7564
+ const outOpts = {
7565
+ ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
7566
+ }
7548
7567
  // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send.
7549
7568
  // General topic (thread 1) sends omit message_thread_id per the outbound convention.
7550
7569
  void swallowingApiCall(
7551
- () =>
7552
- bot.api.sendMessage(msg.chatId, msg.text, {
7553
- ...(parseMode ? { parse_mode: parseMode } : {}),
7554
- ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
7555
- }),
7570
+ (): Promise<{ message_id: number }> =>
7571
+ literalText
7572
+ ? bot.api.sendMessage(msg.chatId, msg.text, outOpts)
7573
+ : bot.api.sendRichMessage(msg.chatId, richMessage(msg.text), outOpts),
7556
7574
  { chat_id: msg.chatId, verb: 'cron-action-send', ...(threadId != null ? { threadId } : {}) },
7557
7575
  )
7558
7576
  process.stderr.write(
@@ -7580,6 +7598,78 @@ const ipcServer: IpcServer = createIpcServer({
7580
7598
  void fireFleetAutoFallback(msg.agentName, untilMs)
7581
7599
  },
7582
7600
 
7601
+ // #2670 one-tap self-improvement — persist a skill-improvement proposal and
7602
+ // post its Approve/Dismiss card. The store transition + apply-injection on
7603
+ // Approve are owned by handleSkillProposalCallback (so a gateway restart
7604
+ // between post and tap still resolves correctly). Placed AFTER
7605
+ // onQuotaWallDetected so it doesn't fall inside the source slice that
7606
+ // send-outbound-wiring.test.ts takes between onSendOutbound and
7607
+ // onQuotaWallDetected.
7608
+ onPostSkillProposal(_client: IpcClient, msg: PostSkillProposalMessage) {
7609
+ const self = process.env.SWITCHROOM_AGENT_NAME
7610
+ if (self && msg.agentName !== self) {
7611
+ process.stderr.write(
7612
+ `telegram gateway: post_skill_proposal rejected — agent mismatch (${msg.agentName} != ${self})\n`,
7613
+ )
7614
+ return
7615
+ }
7616
+ try {
7617
+ assertAllowedChat(msg.chatId)
7618
+ } catch (err) {
7619
+ process.stderr.write(
7620
+ `telegram gateway: post_skill_proposal rejected — ${(err as Error).message}\n`,
7621
+ )
7622
+ return
7623
+ }
7624
+ const stateDir = process.env.TELEGRAM_STATE_DIR
7625
+ if (stateDir == null || stateDir.length === 0) {
7626
+ process.stderr.write(`telegram gateway: post_skill_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
7627
+ return
7628
+ }
7629
+ // Dedup against still-live rejection fingerprints — never re-surface a
7630
+ // proposal the operator already dismissed.
7631
+ if (isSkillProposalSuppressed(stateDir, {
7632
+ lesson: msg.lesson,
7633
+ draft: msg.draft,
7634
+ skill_slug: msg.skillSlug,
7635
+ })) {
7636
+ process.stderr.write(
7637
+ `telegram gateway: post_skill_proposal suppressed (rejected before) slug=${msg.skillSlug}\n`,
7638
+ )
7639
+ return
7640
+ }
7641
+ const proposal = enqueueSkillProposal(stateDir, {
7642
+ skill_slug: msg.skillSlug,
7643
+ is_new: msg.isNew,
7644
+ lesson: msg.lesson,
7645
+ draft: msg.draft,
7646
+ evidence: msg.evidence,
7647
+ chat_id: Number(msg.chatId),
7648
+ })
7649
+ const cardText = renderSkillProposalCard({
7650
+ id: proposal.id,
7651
+ skill_slug: proposal.skill_slug,
7652
+ is_new: proposal.is_new,
7653
+ lesson: proposal.lesson,
7654
+ evidence: proposal.evidence,
7655
+ skill_md: proposal.draft['SKILL.md'],
7656
+ })
7657
+ const threadId = msg.threadId
7658
+ void swallowingApiCall(
7659
+ () =>
7660
+ bot.api.sendMessage(msg.chatId, cardText, {
7661
+ parse_mode: 'HTML',
7662
+ reply_markup: skillProposalKeyboard(proposal.id),
7663
+ ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
7664
+ }),
7665
+ { chat_id: msg.chatId, verb: 'skill-proposal-card', ...(threadId != null ? { threadId } : {}) },
7666
+ )
7667
+ process.stderr.write(
7668
+ `telegram gateway: post_skill_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
7669
+ `proposal=${proposal.id} slug=${proposal.skill_slug} new=${proposal.is_new}\n`,
7670
+ )
7671
+ },
7672
+
7583
7673
  log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
7584
7674
  })
7585
7675
 
@@ -7891,8 +7981,8 @@ function notifyLinearAuthDead(info: { agent: string; reason: LinearAuthDeadReaso
7891
7981
  const text = buildLinearAuthDeadMessage(info.agent, info.reason)
7892
7982
  await swallowingApiCall(
7893
7983
  () =>
7894
- bot.api.sendMessage(chatId, text, {
7895
- parse_mode: 'HTML',
7984
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
7985
+ bot.api.sendRichMessage(chatId, richMessage(text), {
7896
7986
  ...(threadId != null ? { message_thread_id: threadId } : {}),
7897
7987
  }),
7898
7988
  { chat_id: chatId, verb: 'linearAuthDead' },
@@ -8004,11 +8094,9 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8004
8094
  // value, exactly like the voice scrub that follows.
8005
8095
  text = redactOutboundText(text, 'reply')
8006
8096
  // Voice scrub (#1683): replace em / en dashes with commas / periods.
8007
- // Runs BEFORE outboundDedup so retries see the scrubbed key, and
8008
- // BEFORE markdownToHtml so code-block content is correctly parked
8009
- // by the scrubber's own placeholder pass (otherwise the html
8010
- // converter would have already escaped/parked code, and the scrub
8011
- // would see only the parked placeholders). Kill switch:
8097
+ // Runs BEFORE outboundDedup so retries see the scrubbed key, and on the
8098
+ // raw markdown text (the scrubber does its own code-region parking so
8099
+ // dashes inside fences/inline code are preserved). Kill switch:
8012
8100
  // `SWITCHROOM_DISABLE_VOICE_SCRUB=1`.
8013
8101
  {
8014
8102
  const scrub = scrubVoice(text)
@@ -8220,24 +8308,19 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8220
8308
  // Replace the local text with a one-line link. The first line
8221
8309
  // is the chosen title (so the user sees the topic at a glance);
8222
8310
  // the second line is the URL Telegram will Instant-View.
8223
- text = `<b>${title.replace(/[<>&]/g, (c) => c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;')}</b>\n${url}`
8311
+ text = `**${escapeMarkdown(title)}**\n${url}`
8224
8312
  }
8225
8313
  // url null → fall through and chunk; the user still gets the
8226
8314
  // long reply, just split across messages.
8227
8315
  }
8228
8316
 
8229
- let parseMode: 'HTML' | 'MarkdownV2' | undefined
8230
- let effectiveText: string
8231
- if (format === 'html') {
8232
- parseMode = 'HTML'
8233
- effectiveText = markdownToHtml(text)
8234
- } else if (format === 'markdownv2') {
8235
- parseMode = 'MarkdownV2'
8236
- effectiveText = escapeMarkdownV2(text)
8237
- } else {
8238
- parseMode = undefined
8239
- effectiveText = text
8240
- }
8317
+ // Single rich-markdown path (#2669). The only fork is `format:'text'`,
8318
+ // a literal/no-markdown send: the body bypasses the rich parser entirely
8319
+ // (plain `sendMessage`, no rich wrapper). Everything else — the default —
8320
+ // ships the raw GFM markdown via `sendRichMessage`. `effectiveText` is the
8321
+ // raw text either way (no HTML/MarkdownV2 rendering happens here anymore).
8322
+ const literalText = format === 'text'
8323
+ const effectiveText: string = text
8241
8324
 
8242
8325
  assertAllowedChat(chat_id)
8243
8326
 
@@ -8299,11 +8382,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8299
8382
  }
8300
8383
  }
8301
8384
 
8302
- const limit = Math.max(1, Math.min(access.textChunkLimit ?? 4000, MAX_CHUNK_LIMIT))
8385
+ const limit = Math.max(1, Math.min(access.textChunkLimit ?? RICH_MESSAGE_MAX_CHARS, MAX_CHUNK_LIMIT))
8303
8386
  const replyMode = access.replyToMode ?? 'first'
8304
- const chunks = parseMode === 'HTML'
8305
- ? splitHtmlChunks(effectiveText, limit)
8306
- : chunk(effectiveText, limit, access.chunkMode ?? 'length')
8387
+ const chunks = literalText
8388
+ ? chunk(effectiveText, limit, access.chunkMode ?? 'length')
8389
+ : splitMarkdownChunks(effectiveText, limit)
8307
8390
  const sentIds: number[] = []
8308
8391
 
8309
8392
  // #271: validate inline_keyboard and namespace any callback_data with
@@ -8335,7 +8418,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8335
8418
  await openStream.finalize().catch(() => {})
8336
8419
  previewMessageId = openStream.getMessageId()
8337
8420
  activeDraftStreams.delete(replySKey)
8338
- activeDraftParseModes.delete(replySKey)
8339
8421
  lastPtyPreviewByChat.delete(replySKey)
8340
8422
  }
8341
8423
 
@@ -8446,13 +8528,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8446
8528
  })
8447
8529
  if (decision.kind === 'edit-anchor') {
8448
8530
  const editParams: {
8449
- parse_mode?: 'HTML' | 'MarkdownV2'
8450
8531
  message_thread_id?: number
8451
8532
  link_preview_options?: { is_disabled: boolean }
8452
8533
  } = {
8453
8534
  link_preview_options: { is_disabled: disableLinkPreview },
8454
8535
  }
8455
- if (parseMode != null) editParams.parse_mode = parseMode
8456
8536
  if (threadId != null) editParams.message_thread_id = threadId
8457
8537
  try {
8458
8538
  await robustApiCall(
@@ -8460,7 +8540,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8460
8540
  lockedBot.api.editMessageText(
8461
8541
  chat_id,
8462
8542
  decision.messageId,
8463
- decision.mergedText,
8543
+ literalText ? decision.mergedText : richMessage(decision.mergedText),
8464
8544
  editParams,
8465
8545
  ),
8466
8546
  {
@@ -8504,7 +8584,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8504
8584
  pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
8505
8585
  messageId: decision.messageId,
8506
8586
  text: decision.mergedText,
8507
- parseMode,
8587
+ literalText,
8508
8588
  })
8509
8589
  if (HISTORY_ENABLED) {
8510
8590
  try {
@@ -8591,7 +8671,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8591
8671
  },
8592
8672
  }
8593
8673
  : {}),
8594
- ...(parseMode ? { parse_mode: parseMode } : {}),
8595
8674
  ...(threadId != null ? { message_thread_id: threadId } : {}),
8596
8675
  ...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
8597
8676
  ...(replyMarkup != null && isLastChunk ? { reply_markup: replyMarkup } : {}),
@@ -8599,14 +8678,17 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8599
8678
  ...(disableNotification ? { disable_notification: true } : {}),
8600
8679
  }
8601
8680
 
8681
+ // The body argument for send/edit: a rich-markdown wrapper on the
8682
+ // default path, or the literal string for `format:'text'` (#2669).
8683
+ const richOrPlain = (s: string) => (literalText ? s : richMessage(s))
8684
+
8602
8685
  if (i === 0 && previewMessageId != null) {
8603
8686
  const editOpts: Record<string, unknown> = {}
8604
- if (parseMode) editOpts.parse_mode = parseMode
8605
8687
  if (disableLinkPreview) editOpts.link_preview_options = { is_disabled: true }
8606
8688
  if (replyMarkup != null && isLastChunk) editOpts.reply_markup = replyMarkup
8607
8689
  try {
8608
8690
  await robustApiCall(
8609
- () => lockedBot.api.editMessageText(chat_id, previewMessageId!, chunks[i], editOpts),
8691
+ () => lockedBot.api.editMessageText(chat_id, previewMessageId!, richOrPlain(chunks[i]), editOpts),
8610
8692
  { threadId, chat_id },
8611
8693
  )
8612
8694
  sentIds.push(previewMessageId!)
@@ -8625,37 +8707,45 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8625
8707
  }
8626
8708
  }
8627
8709
 
8628
- // Last-resort: resend this chunk as plain text (parse_mode unset).
8629
- // Keeps thread / reply / markup params; only the formatting is
8630
- // sacrificed. Used when Telegram rejects our HTML — better an
8631
- // unformatted answer than a vanished one.
8710
+ // Last-resort: resend this chunk as plain text (no rich wrapper, so
8711
+ // the markdown parser never runs). Keeps thread / reply / markup
8712
+ // params; only the formatting is sacrificed. Used when Telegram
8713
+ // rejects our markdown — better an unformatted answer than a
8714
+ // vanished one. The raw markdown source is itself readable prose, so
8715
+ // we send it verbatim rather than strip anything.
8632
8716
  const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
8633
- const plainOpts = { ...opts }
8634
- delete (plainOpts as { parse_mode?: unknown }).parse_mode
8635
- // A chunk that was pure markup (no text content) strips to ''.
8636
- // Sending '' is a Telegram 400 "message text is empty" — i.e.
8637
- // the answer would still vanish, in the exact path that exists
8638
- // to prevent that. Substitute an honest placeholder so the
8639
- // user at least sees that a fragment was unrenderable.
8640
- const stripped = telegramHtmlToPlainText(chunks[i])
8641
8717
  const plain =
8642
- stripped.length > 0
8643
- ? stripped
8644
- : 'āš ļø (a formatted fragment could not be rendered for Telegram)'
8645
- // allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse-mode policy that just rejected the payload
8646
- const sent = await lockedBot.api.sendMessage(chat_id, plain, plainOpts as never)
8718
+ chunks[i].length > 0
8719
+ ? chunks[i]
8720
+ : 'āš ļø (a fragment could not be rendered for Telegram)'
8721
+ // allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse policy that just rejected the payload
8722
+ const sent = await lockedBot.api.sendMessage(chat_id, plain, opts as never)
8647
8723
  sentIds.push(sent.message_id)
8648
8724
  logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
8649
8725
  process.stderr.write(
8650
- `telegram gateway: HTML parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
8726
+ `telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
8651
8727
  )
8652
8728
  }
8653
8729
 
8730
+ // Literal `format:'text'` sends bypass the rich parser entirely
8731
+ // (plain sendMessage, no markdown). The default path ships rich
8732
+ // markdown via sendRichMessage. Both resolve to a Message with a
8733
+ // message_id, which is all the caller reads.
8734
+ const sendChunk = (opts: Record<string, unknown>): Promise<{ message_id: number }> => {
8735
+ if (literalText) {
8736
+ // allow-raw-bot-api: literal format:'text' send; the chunk-loop's own THREAD_NOT_FOUND + parse-reject handling wraps this
8737
+ return lockedBot.api.sendMessage(chat_id, chunks[i], opts as never)
8738
+ }
8739
+ // sendRichMessage does NOT accept link_preview_options (rich messages
8740
+ // control previews via entity detection) — drop it for the rich path.
8741
+ const richOpts = { ...opts }
8742
+ delete (richOpts as { link_preview_options?: unknown }).link_preview_options
8743
+ // allow-raw-bot-api: sendRichMessage is not in the THREAD_NOT_FOUND blast pattern; thread fallback handled in the catch below
8744
+ return lockedBot.api.sendRichMessage(chat_id, richMessage(chunks[i]), richOpts as never)
8745
+ }
8746
+
8654
8747
  try {
8655
- const sent = await robustApiCall(
8656
- () => lockedBot.api.sendMessage(chat_id, chunks[i], sendOpts as never),
8657
- { threadId, chat_id },
8658
- )
8748
+ const sent = await robustApiCall(() => sendChunk(sendOpts), { threadId, chat_id })
8659
8749
  sentIds.push(sent.message_id)
8660
8750
  logOutbound('reply', chat_id, sent.message_id, chunks[i].length, `chunk=${i + 1}/${chunks.length}`)
8661
8751
  } catch (err) {
@@ -8664,11 +8754,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8664
8754
  const retryOpts = { ...sendOpts }
8665
8755
  delete (retryOpts as any).message_thread_id
8666
8756
  try {
8667
- // allow-raw-bot-api: chunk-loop THREAD_NOT_FOUND fallback; thread already dropped, wrapping would re-enter the throw
8668
- const sent = await lockedBot.api.sendMessage(chat_id, chunks[i], retryOpts as never)
8757
+ const sent = await sendChunk(retryOpts)
8669
8758
  sentIds.push(sent.message_id)
8670
8759
  } catch (retryErr) {
8671
- // Thread dropped, but the HTML is also unparseable — go plain.
8760
+ // Thread dropped, but the markdown is also unparseable — go plain.
8672
8761
  if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
8673
8762
  else throw retryErr
8674
8763
  }
@@ -8716,7 +8805,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8716
8805
  pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
8717
8806
  messageId: anchorMsgId,
8718
8807
  text: chunks[chunks.length - 1],
8719
- parseMode,
8808
+ literalText,
8720
8809
  })
8721
8810
  }
8722
8811
  }
@@ -9161,7 +9250,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9161
9250
  ...(streamReplyMarkup != null ? { reply_markup: streamReplyMarkup } : {}),
9162
9251
  ...(args.disable_notification === true ? { disable_notification: true } : {}),
9163
9252
  },
9164
- { activeDraftStreams, activeDraftParseModes, suppressPtyPreview },
9253
+ { activeDraftStreams, suppressPtyPreview },
9165
9254
  {
9166
9255
  // grammy's Bot<Context, Api<RawApi>> has a wider api shape than the
9167
9256
  // local StreamBotApi interface, but is structurally compatible at
@@ -9169,8 +9258,6 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9169
9258
  // bypass the structural-typing strictness.
9170
9259
  bot: lockedBot as unknown as { api: import('../stream-controller.js').StreamBotApi },
9171
9260
  retry: robustApiCall,
9172
- markdownToHtml,
9173
- escapeMarkdownV2,
9174
9261
  repairEscapedWhitespace,
9175
9262
  assertAllowedChat,
9176
9263
  resolveThreadId,
@@ -9257,16 +9344,11 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9257
9344
  // async dispatch, the framework edits THIS message in place at
9258
9345
  // intervals.
9259
9346
  //
9260
- // #1698 — capture the parse_mode the stream-reply-handler used so
9261
- // the cross-turn edit tick reuses it. Mirrors the format→parseMode
9262
- // logic at stream-reply-handler.ts:355-368. Without this, the first
9263
- // "still working (Nm)" tick edits HTML content as plain text and
9264
- // <b>/<code>/<a> render as literal tags.
9347
+ // #2669 — capture whether the stream-reply-handler sent literally
9348
+ // (format:'text') so the cross-turn edit tick re-edits with the same
9349
+ // shape. The default rich-markdown path re-edits via the rich path.
9265
9350
  const streamFormat = (args.format as string | undefined) ?? (access.parseMode ?? 'html')
9266
- const streamParseMode: 'HTML' | 'MarkdownV2' | undefined =
9267
- streamFormat === 'html' ? 'HTML'
9268
- : streamFormat === 'markdownv2' ? 'MarkdownV2'
9269
- : undefined
9351
+ const streamLiteralText = streamFormat === 'text'
9270
9352
  // #1760 primary fix — clear any stale prior-turn ticker before
9271
9353
  // re-anchoring on stream_reply done. See the matching comment at
9272
9354
  // the executeReply finalize site.
@@ -9274,7 +9356,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9274
9356
  pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
9275
9357
  messageId: result.messageId,
9276
9358
  text: args.text as string,
9277
- parseMode: streamParseMode,
9359
+ literalText: streamLiteralText,
9278
9360
  })
9279
9361
  }
9280
9362
  // #1664 — mark the turn's final answer as delivered. For stream_reply a
@@ -9434,19 +9516,22 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
9434
9516
  // Otherwise fall through to message-send below.
9435
9517
  }
9436
9518
 
9437
- // Send plain message (no quote-reply)
9519
+ // Send plain message (no quote-reply). Single rich-markdown path (#2669):
9520
+ // `parseMode:'text'` config opts into a literal plain send; everything
9521
+ // else ships raw GFM markdown via sendRichMessage.
9438
9522
  const access = loadAccess()
9439
- const configParseMode = access.parseMode ?? 'html'
9440
- const parseMode = configParseMode === 'html' ? 'HTML' : undefined
9441
- const effectiveText = configParseMode === 'html' ? markdownToHtml(text) : text
9523
+ const literalText = (access.parseMode ?? 'html') === 'text'
9442
9524
 
9443
9525
  const sendOpts = {
9444
- ...(parseMode ? { parse_mode: parseMode } : {}),
9445
9526
  ...(threadId != null ? { message_thread_id: threadId } : {}),
9446
9527
  }
9447
9528
 
9448
9529
  const sent = await robustApiCall(
9449
- () => lockedBot.api.sendMessage(chat_id, effectiveText, sendOpts as never),
9530
+ (): Promise<{ message_id: number }> =>
9531
+ literalText
9532
+ // allow-raw-bot-api: literal progress-update send routed through robustApiCall
9533
+ ? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
9534
+ : lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
9450
9535
  { verb: 'sendMessage', chat_id, threadId },
9451
9536
  )
9452
9537
 
@@ -9537,7 +9622,6 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
9537
9622
  // alive (button tap turns into "āœ… <choice>" inline) rather than a
9538
9623
  // graveyard of unanswered questions in the chat history.
9539
9624
  const sendOpts: Record<string, unknown> = {
9540
- parse_mode: 'HTML',
9541
9625
  reply_markup: keyboard,
9542
9626
  }
9543
9627
  if (threadId != null) sendOpts.message_thread_id = threadId
@@ -9553,7 +9637,7 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
9553
9637
  const opts = { ...sendOpts }
9554
9638
  if (tid == null) delete opts.message_thread_id
9555
9639
  else opts.message_thread_id = tid
9556
- return lockedBot.api.sendMessage(args.chatId, args.question, opts)
9640
+ return lockedBot.api.sendRichMessage(args.chatId, richMessage(args.question), opts)
9557
9641
  },
9558
9642
  { threadId, chat_id: args.chatId, verb: 'ask_user.sendMessage' },
9559
9643
  )
@@ -9581,8 +9665,7 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
9581
9665
  lockedBot.api.editMessageText(
9582
9666
  args.chatId,
9583
9667
  entry.messageId!,
9584
- `${args.question}\n\n<i>ā± no response within ${Math.round(args.timeoutMs / 1000)}s</i>`,
9585
- { parse_mode: 'HTML' },
9668
+ richMessage(`${args.question}\n\n_ā± no response within ${Math.round(args.timeoutMs / 1000)}s_`),
9586
9669
  ),
9587
9670
  {
9588
9671
  chat_id: args.chatId,
@@ -9816,13 +9899,13 @@ function buildVaultRequestSaveKeyboard(stageId: string): { inline_keyboard: Arra
9816
9899
 
9817
9900
  function renderVaultRequestSaveCard(req: PendingVaultRequestSave, agentSlug: string): string {
9818
9901
  const lines: string[] = []
9819
- lines.push(`šŸ” <b>${escapeHtmlForTg(agentSlug)}</b> wants to save a secret`)
9820
- lines.push(`key: <code>${escapeHtmlForTg(req.key)}</code>`)
9902
+ lines.push(`šŸ” **${escapeHtmlForTg(agentSlug)}** wants to save a secret`)
9903
+ lines.push(`key: \`${req.key}\``)
9821
9904
  if (req.why && req.why.length > 0) {
9822
- lines.push(`why: <i>${escapeHtmlForTg(req.why)}</i>`)
9905
+ lines.push(`why: _${escapeHtmlForTg(req.why)}_`)
9823
9906
  }
9824
9907
  lines.push('')
9825
- lines.push(`<i>Tap Save to write to the host vault, Rename to change the key name, or Discard to drop it. The value is held in this chat's gateway memory until you decide.</i>`)
9908
+ lines.push(`_Tap Save to write to the host vault, Rename to change the key name, or Discard to drop it. The value is held in this chat's gateway memory until you decide._`)
9826
9909
  return lines.join('\n')
9827
9910
  }
9828
9911
 
@@ -9885,8 +9968,7 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
9885
9968
  const sent = await retryWithThreadFallback<{ message_id: number }>(
9886
9969
  robustApiCall,
9887
9970
  (tid) =>
9888
- lockedBot.api.sendMessage(chat_id, text, {
9889
- parse_mode: 'HTML',
9971
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
9890
9972
  reply_markup: buildVaultRequestSaveKeyboard(stageId),
9891
9973
  ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
9892
9974
  }),
@@ -9959,13 +10041,13 @@ function buildSecretRequestKeyboard(stageId: string): { inline_keyboard: Array<A
9959
10041
 
9960
10042
  function renderSecretRequestCard(req: PendingSecretRequest): string {
9961
10043
  const lines: string[] = [
9962
- `šŸ”’ <b>${escapeHtmlForTg(req.agent)}</b> needs a secret:`,
9963
- `<code>${escapeHtmlForTg(req.key)}</code>`,
10044
+ `šŸ”’ **${escapeHtmlForTg(req.agent)}** needs a secret:`,
10045
+ `\`${req.key}\``,
9964
10046
  ]
9965
- if (req.reason) lines.push(`<i>${escapeHtmlForTg(req.reason)}</i>`)
10047
+ if (req.reason) lines.push(`_${escapeHtmlForTg(req.reason)}_`)
9966
10048
  lines.push(
9967
10049
  '',
9968
- 'Tap <b>Provide securely</b>, then send the value as your next message. I’ll delete it instantly and store it in the vault — it is never shown in chat or to the agent.',
10050
+ 'Tap **Provide securely**, then send the value as your next message. I’ll delete it instantly and store it in the vault — it is never shown in chat or to the agent.',
9969
10051
  )
9970
10052
  return lines.join('\n')
9971
10053
  }
@@ -10005,8 +10087,7 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
10005
10087
  const sent = await retryWithThreadFallback<{ message_id: number }>(
10006
10088
  robustApiCall,
10007
10089
  (tid) =>
10008
- lockedBot.api.sendMessage(chat_id, text, {
10009
- parse_mode: 'HTML',
10090
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
10010
10091
  reply_markup: buildSecretRequestKeyboard(stageId),
10011
10092
  ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
10012
10093
  }),
@@ -10069,8 +10150,8 @@ async function captureProvidedSecret(
10069
10150
  if (!write.ok) {
10070
10151
  const parsed = parseVaultCliError(write.output)
10071
10152
  const rendered = renderVaultCliError(parsed, { verb: 'save', key: armed.key })
10072
- const body = rendered.suppressRaw ? rendered.html : `āš ļø vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
10073
- await switchroomReply(ctx, `${body}\n\n<i>The secret was NOT saved. The agent can re-request with <code>request_secret</code>.</i>`, { html: true })
10153
+ const body = rendered.suppressRaw ? rendered.html : `āš ļø vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
10154
+ await switchroomReply(ctx, `${body}\n\n_The secret was NOT saved. The agent can re-request with \`request_secret\`._`, { html: true })
10074
10155
  // Wake the agent too (review #2047 finding #3): it ended its turn
10075
10156
  // waiting for a synthetic inbound; without this it stalls silently.
10076
10157
  const fts = Date.now()
@@ -10097,7 +10178,7 @@ async function captureProvidedSecret(
10097
10178
 
10098
10179
  await switchroomReply(
10099
10180
  ctx,
10100
- `āœ… saved as <code>vault:${escapeHtmlForTg(armed.key)}</code> (masked: <code>${escapeHtmlForTg(maskToken(value))}</code>). The agent can now reference it.`,
10181
+ `āœ… saved as \`vault:${escapeHtmlForTg(armed.key)}\` (masked: \`${escapeHtmlForTg(maskToken(value))}\`). The agent can now reference it.`,
10101
10182
  { html: true },
10102
10183
  )
10103
10184
 
@@ -10166,8 +10247,8 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
10166
10247
  .editMessageText(
10167
10248
  pending.chat_id,
10168
10249
  pending.card_message_id,
10169
- `šŸ” Send the value for <code>${escapeHtmlForTg(pending.key)}</code> as your next message — a single message, exactly as-is (don't add other text). I’ll delete it instantly and store it in the vault.`,
10170
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
10250
+ richMessage(`šŸ” Send the value for \`${pending.key}\` as your next message — a single message, exactly as-is (don't add other text). I’ll delete it instantly and store it in the vault.`),
10251
+ { reply_markup: { inline_keyboard: [] } },
10171
10252
  )
10172
10253
  .catch(() => {})
10173
10254
  }
@@ -10180,8 +10261,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
10180
10261
  await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
10181
10262
  if (pending.card_message_id != null) {
10182
10263
  await ctx.api
10183
- .editMessageText(pending.chat_id, pending.card_message_id, `🚫 Declined — <code>${escapeHtmlForTg(pending.key)}</code> not provided.`, {
10184
- parse_mode: 'HTML',
10264
+ .editMessageText(pending.chat_id, pending.card_message_id, richMessage(`🚫 Declined — \`${pending.key}\` not provided.`), {
10185
10265
  reply_markup: { inline_keyboard: [] },
10186
10266
  })
10187
10267
  .catch(() => {})
@@ -10233,9 +10313,9 @@ function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
10233
10313
  const scopeLabel = req.scope === 'write' ? 'write' : 'read'
10234
10314
  const days = Math.round(req.ttl_seconds / 86400)
10235
10315
  const durationLabel = days >= 1 ? `${days}d` : `${Math.round(req.ttl_seconds / 3600)}h`
10236
- lines.push(`šŸ” <b>${escapeHtmlForTg(req.agent)}</b> wants vault access`)
10237
- lines.push(`key: <code>${escapeHtmlForTg(req.key)}</code>`)
10238
- lines.push(`scope: <code>${scopeLabel}</code> Ā· duration: <code>${durationLabel}</code>`)
10316
+ lines.push(`šŸ” **${escapeHtmlForTg(req.agent)}** wants vault access`)
10317
+ lines.push(`key: \`${req.key}\``)
10318
+ lines.push(`scope: \`${scopeLabel}\` Ā· duration: \`${durationLabel}\``)
10239
10319
  // #1790 — always render the why-line, even when the agent omitted
10240
10320
  // `reason`. Rendering "not provided" makes a missing rationale
10241
10321
  // visibly an agent-side failure (the tool description nudges the
@@ -10244,12 +10324,12 @@ function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
10244
10324
  // template choice, which the operator couldn't tell apart from a
10245
10325
  // legitimate "no reason needed" case.
10246
10326
  if (req.reason && req.reason.length > 0) {
10247
- lines.push(`why: <i>${escapeHtmlForTg(req.reason)}</i>`)
10327
+ lines.push(`why: _${escapeHtmlForTg(req.reason)}_`)
10248
10328
  } else {
10249
- lines.push(`why: <i>not provided</i>`)
10329
+ lines.push(`why: _not provided_`)
10250
10330
  }
10251
10331
  lines.push('')
10252
- lines.push(`<i>Tap Approve to mint a scoped grant token (same flow as <code>switchroom vault grant</code>). Tap Deny to refuse — the agent will receive a denial result.</i>`)
10332
+ 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._`)
10253
10333
  return lines.join('\n')
10254
10334
  }
10255
10335
 
@@ -10355,8 +10435,7 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
10355
10435
  const sent = await retryWithThreadFallback<{ message_id: number }>(
10356
10436
  robustApiCall,
10357
10437
  (tid) =>
10358
- lockedBot.api.sendMessage(chat_id, text, {
10359
- parse_mode: 'HTML',
10438
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
10360
10439
  reply_markup: buildVaultRequestAccessKeyboard(stageId),
10361
10440
  ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
10362
10441
  }),
@@ -10425,8 +10504,10 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10425
10504
  if (args.text == null || args.text === '') throw new Error('edit_message: text is required and cannot be empty')
10426
10505
  assertAllowedChat(String(args.chat_id ?? ''))
10427
10506
  const editAccess = loadAccess()
10428
- const editConfigMode = editAccess.parseMode ?? 'html'
10429
- const editFormat = (args.format as string | undefined) ?? editConfigMode
10507
+ const editFormat = (args.format as string | undefined) ?? (editAccess.parseMode ?? 'html')
10508
+ // Single rich-markdown path (#2669): `format:'text'` edits as a literal
10509
+ // plain string; everything else edits via the rich-markdown path.
10510
+ const editLiteralText = editFormat === 'text'
10430
10511
  let editRawText = repairEscapedWhitespace(args.text as string)
10431
10512
  // Outbound secret scrub (#2044): an edit must not re-introduce a raw
10432
10513
  // secret into a live bubble or the history row. Mask before scrub/send.
@@ -10447,22 +10528,10 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10447
10528
  })
10448
10529
  }
10449
10530
  }
10450
- let editParseMode: 'HTML' | 'MarkdownV2' | undefined
10451
- let editText: string
10452
- if (editFormat === 'html') {
10453
- editParseMode = 'HTML'
10454
- editText = markdownToHtml(editRawText)
10455
- } else if (editFormat === 'markdownv2') {
10456
- editParseMode = 'MarkdownV2'
10457
- editText = escapeMarkdownV2(editRawText)
10458
- } else {
10459
- editParseMode = undefined
10460
- editText = editRawText
10461
- }
10462
10531
  const edited = await robustApiCall(
10463
10532
  () => lockedBot.api.editMessageText(
10464
- String(args.chat_id ?? ''), Number(args.message_id), editText,
10465
- ...(editParseMode ? [{ parse_mode: editParseMode }] : []),
10533
+ String(args.chat_id ?? ''), Number(args.message_id),
10534
+ editLiteralText ? editRawText : richMessage(editRawText),
10466
10535
  ),
10467
10536
  )
10468
10537
  const id = typeof edited === 'object' && edited ? (edited as any).message_id : args.message_id
@@ -10696,7 +10765,6 @@ function closeActivityLane(chatId: string, threadId: number | undefined): void {
10696
10765
  const stream = activeDraftStreams.get(key)
10697
10766
  if (stream == null) return
10698
10767
  activeDraftStreams.delete(key)
10699
- activeDraftParseModes.delete(key)
10700
10768
  void stream.finalize().catch(() => {})
10701
10769
  }
10702
10770
 
@@ -10708,7 +10776,6 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
10708
10776
  for (const [key, stream] of activeDraftStreams) {
10709
10777
  if (key.startsWith(prefix)) {
10710
10778
  activeDraftStreams.delete(key)
10711
- activeDraftParseModes.delete(key)
10712
10779
  void stream.finalize().catch(() => {})
10713
10780
  }
10714
10781
  }
@@ -10916,7 +10983,7 @@ async function drainActivitySummary(
10916
10983
  break
10917
10984
  }
10918
10985
  // `renderActivityFeed` already emitted ready Telegram HTML with per-line
10919
- // markup (<b>→ current</b> / <i>āœ“ done</i>) and escaped each label's
10986
+ // markup (**→ current** / _āœ“ done_) and escaped each label's
10920
10987
  // <,>,& itself (#1942 class) — send verbatim, do NOT re-escape or
10921
10988
  // re-wrap (double-escaping would surface literal tags).
10922
10989
  const html = target
@@ -10932,9 +10999,9 @@ async function drainActivitySummary(
10932
10999
  try {
10933
11000
  if (turn.activityMessageId == null) {
10934
11001
  const sent = await robustApiCall(
10935
- () => bot.api.sendMessage(chat, html, {
11002
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
11003
+ () => bot.api.sendRichMessage(chat, richMessage(html), {
10936
11004
  ...(thread != null ? { message_thread_id: thread } : {}),
10937
- parse_mode: 'HTML',
10938
11005
  disable_notification: true,
10939
11006
  ...replyAnchor,
10940
11007
  }),
@@ -10945,7 +11012,7 @@ async function drainActivitySummary(
10945
11012
  } else {
10946
11013
  const id = turn.activityMessageId
10947
11014
  await robustApiCall(
10948
- () => bot.api.editMessageText(chat, id, html, { parse_mode: 'HTML' }),
11015
+ () => bot.api.editMessageText(chat, id, richMessage(html), {}),
10949
11016
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
10950
11017
  )
10951
11018
  }
@@ -11257,7 +11324,7 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
11257
11324
  if (finalHtml == null) return
11258
11325
  try {
11259
11326
  await robustApiCall(
11260
- () => bot.api.editMessageText(chat, id, finalHtml, { parse_mode: 'HTML' }),
11327
+ () => bot.api.editMessageText(chat, id, richMessage(finalHtml), {}),
11261
11328
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
11262
11329
  )
11263
11330
  } catch (err) {
@@ -11781,15 +11848,10 @@ function handleSessionEvent(ev: SessionEvent): void {
11781
11848
  // (no flash). The draft transport is permanently retired — both modes
11782
11849
  // use sendMessage + editMessageText for any message that does open.
11783
11850
  minInitialChars: ANSWER_LANE.minInitialChars,
11784
- // Render raw assistant transcript markdown → Telegram HTML before
11785
- // any parse_mode:'HTML' send/edit, matching every other outbound
11786
- // lane (handleStreamReply, the reply handler, the turn-flush
11787
- // backstop, handlePtyPartial). Without this the answer-stream lane
11788
- // shipped raw text — `**bold**` arrived as literal asterisks and
11789
- // agent narration read unformatted (the answer-stream-raw-markdown
11790
- // bug). Injected as a dependency (same pattern as the PTY partial
11791
- // handler's `renderText`) so answer-stream.ts stays format-free.
11792
- renderText: (text: string) => sanitizeTelegramHtml(markdownToHtml(text)),
11851
+ // #2669: the answer-stream ships the RAW transcript markdown; the
11852
+ // sendMessage/editMessageText wrappers below send it via the
11853
+ // rich-message path (`sendRichMessage` / `editMessageText({ markdown })`),
11854
+ // matching every other outbound lane. No render step needed.
11793
11855
  // #1075: route through robustApiCall so flood-wait,
11794
11856
  // benign-400, and THREAD_NOT_FOUND are handled uniformly
11795
11857
  // instead of crashing the answer-stream loop on a deleted
@@ -11825,13 +11887,11 @@ function handleSessionEvent(ev: SessionEvent): void {
11825
11887
  const silent = params?.purpose !== 'materialize'
11826
11888
  const msg = await robustApiCall(
11827
11889
  () =>
11828
- bot.api.sendMessage(chatId, text, {
11829
- parse_mode: params?.parse_mode,
11890
+ // allow-raw-bot-api: sendRichMessage is not in the THREAD_NOT_FOUND blast pattern; answer-stream tolerates failures via its own try/catch
11891
+ // sendRichMessage doesn't accept link_preview_options — omit it.
11892
+ bot.api.sendRichMessage(chatId, richMessage(text), {
11830
11893
  disable_notification: silent,
11831
11894
  ...(tid != null ? { message_thread_id: tid } : {}),
11832
- ...(params?.link_preview_options != null
11833
- ? { link_preview_options: params.link_preview_options }
11834
- : {}),
11835
11895
  ...(params?.reply_parameters != null
11836
11896
  ? { reply_parameters: params.reply_parameters }
11837
11897
  : {}),
@@ -11848,8 +11908,7 @@ function handleSessionEvent(ev: SessionEvent): void {
11848
11908
  const tid = params?.message_thread_id
11849
11909
  return robustApiCall(
11850
11910
  () =>
11851
- bot.api.editMessageText(chatId, messageId, text, {
11852
- parse_mode: params?.parse_mode,
11911
+ bot.api.editMessageText(chatId, messageId, richMessage(text), {
11853
11912
  ...(tid != null ? { message_thread_id: tid } : {}),
11854
11913
  ...(params?.link_preview_options != null
11855
11914
  ? { link_preview_options: params.link_preview_options }
@@ -11945,16 +12004,16 @@ function handleSessionEvent(ev: SessionEvent): void {
11945
12004
  lastContextExhaustionWarningAt = now
11946
12005
  process.stderr.write(`telegram gateway: context exhaustion detected — notifying user\n`)
11947
12006
  const warnOpts = {
11948
- parse_mode: 'HTML' as const,
11949
12007
  ...(threadId != null ? { message_thread_id: threadId } : {}),
11950
12008
  }
11951
12009
  // #1075: thread-id-bearing, fire-and-forget — swallow on
11952
12010
  // THREAD_NOT_FOUND so a deleted topic doesn't crash the gateway.
11953
12011
  void swallowingApiCall(
11954
12012
  () =>
11955
- bot.api.sendMessage(
12013
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
12014
+ bot.api.sendRichMessage(
11956
12015
  chatId,
11957
- 'āš ļø <b>Context window full</b> — send <code>/restart</code> to start a fresh session.',
12016
+ richMessage('āš ļø **Context window full** — send \`/restart\` to start a fresh session.'),
11958
12017
  warnOpts,
11959
12018
  ),
11960
12019
  {
@@ -12313,7 +12372,6 @@ function handleSessionEvent(ev: SessionEvent): void {
12313
12372
  for (const [key] of activeDraftStreams) {
12314
12373
  if (key.startsWith(suppressPrefix)) {
12315
12374
  activeDraftStreams.delete(key)
12316
- activeDraftParseModes.delete(key)
12317
12375
  }
12318
12376
  }
12319
12377
  // Unpin without editing the message so no orphaned card lingers.
@@ -12414,9 +12472,9 @@ function handleSessionEvent(ev: SessionEvent): void {
12414
12472
  // that fires when the model emits raw transcript text WITHOUT
12415
12473
  // calling reply / stream_reply. That captured text bypasses
12416
12474
  // PR #1683's executeReply scrub site entirely and is delivered
12417
- // via sendMessage / editMessageText directly. Scrub the
12418
- // capturedText before markdownToHtml so em-dashes never reach
12419
- // the wire. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
12475
+ // via the rich-message path directly. Scrub the capturedText on the
12476
+ // raw markdown so em-dashes never reach the wire. Kill switch:
12477
+ // SWITCHROOM_DISABLE_VOICE_SCRUB.
12420
12478
  {
12421
12479
  const scrub = scrubVoice(capturedText)
12422
12480
  if (scrub.replaced > 0) {
@@ -12517,13 +12575,11 @@ function handleSessionEvent(ev: SessionEvent): void {
12517
12575
  `(chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? 'none'})\n`,
12518
12576
  )
12519
12577
  const sendOpts = {
12520
- parse_mode: 'HTML' as const,
12521
12578
  ...(backstopThreadId != null ? { message_thread_id: backstopThreadId } : {}),
12522
12579
  link_preview_options: { is_disabled: true },
12523
12580
  }
12524
- const renderedText = markdownToHtml(capturedText)
12525
- const limit = 4000
12526
- const htmlChunks = splitHtmlChunks(renderedText, limit)
12581
+ const limit = RICH_MESSAGE_MAX_CHARS
12582
+ const htmlChunks = splitMarkdownChunks(capturedText, limit)
12527
12583
  const sentIds: number[] = []
12528
12584
  try {
12529
12585
  // #654 deterministic double-message fix. If the progress
@@ -12552,7 +12608,7 @@ function handleSessionEvent(ev: SessionEvent): void {
12552
12608
  bot.api.editMessageText(
12553
12609
  backstopChatId,
12554
12610
  backstopCardMessageId,
12555
- htmlChunks[0],
12611
+ richMessage(htmlChunks[0]),
12556
12612
  sendOpts,
12557
12613
  ),
12558
12614
  {
@@ -12578,11 +12634,10 @@ function handleSessionEvent(ev: SessionEvent): void {
12578
12634
  robustApiCall,
12579
12635
  (tid) => {
12580
12636
  const opts = {
12581
- parse_mode: 'HTML' as const,
12582
12637
  link_preview_options: { is_disabled: true },
12583
12638
  ...(tid != null ? { message_thread_id: tid } : {}),
12584
12639
  }
12585
- return bot.api.sendMessage(backstopChatId, c, opts)
12640
+ return bot.api.sendRichMessage(backstopChatId, richMessage(c), opts)
12586
12641
  },
12587
12642
  {
12588
12643
  threadId: liveThreadId,
@@ -12832,7 +12887,6 @@ function handlePtyPartial(text: string): void {
12832
12887
  currentSessionThreadId: turn?.sessionThreadId,
12833
12888
  pendingPtyPartial: pendingPtyPartial != null ? { text: pendingPtyPartial } : null,
12834
12889
  activeDraftStreams,
12835
- activeDraftParseModes,
12836
12890
  suppressPtyPreview,
12837
12891
  lastPtyPreviewByChat,
12838
12892
  }
@@ -12841,7 +12895,6 @@ function handlePtyPartial(text: string): void {
12841
12895
  // interface; runtime-compatible. Cast through never.
12842
12896
  bot: bot as never,
12843
12897
  retry: robustApiCall,
12844
- renderText: markdownToHtml,
12845
12898
  logEvent: logStreamingEvent,
12846
12899
  onStreamSend: (chatId, messageId, charCount) => {
12847
12900
  logOutbound('pty_preview', chatId, messageId, charCount, 'initial_send')
@@ -12876,13 +12929,11 @@ function handlePtyActivity(text: string): void {
12876
12929
  format: 'text',
12877
12930
  lane: 'activity',
12878
12931
  },
12879
- { activeDraftStreams, activeDraftParseModes },
12932
+ { activeDraftStreams },
12880
12933
  {
12881
12934
  // See StreamBotApi cast comment above (line ~2522 area).
12882
12935
  bot: bot as never,
12883
12936
  retry: robustApiCall,
12884
- markdownToHtml,
12885
- escapeMarkdownV2,
12886
12937
  repairEscapedWhitespace,
12887
12938
  assertAllowedChat,
12888
12939
  resolveThreadId,
@@ -13573,9 +13624,9 @@ async function handleInbound(
13573
13624
  cleanAuthAddScratchDir(pendingAdd.scratchDir)
13574
13625
  await switchroomReply(
13575
13626
  ctx,
13576
- `āœ“ Account <code>${escapeHtmlForTg(pendingAdd.label)}</code> added.\n` +
13627
+ `āœ“ Account \`${pendingAdd.label}\` added.\n` +
13577
13628
  `The fleet's active account hasn't changed. Send ` +
13578
- `<code>/auth use ${escapeHtmlForTg(pendingAdd.label)}</code> to switch to it.`,
13629
+ `\`/auth use ${escapeHtmlForTg(pendingAdd.label)}\` to switch to it.`,
13579
13630
  { html: true },
13580
13631
  )
13581
13632
  } catch (brokerErr) {
@@ -13585,7 +13636,7 @@ async function handleInbound(
13585
13636
  cleanAuthAddScratchDir(pendingAdd.scratchDir)
13586
13637
  await switchroomReply(
13587
13638
  ctx,
13588
- `<b>/auth add failed at broker:</b> ${escapeHtmlForTg((brokerErr as Error)?.message ?? String(brokerErr))}`,
13639
+ `**/auth add failed at broker:** ${escapeHtmlForTg((brokerErr as Error)?.message ?? String(brokerErr))}`,
13589
13640
  { html: true },
13590
13641
  )
13591
13642
  }
@@ -13594,7 +13645,7 @@ async function handleInbound(
13594
13645
  // failure paths (timeout, child exit, stdin broken).
13595
13646
  await switchroomReply(
13596
13647
  ctx,
13597
- `<b>/auth add code failed:</b> ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
13648
+ `**/auth add code failed:** ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
13598
13649
  { html: true },
13599
13650
  )
13600
13651
  }
@@ -13616,7 +13667,7 @@ async function handleInbound(
13616
13667
  pendingReauthFlows.delete(interceptKey)
13617
13668
  const { result, errorText } = execAuthCode(pendingReauth.agent, text.trim())
13618
13669
  if (errorText) {
13619
- await switchroomReply(ctx, `<b>auth code failed:</b>\n${preBlock(formatSwitchroomOutput(errorText))}`, { html: true })
13670
+ await switchroomReply(ctx, `**auth code failed:**\n${preBlock(formatSwitchroomOutput(errorText))}`, { html: true })
13620
13671
  } else if (result) {
13621
13672
  const outcomeMsg = renderAuthCodeOutcome(result.outcome)
13622
13673
  if (outcomeMsg) {
@@ -13668,7 +13719,7 @@ async function handleInbound(
13668
13719
  if (result.ok) {
13669
13720
  await switchroomReply(ctx, 'šŸ”“ Vault broker unlocked.', { html: true })
13670
13721
  } else {
13671
- await switchroomReply(ctx, `<b>vault unlock failed:</b> ${escapeHtmlForTg(result.msg ?? 'unknown error')}`, { html: true })
13722
+ await switchroomReply(ctx, `**vault unlock failed:** ${escapeHtmlForTg(result.msg ?? 'unknown error')}`, { html: true })
13672
13723
  }
13673
13724
  } else if (pendingVault.kind === 'passphrase-for-deferred') {
13674
13725
  // Issue #44: passphrase entered after tapping "šŸ”“ Unlock vault &
@@ -13717,8 +13768,8 @@ async function handleInbound(
13717
13768
  .editMessageText(
13718
13769
  item.cardChatId,
13719
13770
  item.cardMessageId,
13720
- `āŒ› <i>Vault unlocked, but this access-request card expired or was denied before you replied. Ask the agent to re-issue if still needed.</i>`,
13721
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
13771
+ richMessage(`āŒ› _Vault unlocked, but this access-request card expired or was denied before you replied. Ask the agent to re-issue if still needed._`),
13772
+ { reply_markup: { inline_keyboard: [] } },
13722
13773
  )
13723
13774
  .catch(() => {})
13724
13775
  continue
@@ -13732,7 +13783,7 @@ async function handleInbound(
13732
13783
  if (ttlSeconds === null) {
13733
13784
  // Re-set state so user can try again
13734
13785
  pendingVaultOps.set(chat_id, { ...pendingVault, startedAt: Date.now() })
13735
- await switchroomReply(ctx, 'āš ļø Invalid duration. Use <code>Nd</code> (days) or <code>Nh</code> (hours), e.g. <code>30d</code> or <code>12h</code>.', { html: true })
13786
+ await switchroomReply(ctx, 'āš ļø Invalid duration. Use \`Nd\` (days) or \`Nh\` (hours), e.g. \`30d\` or \`12h\`.', { html: true })
13736
13787
  return
13737
13788
  }
13738
13789
  const newState = { ...pendingVault, ttlSeconds, awaitingCustomDuration: false }
@@ -13759,7 +13810,7 @@ async function handleInbound(
13759
13810
  if (!VAULT_KEY_REGEX.test(newKey)) {
13760
13811
  // Re-arm the pending state so the user can try again.
13761
13812
  pendingVaultOps.set(chat_id, { ...pendingVault, startedAt: Date.now() })
13762
- await switchroomReply(ctx, `āš ļø Key must match <code>${VAULT_KEY_REGEX_LABEL}</code>. Send a different name.`, { html: true })
13813
+ await switchroomReply(ctx, `āš ļø Key must match \`${VAULT_KEY_REGEX_LABEL}\`. Send a different name.`, { html: true })
13763
13814
  return
13764
13815
  }
13765
13816
  staged.key = newKey
@@ -13770,8 +13821,8 @@ async function handleInbound(
13770
13821
  .editMessageText(
13771
13822
  staged.chat_id,
13772
13823
  staged.card_message_id,
13773
- renderVaultRequestSaveCard(staged, staged.agent),
13774
- { parse_mode: 'HTML', reply_markup: buildVaultRequestSaveKeyboard(pendingVault.stageId) },
13824
+ richMessage(renderVaultRequestSaveCard(staged, staged.agent)),
13825
+ { reply_markup: buildVaultRequestSaveKeyboard(pendingVault.stageId) },
13775
13826
  )
13776
13827
  .catch(() => {})
13777
13828
  }
@@ -13806,7 +13857,7 @@ async function handleInbound(
13806
13857
  if (verb === 'stash' || verb === 'rename') {
13807
13858
  const cached = vaultPassphraseCache.get(chat_id)
13808
13859
  if (!cached || cached.expiresAt <= Date.now()) {
13809
- await switchroomReply(ctx, 'No vault passphrase cached. Run <code>/vault list</code> first (or any /vault command) to unlock, then re-send <code>stash NAME</code>.', { html: true })
13860
+ await switchroomReply(ctx, 'No vault passphrase cached. Run \`/vault list\` first (or any /vault command) to unlock, then re-send \`stash NAME\`.', { html: true })
13810
13861
  return
13811
13862
  }
13812
13863
  const slugBase = arg && arg.length > 0 ? arg : staged.detection.suggested_slug
@@ -13824,14 +13875,14 @@ async function handleInbound(
13824
13875
  const rendered = renderVaultCliError(parsed, { verb: 'save', key: slug })
13825
13876
  const body = rendered.suppressRaw
13826
13877
  ? rendered.html
13827
- : `<b>vault write failed:</b>\n${preBlock(write.output)}`
13878
+ : `**vault write failed:**\n${preBlock(write.output)}`
13828
13879
  await switchroomReply(ctx, body, { html: true })
13829
13880
  return
13830
13881
  }
13831
13882
  secretStaging.delete(staged.chat_id, staged.message_id)
13832
13883
  if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'stash command')
13833
13884
  await deleteSensitiveMessage(chat_id, staged.message_id, 'detected secret')
13834
- await switchroomReply(ctx, `āœ… stored as <code>vault:${slug}</code> (masked: <code>${maskToken(staged.detection.matched_text)}</code>)`, { html: true })
13885
+ await switchroomReply(ctx, `āœ… stored as \`vault:${slug}\` (masked: \`${maskToken(staged.detection.matched_text)}\`)`, { html: true })
13835
13886
  return
13836
13887
  }
13837
13888
  }
@@ -13921,11 +13972,11 @@ async function handleInbound(
13921
13972
  await deleteSensitiveMessage(chat_id, msgId, 'detected secret')
13922
13973
  }
13923
13974
  const lines = pipeRes.stored.map((s) =>
13924
- `• <code>${s.masked}</code> → <code>vault:${s.actual_slug}</code>`,
13975
+ `• \`${s.masked}\` → \`vault:${s.actual_slug}\``,
13925
13976
  )
13926
13977
  await switchroomReply(
13927
13978
  ctx,
13928
- [`šŸ”’ captured ${pipeRes.stored.length} secret${pipeRes.stored.length === 1 ? '' : 's'}:`, ...lines, '', 'reply <code>rename X</code> or <code>forget</code>.'].join('\n'),
13979
+ [`šŸ”’ captured ${pipeRes.stored.length} secret${pipeRes.stored.length === 1 ? '' : 's'}:`, ...lines, '', 'reply \`rename X\` or \`forget\`.'].join('\n'),
13929
13980
  { html: true },
13930
13981
  )
13931
13982
  for (const s of pipeRes.stored) {
@@ -13978,7 +14029,7 @@ async function handleInbound(
13978
14029
  const top = pipeRes.ambiguous[0]!
13979
14030
  await switchroomReply(
13980
14031
  ctx,
13981
- `šŸ‘€ looks like a high-entropy string (rule: <code>${escapeHtmlForTg(top.rule_id)}</code>). reply <code>stash NAME</code> to store in vault, or <code>ignore</code>.`,
14032
+ `šŸ‘€ looks like a high-entropy string (rule: \`${top.rule_id}\`). reply \`stash NAME\` to store in vault, or \`ignore\`.`,
13982
14033
  { html: true },
13983
14034
  )
13984
14035
  // For ambiguous, we do NOT delete the message or rewrite — let the
@@ -14127,7 +14178,6 @@ async function handleInbound(
14127
14178
  // content bug. Delete from the map FIRST so any concurrent
14128
14179
  // reads can't see the stale stream while we await.
14129
14180
  activeDraftStreams.delete(sKey)
14130
- activeDraftParseModes.delete(sKey)
14131
14181
  await priorStream.finalize().catch(() => {})
14132
14182
  }
14133
14183
  suppressPtyPreview.delete(sKey)
@@ -14647,12 +14697,15 @@ function stripAnsi(text: string): string {
14647
14697
  return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
14648
14698
  }
14649
14699
 
14700
+ // #2669: escape GFM-markdown specials in dynamic values interpolated into
14701
+ // rich-message bodies (kept under the legacy name to avoid churn).
14650
14702
  function escapeHtmlForTg(text: string): string {
14651
- return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
14703
+ return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
14652
14704
  }
14653
14705
 
14706
+ // Wrap CLI/command output in a fenced code block (content is literal there).
14654
14707
  function preBlock(text: string): string {
14655
- return '<pre>' + escapeHtmlForTg(text) + '</pre>'
14708
+ return '```\n' + text.replace(/```/g, '`​``') + '\n```'
14656
14709
  }
14657
14710
 
14658
14711
  type SwitchroomReplyMarkup =
@@ -14682,11 +14735,17 @@ async function switchroomReply(
14682
14735
  ? slashCommandReplyOpts(ctx, options.classification)
14683
14736
  : {}
14684
14737
  const threadId = routedOpts.message_thread_id ?? baseThreadId
14685
- await ctx.reply(text, {
14738
+ const replyOpts = {
14686
14739
  ...(threadId != null ? { message_thread_id: threadId } : {}),
14687
- ...(options.html ? { parse_mode: 'HTML' as const, link_preview_options: { is_disabled: true } } : {}),
14688
14740
  ...(options.reply_markup ? { reply_markup: options.reply_markup } : {}),
14689
- })
14741
+ }
14742
+ // #2669: `options.html` now means "render `text` as GFM markdown via the
14743
+ // rich-message path" (legacy field name kept). Plain otherwise.
14744
+ if (options.html) {
14745
+ await ctx.replyWithRichMessage(richMessage(text), replyOpts)
14746
+ } else {
14747
+ await ctx.reply(text, replyOpts)
14748
+ }
14690
14749
  }
14691
14750
 
14692
14751
  /**
@@ -14725,10 +14784,11 @@ async function deleteSensitiveMessage(
14725
14784
  // the stderr log is the audit trail.
14726
14785
  await swallowingApiCall(
14727
14786
  () =>
14728
- bot.api.sendMessage(
14787
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
14788
+ bot.api.sendRichMessage(
14729
14789
  chatId,
14730
- `āš ļø <b>Could not auto-delete message containing your ${escapeHtmlForTg(reason)}.</b>\n\nPlease delete message <code>${msgId}</code> manually so the value is not retained in chat history.\n\n<i>Reason: ${escapeHtmlForTg(msg)}</i>`,
14731
- { parse_mode: 'HTML' },
14790
+ richMessage(`āš ļø **Could not auto-delete message containing your ${escapeHtmlForTg(reason)}.**\n\nPlease delete message \`${msgId}\` manually so the value is not retained in chat history.\n\n_Reason: ${escapeHtmlForTg(msg)}_`),
14791
+ {},
14732
14792
  ),
14733
14793
  { chat_id: chatId, verb: 'deleteSensitiveMessage.warning' },
14734
14794
  )
@@ -15233,7 +15293,7 @@ function notifyDetachedFailure(
15233
15293
  clearRestartMarker()
15234
15294
  const snippet = tail ? tail.slice(-800) : '(no output captured)'
15235
15295
  const text =
15236
- `āŒ <b>${escapeHtmlForTg(label)} failed</b> (exit ${code}):\n` +
15296
+ `āŒ **${escapeHtmlForTg(label)} failed** (exit ${code}):\n` +
15237
15297
  preBlock(snippet)
15238
15298
  // Fire-and-forget — we're off the command-handler context and don't
15239
15299
  // have an await to block on anyway.
@@ -15242,9 +15302,7 @@ function notifyDetachedFailure(
15242
15302
  // failure handler.
15243
15303
  void swallowingApiCall(
15244
15304
  () =>
15245
- lockedBot.api.sendMessage(chatId, text, {
15246
- parse_mode: 'HTML',
15247
- link_preview_options: { is_disabled: true },
15305
+ lockedBot.api.sendRichMessage(chatId, richMessage(text), {
15248
15306
  // Detached restart/update child-failure notice — status, not
15249
15307
  // the user's answer. Silence the open ping.
15250
15308
  disable_notification: true,
@@ -15353,8 +15411,8 @@ function formatAuthOutputForTelegram(output: string): { text: string; url: strin
15353
15411
  return true
15354
15412
  })
15355
15413
  const rendered = body.map(line => {
15356
- if (line.startsWith('Started Claude auth') || line.startsWith('Auth session already running')) return `<b>${escapeHtmlForTg(line)}</b>`
15357
- if (line.startsWith('Open this URL')) return `<i>${escapeHtmlForTg(line)}</i>`
15414
+ if (line.startsWith('Started Claude auth') || line.startsWith('Auth session already running')) return `**${escapeHtmlForTg(line)}**`
15415
+ if (line.startsWith('Open this URL')) return `_${escapeHtmlForTg(line)}_`
15358
15416
  return escapeHtmlForTg(line)
15359
15417
  })
15360
15418
  // Mobile-native post-script. Two paths depending on which Anthropic
@@ -15378,11 +15436,11 @@ function formatAuthOutputForTelegram(output: string): { text: string; url: strin
15378
15436
  // the same outcome with no API constraint. See PR #30.
15379
15437
  rendered.push(
15380
15438
  '',
15381
- 'šŸ‘‡ Tap <b>šŸ” Open Claude auth</b> below, then <b>reply with the browser code</b>.',
15439
+ 'šŸ‘‡ Tap **šŸ” Open Claude auth** below, then **reply with the browser code**.',
15382
15440
  '',
15383
- '<i>Wrong Anthropic account getting authorized? Long-press the URL below and choose "Copy Link" or "Open in Browser" — lands in your main browser where the right account is signed in, bypassing Telegram\'s in-app browser cookies.</i>',
15441
+ '_Wrong Anthropic account getting authorized? Long-press the URL below and choose "Copy Link" or "Open in Browser" — lands in your main browser where the right account is signed in, bypassing Telegram\'s in-app browser cookies._',
15384
15442
  '',
15385
- `<a href="${escapeHtmlForTg(url)}">${escapeHtmlForTg(url)}</a>`,
15443
+ url,
15386
15444
  )
15387
15445
  return { text: rendered.join('\n'), url }
15388
15446
  }
@@ -15474,7 +15532,7 @@ async function runSwitchroomAuthCommand(ctx: Context, args: string[], label: str
15474
15532
  if (error.message?.includes('ENOENT')) { await switchroomReply(ctx, 'switchroom CLI not found.', { html: true }); return }
15475
15533
  if (error.message?.includes('ETIMEDOUT') || error.message?.includes('timed out')) { await switchroomReply(ctx, `${label}: timed out`); return }
15476
15534
  const detail = stripAnsi(error.stderr?.trim() || error.message || 'unknown error')
15477
- await switchroomReply(ctx, `<b>${escapeHtmlForTg(label)} failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true })
15535
+ await switchroomReply(ctx, `**${escapeHtmlForTg(label)} failed:**\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true })
15478
15536
  }
15479
15537
  }
15480
15538
 
@@ -15521,7 +15579,7 @@ function renderVaultOpFailure(
15521
15579
  const verb = verbLabel === 'delete' ? 'remove' : verbLabel
15522
15580
  const rendered = renderVaultCliError(parsed, { verb, key })
15523
15581
  if (rendered.suppressRaw) return rendered.html
15524
- return `<b>vault ${verbLabel} failed:</b>\n${preBlock(cliOutput)}`
15582
+ return `**vault ${verbLabel} failed:**\n${preBlock(cliOutput)}`
15525
15583
  }
15526
15584
 
15527
15585
  async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' | 'set' | 'delete', key: string | undefined, passphrase: string, setValue: string | undefined): Promise<void> {
@@ -15530,24 +15588,24 @@ async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' |
15530
15588
  if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('list', r.output, undefined), { html: true }); return }
15531
15589
  const keys = r.output.split('\n').filter(Boolean)
15532
15590
  if (keys.length === 0) { await switchroomReply(ctx, 'Vault is empty.', { html: true }) }
15533
- else { await switchroomReply(ctx, `<b>Vault keys (${keys.length}):</b>\n${keys.map(k => `• <code>${escapeHtmlForTg(k)}</code>`).join('\n')}`, { html: true }) }
15591
+ else { await switchroomReply(ctx, `**Vault keys (${keys.length}):**\n${keys.map(k => `• \`${k}\``).join('\n')}`, { html: true }) }
15534
15592
  } else if (op === 'get') {
15535
15593
  const r = runVaultCli(['get', key!], passphrase)
15536
15594
  if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('get', r.output, key), { html: true }); return }
15537
- await switchroomReply(ctx, `<code>${escapeHtmlForTg(key!)}</code> =\n<code>${escapeHtmlForTg(r.output)}</code>`, { html: true })
15595
+ await switchroomReply(ctx, `\`${key!}\` =\n\`${r.output}\``, { html: true })
15538
15596
  } else if (op === 'set') {
15539
15597
  if (setValue === undefined) {
15540
15598
  pendingVaultOps.set(chatId, { kind: 'value', op: 'set', key: key!, passphrase, startedAt: Date.now() })
15541
- await switchroomReply(ctx, `Send the value for <code>${escapeHtmlForTg(key!)}</code>.`, { html: true })
15599
+ await switchroomReply(ctx, `Send the value for \`${key!}\`.`, { html: true })
15542
15600
  return
15543
15601
  }
15544
15602
  const r = runVaultCli(['set', key!], passphrase, setValue)
15545
15603
  if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('set', r.output, key), { html: true }) }
15546
- else { await switchroomReply(ctx, `āœ… <code>${escapeHtmlForTg(key!)}</code> saved to vault.`, { html: true }) }
15604
+ else { await switchroomReply(ctx, `āœ… \`${key!}\` saved to vault.`, { html: true }) }
15547
15605
  } else if (op === 'delete') {
15548
15606
  const r = runVaultCli(['remove', key!], passphrase)
15549
15607
  if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('delete', r.output, key), { html: true }) }
15550
- else { await switchroomReply(ctx, `āœ… <code>${escapeHtmlForTg(key!)}</code> removed from vault.`, { html: true }) }
15608
+ else { await switchroomReply(ctx, `āœ… \`${key!}\` removed from vault.`, { html: true }) }
15551
15609
  }
15552
15610
  }
15553
15611
 
@@ -15598,8 +15656,8 @@ async function dispatchShortVerbViaHostd(
15598
15656
  if (hostdResp.result === 'started') {
15599
15657
  await switchroomReply(
15600
15658
  ctx,
15601
- `šŸ”„ <b>${escapeHtmlForTg(label)}</b> dispatched via hostd ` +
15602
- `(request_id=<code>${escapeHtmlForTg(hostdResp.request_id)}</code>). ` +
15659
+ `šŸ”„ **${escapeHtmlForTg(label)}** dispatched via hostd ` +
15660
+ `(request_id=\`${hostdResp.request_id}\`). ` +
15603
15661
  `Check audit log for completion.`,
15604
15662
  { html: true },
15605
15663
  )
@@ -15613,7 +15671,7 @@ async function dispatchShortVerbViaHostd(
15613
15671
  '(no error tail returned)'
15614
15672
  await switchroomReply(
15615
15673
  ctx,
15616
- `āŒ <b>${escapeHtmlForTg(label)} failed via hostd</b> ` +
15674
+ `āŒ **${escapeHtmlForTg(label)} failed via hostd** ` +
15617
15675
  `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
15618
15676
  preBlock(stripAnsi(errBody)),
15619
15677
  { html: true },
@@ -15640,7 +15698,7 @@ async function runSwitchroomCommand(
15640
15698
  if (error.message?.includes('ENOENT')) { await switchroomReply(ctx, 'switchroom CLI not found.', { html: true, classification }); return }
15641
15699
  if (error.message?.includes('ETIMEDOUT') || error.message?.includes('timed out')) { await switchroomReply(ctx, `${label}: timed out`, { classification }); return }
15642
15700
  const detail = stripAnsi(error.stderr?.trim() || error.message || 'unknown error')
15643
- await switchroomReply(ctx, `<b>${escapeHtmlForTg(label)} failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true, classification })
15701
+ await switchroomReply(ctx, `**${escapeHtmlForTg(label)} failed:**\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true, classification })
15644
15702
  }
15645
15703
  }
15646
15704
 
@@ -15666,16 +15724,16 @@ function statusIcon(status: string): string {
15666
15724
  function renderAuthCodeOutcome(outcome: AuthCodeOutcome | null | undefined): string | null {
15667
15725
  if (!outcome || outcome.kind === 'success') return null
15668
15726
  const tail = outcome.paneTailText
15669
- ? `\n<i>${escapeHtmlForTg(outcome.paneTailText)}</i>`
15727
+ ? `\n_${escapeHtmlForTg(outcome.paneTailText)}_`
15670
15728
  : ''
15671
15729
  switch (outcome.kind) {
15672
15730
  case 'invalid-code':
15673
15731
  case 'expired-code':
15674
- return `Code rejected by Claude — tap <b>Restart flow</b> for a fresh URL.${tail}`
15732
+ return `Code rejected by Claude — tap **Restart flow** for a fresh URL.${tail}`
15675
15733
  case 'pane-not-ready':
15676
- return `Auth pane not ready — tap <b>Retry</b>.`
15734
+ return `Auth pane not ready — tap **Retry**.`
15677
15735
  case 'timeout':
15678
- return `Still waiting after 2 min — tap <b>Retry</b> or check <code>switchroom auth list</code>.${tail}`
15736
+ return `Still waiting after 2 min — tap **Retry** or check \`switchroom auth list\`.${tail}`
15679
15737
  }
15680
15738
  }
15681
15739
 
@@ -15727,7 +15785,7 @@ async function runSwitchroomCommandFormatted(ctx: Context, args: string[], label
15727
15785
  } catch (err: unknown) {
15728
15786
  const error = err as { stderr?: string; message?: string }
15729
15787
  const detail = stripAnsi(error.stderr?.trim() || error.message || 'unknown error')
15730
- await switchroomReply(ctx, `<b>${escapeHtmlForTg(label)} failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true })
15788
+ await switchroomReply(ctx, `**${escapeHtmlForTg(label)} failed:**\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true })
15731
15789
  }
15732
15790
  }
15733
15791
 
@@ -15762,8 +15820,8 @@ bot.use(async (ctx, next) => {
15762
15820
  const nameHtml = escapeHtmlForTg(myName)
15763
15821
  const notFlagged =
15764
15822
  decision.reason === 'other-agent'
15765
- ? `āš ļø <code>${cmdHtml}</code> targeting another agent is an admin operation — this agent (<code>${nameHtml}</code>) isn't admin-flagged. Run it from an admin agent, or set <code>admin: true</code> for this agent in switchroom.yaml. (Self-restart is allowed: send <code>/restart</code> with no arg.)`
15766
- : `āš ļø <code>${cmdHtml}</code> is an admin command — this agent (<code>${nameHtml}</code>) isn't admin-flagged. Run it from an admin agent, or set <code>admin: true</code> for this agent in switchroom.yaml.`
15823
+ ? `āš ļø \`${cmdHtml}\` targeting another agent is an admin operation — this agent (\`${nameHtml}\`) isn't admin-flagged. Run it from an admin agent, or set \`admin: true\` for this agent in switchroom.yaml. (Self-restart is allowed: send \`/restart\` with no arg.)`
15824
+ : `āš ļø \`${cmdHtml}\` is an admin command — this agent (\`${nameHtml}\`) isn't admin-flagged. Run it from an admin agent, or set \`admin: true\` for this agent in switchroom.yaml.`
15767
15825
  if (!AGENT_ADMIN) {
15768
15826
  // Unchanged behaviour: a non-admin agent never executes admin
15769
15827
  // verbs locally and must not forward them to Claude.
@@ -15793,7 +15851,7 @@ bot.use(async (ctx, next) => {
15793
15851
  )
15794
15852
  await switchroomReply(
15795
15853
  ctx,
15796
- `āš ļø <code>${cmdHtml}</code> is a fleet-admin command — it is <b>operator-private</b>. Send it as a direct message to me from your operator account (a private chat where your Telegram ID is on the access allowlist), not in a group or forum.`,
15854
+ `āš ļø \`${cmdHtml}\` is a fleet-admin command — it is **operator-private**. Send it as a direct message to me from your operator account (a private chat where your Telegram ID is on the access allowlist), not in a group or forum.`,
15797
15855
  { html: true },
15798
15856
  )
15799
15857
  return
@@ -16018,12 +16076,12 @@ bot.command('start', async ctx => {
16018
16076
  const gated = dmCommandGate(ctx)
16019
16077
  if (!gated) return
16020
16078
  const disabled = gated.access.dmPolicy === 'disabled'
16021
- await ctx.reply(buildStartText(getMyAgentName(), disabled), { parse_mode: 'HTML' })
16079
+ await ctx.replyWithRichMessage(richMessage(buildStartText(getMyAgentName(), disabled)))
16022
16080
  })
16023
16081
 
16024
16082
  bot.command('help', async ctx => {
16025
16083
  if (!dmCommandGate(ctx)) return
16026
- await ctx.reply(buildHelpText(getMyAgentName()), { parse_mode: 'HTML' })
16084
+ await ctx.replyWithRichMessage(richMessage(buildHelpText(getMyAgentName())))
16027
16085
  })
16028
16086
 
16029
16087
  bot.command('status', async ctx => {
@@ -16038,12 +16096,12 @@ bot.command('status', async ctx => {
16038
16096
  const demo = hasDemoFlag(getCommandArgs(ctx))
16039
16097
  const userTag = from.username ? `@${from.username}` : senderId
16040
16098
  const meta = await buildAgentMetadata(getMyAgentName())
16041
- await ctx.reply(buildStatusPairedText({ user: userTag, meta, demo }), { parse_mode: 'HTML' })
16099
+ await ctx.replyWithRichMessage(richMessage(buildStatusPairedText({ user: userTag, meta, demo })))
16042
16100
  return
16043
16101
  }
16044
16102
  for (const [code, p] of Object.entries(access.pending)) {
16045
16103
  if (p.senderId === senderId) {
16046
- await ctx.reply(buildStatusPendingText(code), { parse_mode: 'HTML' })
16104
+ await ctx.replyWithRichMessage(richMessage(buildStatusPendingText(code)))
16047
16105
  return
16048
16106
  }
16049
16107
  }
@@ -16056,11 +16114,11 @@ bot.command('agents', async ctx => {
16056
16114
  type AgentListResp = { agents: Array<{ name: string; status: string; uptime: string; template: string; topic_name: string; topic_emoji?: string }> }
16057
16115
  const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
16058
16116
  if (!data) return null
16059
- if (data.agents.length === 0) return '<i>No agents defined</i>'
16060
- const lines = ['<b>Agents</b>']
16117
+ if (data.agents.length === 0) return '_No agents defined_'
16118
+ const lines = ['**Agents**']
16061
16119
  for (const a of data.agents) {
16062
- lines.push(`${statusIcon(a.status)} <b>${escapeHtmlForTg(a.name)}</b> Ā· ${escapeHtmlForTg(a.status)} Ā· ${escapeHtmlForTg(a.uptime)}`)
16063
- lines.push(` <i>${escapeHtmlForTg(a.template)} → ${escapeHtmlForTg(a.topic_name)}${a.topic_emoji ? ' ' + a.topic_emoji : ''}</i>`)
16120
+ lines.push(`${statusIcon(a.status)} **${escapeHtmlForTg(a.name)}** Ā· ${escapeHtmlForTg(a.status)} Ā· ${escapeHtmlForTg(a.uptime)}`)
16121
+ lines.push(` _${escapeHtmlForTg(a.template)} → ${escapeHtmlForTg(a.topic_name)}${a.topic_emoji ? ' ' + a.topic_emoji : ''}_`)
16064
16122
  }
16065
16123
  return lines.join('\n')
16066
16124
  })
@@ -16335,8 +16393,7 @@ bot.command('restart', async ctx => {
16335
16393
  const sent = await retryWithThreadFallback<{ message_id: number }>(
16336
16394
  robustApiCall,
16337
16395
  (tid) =>
16338
- lockedBot.api.sendMessage(chatId, ackText, {
16339
- parse_mode: 'HTML', link_preview_options: { is_disabled: true },
16396
+ lockedBot.api.sendRichMessage(chatId, richMessage(ackText), {
16340
16397
  // Restart acknowledgement is a status notice — silence the
16341
16398
  // open ping (the "restarted — ready" follow-up is what matters).
16342
16399
  disable_notification: true,
@@ -16378,7 +16435,7 @@ bot.command('restart', async ctx => {
16378
16435
  clearRestartMarker()
16379
16436
  await switchroomReply(
16380
16437
  ctx,
16381
- `āŒ <b>restart ${escapeHtmlForTg(name)} failed via hostd</b> ` +
16438
+ `āŒ **restart ${escapeHtmlForTg(name)} failed via hostd** ` +
16382
16439
  `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
16383
16440
  preBlock(hostdResp.error ?? '(no error message)'),
16384
16441
  { html: true },
@@ -16435,7 +16492,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
16435
16492
  await switchroomReply(
16436
16493
  ctx,
16437
16494
  `/${kind} only supports a single agent — ā€œallā€ would leave other agents with stale handoff briefings. ` +
16438
- `Run /${kind} from each agent’s own topic, or use <code>switchroom agent restart all</code> if you just want a plain restart without flushing sessions.`,
16495
+ `Run /${kind} from each agent’s own topic, or use \`switchroom agent restart all\` if you just want a plain restart without flushing sessions.`,
16439
16496
  { html: true },
16440
16497
  )
16441
16498
  return
@@ -16446,9 +16503,9 @@ async function handleNewCommand(ctx: Context): Promise<void> {
16446
16503
  if (!isSelfTargetingCommand(name)) {
16447
16504
  await switchroomReply(
16448
16505
  ctx,
16449
- `/${kind} only supports the current agent (<b>${escapeHtmlForTg(getMyAgentName())}</b>). ` +
16506
+ `/${kind} only supports the current agent (**${escapeHtmlForTg(getMyAgentName())}**). ` +
16450
16507
  `To restart another agent with a fresh session, run /${kind} from its own topic, ` +
16451
- `or use <code>switchroom agent restart ${escapeHtmlForTg(name)}</code>.`,
16508
+ `or use \`switchroom agent restart ${escapeHtmlForTg(name)}\`.`,
16452
16509
  { html: true },
16453
16510
  )
16454
16511
  return
@@ -16460,7 +16517,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
16460
16517
  if (existing && Date.now() - existing.ts < 15_000) {
16461
16518
  await switchroomReply(
16462
16519
  ctx,
16463
- `ā³ Restart of <b>${escapeHtmlForTg(name)}</b> already in progress (${Math.round((Date.now() - existing.ts) / 1000)}s ago) — ignoring duplicate /${kind}.`,
16520
+ `ā³ Restart of **${escapeHtmlForTg(name)}** already in progress (${Math.round((Date.now() - existing.ts) / 1000)}s ago) — ignoring duplicate /${kind}.`,
16464
16521
  { html: true },
16465
16522
  )
16466
16523
  return
@@ -16479,8 +16536,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
16479
16536
  const sent = await retryWithThreadFallback<{ message_id: number }>(
16480
16537
  robustApiCall,
16481
16538
  (tid) =>
16482
- lockedBot.api.sendMessage(chatId, ackText, {
16483
- parse_mode: 'HTML', link_preview_options: { is_disabled: true },
16539
+ lockedBot.api.sendRichMessage(chatId, richMessage(ackText), {
16484
16540
  // /new /reset acknowledgement is a status notice — silence the
16485
16541
  // open ping (the post-restart greeting card is what matters).
16486
16542
  disable_notification: true,
@@ -16534,7 +16590,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
16534
16590
  clearRestartMarker()
16535
16591
  await switchroomReply(
16536
16592
  ctx,
16537
- `āŒ <b>${escapeHtmlForTg(kind)} ${escapeHtmlForTg(name)} failed via hostd</b> ` +
16593
+ `āŒ **${escapeHtmlForTg(kind)} ${escapeHtmlForTg(name)} failed via hostd** ` +
16538
16594
  `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
16539
16595
  preBlock(hostdResp.error ?? '(no error message)'),
16540
16596
  { html: true },
@@ -16555,7 +16611,7 @@ bot.command('update', async ctx => {
16555
16611
  await runSwitchroomCommand(ctx, ['update', '--check'], 'update --check')
16556
16612
  await switchroomReply(
16557
16613
  ctx,
16558
- 'Reply with <code>/update apply</code> to execute, or <code>/update apply --skip-images</code> to skip the image pull.',
16614
+ 'Reply with \`/update apply\` to execute, or \`/update apply --skip-images\` to skip the image pull.',
16559
16615
  { html: true },
16560
16616
  )
16561
16617
  return
@@ -16567,7 +16623,7 @@ bot.command('update', async ctx => {
16567
16623
  if (tokens[0] !== 'apply' && tokens[0] !== '--apply') {
16568
16624
  await switchroomReply(
16569
16625
  ctx,
16570
- 'Usage: <code>/update</code> (dry-run) or <code>/update apply [--skip-images] [--rebuild]</code>',
16626
+ 'Usage: \`/update\` (dry-run) or \`/update apply [--skip-images] [--rebuild]\`',
16571
16627
  { html: true },
16572
16628
  )
16573
16629
  return
@@ -16581,8 +16637,8 @@ bot.command('update', async ctx => {
16581
16637
  if (!ALLOWED_FLAGS.has(tok)) {
16582
16638
  await switchroomReply(
16583
16639
  ctx,
16584
- `Refusing to pass unknown flag: <code>${escapeHtmlForTg(tok)}</code>. ` +
16585
- `Allowed: <code>--skip-images</code>, <code>--rebuild</code>.`,
16640
+ `Refusing to pass unknown flag: \`${tok}\`. ` +
16641
+ `Allowed: \`--skip-images\`, \`--rebuild\`.`,
16586
16642
  { html: true },
16587
16643
  )
16588
16644
  return
@@ -16615,16 +16671,16 @@ bot.command('update', async ctx => {
16615
16671
  // hostd hasn't been installed yet, or its daemon is down.
16616
16672
  await switchroomReply(
16617
16673
  ctx,
16618
- `āŒ <b>/update apply</b> needs <code>hostd</code>, but its socket ` +
16619
- `for agent <code>${escapeHtmlForTg(myAgentName)}</code> isn't ` +
16674
+ `āŒ **/update apply** needs \`hostd\`, but its socket ` +
16675
+ `for agent \`${myAgentName}\` isn't ` +
16620
16676
  `bound and in-container docker access isn't available either.\n\n` +
16621
- `<code>host_control.enabled</code> is on in <code>switchroom.yaml</code>, ` +
16677
+ `\`host_control.enabled\` is on in \`switchroom.yaml\`, ` +
16622
16678
  `so hostd is the expected dispatch path — but no socket at ` +
16623
- `<code>/run/switchroom/hostd/${escapeHtmlForTg(myAgentName)}/sock</code>. ` +
16679
+ `\`/run/switchroom/hostd/${escapeHtmlForTg(myAgentName)}/sock\`. ` +
16624
16680
  `On a fresh docker install this usually means hostd hasn't been ` +
16625
16681
  `installed yet.\n\n` +
16626
- `Run <code>switchroom hostd install</code> on the host to install + ` +
16627
- `start the daemon, then retry. Send <code>/upgradestatus</code> to ` +
16682
+ `Run \`switchroom hostd install\` on the host to install + ` +
16683
+ `start the daemon, then retry. Send \`/upgradestatus\` to ` +
16628
16684
  `re-check the daemon state from here.`,
16629
16685
  { html: true },
16630
16686
  )
@@ -16634,14 +16690,14 @@ bot.command('update', async ctx => {
16634
16690
  // pick one of: host CLI, or enable hostd.
16635
16691
  await switchroomReply(
16636
16692
  ctx,
16637
- `āŒ <b>/update apply</b> needs docker access from inside the agent ` +
16638
- `container, but it's not available (no <code>docker</code> binary on ` +
16639
- `PATH, no <code>/var/run/docker.sock</code> mount) and ` +
16640
- `<code>host_control.enabled</code> is off.\n\n` +
16641
- `Either run <code>switchroom update</code> from the host shell, or ` +
16642
- `set <code>host_control.enabled: true</code> in ` +
16643
- `<code>switchroom.yaml</code> and run ` +
16644
- `<code>switchroom hostd install</code> on the host so this verb ` +
16693
+ `āŒ **/update apply** needs docker access from inside the agent ` +
16694
+ `container, but it's not available (no \`docker\` binary on ` +
16695
+ `PATH, no \`/var/run/docker.sock\` mount) and ` +
16696
+ `\`host_control.enabled\` is off.\n\n` +
16697
+ `Either run \`switchroom update\` from the host shell, or ` +
16698
+ `set \`host_control.enabled: true\` in ` +
16699
+ `\`switchroom.yaml\` and run ` +
16700
+ `\`switchroom hostd install\` on the host so this verb ` +
16645
16701
  `can dispatch through the host-side daemon.`,
16646
16702
  { html: true },
16647
16703
  )
@@ -16671,9 +16727,9 @@ bot.command('update', async ctx => {
16671
16727
  // /restart handler (gateway.ts ~6273) so the boot-card lookup
16672
16728
  // (gateway.ts ~10393) finds chat_id + ack_message_id in the marker.
16673
16729
  const ackText =
16674
- `šŸš€ <b>update started</b> — running ${[
16675
- '<code>switchroom update</code>',
16676
- ...passthrough.map((t) => `<code>${escapeHtmlForTg(t)}</code>`),
16730
+ `šŸš€ **update started** — running ${[
16731
+ '\`switchroom update\`',
16732
+ ...passthrough.map((t) => `\`${t}\``),
16677
16733
  ].join(' ')}\n` +
16678
16734
  `\nThe gateway will restart as part of the recreate step; watch ` +
16679
16735
  `for the post-restart greeting card to confirm completion.`
@@ -16683,9 +16739,7 @@ bot.command('update', async ctx => {
16683
16739
  const sent = await retryWithThreadFallback<{ message_id: number }>(
16684
16740
  robustApiCall,
16685
16741
  (tid) =>
16686
- lockedBot.api.sendMessage(chatId, ackText, {
16687
- parse_mode: 'HTML',
16688
- link_preview_options: { is_disabled: true },
16742
+ lockedBot.api.sendRichMessage(chatId, richMessage(ackText), {
16689
16743
  // "update started" acknowledgement is a status notice — silence
16690
16744
  // the open ping (the post-restart greeting card is what matters).
16691
16745
  disable_notification: true,
@@ -16778,15 +16832,14 @@ bot.command('update', async ctx => {
16778
16832
  terminal.stdout_tail ??
16779
16833
  '(no error tail returned)'
16780
16834
  const editedText =
16781
- `šŸš€ <b>update started</b> — <b>FAILED</b> via hostd ` +
16835
+ `šŸš€ **update started** — **FAILED** via hostd ` +
16782
16836
  `(result=${escapeHtmlForTg(terminal.result)}):\n` +
16783
16837
  preBlock(errBody)
16784
16838
  if (ackId != null) {
16785
16839
  try {
16786
16840
  await robustApiCall(
16787
16841
  () =>
16788
- lockedBot.api.editMessageText(chatId, ackId!, editedText, {
16789
- parse_mode: 'HTML',
16842
+ lockedBot.api.editMessageText(chatId, ackId!, richMessage(editedText), {
16790
16843
  link_preview_options: { is_disabled: true },
16791
16844
  }),
16792
16845
  { verb: 'update.poll.editAck' },
@@ -16814,7 +16867,7 @@ bot.command('update', async ctx => {
16814
16867
  clearRestartMarker()
16815
16868
  await switchroomReply(
16816
16869
  ctx,
16817
- `āŒ <b>/update apply failed via hostd</b> ` +
16870
+ `āŒ **/update apply failed via hostd** ` +
16818
16871
  `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
16819
16872
  preBlock(hostdResp.error ?? '(no error message)'),
16820
16873
  { html: true },
@@ -16841,8 +16894,8 @@ bot.command('upgrade', async ctx => {
16841
16894
  if (!isAuthorizedSender(ctx)) return
16842
16895
  await switchroomReply(
16843
16896
  ctx,
16844
- 'Did you mean <code>/upgradestatus</code> (no hyphen — Telegram slash-command grammar)? ' +
16845
- 'Or <code>/update</code> to plan, <code>/update apply</code> to execute.',
16897
+ 'Did you mean \`/upgradestatus\` (no hyphen — Telegram slash-command grammar)? ' +
16898
+ 'Or \`/update\` to plan, \`/update apply\` to execute.',
16846
16899
  { html: true },
16847
16900
  )
16848
16901
  })
@@ -16858,7 +16911,7 @@ bot.command('audit', async ctx => {
16858
16911
  if (arg === '' || arg === 'help' || arg === '--help') {
16859
16912
  await switchroomReply(
16860
16913
  ctx,
16861
- 'Usage: <code>/audit hostd [--tail N] [--agent &lt;name&gt;] [--op &lt;verb&gt;] [--error] [--verbose]</code>',
16914
+ 'Usage: \`/audit hostd [--tail N] [--agent <name>] [--op <verb>] [--error] [--verbose]\`',
16862
16915
  { html: true },
16863
16916
  )
16864
16917
  return
@@ -16868,8 +16921,8 @@ bot.command('audit', async ctx => {
16868
16921
  if (sub !== 'hostd') {
16869
16922
  await switchroomReply(
16870
16923
  ctx,
16871
- `Unknown audit target <code>${escapeHtmlForTg(sub ?? '')}</code>. ` +
16872
- `Supported: <code>hostd</code>.`,
16924
+ `Unknown audit target \`${sub ?? ''}\`. ` +
16925
+ `Supported: \`hostd\`.`,
16873
16926
  { html: true },
16874
16927
  )
16875
16928
  return
@@ -16889,22 +16942,22 @@ bot.command('audit', async ctx => {
16889
16942
  if (t === '--tail' || t === '--agent' || t === '--op') {
16890
16943
  const v = tokens[++i]
16891
16944
  if (v == null) {
16892
- await switchroomReply(ctx, `Flag <code>${t}</code> requires a value.`, { html: true })
16945
+ await switchroomReply(ctx, `Flag \`${t}\` requires a value.`, { html: true })
16893
16946
  return
16894
16947
  }
16895
16948
  if (t === '--tail' && !/^[0-9]{1,4}$/.test(v)) {
16896
- await switchroomReply(ctx, `<code>--tail</code> must be an integer (1-9999).`, { html: true })
16949
+ await switchroomReply(ctx, `\`--tail\` must be an integer (1-9999).`, { html: true })
16897
16950
  return
16898
16951
  }
16899
16952
  if (t === '--agent' && !/^[a-z][a-z0-9-]{0,62}$/i.test(v)) {
16900
- await switchroomReply(ctx, `<code>--agent</code> name has an invalid shape.`, { html: true })
16953
+ await switchroomReply(ctx, `\`--agent\` name has an invalid shape.`, { html: true })
16901
16954
  return
16902
16955
  }
16903
16956
  if (t === '--op' && !ALLOWED_OPS.has(v)) {
16904
16957
  await switchroomReply(
16905
16958
  ctx,
16906
- `Unknown hostd verb <code>${escapeHtmlForTg(v)}</code>. ` +
16907
- `Known: ${[...ALLOWED_OPS].sort().map(o => `<code>${o}</code>`).join(', ')}.`,
16959
+ `Unknown hostd verb \`${v}\`. ` +
16960
+ `Known: ${[...ALLOWED_OPS].sort().map(o => `\`${o}\``).join(', ')}.`,
16908
16961
  { html: true },
16909
16962
  )
16910
16963
  return
@@ -16914,8 +16967,8 @@ bot.command('audit', async ctx => {
16914
16967
  }
16915
16968
  await switchroomReply(
16916
16969
  ctx,
16917
- `Unknown flag <code>${escapeHtmlForTg(t)}</code>. ` +
16918
- `Allowed: <code>--tail</code>, <code>--agent</code>, <code>--op</code>, <code>--error</code>, <code>--verbose</code>.`,
16970
+ `Unknown flag \`${t}\`. ` +
16971
+ `Allowed: \`--tail\`, \`--agent\`, \`--op\`, \`--error\`, \`--verbose\`.`,
16919
16972
  { html: true },
16920
16973
  )
16921
16974
  return
@@ -16964,7 +17017,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
16964
17017
  if (!details) {
16965
17018
  await switchroomReply(
16966
17019
  ctx,
16967
- `No pending permission for id <code>${escapeHtmlForTg(request_id)}</code>. It may have already been answered or timed out.`,
17020
+ `No pending permission for id \`${request_id}\`. It may have already been answered or timed out.`,
16968
17021
  { html: true },
16969
17022
  )
16970
17023
  return
@@ -16983,10 +17036,10 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
16983
17036
  `[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
16984
17037
  )
16985
17038
  const lbl = behavior === 'allow' ? 'āœ… Allowed' : 'āŒ Denied'
16986
- const suffix = details.tool_name ? ` (<code>${escapeHtmlForTg(details.tool_name)}</code>)` : ''
17039
+ const suffix = details.tool_name ? ` (\`${details.tool_name}\`)` : ''
16987
17040
  await switchroomReply(
16988
17041
  ctx,
16989
- `${lbl}${suffix} via /${behavior} <code>${escapeHtmlForTg(request_id)}</code>`,
17042
+ `${lbl}${suffix} via /${behavior} \`${request_id}\``,
16990
17043
  { html: true },
16991
17044
  )
16992
17045
  }
@@ -17064,9 +17117,9 @@ bot.command('pending', async ctx => {
17064
17117
  await switchroomReply(ctx, 'No pending permission prompts.')
17065
17118
  return
17066
17119
  }
17067
- const lines: string[] = ['<b>Pending permission prompts</b>']
17120
+ const lines: string[] = ['**Pending permission prompts**']
17068
17121
  for (const [id, details] of pendingPermissions.entries()) {
17069
- lines.push(`• <code>${escapeHtmlForTg(id)}</code> — ${escapeHtmlForTg(details.tool_name)}`)
17122
+ lines.push(`• \`${id}\` — ${escapeHtmlForTg(details.tool_name)}`)
17070
17123
  }
17071
17124
  await switchroomReply(ctx, lines.join('\n'), { html: true })
17072
17125
  })
@@ -17266,7 +17319,8 @@ function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): vo
17266
17319
  const html = renderFallbackFailureNotice(triggerAgent, reason)
17267
17320
  for (const chat_id of access.allowFrom) {
17268
17321
  void swallowingApiCall(
17269
- () => bot.api.sendMessage(chat_id, html, { parse_mode: 'HTML' as const }),
17322
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
17323
+ () => bot.api.sendRichMessage(chat_id, richMessage(html), {}),
17270
17324
  { chat_id, verb: 'fleet-fallback:failure-notify' },
17271
17325
  )
17272
17326
  }
@@ -17356,10 +17410,11 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
17356
17410
  if (access.allowFrom.length === 0) return outcome.kind === 'switched'
17357
17411
  // Account-switch / all-blocked announcement is a system status notice,
17358
17412
  // not the user's answer — silence the open ping.
17359
- const opts = { parse_mode: 'HTML' as const, disable_notification: true }
17413
+ const opts = { disable_notification: true }
17360
17414
  for (const chat_id of access.allowFrom) {
17361
17415
  void swallowingApiCall(
17362
- () => bot.api.sendMessage(chat_id, outcome.announcement, opts),
17416
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
17417
+ () => bot.api.sendRichMessage(chat_id, richMessage(outcome.announcement), opts),
17363
17418
  { chat_id, verb: 'fleet-fallback:notify' },
17364
17419
  )
17365
17420
  }
@@ -17447,9 +17502,7 @@ async function runCreditWatch(): Promise<void> {
17447
17502
  // contract enforced by scripts/check-bot-api-wrapping.sh (#1075).
17448
17503
  await swallowingApiCall(
17449
17504
  () =>
17450
- bot.api.sendMessage(chat_id, decision.message, {
17451
- parse_mode: 'HTML',
17452
- link_preview_options: { is_disabled: true },
17505
+ bot.api.sendRichMessage(chat_id, richMessage(decision.message), {
17453
17506
  // Credit/quota warning is a system status notice — silence the
17454
17507
  // open ping (the user isn't waiting to tap anything).
17455
17508
  disable_notification: true,
@@ -17589,9 +17642,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
17589
17642
  }
17590
17643
  await swallowingApiCall(
17591
17644
  () =>
17592
- bot.api.sendMessage(chat_id, fleetDecision.message, {
17593
- parse_mode: 'HTML',
17594
- link_preview_options: { is_disabled: true },
17645
+ bot.api.sendRichMessage(chat_id, richMessage(fleetDecision.message), {
17595
17646
  // Quota status notice — silence the open ping.
17596
17647
  disable_notification: true,
17597
17648
  }),
@@ -17812,9 +17863,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
17812
17863
  // contract enforced by scripts/check-bot-api-wrapping.sh (#1075).
17813
17864
  await swallowingApiCall(
17814
17865
  () =>
17815
- bot.api.sendMessage(chat_id, message, {
17816
- parse_mode: 'HTML',
17817
- link_preview_options: { is_disabled: true },
17866
+ bot.api.sendRichMessage(chat_id, richMessage(message), {
17818
17867
  // Quota throttling status notice — silence the open ping.
17819
17868
  disable_notification: true,
17820
17869
  }),
@@ -17845,8 +17894,7 @@ async function editMicrosoftConnectCard(
17845
17894
  ): Promise<void> {
17846
17895
  await robustApiCall(
17847
17896
  // allow-raw-bot-api: background connect-card edit by message id (no thread_id; not the reply chunk loop)
17848
- () => bot.api.editMessageText(chatId, messageId, html, {
17849
- parse_mode: 'HTML',
17897
+ () => bot.api.editMessageText(chatId, messageId, richMessage(html), {
17850
17898
  link_preview_options: { is_disabled: true },
17851
17899
  }),
17852
17900
  { chat_id: String(chatId), verb: 'microsoftConnectCard' },
@@ -17874,21 +17922,21 @@ async function finalizeMicrosoftConnect(key: string): Promise<void> {
17874
17922
  let html: string
17875
17923
  if (result.kind === 'connected') {
17876
17924
  html =
17877
- `āœ… <b>Connected</b> <code>${escapeHtmlForTg(result.account)}</code> ` +
17925
+ `āœ… **Connected** \`${result.account}\` ` +
17878
17926
  `(${result.accountType}).\n\n` +
17879
- `To let <b>${escapeHtmlForTg(agentName)}</b> use it, run on the host:\n` +
17880
- `<code>switchroom auth microsoft enable ${escapeHtmlForTg(result.account)} ${escapeHtmlForTg(agentName)}</code>\n` +
17927
+ `To let **${escapeHtmlForTg(agentName)}** use it, run on the host:\n` +
17928
+ `\`switchroom auth microsoft enable ${escapeHtmlForTg(result.account)} ${escapeHtmlForTg(agentName)}\`\n` +
17881
17929
  `then restart ${escapeHtmlForTg(agentName)}.`
17882
17930
  } else if (result.kind === 'no-refresh-token') {
17883
17931
  html =
17884
17932
  `⚠ Microsoft returned no refresh token (the account would expire in ~1h), ` +
17885
- `so it was not registered. Try <code>/connect microsoft</code> again, or ` +
17933
+ `so it was not registered. Try \`/connect microsoft\` again, or ` +
17886
17934
  `connect from the host CLI.`
17887
17935
  } else {
17888
17936
  html =
17889
- `āŒ <b>Connect failed:</b> ${escapeHtmlForTg(result.message)}\n\n` +
17890
- `<i>Work/school accounts can't use the phone flow at /common — connect those ` +
17891
- `from the host: <code>switchroom auth microsoft account add &lt;email&gt;</code>.</i>`
17937
+ `āŒ **Connect failed:** ${escapeHtmlForTg(result.message)}\n\n` +
17938
+ `_Work/school accounts can't use the phone flow at /common — connect those ` +
17939
+ `from the host: \`switchroom auth microsoft account add <email>\`._`
17892
17940
  }
17893
17941
  await editMicrosoftConnectCard(flow.cardChatId, flow.cardMessageId, html)
17894
17942
  }
@@ -17918,7 +17966,7 @@ bot.command('connect', async ctx => {
17918
17966
  )
17919
17967
  await switchroomReply(
17920
17968
  ctx,
17921
- `āš ļø <code>/connect</code> links account credentials — it is <b>operator-private</b>. ` +
17969
+ `āš ļø \`/connect\` links account credentials — it is **operator-private**. ` +
17922
17970
  `Send it as a direct message to me from your operator account (a private chat where ` +
17923
17971
  `your Telegram ID is on the access allowlist), not in a group or forum.`,
17924
17972
  { html: true },
@@ -17945,8 +17993,8 @@ bot.command('connect', async ctx => {
17945
17993
  if (!isAuthAdmin({ isAdmin })) {
17946
17994
  await switchroomReply(
17947
17995
  ctx,
17948
- `<b>Not authorized.</b> <code>/connect</code> requires this agent to have ` +
17949
- `<code>admin: true</code> in switchroom.yaml.`,
17996
+ `**Not authorized.** \`/connect\` requires this agent to have ` +
17997
+ `\`admin: true\` in switchroom.yaml.`,
17950
17998
  { html: true },
17951
17999
  )
17952
18000
  return
@@ -17955,7 +18003,7 @@ bot.command('connect', async ctx => {
17955
18003
  if (arg === 'cancel') {
17956
18004
  const existing = pendingMicrosoftConnectFlows.get(key)
17957
18005
  if (!existing) {
17958
- await switchroomReply(ctx, '<i>No pending connect flow in this chat.</i>', { html: true })
18006
+ await switchroomReply(ctx, '_No pending connect flow in this chat._', { html: true })
17959
18007
  return
17960
18008
  }
17961
18009
  existing.cancelled = true
@@ -17967,9 +18015,9 @@ bot.command('connect', async ctx => {
17967
18015
  if (arg !== '' && arg !== 'microsoft' && arg !== 'ms') {
17968
18016
  await switchroomReply(
17969
18017
  ctx,
17970
- `<b>Usage:</b> <code>/connect microsoft</code> to link a Microsoft account ` +
17971
- `(Outlook / Office 365), or <code>/connect cancel</code>.\n` +
17972
- `<i>Google accounts are connected from the host CLI.</i>`,
18018
+ `**Usage:** \`/connect microsoft\` to link a Microsoft account ` +
18019
+ `(Outlook / Office 365), or \`/connect cancel\`.\n` +
18020
+ `_Google accounts are connected from the host CLI._`,
17973
18021
  { html: true },
17974
18022
  )
17975
18023
  return
@@ -17978,8 +18026,8 @@ bot.command('connect', async ctx => {
17978
18026
  if (pendingMicrosoftConnectFlows.has(key)) {
17979
18027
  await switchroomReply(
17980
18028
  ctx,
17981
- '<i>A Microsoft connect flow is already in progress here. Finish it on your phone, ' +
17982
- 'or send <code>/connect cancel</code>.</i>',
18029
+ '_A Microsoft connect flow is already in progress here. Finish it on your phone, ' +
18030
+ 'or send \`/connect cancel\`._',
17983
18031
  { html: true },
17984
18032
  )
17985
18033
  return
@@ -17999,9 +18047,9 @@ bot.command('connect', async ctx => {
17999
18047
  if (started.kind === 'byo-vault') {
18000
18048
  await switchroomReply(
18001
18049
  ctx,
18002
- `<b>Can't connect from chat:</b> this install uses a vaulted custom Microsoft ` +
18003
- `app (<code>${escapeHtmlForTg(started.ref)}</code>) only the host CLI can read. ` +
18004
- `Run <code>switchroom auth microsoft account add &lt;email&gt;</code> on the host.`,
18050
+ `**Can't connect from chat:** this install uses a vaulted custom Microsoft ` +
18051
+ `app (\`${started.ref}\`) only the host CLI can read. ` +
18052
+ `Run \`switchroom auth microsoft account add <email>\` on the host.`,
18005
18053
  { html: true },
18006
18054
  )
18007
18055
  return
@@ -18009,7 +18057,7 @@ bot.command('connect', async ctx => {
18009
18057
  if (started.kind === 'error') {
18010
18058
  await switchroomReply(
18011
18059
  ctx,
18012
- `<b>/connect failed:</b> ${escapeHtmlForTg(started.message)}`,
18060
+ `**/connect failed:** ${escapeHtmlForTg(started.message)}`,
18013
18061
  { html: true },
18014
18062
  )
18015
18063
  return
@@ -18017,21 +18065,21 @@ bot.command('connect', async ctx => {
18017
18065
 
18018
18066
  const appNote =
18019
18067
  started.source === 'default'
18020
- ? "<i>Using switchroom's shipped Microsoft app.</i>"
18021
- : '<i>Using your configured Microsoft app.</i>'
18068
+ ? "_Using switchroom's shipped Microsoft app._"
18069
+ : '_Using your configured Microsoft app._'
18022
18070
  const keyboard = new InlineKeyboard()
18023
18071
  .url('šŸ” Sign in to Microsoft', started.device.verification_uri)
18024
18072
  .row()
18025
18073
  .text('āœ– Cancel', `cn:cancel:${key}`)
18026
- const sent = await ctx.reply(
18027
- `šŸ”— <b>Connect a Microsoft account</b>\n\n` +
18028
- `1. Tap <b>Sign in to Microsoft</b> below.\n` +
18029
- `2. Enter this code: <code>${escapeHtmlForTg(started.device.user_code)}</code>\n` +
18074
+ const sent = await ctx.replyWithRichMessage(
18075
+ richMessage(
18076
+ `šŸ”— **Connect a Microsoft account**\n\n` +
18077
+ `1. Tap **Sign in to Microsoft** below.\n` +
18078
+ `2. Enter this code: \`${started.device.user_code}\`\n` +
18030
18079
  `3. Approve the requested permissions (Mail, Calendar, Files).\n\n` +
18031
18080
  `I'll confirm here once it's connected (within ~15 min).\n${appNote}`,
18081
+ ),
18032
18082
  {
18033
- parse_mode: 'HTML',
18034
- link_preview_options: { is_disabled: true },
18035
18083
  reply_markup: keyboard,
18036
18084
  ...(ctx.message?.message_thread_id != null
18037
18085
  ? { message_thread_id: ctx.message.message_thread_id }
@@ -18082,7 +18130,7 @@ bot.command("auth", async ctx => {
18082
18130
  )
18083
18131
  await switchroomReply(
18084
18132
  ctx,
18085
- `āš ļø <code>/auth</code> manages account credentials — it is <b>operator-private</b>. Send it as a direct message to me from your operator account (a private chat where your Telegram ID is on the access allowlist), not in a group or forum.`,
18133
+ `āš ļø \`/auth\` manages account credentials — it is **operator-private**. Send it as a direct message to me from your operator account (a private chat where your Telegram ID is on the access allowlist), not in a group or forum.`,
18086
18134
  { html: true },
18087
18135
  ).catch(() => {})
18088
18136
  }
@@ -18121,10 +18169,10 @@ bot.command("auth", async ctx => {
18121
18169
  if (!isAuthAdmin({ isAdmin })) {
18122
18170
  await switchroomReply(
18123
18171
  ctx,
18124
- `<b>Not authorized.</b> <code>/auth ${parsed.kind}</code> is admin-only.\n` +
18125
- `Set <code>admin: true</code> on this agent in switchroom.yaml to unlock ` +
18126
- `(the same flag that gates <code>/agents</code>, <code>/restart</code>, ` +
18127
- `<code>/update</code> etc.).`,
18172
+ `**Not authorized.** \`/auth ${parsed.kind}\` is admin-only.\n` +
18173
+ `Set \`admin: true\` on this agent in switchroom.yaml to unlock ` +
18174
+ `(the same flag that gates \`/agents\`, \`/restart\`, ` +
18175
+ `\`/update\` etc.).`,
18128
18176
  { html: true },
18129
18177
  )
18130
18178
  return
@@ -18137,7 +18185,7 @@ bot.command("auth", async ctx => {
18137
18185
  if (parsed.kind === 'cancel') {
18138
18186
  const existing = pendingAuthAddFlows.get(authAddKey)
18139
18187
  if (!existing) {
18140
- await switchroomReply(ctx, "<i>No pending <code>/auth add</code> flow in this chat.</i>", { html: true })
18188
+ await switchroomReply(ctx, "_No pending \`/auth add\` flow in this chat._", { html: true })
18141
18189
  return
18142
18190
  }
18143
18191
  cancelAccountAuthSession(existing)
@@ -18149,8 +18197,8 @@ bot.command("auth", async ctx => {
18149
18197
  if (pendingAuthAddFlows.has(authAddKey)) {
18150
18198
  await switchroomReply(
18151
18199
  ctx,
18152
- "<i>An <code>/auth add</code> flow is already in progress for this chat. " +
18153
- "Finish the paste, or send <code>/auth cancel</code> to abort.</i>",
18200
+ "_An \`/auth add\` flow is already in progress for this chat. " +
18201
+ "Finish the paste, or send \`/auth cancel\` to abort._",
18154
18202
  { html: true },
18155
18203
  )
18156
18204
  return
@@ -18166,17 +18214,17 @@ bot.command("auth", async ctx => {
18166
18214
  })
18167
18215
  await switchroomReply(
18168
18216
  ctx,
18169
- `<b>Adding account</b> <code>${escapeHtmlForTg(parsed.label)}</code>\n\n` +
18217
+ `**Adding account** \`${parsed.label}\`\n\n` +
18170
18218
  `1. Open this URL on your phone:\n${loginUrl}\n\n` +
18171
18219
  `2. Log into Anthropic, copy the code Claude shows.\n` +
18172
18220
  `3. Paste it back here.\n\n` +
18173
- `Send <code>/auth cancel</code> to abort.`,
18221
+ `Send \`/auth cancel\` to abort.`,
18174
18222
  { html: true },
18175
18223
  )
18176
18224
  } catch (err) {
18177
18225
  await switchroomReply(
18178
18226
  ctx,
18179
- `<b>/auth add failed:</b> ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
18227
+ `**/auth add failed:** ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
18180
18228
  { html: true },
18181
18229
  )
18182
18230
  }
@@ -18185,7 +18233,7 @@ bot.command("auth", async ctx => {
18185
18233
 
18186
18234
  const client = await getAuthBrokerClient(currentAgent)
18187
18235
  if (!client) {
18188
- await switchroomReply(ctx, "<b>/auth unavailable:</b> auth-broker client is not loaded (post-RFC-H rewire in progress?).", { html: true })
18236
+ await switchroomReply(ctx, "**/auth unavailable:** auth-broker client is not loaded (post-RFC-H rewire in progress?).", { html: true })
18189
18237
  return
18190
18238
  }
18191
18239
  const reply = await handleAuthCommand(parsed, {
@@ -18437,7 +18485,7 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
18437
18485
  return
18438
18486
  }
18439
18487
  if (result.kind === 'error') {
18440
- await switchroomReply(ctx, `<b>mint_grant failed:</b> ${escapeHtmlForTg(result.msg)}`, { html: true })
18488
+ await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
18441
18489
  return
18442
18490
  }
18443
18491
  // Write the token to the agent's .vault-token file — same flow as the
@@ -18451,10 +18499,10 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
18451
18499
  } catch (err) {
18452
18500
  await switchroomReply(
18453
18501
  ctx,
18454
- `<b>Grant created (${escapeHtmlForTg(id)}) but token write failed:</b> ` +
18502
+ `**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
18455
18503
  `${escapeHtmlForTg(String(err))}\n` +
18456
- `<i>Recover with: <code>switchroom vault grant ${escapeHtmlForTg(agentName)} ` +
18457
- `--keys ${escapeHtmlForTg(keyName)} --duration 30d</code> on the host.</i>`,
18504
+ `_Recover with: \`switchroom vault grant ${escapeHtmlForTg(agentName)} ` +
18505
+ `--keys ${escapeHtmlForTg(keyName)} --duration 30d\` on the host._`,
18458
18506
  { html: true },
18459
18507
  )
18460
18508
  return
@@ -18479,14 +18527,13 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
18479
18527
  ? escapeHtmlForTg(sourceMsg.text)
18480
18528
  : ''
18481
18529
  const statusLine =
18482
- `\n\nāœ… <b>${escapeHtmlForTg(agentName)}</b> granted read access to ` +
18483
- `<code>${escapeHtmlForTg(keyName)}</code> for 30 days ` +
18484
- `(grant <code>${escapeHtmlForTg(id)}</code>). ` +
18530
+ `\n\nāœ… **${escapeHtmlForTg(agentName)}** granted read access to ` +
18531
+ `\`${keyName}\` for 30 days ` +
18532
+ `(grant \`${id}\`). ` +
18485
18533
  `Re-run /vault audit to act on remaining denials.`
18486
18534
  await finalizeCallback(ctx, {
18487
18535
  ackText: 'āœ… Grant minted',
18488
18536
  newText: baseText ? `${baseText}${statusLine}` : statusLine,
18489
- parseMode: 'HTML',
18490
18537
  // No synthInbound — operator-only flow. The granted agent picks
18491
18538
  // up the token via .vault-token file on next CLI invocation; no
18492
18539
  // turn-wake needed.
@@ -18553,11 +18600,11 @@ async function performVaultAccessApproval(
18553
18600
  .editMessageText(
18554
18601
  pending.chat_id,
18555
18602
  pending.card_message_id,
18556
- `ā„¹ļø <b>${escapeHtmlForTg(pending.agent)}</b> already has standing-ACL access to ` +
18557
- `<code>${escapeHtmlForTg(pending.key)}</code> (schedule.secrets[]). ` +
18558
- `<b>No grant minted</b> — a token would shadow the standing ACL. ` +
18559
- `The agent can read it directly.`,
18560
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18603
+ `ā„¹ļø **${escapeHtmlForTg(pending.agent)}** already has standing-ACL access to ` +
18604
+ `\`${pending.key}\` (schedule.secrets[]). ` +
18605
+ `**No grant minted** — a token would shadow the standing ACL. ` +
18606
+ richMessage(`The agent can read it directly.`),
18607
+ { reply_markup: { inline_keyboard: [] } },
18561
18608
  )
18562
18609
  .catch(() => {})
18563
18610
  }
@@ -18651,8 +18698,8 @@ async function performVaultAccessApproval(
18651
18698
  .editMessageText(
18652
18699
  pending.chat_id,
18653
18700
  pending.card_message_id,
18654
- `<b>mint_grant failed:</b> ${escapeHtmlForTg(result.msg)}`,
18655
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18701
+ richMessage(`**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`),
18702
+ { reply_markup: { inline_keyboard: [] } },
18656
18703
  )
18657
18704
  .catch(() => {})
18658
18705
  }
@@ -18667,10 +18714,10 @@ async function performVaultAccessApproval(
18667
18714
  } catch (err) {
18668
18715
  await switchroomReply(
18669
18716
  ctx,
18670
- `<b>Grant created (${escapeHtmlForTg(id)}) but token write failed:</b> ` +
18717
+ `**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
18671
18718
  `${escapeHtmlForTg(String(err))}\n` +
18672
- `<i>Recover with: <code>switchroom vault grant ${escapeHtmlForTg(pending.agent)} ` +
18673
- `--keys ${escapeHtmlForTg(pending.key)} --duration ${Math.round(pending.ttl_seconds / 86400)}d</code> on the host.</i>`,
18719
+ `_Recover with: \`switchroom vault grant ${escapeHtmlForTg(pending.agent)} ` +
18720
+ `--keys ${escapeHtmlForTg(pending.key)} --duration ${Math.round(pending.ttl_seconds / 86400)}d\` on the host._`,
18674
18721
  { html: true },
18675
18722
  )
18676
18723
  return
@@ -18681,16 +18728,16 @@ async function performVaultAccessApproval(
18681
18728
  const days = Math.round(pending.ttl_seconds / 86400)
18682
18729
  const footer =
18683
18730
  VAULT_APPROVAL_AUTH_MODE === 'telegram-id'
18684
- ? `\n<i>Approver verified by Telegram identity — broker auto-unlocked at startup.</i>`
18731
+ ? `\n_Approver verified by Telegram identity — broker auto-unlocked at startup._`
18685
18732
  : ''
18686
18733
  await ctx.api
18687
18734
  .editMessageText(
18688
18735
  pending.chat_id,
18689
18736
  pending.card_message_id,
18690
- `āœ… Granted <b>${escapeHtmlForTg(pending.agent)}</b> ${pending.scope} access to ` +
18691
- `<code>${escapeHtmlForTg(pending.key)}</code> for ${days}d. ` +
18692
- `(grant <code>${escapeHtmlForTg(id)}</code>)` + footer,
18693
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18737
+ `āœ… Granted **${escapeHtmlForTg(pending.agent)}** ${pending.scope} access to ` +
18738
+ `\`${pending.key}\` for ${days}d. ` +
18739
+ richMessage(`(grant \`${id}\`)` + footer),
18740
+ { reply_markup: { inline_keyboard: [] } },
18694
18741
  )
18695
18742
  .catch(() => {})
18696
18743
  }
@@ -18734,6 +18781,97 @@ async function performVaultAccessApproval(
18734
18781
  )
18735
18782
  }
18736
18783
 
18784
+ /**
18785
+ * #2670 — handle an Approve / Dismiss tap on a one-tap skill-improvement
18786
+ * proposal card.
18787
+ *
18788
+ * Approve → mark the proposal approved + inject a synthetic
18789
+ * `skill_proposal_apply` turn instructing the live agent to
18790
+ * write the stored draft through `skill_*_personal` (so the
18791
+ * secret-scan pipeline runs; agent never self-applies).
18792
+ * Dismiss → mark rejected + write a rejection fingerprint so the weekly
18793
+ * synthesis cron doesn't re-propose it.
18794
+ */
18795
+ async function handleSkillProposalCallback(ctx: Context, data: string): Promise<void> {
18796
+ const senderId = String(ctx.from?.id ?? '')
18797
+ const access = loadAccess()
18798
+ if (!access.allowFrom.includes(senderId)) {
18799
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
18800
+ return
18801
+ }
18802
+ const parsed = parseSkillProposalCallback(data)
18803
+ if (parsed == null) {
18804
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
18805
+ return
18806
+ }
18807
+ const stateDir = process.env.TELEGRAM_STATE_DIR
18808
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
18809
+ if (stateDir == null || stateDir.length === 0) {
18810
+ await ctx.answerCallbackQuery({ text: 'State dir unset — cannot apply.' }).catch(() => {})
18811
+ return
18812
+ }
18813
+ const proposal = getSkillProposal(stateDir, parsed.id)
18814
+ if (proposal == null) {
18815
+ await ctx.answerCallbackQuery({ text: 'Proposal expired or already actioned.' }).catch(() => {})
18816
+ if (ctx.callbackQuery?.message) {
18817
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
18818
+ }
18819
+ return
18820
+ }
18821
+ if (proposal.status !== 'pending') {
18822
+ await ctx.answerCallbackQuery({ text: `Already ${proposal.status}.` }).catch(() => {})
18823
+ if (ctx.callbackQuery?.message) {
18824
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
18825
+ }
18826
+ return
18827
+ }
18828
+
18829
+ const cbChatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
18830
+ const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
18831
+
18832
+ if (parsed.action === 'deny') {
18833
+ setSkillProposalStatus(stateDir, parsed.id, 'rejected')
18834
+ await ctx.answerCallbackQuery({ text: '🚫 Dismissed — won’t be proposed again.' }).catch(() => {})
18835
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
18836
+ await ctx
18837
+ .editMessageText(
18838
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
18839
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18840
+ )
18841
+ .catch(() => {})
18842
+ }
18843
+ return
18844
+ }
18845
+
18846
+ // Approve.
18847
+ setSkillProposalStatus(stateDir, parsed.id, 'approved')
18848
+ const synthetic = buildSkillProposalApplyInbound({
18849
+ ctx: {
18850
+ agent,
18851
+ chat_id: cbChatId,
18852
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
18853
+ },
18854
+ proposalId: proposal.id,
18855
+ skillSlug: proposal.skill_slug,
18856
+ isNew: proposal.is_new,
18857
+ operatorId: senderId,
18858
+ })
18859
+ const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
18860
+ await ctx.answerCallbackQuery({ text: 'āœ… Applying the skill…' }).catch(() => {})
18861
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
18862
+ await ctx
18863
+ .editMessageText(
18864
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\nāœ… <i>Approved — applying.</i>`,
18865
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18866
+ )
18867
+ .catch(() => {})
18868
+ }
18869
+ process.stderr.write(
18870
+ `telegram gateway: skill_proposal_apply injection agent=${agent} ` +
18871
+ `proposal=${proposal.id} slug=${proposal.skill_slug} delivered=${delivered}\n`,
18872
+ )
18873
+ }
18874
+
18737
18875
  async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
18738
18876
  const senderId = String(ctx.from?.id ?? '')
18739
18877
  const access = loadAccess()
@@ -18756,8 +18894,8 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
18756
18894
  .editMessageText(
18757
18895
  ctx.callbackQuery.message.chat.id,
18758
18896
  ctx.callbackQuery.message.message_id,
18759
- 'āŒ› <i>This access-request card expired before you tapped. Ask the agent to re-issue if the need still stands.</i>',
18760
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18897
+ richMessage('āŒ› _This access-request card expired before you tapped. Ask the agent to re-issue if the need still stands._'),
18898
+ { reply_markup: { inline_keyboard: [] } },
18761
18899
  )
18762
18900
  .catch(() => {})
18763
18901
  }
@@ -18772,8 +18910,8 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
18772
18910
  .editMessageText(
18773
18911
  pending.chat_id,
18774
18912
  pending.card_message_id,
18775
- `🚫 <i>Denied. <b>${escapeHtmlForTg(pending.agent)}</b> will not get access to <code>${escapeHtmlForTg(pending.key)}</code>.</i>`,
18776
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18913
+ richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}** will not get access to \`${pending.key}\`._`),
18914
+ { reply_markup: { inline_keyboard: [] } },
18777
18915
  )
18778
18916
  .catch(() => {})
18779
18917
  }
@@ -18837,8 +18975,8 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
18837
18975
  .editMessageText(
18838
18976
  pending.chat_id,
18839
18977
  pending.card_message_id,
18840
- `āœ… Approved by @${escapeHtmlForTg(username)} — minting…`,
18841
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18978
+ richMessage(`āœ… Approved by @${escapeHtmlForTg(username)} — minting…`),
18979
+ { reply_markup: { inline_keyboard: [] } },
18842
18980
  )
18843
18981
  .catch(() => {})
18844
18982
  }
@@ -18894,13 +19032,13 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
18894
19032
  pending.chat_id,
18895
19033
  pending.card_message_id,
18896
19034
  joiningBatch
18897
- ? `šŸ” <b>Queued behind an earlier card.</b> Type your passphrase as your next message — it covers <b>${items.length}</b> pending approvals in this chat (one entry mints all grants, no re-type per card).`
19035
+ ? `šŸ” **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).`
18898
19036
  : isAdminOnly
18899
- ? `šŸ”’ <b>Admin-only credential.</b> <code>${escapeHtmlForTg(pending.key)}</code> requires your vault passphrase to grant — reply with it as your next message and we'll mint the grant for <b>${escapeHtmlForTg(pending.agent)}</b>, then delete the passphrase message.\n\n` +
18900
- `<i>The passphrase is what proves it's you: an agent can never mint this key on its own.</i>`
18901
- : `šŸ” <b>Vault is locked.</b> Reply with your passphrase as your next message — we'll unlock, mint the grant for <b>${escapeHtmlForTg(pending.agent)}</b>, and delete the passphrase message in one step.\n\n` +
18902
- `<i>Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches.</i>`,
18903
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19037
+ ? `šŸ”’ **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` +
19038
+ `_The passphrase is what proves it's you: an agent can never mint this key on its own._`
19039
+ : `šŸ” **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` +
19040
+ richMessage(`_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches._`),
19041
+ { reply_markup: { inline_keyboard: [] } },
18904
19042
  )
18905
19043
  .catch(() => {})
18906
19044
  return
@@ -18937,8 +19075,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
18937
19075
  .editMessageText(
18938
19076
  ctx.callbackQuery.message.chat.id,
18939
19077
  ctx.callbackQuery.message.message_id,
18940
- 'āŒ› <i>This vault-save card expired before you tapped. Ask the agent to re-issue if you still want to save.</i>',
18941
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19078
+ richMessage('āŒ› _This vault-save card expired before you tapped. Ask the agent to re-issue if you still want to save._'),
19079
+ { reply_markup: { inline_keyboard: [] } },
18942
19080
  )
18943
19081
  .catch(() => {})
18944
19082
  }
@@ -18953,8 +19091,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
18953
19091
  .editMessageText(
18954
19092
  pending.chat_id,
18955
19093
  pending.card_message_id,
18956
- `🚫 <i>Discarded. The secret was not written to the vault.</i>`,
18957
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19094
+ richMessage(`🚫 _Discarded. The secret was not written to the vault._`),
19095
+ { reply_markup: { inline_keyboard: [] } },
18958
19096
  )
18959
19097
  .catch(() => {})
18960
19098
  }
@@ -19003,12 +19141,11 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
19003
19141
  ? escapeHtmlForTg(sourceMsg.text)
19004
19142
  : ''
19005
19143
  const statusLine =
19006
- `\n\nāœļø <b>Rename mode</b> — send the new key name as your next message. ` +
19007
- `The current proposed key is <code>${escapeHtmlForTg(pending.key)}</code>.`
19144
+ `\n\nāœļø **Rename mode** — send the new key name as your next message. ` +
19145
+ `The current proposed key is \`${pending.key}\`.`
19008
19146
  await finalizeCallback(ctx, {
19009
19147
  ackText: 'Send the new key name as your next message.',
19010
19148
  newText: baseText ? `${baseText}${statusLine}` : statusLine,
19011
- parseMode: 'HTML',
19012
19149
  })
19013
19150
  return
19014
19151
  }
@@ -19047,8 +19184,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
19047
19184
  .editMessageText(
19048
19185
  pending.chat_id,
19049
19186
  pending.card_message_id,
19050
- `šŸ”’ <b>Passphrase not cached for this chat.</b> Run <code>/vault unlock</code> (or any /vault command) to cache it, then tap Save again on the next card.`,
19051
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19187
+ richMessage(`šŸ”’ **Passphrase not cached for this chat.** Run \`/vault unlock\` (or any /vault command) to cache it, then tap Save again on the next card.`),
19188
+ { reply_markup: { inline_keyboard: [] } },
19052
19189
  )
19053
19190
  .catch(() => {})
19054
19191
  }
@@ -19069,14 +19206,14 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
19069
19206
  const rendered = renderVaultCliError(parsed, { verb: 'save', key: pending.key })
19070
19207
  const body = rendered.suppressRaw
19071
19208
  ? rendered.html
19072
- : `āš ļø vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
19209
+ : `āš ļø vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
19073
19210
  if (pending.card_message_id != null) {
19074
19211
  await ctx.api
19075
19212
  .editMessageText(
19076
19213
  pending.chat_id,
19077
19214
  pending.card_message_id,
19078
- `${body}\n\n<i>Tap a fresh card after fixing the underlying issue.</i>`,
19079
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19215
+ richMessage(`${body}\n\n_Tap a fresh card after fixing the underlying issue._`),
19216
+ { reply_markup: { inline_keyboard: [] } },
19080
19217
  )
19081
19218
  .catch(() => {})
19082
19219
  }
@@ -19114,8 +19251,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
19114
19251
  .editMessageText(
19115
19252
  pending.chat_id,
19116
19253
  pending.card_message_id,
19117
- `āœ… saved as <code>vault:${escapeHtmlForTg(pending.key)}</code> (masked: <code>${escapeHtmlForTg(maskToken(pending.value))}</code>)\n<i>The agent can now reference this as <code>vault:${escapeHtmlForTg(pending.key)}</code>.</i>`,
19118
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19254
+ richMessage(`āœ… saved as \`vault:${escapeHtmlForTg(pending.key)}\` (masked: \`${escapeHtmlForTg(maskToken(pending.value))}\`)\n_The agent can now reference this as \`vault:${escapeHtmlForTg(pending.key)}\`._`),
19255
+ { reply_markup: { inline_keyboard: [] } },
19119
19256
  )
19120
19257
  .catch(() => {})
19121
19258
  }
@@ -19238,8 +19375,8 @@ async function handleVaultDeferCallback(ctx: Context, data: string): Promise<voi
19238
19375
  await ctx.answerCallbackQuery({ text: 'Send your passphrase…' }).catch(() => {})
19239
19376
  await ctx
19240
19377
  .editMessageText(
19241
- 'šŸ” Send your vault passphrase as your next message — we\'ll save the held secret automatically and delete the passphrase message.',
19242
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19378
+ richMessage('šŸ” Send your vault passphrase as your next message — we\'ll save the held secret automatically and delete the passphrase message.'),
19379
+ { reply_markup: { inline_keyboard: [] } },
19243
19380
  )
19244
19381
  .catch(() => {})
19245
19382
  return
@@ -19324,7 +19461,7 @@ async function startGrantWizardStep1(ctx: Context, chatId: string): Promise<void
19324
19461
  return
19325
19462
  }
19326
19463
  const kb = buildGrantAgentKeyboard(agents)
19327
- const sent = await switchroomReply(ctx, '<b>Grant capability token — Step 1/3</b>\n\nWhich agent?', { html: true, reply_markup: kb })
19464
+ const sent = await switchroomReply(ctx, '**Grant capability token — Step 1/3**\n\nWhich agent?', { html: true, reply_markup: kb })
19328
19465
  const wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
19329
19466
  pendingVaultOps.set(chatId, {
19330
19467
  kind: 'grant-wizard',
@@ -19343,16 +19480,16 @@ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wiz
19343
19480
  return
19344
19481
  }
19345
19482
  if (keys.length === 0) {
19346
- await switchroomReply(ctx, 'āš ļø No vault keys found. Add secrets first with <code>/vault set</code>.', { html: true })
19483
+ await switchroomReply(ctx, 'āš ļø No vault keys found. Add secrets first with \`/vault set\`.', { html: true })
19347
19484
  pendingVaultOps.delete(chatId)
19348
19485
  return
19349
19486
  }
19350
19487
  const selected = new Set<string>()
19351
19488
  const kb = buildGrantKeysKeyboard(keys, selected)
19352
- const text = `<b>Grant capability token — Step 2/3</b>\n\nWhich keys for <code>${escapeHtmlForTg(agent)}</code>?\n<i>Tap to toggle; tap Continue when done.</i>`
19489
+ const text = `**Grant capability token — Step 2/3**\n\nWhich keys for \`${agent}\`?\n_Tap to toggle; tap Continue when done._`
19353
19490
  if (wizardMsgId != null) {
19354
19491
  // allow-raw-bot-api: vault grant wizard step 2/3; already .catch-swallows, tap-driven UI re-renders on retry
19355
- await ctx.api.editMessageText(chatId, wizardMsgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
19492
+ await ctx.api.editMessageText(chatId, wizardMsgId, richMessage(text), { reply_markup: kb }).catch(() => {})
19356
19493
  } else {
19357
19494
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
19358
19495
  wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
@@ -19371,12 +19508,12 @@ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wiz
19371
19508
  /** Advance grant wizard to step 3 (pick duration). */
19372
19509
  async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
19373
19510
  const kb = buildGrantDurationKeyboard()
19374
- const keyList = state.selectedKeys!.map(k => `• <code>${escapeHtmlForTg(k)}</code>`).join('\n')
19375
- const text = `<b>Grant capability token — Step 3/3</b>\n\nKeys for <code>${escapeHtmlForTg(state.agent!)}</code>:\n${keyList}\n\nHow long should this grant be valid?`
19511
+ const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
19512
+ const text = `**Grant capability token — Step 3/3**\n\nKeys for \`${state.agent!}\`:\n${keyList}\n\nHow long should this grant be valid?`
19376
19513
  const msgId = state.wizardMsgId
19377
19514
  if (msgId != null) {
19378
19515
  // allow-raw-bot-api: vault grant wizard step 3/3 (TTL select); already .catch-swallows, tap-driven UI re-renders on retry
19379
- await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
19516
+ await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
19380
19517
  } else {
19381
19518
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
19382
19519
  state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
@@ -19388,20 +19525,20 @@ async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<Pen
19388
19525
  async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
19389
19526
  const kb = buildGrantConfirmKeyboard()
19390
19527
  const expiresLabel = formatGrantExpiry(state.ttlSeconds!)
19391
- const keyList = state.selectedKeys!.map(k => `• <code>${escapeHtmlForTg(k)}</code>`).join('\n')
19528
+ const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
19392
19529
  const text = [
19393
- '<b>Confirm grant</b>',
19530
+ '**Confirm grant**',
19394
19531
  '',
19395
- `Agent: <code>${escapeHtmlForTg(state.agent!)}</code>`,
19532
+ `Agent: \`${state.agent!}\``,
19396
19533
  `Keys:\n${keyList}`,
19397
- `Expires: <b>${escapeHtmlForTg(expiresLabel)}</b>`,
19534
+ `Expires: **${escapeHtmlForTg(expiresLabel)}**`,
19398
19535
  '',
19399
- 'Tap <b>Generate</b> to mint the token.',
19536
+ 'Tap **Generate** to mint the token.',
19400
19537
  ].join('\n')
19401
19538
  const msgId = state.wizardMsgId
19402
19539
  if (msgId != null) {
19403
19540
  // allow-raw-bot-api: vault grant wizard confirm step; already .catch-swallows, tap-driven UI re-renders on retry
19404
- await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
19541
+ await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
19405
19542
  } else {
19406
19543
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
19407
19544
  state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
@@ -19460,7 +19597,7 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
19460
19597
  return
19461
19598
  }
19462
19599
  if (result.kind === 'error') {
19463
- await switchroomReply(ctx, `<b>mint_grant failed:</b> ${escapeHtmlForTg(result.msg)}`, { html: true })
19600
+ await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
19464
19601
  return
19465
19602
  }
19466
19603
  // Write token to the agent's .vault-token file
@@ -19470,7 +19607,7 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
19470
19607
  mkdirSync(join(homedir(), '.switchroom', 'agents', state.agent!), { recursive: true })
19471
19608
  writeFileSync(tokenPath, token, { mode: 0o600 })
19472
19609
  } catch (err) {
19473
- await switchroomReply(ctx, `<b>Grant created but token write failed:</b> ${escapeHtmlForTg(String(err))}`, { html: true })
19610
+ await switchroomReply(ctx, `**Grant created but token write failed:** ${escapeHtmlForTg(String(err))}`, { html: true })
19474
19611
  return
19475
19612
  }
19476
19613
  // Collapse wizard message to just the outcome.
@@ -19481,12 +19618,11 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
19481
19618
  // Strip the keyboard atomically with the success text via the
19482
19619
  // finalizeCallback helper.
19483
19620
  const msgId = state.wizardMsgId
19484
- const successText = `āœ… Grant <code>${escapeHtmlForTg(id)}</code> created. Written to <code>~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token</code>`
19621
+ const successText = `āœ… Grant \`${id}\` created. Written to \`~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token\``
19485
19622
  if (msgId != null) {
19486
19623
  await finalizeCallback(ctx, {
19487
19624
  ackText: 'āœ… Grant created',
19488
19625
  newText: successText,
19489
- parseMode: 'HTML',
19490
19626
  // No synthInbound — operator-only flow.
19491
19627
  })
19492
19628
  } else {
@@ -19537,15 +19673,14 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
19537
19673
  return
19538
19674
  }
19539
19675
  const cardText =
19540
- `šŸ—‘ Revoke <code>${escapeHtmlForTg(grantId)}</code>?\n` +
19541
- `Agent: <b>${escapeHtmlForTg(grant.agent_slug)}</b>\n` +
19542
- `Keys: <code>${escapeHtmlForTg(grant.key_allow.join(', '))}</code>`
19676
+ `šŸ—‘ Revoke \`${grantId}\`?\n` +
19677
+ `Agent: **${escapeHtmlForTg(grant.agent_slug)}**\n` +
19678
+ `Keys: \`${escapeHtmlForTg(grant.key_allow.join(', '))}\``
19543
19679
  const confirmKeyboard = new InlineKeyboard()
19544
19680
  .text('āœ… Confirm Revoke', `vg:confirm:${grantId}`)
19545
19681
  .text('āŒ Cancel', `vg:cancel:${grantId}`)
19546
19682
  await ctx.answerCallbackQuery().catch(() => {})
19547
- await ctx.editMessageText(cardText, {
19548
- parse_mode: 'HTML',
19683
+ await ctx.editMessageText(richMessage(cardText), {
19549
19684
  reply_markup: confirmKeyboard,
19550
19685
  }).catch(async () => {
19551
19686
  const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
@@ -19554,8 +19689,7 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
19554
19689
  // #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND.
19555
19690
  await swallowingApiCall(
19556
19691
  () =>
19557
- bot.api.sendMessage(chatId, cardText, {
19558
- parse_mode: 'HTML',
19692
+ bot.api.sendRichMessage(chatId, richMessage(cardText), {
19559
19693
  reply_markup: confirmKeyboard,
19560
19694
  ...(threadId != null ? { message_thread_id: threadId } : {}),
19561
19695
  }),
@@ -19584,8 +19718,8 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
19584
19718
  }
19585
19719
  await ctx.answerCallbackQuery({ text: 'āœ… Revoked' }).catch(() => {})
19586
19720
  await ctx.editMessageText(
19587
- `āœ… Grant <code>${escapeHtmlForTg(grantId)}</code> revoked. Token file removed.`,
19588
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19721
+ richMessage(`āœ… Grant \`${grantId}\` revoked. Token file removed.`),
19722
+ { reply_markup: { inline_keyboard: [] } },
19589
19723
  ).catch(() => {})
19590
19724
  return
19591
19725
  }
@@ -19700,8 +19834,8 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
19700
19834
  // `.catch(() => {})` would swallow the failure silently — same
19701
19835
  // hazard PR #1158 caught on the operator-event card.
19702
19836
  await ctx.editMessageText(
19703
- escapeHtmlForTg(msg.text) + '\n\n<i>Send a duration like <code>30d</code> or <code>12h</code>:</i>',
19704
- { parse_mode: 'HTML', reply_markup: buildGrantDurationKeyboard() },
19837
+ richMessage(escapeHtmlForTg(msg.text) + '\n\n_Send a duration like \`30d\` or \`12h\`:_'),
19838
+ { reply_markup: buildGrantDurationKeyboard() },
19705
19839
  ).catch(() => {})
19706
19840
  }
19707
19841
  await ackSilently()
@@ -19803,15 +19937,14 @@ async function executeDeferredSecretSave(
19803
19937
  const rendered = renderVaultCliError(parsed, { verb: "save", key: slug })
19804
19938
  const body = rendered.suppressRaw
19805
19939
  ? rendered.html
19806
- : `āš ļø vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
19940
+ : `āš ļø vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
19807
19941
  if (cardMessageId != null) {
19808
19942
  await ctx.api
19809
19943
  .editMessageText(
19810
19944
  deferred.chat_id,
19811
19945
  cardMessageId,
19812
- `${body}\n\nRe-tap to retry.`,
19946
+ richMessage(`${body}\n\nRe-tap to retry.`),
19813
19947
  {
19814
- parse_mode: 'HTML',
19815
19948
  reply_markup: buildDeferredSecretKeyboard(deferKey).inline_keyboard.length > 0
19816
19949
  ? buildDeferredSecretKeyboard(deferKey)
19817
19950
  : undefined,
@@ -19829,8 +19962,8 @@ async function executeDeferredSecretSave(
19829
19962
  .editMessageText(
19830
19963
  deferred.chat_id,
19831
19964
  cardMessageId,
19832
- `āœ… stored as <code>vault:${slug}</code> (masked: <code>${masked}</code>)\n\nReply <code>rename NEW_NAME</code> to relabel.`,
19833
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19965
+ richMessage(`āœ… stored as \`vault:${slug}\` (masked: \`${masked}\`)\n\nReply \`rename NEW_NAME\` to relabel.`),
19966
+ { reply_markup: { inline_keyboard: [] } },
19834
19967
  )
19835
19968
  .catch(() => {})
19836
19969
  }
@@ -19907,11 +20040,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19907
20040
  case 'dismiss': {
19908
20041
  // #1150 audit P1: was strip-only. Now appends a status line so
19909
20042
  // scrollback shows the dismissal.
19910
- const status = `\n\nāœ— <i>Dismissed by operator.</i>`
20043
+ const status = `\n\nāœ— _Dismissed by operator._`
19911
20044
  await finalizeCallback(ctx, {
19912
20045
  ackText: 'Dismissed',
19913
20046
  newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
19914
- parseMode: 'HTML',
19915
20047
  // No synthInbound — dismiss is operator-only, no model in loop.
19916
20048
  })
19917
20049
  return
@@ -19922,11 +20054,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19922
20054
  // #1150 audit P1: was reply + editMessageReplyMarkup (two
19923
20055
  // separate edits). Atomic via finalizeCallback now — the
19924
20056
  // status line is the announcement, no separate reply needed.
19925
- const status = `\n\nšŸ”„ <i><b>${escapeHtmlForTg(agent)}</b> restart requested by operator.</i>`
20057
+ const status = `\n\nšŸ”„ _**${escapeHtmlForTg(agent)}** restart requested by operator._`
19926
20058
  await finalizeCallback(ctx, {
19927
20059
  ackText: `Restarting ${agent}…`,
19928
20060
  newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
19929
- parseMode: 'HTML',
19930
20061
  })
19931
20062
  } else {
19932
20063
  // Failure-path: leave the keyboard tappable so the operator
@@ -19936,11 +20067,9 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19936
20067
  const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
19937
20068
  const detail = isDocker
19938
20069
  ? `cross-agent restart is not supported under docker. ` +
19939
- `Restart from the host: <code>docker compose -p switchroom restart agent-${agent}</code>.`
20070
+ `Restart from the host: \`docker compose -p switchroom restart agent-${agent}\`.`
19940
20071
  : 'restart trigger failed'
19941
- await ctx.reply(`<b>Restart failed for ${agent}:</b> ${detail}`, {
19942
- parse_mode: 'HTML',
19943
- })
20072
+ await ctx.replyWithRichMessage(richMessage(`**Restart failed for ${agent}:** ${detail}`))
19944
20073
  }
19945
20074
  return
19946
20075
  }
@@ -19951,11 +20080,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19951
20080
  // for the login URL state. Strip the keyboard and append a status
19952
20081
  // line; the new reauth-flow's own messages appear below the
19953
20082
  // collapsed card.
19954
- const status = `\n\nšŸ” <i>Reauth started for <b>${escapeHtmlForTg(agent)}</b> — follow the login URL below.</i>`
20083
+ const status = `\n\nšŸ” _Reauth started for **${escapeHtmlForTg(agent)}** — follow the login URL below._`
19955
20084
  await finalizeCallback(ctx, {
19956
20085
  ackText: `Starting reauth for ${agent}…`,
19957
20086
  newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
19958
- parseMode: 'HTML',
19959
20087
  synthInbound: async () => {
19960
20088
  await runSwitchroomAuthCommand(ctx, ['auth', 'reauth', agent], `auth reauth ${agent}`)
19961
20089
  // PR3 supergroup-mode: key by (chat, thread) so an OAuth code
@@ -19980,11 +20108,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19980
20108
  // path systemd-only.
19981
20109
  const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
19982
20110
  if (isDocker) {
19983
- await ctx.reply(
19984
- `<i>Inline log fetch is not available under docker mode (no docker.sock in agent containers). ` +
19985
- `Run from the host: <code>docker logs --since 30m --tail 30 switchroom-${agent}</code></i>`,
19986
- { parse_mode: 'HTML' },
19987
- )
20111
+ await ctx.replyWithRichMessage(richMessage(
20112
+ `_Inline log fetch is not available under docker mode (no docker.sock in agent containers). ` +
20113
+ `Run from the host: \`docker logs --since 30m --tail 30 switchroom-${agent}\`_`,
20114
+ ))
19988
20115
  return
19989
20116
  }
19990
20117
  try {
@@ -19994,17 +20121,15 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19994
20121
  { encoding: 'utf-8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] },
19995
20122
  ) as string
19996
20123
  const trimmed = out.trim().slice(-3500)
19997
- await ctx.reply(
20124
+ await ctx.replyWithRichMessage(richMessage(
19998
20125
  trimmed
19999
- ? `<pre>${trimmed.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`
20000
- : `<i>No logs for ${agent}.</i>`,
20001
- { parse_mode: 'HTML' },
20002
- )
20126
+ ? `\`\`\`\n${trimmed.replace(/```/g, '`​``')}\n\`\`\``
20127
+ : `_No logs for ${agent}._`,
20128
+ ))
20003
20129
  } catch (err) {
20004
- await ctx.reply(
20005
- `<b>logs failed:</b> ${escapeHtmlForTg((err as Error).message)}`,
20006
- { parse_mode: 'HTML' },
20007
- )
20130
+ await ctx.replyWithRichMessage(richMessage(
20131
+ `**logs failed:** ${escapeHtmlForTg((err as Error).message)}`,
20132
+ ))
20008
20133
  }
20009
20134
  return
20010
20135
  }
@@ -20058,10 +20183,12 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
20058
20183
  bot.api.editMessageText(
20059
20184
  msg.chat.id,
20060
20185
  msg.message_id,
20061
- `<b>Active account →</b> <code>${escapeHtmlForTg(result.active)}</code>\n` +
20062
- `<i>Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}.</i>\n\n` +
20063
- `<i>Tap /auth to see updated quota for the new active account.</i>`,
20064
- { parse_mode: 'HTML' },
20186
+ richMessage(
20187
+ `**Active account →** \`${result.active}\`\n` +
20188
+ `_Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}._\n\n` +
20189
+ `_Tap /auth to see updated quota for the new active account._`,
20190
+ ),
20191
+ {},
20065
20192
  ),
20066
20193
  { chat_id: String(msg.chat.id), verb: 'auth:use:edit' },
20067
20194
  )
@@ -20140,8 +20267,7 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
20140
20267
  if (msg) {
20141
20268
  await swallowingApiCall(
20142
20269
  () =>
20143
- bot.api.editMessageText(msg.chat.id, msg.message_id, text, {
20144
- parse_mode: 'HTML',
20270
+ bot.api.editMessageText(msg.chat.id, msg.message_id, richMessage(text), {
20145
20271
  reply_markup: { inline_keyboard },
20146
20272
  }),
20147
20273
  { chat_id: String(msg.chat.id), verb: 'auth:refresh:edit' },
@@ -20189,17 +20315,17 @@ bot.command('vault', async ctx => {
20189
20315
  const key = args[1]
20190
20316
  if (!sub || sub === 'help') {
20191
20317
  await switchroomReply(ctx, [
20192
- '<b>Vault commands</b>',
20318
+ '**Vault commands**',
20193
20319
  '/vault list — list all secret keys',
20194
- '/vault get &lt;key&gt; — read a secret value',
20195
- '/vault set &lt;key&gt; — set a secret',
20196
- '/vault delete &lt;key&gt; — remove a secret',
20320
+ '/vault get <key> — read a secret value',
20321
+ '/vault set <key> — set a secret',
20322
+ '/vault delete <key> — remove a secret',
20197
20323
  '/vault status — show broker state',
20198
20324
  '/vault unlock — unlock the broker (prompts for passphrase)',
20199
20325
  '/vault lock — lock the broker',
20200
20326
  '/vault grant — mint a capability token (inline wizard)',
20201
20327
  '/vault grants [agent] — list active capability grants (tap to revoke)',
20202
- '/vault audit &lt;agent&gt; — unified view of an agent\'s vault access',
20328
+ '/vault audit <agent> — unified view of an agent\'s vault access',
20203
20329
  '',
20204
20330
  'Your passphrase is cached in memory for 30 min after first use.',
20205
20331
  ].join('\n'), { html: true })
@@ -20212,7 +20338,7 @@ bot.command('vault', async ctx => {
20212
20338
  if (sub === 'audit') {
20213
20339
  const targetAgent = args[1]
20214
20340
  if (!targetAgent) {
20215
- await switchroomReply(ctx, 'Usage: /vault audit &lt;agent&gt;', { html: true })
20341
+ await switchroomReply(ctx, 'Usage: /vault audit <agent>', { html: true })
20216
20342
  return
20217
20343
  }
20218
20344
  // Sanity-check the agent name shape — same charset constraint as
@@ -20270,48 +20396,48 @@ bot.command('vault', async ctx => {
20270
20396
  }
20271
20397
 
20272
20398
  // 3. Compose the rendered audit view.
20273
- const lines: string[] = [`<b>šŸ” ${escapeHtmlForTg(targetAgent)} — vault access</b>`, '']
20399
+ const lines: string[] = [`**šŸ” ${escapeHtmlForTg(targetAgent)} — vault access**`, '']
20274
20400
 
20275
20401
  if (grantsError) {
20276
- lines.push(`<i>Grants: āš ļø ${escapeHtmlForTg(grantsError)}</i>`)
20402
+ lines.push(`_Grants: āš ļø ${escapeHtmlForTg(grantsError)}_`)
20277
20403
  lines.push('')
20278
20404
  } else {
20279
- lines.push('<b>Read grants:</b>')
20405
+ lines.push('**Read grants:**')
20280
20406
  if (readGrants.length === 0) {
20281
- lines.push(' <i>none</i>')
20407
+ lines.push(' _none_')
20282
20408
  } else {
20283
20409
  for (const g of readGrants) {
20284
20410
  const expiry = g.expiresAt
20285
20411
  ? new Date(g.expiresAt * 1000).toISOString().slice(0, 10)
20286
20412
  : 'no expiry'
20287
20413
  const keysJoined = g.keys.join(', ')
20288
- lines.push(` <code>${escapeHtmlForTg(g.id)}</code> Ā· ${escapeHtmlForTg(keysJoined)} Ā· expires ${expiry}`)
20414
+ lines.push(` \`${g.id}\` Ā· ${escapeHtmlForTg(keysJoined)} Ā· expires ${expiry}`)
20289
20415
  }
20290
20416
  }
20291
20417
  lines.push('')
20292
- lines.push('<b>Write grants:</b>')
20418
+ lines.push('**Write grants:**')
20293
20419
  if (writeGrants.length === 0) {
20294
- lines.push(' <i>none</i>')
20420
+ lines.push(' _none_')
20295
20421
  } else {
20296
20422
  for (const g of writeGrants) {
20297
20423
  const expiry = g.expiresAt
20298
20424
  ? new Date(g.expiresAt * 1000).toISOString().slice(0, 10)
20299
20425
  : 'no expiry'
20300
20426
  const keysJoined = g.keys.join(', ')
20301
- lines.push(` <code>${escapeHtmlForTg(g.id)}</code> Ā· ${escapeHtmlForTg(keysJoined)} Ā· expires ${expiry}`)
20427
+ lines.push(` \`${g.id}\` Ā· ${escapeHtmlForTg(keysJoined)} Ā· expires ${expiry}`)
20302
20428
  }
20303
20429
  }
20304
20430
  lines.push('')
20305
20431
  }
20306
20432
 
20307
- lines.push('<b>Cron-declared secrets:</b>')
20433
+ lines.push('**Cron-declared secrets:**')
20308
20434
  if (configError) {
20309
- lines.push(` <i>āš ļø ${escapeHtmlForTg(configError)}</i>`)
20435
+ lines.push(` _āš ļø ${escapeHtmlForTg(configError)}_`)
20310
20436
  } else if (cronEntries.length === 0) {
20311
- lines.push(' <i>none</i>')
20437
+ lines.push(' _none_')
20312
20438
  } else {
20313
20439
  for (const entry of cronEntries) {
20314
- const cronLabel = entry.cron ? ` (<code>${escapeHtmlForTg(entry.cron)}</code>)` : ''
20440
+ const cronLabel = entry.cron ? ` (\`${entry.cron}\`)` : ''
20315
20441
  const keysJoined = entry.secrets.join(', ')
20316
20442
  lines.push(` schedule[${entry.index}]${cronLabel}: ${escapeHtmlForTg(keysJoined)}`)
20317
20443
  }
@@ -20326,11 +20452,11 @@ bot.command('vault', async ctx => {
20326
20452
  const denials = readRecentDenialsForAgent(targetAgent, 7 * 24 * 60 * 60 * 1000, 5)
20327
20453
  const denialKeyboard = new InlineKeyboard()
20328
20454
  if (denials.length > 0) {
20329
- lines.push('<b>Recent denials (last 7d):</b>')
20455
+ lines.push('**Recent denials (last 7d):**')
20330
20456
  for (const d of denials) {
20331
20457
  const when = new Date(d.lastSeenMs).toISOString().slice(0, 16).replace('T', ' ')
20332
20458
  lines.push(
20333
- ` <code>${escapeHtmlForTg(d.key)}</code> Ā· ${d.count}Ɨ Ā· last ${when}`,
20459
+ ` \`${d.key}\` Ā· ${d.count}Ɨ Ā· last ${when}`,
20334
20460
  )
20335
20461
  // callback_data must stay ≤ 64 bytes. Format:
20336
20462
  // vrd:<agent>:<key>
@@ -20348,17 +20474,15 @@ bot.command('vault', async ctx => {
20348
20474
  }
20349
20475
 
20350
20476
  lines.push(
20351
- `<i>Summary: ${readGrants.length} read grant${readGrants.length === 1 ? '' : 's'}, ` +
20477
+ `_Summary: ${readGrants.length} read grant${readGrants.length === 1 ? '' : 's'}, ` +
20352
20478
  `${writeGrants.length} write grant${writeGrants.length === 1 ? '' : 's'}, ` +
20353
20479
  `${cronEntries.length} cron entr${cronEntries.length === 1 ? 'y' : 'ies'}, ` +
20354
- `${denials.length} recent denial${denials.length === 1 ? '' : 's'}.</i>`,
20480
+ `${denials.length} recent denial${denials.length === 1 ? '' : 's'}._`,
20355
20481
  )
20356
20482
 
20357
20483
  const chatIdAudit = String(ctx.chat!.id)
20358
20484
  const threadIdAudit = resolveThreadId(chatIdAudit, ctx.message?.message_thread_id)
20359
- await ctx.reply(lines.join('\n'), {
20360
- parse_mode: 'HTML',
20361
- link_preview_options: { is_disabled: true },
20485
+ await ctx.replyWithRichMessage(richMessage(lines.join('\n')), {
20362
20486
  ...(denials.length > 0 ? { reply_markup: denialKeyboard } : {}),
20363
20487
  ...(threadIdAudit != null ? { message_thread_id: threadIdAudit } : {}),
20364
20488
  })
@@ -20379,7 +20503,7 @@ bot.command('vault', async ctx => {
20379
20503
  }
20380
20504
  const { grants } = result
20381
20505
  if (grants.length === 0) {
20382
- const filterNote = agentFilter ? ` for <code>${escapeHtmlForTg(agentFilter)}</code>` : ''
20506
+ const filterNote = agentFilter ? ` for \`${agentFilter}\`` : ''
20383
20507
  await switchroomReply(ctx, `šŸ“œ No active grants${filterNote}.`, { html: true })
20384
20508
  return
20385
20509
  }
@@ -20391,16 +20515,16 @@ bot.command('vault', async ctx => {
20391
20515
  byAgent.set(g.agent_slug, list)
20392
20516
  }
20393
20517
  // Build message text (grouped) + inline keyboard (one [Revoke] per grant per row)
20394
- const lines: string[] = ['<b>šŸ“œ Active grants</b>', '']
20518
+ const lines: string[] = ['**šŸ“œ Active grants**', '']
20395
20519
  const keyboard = new InlineKeyboard()
20396
20520
  for (const [agentName, agentGrants] of byAgent) {
20397
- lines.push(`<b>${escapeHtmlForTg(agentName)}:</b>`)
20521
+ lines.push(`**${escapeHtmlForTg(agentName)}:**`)
20398
20522
  for (const g of agentGrants) {
20399
20523
  const keys = g.key_allow.join(', ')
20400
20524
  const expiry = g.expires_at
20401
20525
  ? new Date(g.expires_at * 1000).toISOString().slice(0, 10)
20402
20526
  : 'no expiry'
20403
- lines.push(`• <code>${escapeHtmlForTg(g.id)}</code> — ${escapeHtmlForTg(keys)}, expires ${expiry}`)
20527
+ lines.push(`• \`${g.id}\` — ${escapeHtmlForTg(keys)}, expires ${expiry}`)
20404
20528
  // callback_data: vg:revoke:<id> — max 64 bytes; grant IDs are "vg_" + 6 chars = 9 chars total → well within limit
20405
20529
  keyboard.text(`šŸ—‘ Revoke ${g.id}`, `vg:revoke:${g.id}`).row()
20406
20530
  }
@@ -20408,9 +20532,7 @@ bot.command('vault', async ctx => {
20408
20532
  }
20409
20533
  const chatId2 = String(ctx.chat!.id)
20410
20534
  const threadId2 = resolveThreadId(chatId2, ctx.message?.message_thread_id)
20411
- await ctx.reply(lines.join('\n'), {
20412
- parse_mode: 'HTML',
20413
- link_preview_options: { is_disabled: true },
20535
+ await ctx.replyWithRichMessage(richMessage(lines.join('\n')), {
20414
20536
  reply_markup: keyboard,
20415
20537
  ...(threadId2 != null ? { message_thread_id: threadId2 } : {}),
20416
20538
  })
@@ -20460,11 +20582,11 @@ bot.command('vault', async ctx => {
20460
20582
  }
20461
20583
 
20462
20584
  if (!['list', 'get', 'set', 'delete', 'remove'].includes(sub)) {
20463
- await switchroomReply(ctx, `Unknown vault subcommand: <code>${escapeHtmlForTg(sub)}</code>. Try /vault help`, { html: true })
20585
+ await switchroomReply(ctx, `Unknown vault subcommand: \`${sub}\`. Try /vault help`, { html: true })
20464
20586
  return
20465
20587
  }
20466
- if ((sub === 'get' || sub === 'delete' || sub === 'remove') && !key) { await switchroomReply(ctx, `Usage: /vault ${sub} &lt;key&gt;`, { html: true }); return }
20467
- if (sub === 'set' && !key) { await switchroomReply(ctx, 'Usage: /vault set &lt;key&gt;', { html: true }); return }
20588
+ if ((sub === 'get' || sub === 'delete' || sub === 'remove') && !key) { await switchroomReply(ctx, `Usage: /vault ${sub} <key>`, { html: true }); return }
20589
+ if (sub === 'set' && !key) { await switchroomReply(ctx, 'Usage: /vault set <key>', { html: true }); return }
20468
20590
 
20469
20591
  const cached = vaultPassphraseCache.get(chatId)
20470
20592
  const passphrase = cached && cached.expiresAt > Date.now() ? cached.passphrase : undefined
@@ -20551,9 +20673,9 @@ bot.command('issues', async ctx => {
20551
20673
  await switchroomReply(
20552
20674
  ctx,
20553
20675
  [
20554
- `This will resolve <b>${n} ${noun}</b>. To confirm, run:`,
20676
+ `This will resolve **${n} ${noun}**. To confirm, run:`,
20555
20677
  '',
20556
- '<code>/issues clear --confirm</code>',
20678
+ '\`/issues clear --confirm\`',
20557
20679
  ].join('\n'),
20558
20680
  { html: true },
20559
20681
  )
@@ -20573,7 +20695,7 @@ bot.command('issues', async ctx => {
20573
20695
  }
20574
20696
  await switchroomReply(
20575
20697
  ctx,
20576
- 'Usage: <code>/issues</code> | <code>/issues resolve &lt;fingerprint&gt;</code> | <code>/issues clear [--confirm]</code>',
20698
+ 'Usage: \`/issues\` | \`/issues resolve <fingerprint>\` | \`/issues clear [--confirm]\`',
20577
20699
  { html: true },
20578
20700
  )
20579
20701
  })
@@ -20633,12 +20755,12 @@ bot.command('usage', async ctx => {
20633
20755
  // socket bind, etc.). Same shape /usage shipped with originally.
20634
20756
  const agentDir = resolveAgentDirFromEnv()
20635
20757
  if (!agentDir) {
20636
- await switchroomReply(ctx, '<b>/usage:</b> cannot resolve agent dir.', { html: true })
20758
+ await switchroomReply(ctx, '**/usage:** cannot resolve agent dir.', { html: true })
20637
20759
  return
20638
20760
  }
20639
20761
  const result = await fetchQuota({ claudeConfigDir: join(agentDir, '.claude') })
20640
20762
  if (!result.ok) {
20641
- await switchroomReply(ctx, `<b>/usage:</b> ${escapeHtmlForTg(result.reason)}`, { html: true })
20763
+ await switchroomReply(ctx, `**/usage:** ${escapeHtmlForTg(result.reason)}`, { html: true })
20642
20764
  return
20643
20765
  }
20644
20766
  await switchroomReply(ctx, formatQuotaBlock(result.data), { html: true })
@@ -20694,7 +20816,7 @@ async function renderFleetDoctor(ctx: Context): Promise<void> {
20694
20816
  return
20695
20817
  }
20696
20818
  if (resp.result === 'denied') {
20697
- await switchroomReply(ctx, `🩺 <b>Whole-fleet doctor denied by hostd:</b>\n${preBlock(formatSwitchroomOutput(resp.error ?? 'admin required'))}`, { html: true })
20819
+ await switchroomReply(ctx, `🩺 **Whole-fleet doctor denied by hostd:**\n${preBlock(formatSwitchroomOutput(resp.error ?? 'admin required'))}`, { html: true })
20698
20820
  return
20699
20821
  }
20700
20822
  // `completed` (including `switchroom doctor` exit 1 = "found
@@ -20712,7 +20834,7 @@ bot.command('doctor', async ctx => {
20712
20834
  // approval card — doctor is read-only). Everyone else keeps the
20713
20835
  // original zero-extra-tap in-container behaviour.
20714
20836
  if (AGENT_ADMIN && hostdWillBeUsed(getMyAgentName())) {
20715
- await switchroomReply(ctx, '🩺 <b>Doctor</b> — which scope?', {
20837
+ await switchroomReply(ctx, '🩺 **Doctor** — which scope?', {
20716
20838
  html: true,
20717
20839
  reply_markup: buildDoctorScopeKeyboard(),
20718
20840
  })
@@ -20720,7 +20842,7 @@ bot.command('doctor', async ctx => {
20720
20842
  }
20721
20843
  await renderSelfDoctor(ctx)
20722
20844
  } catch (err: unknown) {
20723
- await switchroomReply(ctx, `<b>doctor failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
20845
+ await switchroomReply(ctx, `**doctor failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
20724
20846
  }
20725
20847
  })
20726
20848
 
@@ -20770,7 +20892,7 @@ bot.command('version', async ctx => {
20770
20892
  if (!trimmed) { await switchroomReply(ctx, 'version: no output'); return }
20771
20893
  await switchroomReply(ctx, preBlock(formatSwitchroomOutput(trimmed)), { html: true })
20772
20894
  } catch (err: unknown) {
20773
- await switchroomReply(ctx, `<b>version failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
20895
+ await switchroomReply(ctx, `**version failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
20774
20896
  }
20775
20897
  })
20776
20898
 
@@ -20793,7 +20915,7 @@ bot.command('whoami', async ctx => {
20793
20915
  catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
20794
20916
  await switchroomReply(ctx, card, { html: true })
20795
20917
  } catch (err: unknown) {
20796
- await switchroomReply(ctx, `<b>whoami failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
20918
+ await switchroomReply(ctx, `**whoami failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
20797
20919
  }
20798
20920
  })
20799
20921
 
@@ -20811,7 +20933,7 @@ function formatWhoamiCard(v: {
20811
20933
  const esc = escapeHtmlForTg
20812
20934
  const yn = (b?: boolean) => (b ? 'āœ“' : 'āœ—')
20813
20935
  const lines: string[] = []
20814
- lines.push(`šŸ‘¤ <b>${esc(v.name ?? '?')}</b> Ā· ${esc(v.tier ?? 'standard')}`)
20936
+ lines.push(`šŸ‘¤ **${esc(v.name ?? '?')}** Ā· ${esc(v.tier ?? 'standard')}`)
20815
20937
  if (v.persona) lines.push(esc(v.persona))
20816
20938
  if (v.model) lines.push(`Model: ${esc(v.model)}`)
20817
20939
  const allow = v.tools?.allow ?? []
@@ -20882,8 +21004,7 @@ bot.on('callback_query:data', async ctx => {
20882
21004
  try {
20883
21005
  const outcome = await handleEffortMenuCallback(data, buildEffortDeps())
20884
21006
  await ctx
20885
- .editMessageText(outcome.reply.text, {
20886
- parse_mode: 'HTML',
21007
+ .editMessageText(richMessage(outcome.reply.text), {
20887
21008
  reply_markup: effortMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
20888
21009
  })
20889
21010
  .catch(() => {})
@@ -20908,8 +21029,7 @@ bot.on('callback_query:data', async ctx => {
20908
21029
  if (process.env.SWITCHROOM_MODEL_MENU === '0') {
20909
21030
  await ctx.answerCallbackQuery({ text: 'Model menu is disabled (SWITCHROOM_MODEL_MENU=0).' }).catch(() => {})
20910
21031
  await ctx
20911
- .editMessageText('Model menu is disabled on this agent. Use <code>/model &lt;name&gt;</code>.', {
20912
- parse_mode: 'HTML',
21032
+ .editMessageText(richMessage('Model menu is disabled on this agent. Use \`/model <name>\`.'), {
20913
21033
  reply_markup: { inline_keyboard: [] },
20914
21034
  })
20915
21035
  .catch(() => {})
@@ -20953,7 +21073,7 @@ bot.on('callback_query:data', async ctx => {
20953
21073
  if (data.startsWith(MODEL_CALLBACK_SR)) {
20954
21074
  const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)))
20955
21075
  await ctx
20956
- .editMessageText(`ā³ Switching session to <b>${srLabel}</b>…`, { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } })
21076
+ .editMessageText(richMessage(`ā³ Switching session to **${srLabel}**…`), { reply_markup: { inline_keyboard: [] } })
20957
21077
  .catch(() => {})
20958
21078
  didInterimSrEdit = true
20959
21079
  }
@@ -20982,8 +21102,8 @@ bot.on('callback_query:data', async ctx => {
20982
21102
  // Replace the menu with a restart notice (no buttons — session is ending).
20983
21103
  await ctx
20984
21104
  .editMessageText(
20985
- `šŸ”„ Switching from <b>${escapeHtmlForTg(prevSessionModel!)}</b> back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
20986
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
21105
+ richMessage(`šŸ”„ Switching from **${escapeHtmlForTg(prevSessionModel!)}** back to Claude — restarting session cleanly. Claude will be ready in ~30s.`),
21106
+ { reply_markup: { inline_keyboard: [] } },
20987
21107
  )
20988
21108
  .catch(() => {})
20989
21109
  // Write the restart marker so the post-restart boot card edits into this chat.
@@ -21001,8 +21121,7 @@ bot.on('callback_query:data', async ctx => {
21001
21121
  }
21002
21122
 
21003
21123
  await ctx
21004
- .editMessageText(outcome.reply.text, {
21005
- parse_mode: 'HTML',
21124
+ .editMessageText(richMessage(outcome.reply.text), {
21006
21125
  reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
21007
21126
  })
21008
21127
  .catch(() => {})
@@ -21099,8 +21218,7 @@ bot.on('callback_query:data', async ctx => {
21099
21218
  editCard: async (args) => {
21100
21219
  try {
21101
21220
  await robustApiCall(() =>
21102
- bot.api.editMessageText(args.chatId, args.messageId, args.text, {
21103
- parse_mode: 'HTML',
21221
+ bot.api.editMessageText(args.chatId, args.messageId, richMessage(args.text), {
21104
21222
  // Resolved on tap — strip the keyboard so it can't be re-tapped.
21105
21223
  ...(args.stripKeyboard ? { reply_markup: { inline_keyboard: [] } } : {}),
21106
21224
  }),
@@ -21185,7 +21303,7 @@ bot.on('callback_query:data', async ctx => {
21185
21303
  if (scope === 'fleet') await renderFleetDoctor(ctx)
21186
21304
  else await renderSelfDoctor(ctx)
21187
21305
  } catch (err: unknown) {
21188
- await switchroomReply(ctx, `<b>doctor failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
21306
+ await switchroomReply(ctx, `**doctor failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
21189
21307
  }
21190
21308
  return
21191
21309
  }
@@ -21224,6 +21342,16 @@ bot.on('callback_query:data', async ctx => {
21224
21342
  return
21225
21343
  }
21226
21344
 
21345
+ // #2670: one-tap skill-improvement proposal card.
21346
+ // skprop:approve:<id> — apply the stored draft via the personal-skill
21347
+ // write pipeline (inject a turn; agent writes it)
21348
+ // skprop:deny:<id> — dismiss + record a rejection fingerprint so it
21349
+ // isn't re-proposed
21350
+ if (data.startsWith('skprop:')) {
21351
+ await handleSkillProposalCallback(ctx, data)
21352
+ return
21353
+ }
21354
+
21227
21355
  // Issue #969 P2b: vault recent-denial one-tap approval.
21228
21356
  // vrd:<agent>:<key> — mint a 30-day read-grant for the agent + key
21229
21357
  // Posted by /vault audit <agent> in the "Recent denials" section.
@@ -21279,8 +21407,7 @@ bot.on('callback_query:data', async ctx => {
21279
21407
  if (sourceMsg && 'text' in sourceMsg && sourceMsg.text != null) {
21280
21408
  try {
21281
21409
  await ctx.editMessageText(
21282
- `${escapeHtmlForTg(sourceMsg.text)}\n\nāœ… <b>${escapeHtmlForTg(choice)}</b>`,
21283
- { parse_mode: 'HTML' },
21410
+ richMessage(`${escapeHtmlForTg(sourceMsg.text)}\n\nāœ… **${escapeHtmlForTg(choice)}**`),
21284
21411
  )
21285
21412
  } catch {
21286
21413
  /* edit-failed is fine — the answer-callback below still acks */
@@ -21658,13 +21785,13 @@ bot.on('callback_query:data', async ctx => {
21658
21785
  : ''
21659
21786
  const editLabel = ok
21660
21787
  ? (legacyNote
21661
- ? `āœ… <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking (legacy path); restart agent for full effect`
21788
+ ? `āœ… **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking (legacy path); restart agent for full effect`
21662
21789
  : restartScheduled
21663
- ? `āœ… <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking — applying now (restarting to take effect).`
21664
- : `āœ… <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect`)
21790
+ ? `āœ… **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking — applying now (restarting to take effect).`
21791
+ : `āœ… **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking; restart agent for full effect`)
21665
21792
  : (editLockHint
21666
- ? `āš ļø <b>Allowed for now — "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.`
21667
- : `āš ļø <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`)
21793
+ ? `āš ļø **Allowed for now — "always" did NOT save.** Config edits are locked; enable \`hostd.config_edit_enabled\`.`
21794
+ : `āš ļø **Allowed for now — "always" did NOT save.** It will ask again after restart. Check gateway log.`)
21668
21795
  // #1150 audit: route through finalizeCallback so the keyboard
21669
21796
  // strips alongside the status-line edit. The in-flight verdict was
21670
21797
  // ALREADY dispatched above (independently of this host round-trip)
@@ -21673,7 +21800,6 @@ bot.on('callback_query:data', async ctx => {
21673
21800
  await finalizeCallback(ctx, {
21674
21801
  ackText: ackText.slice(0, 200),
21675
21802
  newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
21676
- parseMode: 'HTML',
21677
21803
  })
21678
21804
  return
21679
21805
  }
@@ -21718,10 +21844,10 @@ bot.on('callback_query:data', async ctx => {
21718
21844
  ? 'āŒ Denied'
21719
21845
  : (windowGranted ? `āœ… Allowed (${scopedMins} min)` : 'āœ… Allowed once')
21720
21846
  const htmlLabel = behavior === 'deny'
21721
- ? 'āŒ <b>Denied</b>'
21847
+ ? 'āŒ **Denied**'
21722
21848
  : (windowGranted
21723
- ? `āœ… <b>Allowed — won't ask again about ${escapeHtmlForTg(timeBox!.breadth)} for ${scopedMins} min</b>`
21724
- : 'āœ… <b>Allowed once</b>')
21849
+ ? `āœ… **Allowed — won't ask again about ${escapeHtmlForTg(timeBox!.breadth)} for ${scopedMins} min**`
21850
+ : 'āœ… **Allowed once**')
21725
21851
  // HTML-escape the source text — same hazard as the scope-commit and
21726
21852
  // recent-denial paths above. The permission card body
21727
21853
  // (formatPermissionCardBody) appends claude-supplied `description`
@@ -21742,7 +21868,6 @@ bot.on('callback_query:data', async ctx => {
21742
21868
  await finalizeCallback(ctx, {
21743
21869
  ackText: ackText.slice(0, 200),
21744
21870
  newText: baseText ? `${baseText}\n\n${htmlLabel}` : htmlLabel,
21745
- parseMode: 'HTML',
21746
21871
  synthInbound: () => {
21747
21872
  // No `rule` → the bridge does NOT cache this (truly once on the bridge);
21748
21873
  // any 30-min stickiness lives only in scopedGrants (recorded above).
@@ -23633,14 +23758,16 @@ void (async () => {
23633
23758
  const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS)
23634
23759
  workerActivityFeed?.stop()
23635
23760
  workerActivityFeed = createWorkerActivityFeed({
23761
+ // #2669: worker-feed body is raw GFM markdown — send via rich.
23636
23762
  bot: {
23637
23763
  sendMessage: async (cid, text, sendOpts) => {
23638
23764
  const sent = await robustApiCall(
23639
23765
  () =>
23640
- lockedBot.api.sendMessage(
23766
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
23767
+ lockedBot.api.sendRichMessage(
23641
23768
  cid,
23642
- text,
23643
- sendOpts as Parameters<typeof lockedBot.api.sendMessage>[2],
23769
+ richMessage(text),
23770
+ sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
23644
23771
  ),
23645
23772
  { chat_id: cid, verb: 'worker-feed' },
23646
23773
  )
@@ -23652,7 +23779,7 @@ void (async () => {
23652
23779
  lockedBot.api.editMessageText(
23653
23780
  cid,
23654
23781
  mid,
23655
- text,
23782
+ richMessage(text),
23656
23783
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
23657
23784
  ),
23658
23785
  { chat_id: cid, verb: 'worker-feed' },