switchroom 0.16.23 → 0.16.27

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 (138) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +82 -82
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  7. package/dist/cli/skill-validate-pretool.mjs +2936 -119
  8. package/dist/cli/switchroom.js +804 -465
  9. package/dist/host-control/main.js +169 -163
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +4 -4
  13. package/telegram-plugin/answer-stream.ts +20 -49
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/bridge/bridge.ts +1 -1
  17. package/telegram-plugin/card-format.ts +28 -25
  18. package/telegram-plugin/credits-watch.ts +5 -10
  19. package/telegram-plugin/dist/bridge/bridge.js +113 -113
  20. package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
  21. package/telegram-plugin/dist/server.js +161 -161
  22. package/telegram-plugin/draft-stream.ts +4 -4
  23. package/telegram-plugin/format.ts +427 -680
  24. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  25. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  26. package/telegram-plugin/gateway/approval-card.ts +16 -6
  27. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  28. package/telegram-plugin/gateway/auth-command.ts +74 -74
  29. package/telegram-plugin/gateway/auth-line.ts +5 -15
  30. package/telegram-plugin/gateway/boot-card.ts +20 -22
  31. package/telegram-plugin/gateway/boot-version.ts +3 -2
  32. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  33. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  34. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  35. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  36. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  37. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  38. package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
  39. package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
  40. package/telegram-plugin/gateway/effort-command.ts +17 -17
  41. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  42. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  43. package/telegram-plugin/gateway/gateway.ts +881 -633
  44. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  45. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  46. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  47. package/telegram-plugin/gateway/ipc-server.ts +39 -6
  48. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  49. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  50. package/telegram-plugin/gateway/model-command.ts +25 -25
  51. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  52. package/telegram-plugin/gateway/permission-timeout.ts +76 -0
  53. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  54. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  55. package/telegram-plugin/issues-card.ts +6 -7
  56. package/telegram-plugin/model-unavailable.ts +8 -12
  57. package/telegram-plugin/operator-events-history.ts +1 -1
  58. package/telegram-plugin/operator-events.ts +24 -28
  59. package/telegram-plugin/package.json +1 -1
  60. package/telegram-plugin/pending-work-progress.ts +36 -36
  61. package/telegram-plugin/permission-title.ts +39 -20
  62. package/telegram-plugin/pty-partial-handler.ts +5 -13
  63. package/telegram-plugin/quota-check.ts +5 -5
  64. package/telegram-plugin/quota-watch.ts +13 -18
  65. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  66. package/telegram-plugin/registry/turns-schema.ts +43 -3
  67. package/telegram-plugin/retry-api-call.ts +42 -7
  68. package/telegram-plugin/rich-send.ts +86 -0
  69. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  70. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  71. package/telegram-plugin/shared/bot-runtime.ts +29 -7
  72. package/telegram-plugin/silence-poke.ts +26 -69
  73. package/telegram-plugin/silent-reply-anchor.ts +9 -2
  74. package/telegram-plugin/slot-banner-driver.ts +9 -6
  75. package/telegram-plugin/slot-banner.ts +5 -8
  76. package/telegram-plugin/status-no-truncate.ts +11 -5
  77. package/telegram-plugin/steering.ts +0 -4
  78. package/telegram-plugin/stream-controller.ts +59 -62
  79. package/telegram-plugin/stream-reply-handler.ts +49 -98
  80. package/telegram-plugin/subagent-watcher.ts +2 -2
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  82. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  83. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  84. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  87. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  88. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  89. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  90. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  91. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  92. package/telegram-plugin/tests/card-format.test.ts +6 -4
  93. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  94. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  95. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  96. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  97. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  98. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  99. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
  100. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  101. package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
  102. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  103. package/telegram-plugin/tests/model-command.test.ts +2 -2
  104. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  105. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  106. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  107. package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
  108. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  109. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
  110. package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +88 -41
  112. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  113. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  114. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  115. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  116. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  117. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  118. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  119. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  120. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  121. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  122. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  123. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  124. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  125. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  126. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  127. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  128. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  129. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  130. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  131. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  132. package/telegram-plugin/text-voice-scrub.ts +8 -11
  133. package/telegram-plugin/tool-activity-summary.ts +29 -29
  134. package/telegram-plugin/welcome-text.ts +82 -83
  135. package/telegram-plugin/worker-activity-feed.ts +2 -3
  136. package/telegram-plugin/html-sanitize.ts +0 -244
  137. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  138. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -72,6 +72,11 @@ import {
72
72
  timeoutDenyMessage,
73
73
  duplicateDenyMessage,
74
74
  isRecentTimeoutDuplicate,
75
+ PERMISSION_TTL_MS,
76
+ ttlForTool,
77
+ buildTimedOutCardEdits,
78
+ STALE_TAP_NOTICE,
79
+ type PermissionCardRef,
75
80
  } from './permission-timeout.js'
76
81
  import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
77
82
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
@@ -89,6 +94,7 @@ import {
89
94
  createSwallowingRetryApiCall,
90
95
  retryWithThreadFallback,
91
96
  isHtmlParseRejectError,
97
+ isMessageTooLongError,
92
98
  } from '../retry-api-call.js'
93
99
  import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
94
100
  import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
@@ -189,8 +195,8 @@ const REPLY_TO_TEXT_MAX = 200
189
195
  const SILENT_END_FALLBACK_TEXT =
190
196
  '⚠️ The agent finished working but didn’t send a reply — your last ' +
191
197
  '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'
198
+ import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, escapeMarkdown, RICH_MESSAGE_MAX_CHARS } from '../format.js'
199
+ import { richMessage } from '../rich-send.js'
194
200
  import { scrubVoice } from '../text-voice-scrub.js'
195
201
  import {
196
202
  validateInlineKeyboard,
@@ -384,6 +390,18 @@ import {
384
390
  buildVaultSaveFailedInbound,
385
391
  buildVaultSaveDiscardedInbound,
386
392
  } from './vault-grant-inbound-builders.js'
393
+ import {
394
+ parseSkillProposalCallback,
395
+ buildSkillProposalApplyInbound,
396
+ renderSkillProposalCard,
397
+ skillProposalKeyboard,
398
+ } from './skill-proposal-card.js'
399
+ import {
400
+ getProposal as getSkillProposal,
401
+ setProposalStatus as setSkillProposalStatus,
402
+ enqueueProposal as enqueueSkillProposal,
403
+ isSuppressed as isSkillProposalSuppressed,
404
+ } from '../../src/self-improve/skill-proposals.js'
387
405
  import { decideSubagentHandback } from './subagent-handback-inbound-builder.js'
388
406
  import {
389
407
  decideSubagentProgress,
@@ -408,6 +426,7 @@ import type {
408
426
  InjectInboundMessage,
409
427
  SendOutboundMessage,
410
428
  QuotaWallDetectedMessage,
429
+ PostSkillProposalMessage,
411
430
  PermissionEvent,
412
431
  } from './ipc-protocol.js'
413
432
  import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
@@ -934,7 +953,8 @@ function defaultAccess(): Access {
934
953
  return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} }
935
954
  }
936
955
 
937
- const MAX_CHUNK_LIMIT = 4096
956
+ // Rich-message wire cap (#2669): the rich path allows 32768 UTF-8 chars.
957
+ const MAX_CHUNK_LIMIT = RICH_MESSAGE_MAX_CHARS
938
958
  const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
939
959
 
940
960
  function assertSendable(f: string): void {
@@ -1922,7 +1942,6 @@ interface OutstandingCompactCard {
1922
1942
  let compactNotifyState: CompactNotifyState = idleCompactNotifyState()
1923
1943
  let outstandingCompactCard: OutstandingCompactCard | null = null
1924
1944
  const activeDraftStreams = new Map<string, DraftStreamHandle>()
1925
- const activeDraftParseModes = new Map<string, 'HTML' | 'MarkdownV2' | undefined>()
1926
1945
  const suppressPtyPreview = new Set<string>()
1927
1946
  const lastPtyPreviewByChat = new Map<string, string>()
1928
1947
  const progressUpdateLastSent = new Map<string, number>()
@@ -3662,15 +3681,14 @@ async function postCompactCard(occ: number, cap: number): Promise<void> {
3662
3681
  supergroupChatId: resolveAgentSupergroupChatId(),
3663
3682
  });
3664
3683
  const text =
3665
- `🗜️ <b>Context compaction</b>\n` +
3684
+ `🗜️ **Context compaction**\n` +
3666
3685
  `Working context hit ~${occ.toLocaleString()} tokens ` +
3667
- `(cap ${cap.toLocaleString()}) — running <code>/compact</code>. ` +
3686
+ `(cap ${cap.toLocaleString()}) — running \`/compact\`. ` +
3668
3687
  `Older detail moves to Hindsight; I'll confirm here once the ` +
3669
3688
  `context has shrunk (may take a turn or two).`;
3670
3689
  const sent = await swallowingApiCall(
3671
3690
  () =>
3672
- bot.api.sendMessage(chatId, text, {
3673
- parse_mode: 'HTML',
3691
+ bot.api.sendRichMessage(chatId, richMessage(text), {
3674
3692
  ...(threadId != null ? { message_thread_id: threadId } : {}),
3675
3693
  }),
3676
3694
  { chat_id: chatId, verb: 'proactiveCompact.start' },
@@ -3720,7 +3738,7 @@ async function resolveCompactCard(
3720
3738
  let text: string;
3721
3739
  if (kind === 'finished') {
3722
3740
  text =
3723
- `✅ <b>Context compacted</b>\n` +
3741
+ `✅ **Context compacted**\n` +
3724
3742
  `Working context reduced` +
3725
3743
  (occNow != null
3726
3744
  ? ` (~${card.occAtStart.toLocaleString()} → ` +
@@ -3729,19 +3747,18 @@ async function resolveCompactCard(
3729
3747
  `. Hindsight retains the detail.`;
3730
3748
  } else if (kind === 'superseded') {
3731
3749
  text =
3732
- `↩️ <b>Context compaction superseded</b>\n` +
3750
+ `↩️ **Context compaction superseded**\n` +
3733
3751
  `A newer compaction started before this one confirmed.`;
3734
3752
  } else {
3735
3753
  text =
3736
- `⚠️ <b>Compaction issued</b>\n` +
3737
- `<code>/compact</code> was requested but the context isn't ` +
3754
+ `⚠️ **Compaction issued**\n` +
3755
+ `\`/compact\` was requested but the context isn't ` +
3738
3756
  `confirmed reduced yet. Native compaction and Hindsight still apply.`;
3739
3757
  }
3740
3758
  try {
3741
3759
  await swallowingApiCall(
3742
3760
  () =>
3743
- bot.api.editMessageText(card.chatId, card.messageId, text, {
3744
- parse_mode: 'HTML',
3761
+ bot.api.editMessageText(card.chatId, card.messageId, richMessage(text), {
3745
3762
  }),
3746
3763
  { chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
3747
3764
  );
@@ -3945,8 +3962,7 @@ function postPermissionResumeMessage(opts: {
3945
3962
  // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
3946
3963
  void swallowingApiCall(
3947
3964
  () =>
3948
- bot.api.sendMessage(chatId, text, {
3949
- parse_mode: 'HTML',
3965
+ bot.api.sendRichMessage(chatId, richMessage(text), {
3950
3966
  ...(threadId != null ? { message_thread_id: threadId } : {}),
3951
3967
  }),
3952
3968
  { chat_id: chatId, verb: 'permission-resume', ...(threadId != null ? { threadId } : {}) },
@@ -4033,21 +4049,6 @@ function chunk(text: string, limit: number, mode: 'length' | 'newline'): string[
4033
4049
  return out
4034
4050
  }
4035
4051
 
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
4052
  // ─── Typing indicator ─────────────────────────────────────────────────────
4052
4053
  // All four state maps re-keyed from `chat_id` to `chatKey(chat, thread)`
4053
4054
  // in PR3 of the supergroup-mode rollout. In supergroup mode one agent
@@ -4193,14 +4194,17 @@ function wrapBootCardApi(
4193
4194
  verb: 'boot-card',
4194
4195
  ...(threadId != null ? { threadId } : {}),
4195
4196
  })
4197
+ // #2669: card bodies are raw GFM markdown — send them via the
4198
+ // rich-message path. The card modules pass plain `opts` (no parse_mode).
4196
4199
  return {
4197
4200
  sendMessage: async (cid, text, sendOpts) => {
4198
4201
  const sent = await robustApiCall(
4199
4202
  () =>
4200
- lockedBot.api.sendMessage(
4203
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
4204
+ lockedBot.api.sendRichMessage(
4201
4205
  cid,
4202
- text,
4203
- sendOpts as Parameters<typeof lockedBot.api.sendMessage>[2],
4206
+ richMessage(text),
4207
+ sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
4204
4208
  ),
4205
4209
  opts(cid),
4206
4210
  )
@@ -4212,7 +4216,7 @@ function wrapBootCardApi(
4212
4216
  lockedBot.api.editMessageText(
4213
4217
  cid,
4214
4218
  mid,
4215
- text,
4219
+ richMessage(text),
4216
4220
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
4217
4221
  ),
4218
4222
  opts(cid),
@@ -4226,7 +4230,7 @@ function wrapBootCardApi(
4226
4230
  type EditOpts = Parameters<Api['editMessageText']>[3]
4227
4231
  try {
4228
4232
  // 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)
4233
+ await lockedBot.api.editMessageText(cid, mid, richMessage(text), editOpts as EditOpts)
4230
4234
  return 'edited'
4231
4235
  } catch (err) {
4232
4236
  const desc =
@@ -4256,14 +4260,16 @@ function wrapIssuesCardApi(
4256
4260
  verb: 'issues-card',
4257
4261
  ...(threadId != null ? { threadId } : {}),
4258
4262
  })
4263
+ // #2669: issues-card body is raw GFM markdown — send via the rich path.
4259
4264
  return {
4260
4265
  sendMessage: async (cid, text, sendOpts) => {
4261
4266
  const sent = await robustApiCall(
4262
4267
  () =>
4263
- lockedBot.api.sendMessage(
4268
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
4269
+ lockedBot.api.sendRichMessage(
4264
4270
  cid,
4265
- text,
4266
- sendOpts as Parameters<typeof lockedBot.api.sendMessage>[2],
4271
+ richMessage(text),
4272
+ sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
4267
4273
  ),
4268
4274
  opts(cid),
4269
4275
  )
@@ -4275,7 +4281,7 @@ function wrapIssuesCardApi(
4275
4281
  lockedBot.api.editMessageText(
4276
4282
  cid,
4277
4283
  mid,
4278
- text,
4284
+ richMessage(text),
4279
4285
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
4280
4286
  ),
4281
4287
  opts(cid),
@@ -4312,8 +4318,10 @@ const STATUS_QUERY_RE = /^\s*status\??\s*$/i
4312
4318
 
4313
4319
  // ─── Permission handling ──────────────────────────────────────────────────
4314
4320
  const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
4315
- const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number }>()
4316
- const PERMISSION_TTL_MS = 10 * 60_000
4321
+ const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number; card_text: string; cards: { chatId: string; messageId: number }[] }>()
4322
+ // PERMISSION_TTL_MS / ttlForTool / the timed-out card builder now live in
4323
+ // ./permission-timeout.ts (pure + unit-testable). hostd gated verbs get a
4324
+ // 30-min window; everything else keeps the 10-min default.
4317
4325
  // No-repeat-on-timeout (marko Rentals-budget loop, 2026-06-17). When a card
4318
4326
  // auto-denies on TTL, the model is told it was a TIMEOUT (not a denial) so it
4319
4327
  // doesn't retry; if it retries the identical (tool, input) anyway while the
@@ -4944,7 +4952,10 @@ const pendingStateReaper = setInterval(() => {
4944
4952
  if (now - v.startedAt > VAULT_INPUT_TTL_MS) pendingVaultOps.delete(k)
4945
4953
  }
4946
4954
  for (const [k, v] of pendingPermissions) {
4947
- if (now - v.startedAt > PERMISSION_TTL_MS) {
4955
+ // hostd gated fleet-mutation verbs get a longer (30-min) human-scale
4956
+ // decision window than the 10-min default (Bug 2 fix #2).
4957
+ const ttl = ttlForTool(v.tool_name)
4958
+ if (now - v.startedAt > ttl) {
4948
4959
  // Don't just drop it: the claude turn is suspended INSIDE the MCP
4949
4960
  // permission call waiting for a verdict. A silent delete left it
4950
4961
  // wedged forever when the operator never tapped — permanent
@@ -4956,13 +4967,20 @@ const pendingStateReaper = setInterval(() => {
4956
4967
  // Carry a TIMEOUT reason to the model (claude renders it as "…the user
4957
4968
  // said: …") so it can tell a timeout from a real denial and not retry
4958
4969
  // the identical call — the duplicate-card loop this series closes.
4959
- const timeoutMinutes = Math.round(PERMISSION_TTL_MS / 60000)
4970
+ const timeoutMinutes = Math.round(ttl / 60000)
4960
4971
  dispatchPermissionVerdict({
4961
4972
  type: 'permission',
4962
4973
  requestId: k,
4963
4974
  behavior: 'deny',
4964
4975
  message: timeoutDenyMessage(timeoutMinutes),
4965
4976
  })
4977
+ // Strip the card's inline keyboard + mark it timed out (Bug 2 fix #1).
4978
+ // Before this, the reaper deleted the pending entry but left a LIVE
4979
+ // Approve button — a late operator tap then dispatched a verdict for an
4980
+ // already-resolved/dead request_id, which Claude Code ignores, so the
4981
+ // operator "approved" but work never continued. Stripping the keyboard
4982
+ // makes the timeout legible and removes the stale tappable button.
4983
+ void stripTimedOutPermissionCards(v.card_text, v.cards)
4966
4984
  // The auto-deny un-parks the suspended turn — flip 🙏 → working so
4967
4985
  // it doesn't sit on the awaiting glyph (or stall) after the timeout.
4968
4986
  resumeReactionAfterVerdict()
@@ -5320,7 +5338,6 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
5320
5338
  // grammy's Other<...> opts type is generated and stricter than our
5321
5339
  // call shape; runtime accepts both. Cast through unknown.
5322
5340
  const opts = {
5323
- parse_mode: 'HTML' as const,
5324
5341
  ...(renderedKeyboard ? { reply_markup: renderedKeyboard } : {}),
5325
5342
  ...(opEventThread != null ? { message_thread_id: opEventThread } : {}),
5326
5343
  }
@@ -5328,7 +5345,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
5328
5345
  // very next line is what unlocks the raw bot.api call.
5329
5346
  // Opts now includes message_thread_id when supergroup mode is on.
5330
5347
  // allow-raw-bot-api: operator-event broadcast loop; topic-aware opts
5331
- void bot.api.sendMessage(chat_id, renderedText, opts as never).catch(e => {
5348
+ void bot.api.sendRichMessage(chat_id, richMessage(renderedText), opts as never).catch(e => {
5332
5349
  process.stderr.write(
5333
5350
  `telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
5334
5351
  )
@@ -5356,9 +5373,7 @@ function postLegacyBanner(
5356
5373
  retryWithThreadFallback(
5357
5374
  robustApiCall,
5358
5375
  (tid) =>
5359
- lockedBot.api.sendMessage(chatId, text, {
5360
- parse_mode: 'HTML',
5361
- link_preview_options: { is_disabled: true },
5376
+ lockedBot.api.sendRichMessage(chatId, richMessage(text), {
5362
5377
  ...(tid != null ? { message_thread_id: tid } : {}),
5363
5378
  ...(ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {}),
5364
5379
  }),
@@ -5612,11 +5627,14 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
5612
5627
 
5613
5628
  // #1122: framework safety-net for "model is silent to the user for >5min."
5614
5629
  // 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
5630
+ // silence the framework unwedges the turn. The generic "still working… /
5631
+ // still thinking…" stall message it used to send was a stop-gap from
5632
+ // before the live draft carried the progress beats and has been retired
5633
+ // (the stall-stop-gap PR) — the surviving user-visible sends are the
5634
+ // deterministic update_apply phase line and the approval-blocked re-ping.
5635
+ // The model-targeted nudge ladder (ack/soft/firm) and the 60s awareness
5636
+ // ping were retired earlier once the live-updating reply/draft took over
5637
+ // the pacing job — only this single unwedge fallback remains. Honours
5620
5638
  // SWITCHROOM_DISABLE_SILENCE_POKE=1 kill switch (no-op if set).
5621
5639
  // Set when this gateway dispatches an `update_apply` to hostd that
5622
5640
  // returns `started`; cleared when the dispatch poll resolves (terminal
@@ -5752,6 +5770,14 @@ silencePoke.startTimer({
5752
5770
  ctx.inFlightTools,
5753
5771
  blockedOnApproval,
5754
5772
  )
5773
+ // The stall-notice stop-gap is retired: `formatFrameworkFallbackText`
5774
+ // returns null for the pure-liveness "still working / running <Tool>"
5775
+ // beat (the only string it still emits is the approval-blocked re-ping).
5776
+ // The early quiet floor beat existed ONLY to surface that stall notice, so
5777
+ // with the notice gone there is nothing to send here unless the turn is
5778
+ // parked on an approval card. Skip silently otherwise; the live draft +
5779
+ // the model's own pacing beats carry progress.
5780
+ if (text == null) return
5755
5781
  try {
5756
5782
  await robustApiCall(
5757
5783
  () => bot.api.sendMessage(ctx.chatId, text, {
@@ -5849,7 +5875,10 @@ silencePoke.startTimer({
5849
5875
  )
5850
5876
  }
5851
5877
  // #2527: log the actual poke fire with structured data before sending,
5852
- // so the event is visible even if the send fails.
5878
+ // so the event is visible even if the send fails. The stall-notice
5879
+ // stop-gap is retired (the user-visible send below is now gated), but the
5880
+ // fire event is still logged so observability + the unwedge accounting are
5881
+ // unchanged — mirrors PR #2641 (drop the chat badge, keep the log).
5853
5882
  logStreamingEvent({
5854
5883
  kind: 'silence_poke_fire',
5855
5884
  chatId: ctx.chatId,
@@ -5857,24 +5886,34 @@ silencePoke.startTimer({
5857
5886
  silenceMs: ctx.silenceMs,
5858
5887
  fallbackKind: ctx.fallbackKind,
5859
5888
  })
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
- )
5889
+ // Send a user-visible message ONLY when there's something honest to say:
5890
+ // - a deterministic in-flight update_apply phase (`text` set above), or
5891
+ // - the approval-blocked re-ping (`formatFrameworkFallbackText` returns a
5892
+ // string only for that case).
5893
+ // The generic "still working… (no update from agent in N min)" stall notice
5894
+ // is retired — `formatFrameworkFallbackText` returns null for it, so `text`
5895
+ // stays null and we skip the send. The turn-teardown below (the unwedge —
5896
+ // the one job the live draft can't do, per the conversational-pacing RFC)
5897
+ // still runs unconditionally.
5898
+ if (text != null) {
5899
+ try {
5900
+ await robustApiCall(
5901
+ () => bot.api.sendMessage(ctx.chatId, text, {
5902
+ ...(ctx.threadId != null ? { message_thread_id: ctx.threadId } : {}),
5903
+ // Conditional: when the turn is parked on an approval card, this
5904
+ // fallback carries a user-gating re-ping ("waiting for your
5905
+ // approval — tap Approve or Deny …") — that must PING, because the
5906
+ // user is the one being waited on. The deterministic update-status
5907
+ // line stays SILENT (a status surface). Gate on `blockedOnApproval`.
5908
+ disable_notification: blockedOnApproval ? false : true,
5909
+ }),
5910
+ { chat_id: ctx.chatId, ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}) },
5911
+ )
5912
+ } catch (err) {
5913
+ process.stderr.write(
5914
+ `silence-poke fallback sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
5915
+ )
5916
+ }
5878
5917
  }
5879
5918
  // #1122 follow-up: end the wedged turn AFTER the fallback fires.
5880
5919
  // Without this, `activeTurnStartedAt` stays set, every subsequent
@@ -6197,19 +6236,18 @@ _deliveryConfirmSweep.unref?.()
6197
6236
  // `SWITCHROOM_DISABLE_PENDING_PROGRESS=1`.
6198
6237
  pendingProgress.startTimer({
6199
6238
  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
6239
+ // #2669: re-edit via the SAME shape the anchor was sent with. A rich
6240
+ // anchor (the reply tool's default) re-edits via the rich-markdown
6241
+ // path so the body keeps rendering its markdown; a literal
6242
+ // `format:'text'` anchor re-edits as a plain string. This is the
6243
+ // single-rich-path successor to the #1698 parse-mode contract.
6206
6244
  await swallowingApiCall(
6207
6245
  () =>
6208
6246
  lockedBot.api.editMessageText(
6209
6247
  ctx.chatId,
6210
6248
  ctx.messageId,
6211
- ctx.newText,
6212
- editOpts,
6249
+ ctx.literalText ? ctx.newText : richMessage(ctx.newText),
6250
+ undefined,
6213
6251
  ),
6214
6252
  {
6215
6253
  chat_id: ctx.chatId,
@@ -6545,6 +6583,32 @@ function buildPermissionActionRow(
6545
6583
  return kb
6546
6584
  }
6547
6585
 
6586
+ /**
6587
+ * Strip the inline keyboard from one or more timed-out permission cards and
6588
+ * append a "timed out — re-request to act" line (Bug 2 fix #1). Called from
6589
+ * the pendingStateReaper, which has no grammy `ctx`, so it edits via the
6590
+ * bot.api directly — routed through `swallowingApiCall` so a deleted card
6591
+ * (operator removed the message) is swallowed rather than crashing the sweep.
6592
+ *
6593
+ * A single `editMessageText` re-renders the original card body plus a
6594
+ * timed-out footer; omitting `reply_markup` drops the keyboard atomically
6595
+ * with the text edit (same approach the normal tap finalizer uses, which
6596
+ * strips the keyboard alongside its status-line edit). Best-effort: a failed
6597
+ * edit never blocks the auto-deny that already fired.
6598
+ */
6599
+ async function stripTimedOutPermissionCards(
6600
+ cardText: string,
6601
+ cards: PermissionCardRef[],
6602
+ ): Promise<void> {
6603
+ for (const edit of buildTimedOutCardEdits(cardText, cards)) {
6604
+ await swallowingApiCall(
6605
+ // allow-raw-bot-api: routed through swallowingApiCall (retry policy); message-id-targeted edit (no thread to lose). Passing {} as opts (no reply_markup) strips the stale Allow/Deny keyboard atomically with the text edit.
6606
+ () => bot.api.editMessageText(edit.chatId, edit.messageId, richMessage(edit.text), {}),
6607
+ { chat_id: edit.chatId, verb: 'permission_timeout.strip' },
6608
+ )
6609
+ }
6610
+ }
6611
+
6548
6612
  function dispatchPermissionVerdict(ev: PermissionEvent): void {
6549
6613
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
6550
6614
  const delivered = ipcServer.sendToAgent(selfAgent, ev)
@@ -6792,7 +6856,6 @@ const ipcServer: IpcServer = createIpcServer({
6792
6856
  activeTurnStartedAt,
6793
6857
  claudeBusyKeys,
6794
6858
  activeDraftStreams,
6795
- activeDraftParseModes,
6796
6859
  clearActiveReactions: () => {
6797
6860
  const ad = resolveAgentDirFromEnv()
6798
6861
  if (ad) clearActiveReactions(ad)
@@ -6977,18 +7040,21 @@ const ipcServer: IpcServer = createIpcServer({
6977
7040
  return
6978
7041
  }
6979
7042
  }
6980
- pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
6981
7043
  // Natural-language card body — a plain sentence ("Gymbro wants to
6982
7044
  // edit: supplement-log.md" + a why-line), never a raw tool id.
6983
7045
  // 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.
7046
+ // Mirrors the `vault_request_access` card layout. Sent via the
7047
+ // rich-markdown path below.
6986
7048
  const text = formatPermissionCardBody({
6987
7049
  toolName,
6988
7050
  inputPreview,
6989
7051
  description,
6990
7052
  agentName: _client.agentName,
6991
7053
  })
7054
+ // `card_text` is retained so the TTL reaper can re-edit the SAME body
7055
+ // with a "timed out" footer while stripping the keyboard atomically
7056
+ // (Bug 2 fix #1) — the reaper has no grammy ctx to read the live text.
7057
+ pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now(), card_text: text, cards: [] })
6992
7058
  // Compact action row: ❌ Deny · ✅ Allow · 🔁 Always… — the scope of an
6993
7059
  // "always" grant stays hidden until the operator taps "🔁 Always…",
6994
7060
  // which swaps the row for a scope choice (this file / any file ⚠️). The
@@ -7009,8 +7075,8 @@ const ipcServer: IpcServer = createIpcServer({
7009
7075
  const activeTurn = currentTurn
7010
7076
  const targets = resolvePermissionCardTargets()
7011
7077
  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
7078
+ // The rich-markdown path pairs with formatPermissionCardBody (#1790)
7079
+ // so its bold/italic render. retryWithThreadFallback: if the topic was
7014
7080
  // deleted/recreated (stale thread id → 400 "message thread not
7015
7081
  // found"), re-send thread-less into the main chat so the card still
7016
7082
  // ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
@@ -7018,13 +7084,22 @@ const ipcServer: IpcServer = createIpcServer({
7018
7084
  void retryWithThreadFallback<{ message_id: number }>(
7019
7085
  robustApiCall,
7020
7086
  (tid) =>
7021
- bot.api.sendMessage(chatId, text, {
7022
- parse_mode: 'HTML',
7087
+ bot.api.sendRichMessage(chatId, richMessage(text), {
7023
7088
  reply_markup: keyboard,
7024
7089
  ...(tid != null ? { message_thread_id: tid } : {}),
7025
7090
  }),
7026
7091
  { threadId, chat_id: chatId, verb: 'permission_request' },
7027
- ).catch(e => {
7092
+ ).then(sent => {
7093
+ // Record the live card's (chat, message) so the TTL reaper can strip
7094
+ // its inline keyboard on auto-deny — a stale Approve button left
7095
+ // tappable dispatches a verdict for a dead request_id (Bug 2). The
7096
+ // entry may already be gone (operator tapped before this resolved);
7097
+ // guard the lookup.
7098
+ const pend = pendingPermissions.get(requestId)
7099
+ if (pend && sent && typeof sent.message_id === 'number') {
7100
+ pend.cards.push({ chatId, messageId: sent.message_id })
7101
+ }
7102
+ }).catch(e => {
7028
7103
  process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
7029
7104
  })
7030
7105
  }
@@ -7198,8 +7273,7 @@ const ipcServer: IpcServer = createIpcServer({
7198
7273
  try {
7199
7274
  const sent = await robustApiCall(
7200
7275
  () =>
7201
- bot.api.sendMessage(args.chatId, args.text, {
7202
- parse_mode: 'HTML',
7276
+ bot.api.sendRichMessage(args.chatId, richMessage(args.text), {
7203
7277
  ...(args.threadId !== undefined
7204
7278
  ? { message_thread_id: args.threadId }
7205
7279
  : {}),
@@ -7349,8 +7423,8 @@ const ipcServer: IpcServer = createIpcServer({
7349
7423
  try {
7350
7424
  const sent = await robustApiCall(
7351
7425
  () =>
7352
- bot.api.sendMessage(args.chatId, args.text, {
7353
- parse_mode: 'HTML',
7426
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
7427
+ bot.api.sendRichMessage(args.chatId, richMessage(args.text), {
7354
7428
  ...(args.threadId !== undefined
7355
7429
  ? { message_thread_id: args.threadId }
7356
7430
  : {}),
@@ -7374,8 +7448,7 @@ const ipcServer: IpcServer = createIpcServer({
7374
7448
  try {
7375
7449
  await robustApiCall(
7376
7450
  () =>
7377
- bot.api.editMessageText(args.chatId, args.messageId, args.text, {
7378
- parse_mode: 'HTML',
7451
+ bot.api.editMessageText(args.chatId, args.messageId, richMessage(args.text), {
7379
7452
  // Strip the inline keyboard on a terminal/interim edit so the
7380
7453
  // [Approve]/[Deny] buttons stop being tappable on a resolved card.
7381
7454
  ...(args.stripKeyboard ? { reply_markup: { inline_keyboard: [] } } : {}),
@@ -7446,8 +7519,7 @@ const ipcServer: IpcServer = createIpcServer({
7446
7519
  try {
7447
7520
  await robustApiCall(
7448
7521
  () =>
7449
- bot.api.editMessageText(args.chatId, args.messageId, args.text, {
7450
- parse_mode: 'HTML',
7522
+ bot.api.editMessageText(args.chatId, args.messageId, richMessage(args.text), {
7451
7523
  // Finalize is terminal — drop the keyboard so buttons are gone.
7452
7524
  ...(args.stripKeyboard ? { reply_markup: { inline_keyboard: [] } } : {}),
7453
7525
  }),
@@ -7544,15 +7616,18 @@ const ipcServer: IpcServer = createIpcServer({
7544
7616
  return
7545
7617
  }
7546
7618
  const threadId = msg.threadId
7547
- const parseMode = msg.parseMode === 'text' ? undefined : 'HTML'
7619
+ // #2669: literal `text` mode → plain send; everything else → rich markdown.
7620
+ const literalText = msg.parseMode === 'text'
7621
+ const outOpts = {
7622
+ ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
7623
+ }
7548
7624
  // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send.
7549
7625
  // General topic (thread 1) sends omit message_thread_id per the outbound convention.
7550
7626
  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
- }),
7627
+ (): Promise<{ message_id: number }> =>
7628
+ literalText
7629
+ ? bot.api.sendMessage(msg.chatId, msg.text, outOpts)
7630
+ : bot.api.sendRichMessage(msg.chatId, richMessage(msg.text), outOpts),
7556
7631
  { chat_id: msg.chatId, verb: 'cron-action-send', ...(threadId != null ? { threadId } : {}) },
7557
7632
  )
7558
7633
  process.stderr.write(
@@ -7580,6 +7655,78 @@ const ipcServer: IpcServer = createIpcServer({
7580
7655
  void fireFleetAutoFallback(msg.agentName, untilMs)
7581
7656
  },
7582
7657
 
7658
+ // #2670 one-tap self-improvement — persist a skill-improvement proposal and
7659
+ // post its Approve/Dismiss card. The store transition + apply-injection on
7660
+ // Approve are owned by handleSkillProposalCallback (so a gateway restart
7661
+ // between post and tap still resolves correctly). Placed AFTER
7662
+ // onQuotaWallDetected so it doesn't fall inside the source slice that
7663
+ // send-outbound-wiring.test.ts takes between onSendOutbound and
7664
+ // onQuotaWallDetected.
7665
+ onPostSkillProposal(_client: IpcClient, msg: PostSkillProposalMessage) {
7666
+ const self = process.env.SWITCHROOM_AGENT_NAME
7667
+ if (self && msg.agentName !== self) {
7668
+ process.stderr.write(
7669
+ `telegram gateway: post_skill_proposal rejected — agent mismatch (${msg.agentName} != ${self})\n`,
7670
+ )
7671
+ return
7672
+ }
7673
+ try {
7674
+ assertAllowedChat(msg.chatId)
7675
+ } catch (err) {
7676
+ process.stderr.write(
7677
+ `telegram gateway: post_skill_proposal rejected — ${(err as Error).message}\n`,
7678
+ )
7679
+ return
7680
+ }
7681
+ const stateDir = process.env.TELEGRAM_STATE_DIR
7682
+ if (stateDir == null || stateDir.length === 0) {
7683
+ process.stderr.write(`telegram gateway: post_skill_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
7684
+ return
7685
+ }
7686
+ // Dedup against still-live rejection fingerprints — never re-surface a
7687
+ // proposal the operator already dismissed.
7688
+ if (isSkillProposalSuppressed(stateDir, {
7689
+ lesson: msg.lesson,
7690
+ draft: msg.draft,
7691
+ skill_slug: msg.skillSlug,
7692
+ })) {
7693
+ process.stderr.write(
7694
+ `telegram gateway: post_skill_proposal suppressed (rejected before) slug=${msg.skillSlug}\n`,
7695
+ )
7696
+ return
7697
+ }
7698
+ const proposal = enqueueSkillProposal(stateDir, {
7699
+ skill_slug: msg.skillSlug,
7700
+ is_new: msg.isNew,
7701
+ lesson: msg.lesson,
7702
+ draft: msg.draft,
7703
+ evidence: msg.evidence,
7704
+ chat_id: Number(msg.chatId),
7705
+ })
7706
+ const cardText = renderSkillProposalCard({
7707
+ id: proposal.id,
7708
+ skill_slug: proposal.skill_slug,
7709
+ is_new: proposal.is_new,
7710
+ lesson: proposal.lesson,
7711
+ evidence: proposal.evidence,
7712
+ skill_md: proposal.draft['SKILL.md'],
7713
+ })
7714
+ const threadId = msg.threadId
7715
+ void swallowingApiCall(
7716
+ () =>
7717
+ bot.api.sendMessage(msg.chatId, cardText, {
7718
+ parse_mode: 'HTML',
7719
+ reply_markup: skillProposalKeyboard(proposal.id),
7720
+ ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
7721
+ }),
7722
+ { chat_id: msg.chatId, verb: 'skill-proposal-card', ...(threadId != null ? { threadId } : {}) },
7723
+ )
7724
+ process.stderr.write(
7725
+ `telegram gateway: post_skill_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
7726
+ `proposal=${proposal.id} slug=${proposal.skill_slug} new=${proposal.is_new}\n`,
7727
+ )
7728
+ },
7729
+
7583
7730
  log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
7584
7731
  })
7585
7732
 
@@ -7891,8 +8038,8 @@ function notifyLinearAuthDead(info: { agent: string; reason: LinearAuthDeadReaso
7891
8038
  const text = buildLinearAuthDeadMessage(info.agent, info.reason)
7892
8039
  await swallowingApiCall(
7893
8040
  () =>
7894
- bot.api.sendMessage(chatId, text, {
7895
- parse_mode: 'HTML',
8041
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
8042
+ bot.api.sendRichMessage(chatId, richMessage(text), {
7896
8043
  ...(threadId != null ? { message_thread_id: threadId } : {}),
7897
8044
  }),
7898
8045
  { chat_id: chatId, verb: 'linearAuthDead' },
@@ -7997,18 +8144,19 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
7997
8144
  })()
7998
8145
  const rawText = args.text as string | undefined
7999
8146
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
8000
- let text = repairEscapedWhitespace(rawText)
8147
+ // Repair LLM JSON-escape bungles, then promote lone prose paragraph breaks
8148
+ // into GFM hard breaks so the rich path doesn't collapse them (lists/tables/
8149
+ // code are left untouched — see normalizeParagraphBreaks).
8150
+ let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
8001
8151
  // Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
8002
8152
  // the stderr preview below, the dedup key, the send, and the history
8003
8153
  // record. Mutates `text` so every downstream consumer sees the masked
8004
8154
  // value, exactly like the voice scrub that follows.
8005
8155
  text = redactOutboundText(text, 'reply')
8006
8156
  // 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:
8157
+ // Runs BEFORE outboundDedup so retries see the scrubbed key, and on the
8158
+ // raw markdown text (the scrubber does its own code-region parking so
8159
+ // dashes inside fences/inline code are preserved). Kill switch:
8012
8160
  // `SWITCHROOM_DISABLE_VOICE_SCRUB=1`.
8013
8161
  {
8014
8162
  const scrub = scrubVoice(text)
@@ -8220,24 +8368,19 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8220
8368
  // Replace the local text with a one-line link. The first line
8221
8369
  // is the chosen title (so the user sees the topic at a glance);
8222
8370
  // 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}`
8371
+ text = `**${escapeMarkdown(title)}**\n${url}`
8224
8372
  }
8225
8373
  // url null → fall through and chunk; the user still gets the
8226
8374
  // long reply, just split across messages.
8227
8375
  }
8228
8376
 
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
- }
8377
+ // Single rich-markdown path (#2669). The only fork is `format:'text'`,
8378
+ // a literal/no-markdown send: the body bypasses the rich parser entirely
8379
+ // (plain `sendMessage`, no rich wrapper). Everything else — the default —
8380
+ // ships the raw GFM markdown via `sendRichMessage`. `effectiveText` is the
8381
+ // raw text either way (no HTML/MarkdownV2 rendering happens here anymore).
8382
+ const literalText = format === 'text'
8383
+ const effectiveText: string = text
8241
8384
 
8242
8385
  assertAllowedChat(chat_id)
8243
8386
 
@@ -8299,11 +8442,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8299
8442
  }
8300
8443
  }
8301
8444
 
8302
- const limit = Math.max(1, Math.min(access.textChunkLimit ?? 4000, MAX_CHUNK_LIMIT))
8445
+ const limit = Math.max(1, Math.min(access.textChunkLimit ?? RICH_MESSAGE_MAX_CHARS, MAX_CHUNK_LIMIT))
8303
8446
  const replyMode = access.replyToMode ?? 'first'
8304
- const chunks = parseMode === 'HTML'
8305
- ? splitHtmlChunks(effectiveText, limit)
8306
- : chunk(effectiveText, limit, access.chunkMode ?? 'length')
8447
+ const chunks = literalText
8448
+ ? chunk(effectiveText, limit, access.chunkMode ?? 'length')
8449
+ : splitMarkdownChunks(effectiveText, limit)
8307
8450
  const sentIds: number[] = []
8308
8451
 
8309
8452
  // #271: validate inline_keyboard and namespace any callback_data with
@@ -8335,7 +8478,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8335
8478
  await openStream.finalize().catch(() => {})
8336
8479
  previewMessageId = openStream.getMessageId()
8337
8480
  activeDraftStreams.delete(replySKey)
8338
- activeDraftParseModes.delete(replySKey)
8339
8481
  lastPtyPreviewByChat.delete(replySKey)
8340
8482
  }
8341
8483
 
@@ -8446,13 +8588,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8446
8588
  })
8447
8589
  if (decision.kind === 'edit-anchor') {
8448
8590
  const editParams: {
8449
- parse_mode?: 'HTML' | 'MarkdownV2'
8450
8591
  message_thread_id?: number
8451
8592
  link_preview_options?: { is_disabled: boolean }
8452
8593
  } = {
8453
8594
  link_preview_options: { is_disabled: disableLinkPreview },
8454
8595
  }
8455
- if (parseMode != null) editParams.parse_mode = parseMode
8456
8596
  if (threadId != null) editParams.message_thread_id = threadId
8457
8597
  try {
8458
8598
  await robustApiCall(
@@ -8460,7 +8600,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8460
8600
  lockedBot.api.editMessageText(
8461
8601
  chat_id,
8462
8602
  decision.messageId,
8463
- decision.mergedText,
8603
+ literalText ? decision.mergedText : richMessage(decision.mergedText),
8464
8604
  editParams,
8465
8605
  ),
8466
8606
  {
@@ -8504,7 +8644,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8504
8644
  pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
8505
8645
  messageId: decision.messageId,
8506
8646
  text: decision.mergedText,
8507
- parseMode,
8647
+ literalText,
8508
8648
  })
8509
8649
  if (HISTORY_ENABLED) {
8510
8650
  try {
@@ -8591,7 +8731,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8591
8731
  },
8592
8732
  }
8593
8733
  : {}),
8594
- ...(parseMode ? { parse_mode: parseMode } : {}),
8595
8734
  ...(threadId != null ? { message_thread_id: threadId } : {}),
8596
8735
  ...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
8597
8736
  ...(replyMarkup != null && isLastChunk ? { reply_markup: replyMarkup } : {}),
@@ -8599,14 +8738,17 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8599
8738
  ...(disableNotification ? { disable_notification: true } : {}),
8600
8739
  }
8601
8740
 
8741
+ // The body argument for send/edit: a rich-markdown wrapper on the
8742
+ // default path, or the literal string for `format:'text'` (#2669).
8743
+ const richOrPlain = (s: string) => (literalText ? s : richMessage(s))
8744
+
8602
8745
  if (i === 0 && previewMessageId != null) {
8603
8746
  const editOpts: Record<string, unknown> = {}
8604
- if (parseMode) editOpts.parse_mode = parseMode
8605
8747
  if (disableLinkPreview) editOpts.link_preview_options = { is_disabled: true }
8606
8748
  if (replyMarkup != null && isLastChunk) editOpts.reply_markup = replyMarkup
8607
8749
  try {
8608
8750
  await robustApiCall(
8609
- () => lockedBot.api.editMessageText(chat_id, previewMessageId!, chunks[i], editOpts),
8751
+ () => lockedBot.api.editMessageText(chat_id, previewMessageId!, richOrPlain(chunks[i]), editOpts),
8610
8752
  { threadId, chat_id },
8611
8753
  )
8612
8754
  sentIds.push(previewMessageId!)
@@ -8625,37 +8767,80 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8625
8767
  }
8626
8768
  }
8627
8769
 
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.
8770
+ // Last-resort: resend this chunk as plain text (no rich wrapper, so
8771
+ // the markdown parser never runs). Keeps thread / reply / markup
8772
+ // params; only the formatting is sacrificed. Used when Telegram
8773
+ // rejects our markdown — better an unformatted answer than a
8774
+ // vanished one. The raw markdown source is itself readable prose, so
8775
+ // we send it verbatim rather than strip anything.
8632
8776
  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
8777
  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)
8778
+ chunks[i].length > 0
8779
+ ? chunks[i]
8780
+ : '⚠️ (a fragment could not be rendered for Telegram)'
8781
+ // allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse policy that just rejected the payload
8782
+ const sent = await lockedBot.api.sendMessage(chat_id, plain, opts as never)
8647
8783
  sentIds.push(sent.message_id)
8648
8784
  logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
8649
8785
  process.stderr.write(
8650
- `telegram gateway: HTML parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
8786
+ `telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
8651
8787
  )
8652
8788
  }
8653
8789
 
8654
- try {
8655
- const sent = await robustApiCall(
8656
- () => lockedBot.api.sendMessage(chat_id, chunks[i], sendOpts as never),
8657
- { threadId, chat_id },
8790
+ // Literal `format:'text'` sends bypass the rich parser entirely
8791
+ // (plain sendMessage, no markdown). The default path ships rich
8792
+ // markdown via sendRichMessage. Both resolve to a Message with a
8793
+ // message_id, which is all the caller reads.
8794
+ const sendChunk = (opts: Record<string, unknown>): Promise<{ message_id: number }> => {
8795
+ if (literalText) {
8796
+ // allow-raw-bot-api: literal format:'text' send; the chunk-loop's own THREAD_NOT_FOUND + parse-reject handling wraps this
8797
+ return lockedBot.api.sendMessage(chat_id, chunks[i], opts as never)
8798
+ }
8799
+ // sendRichMessage does NOT accept link_preview_options (rich messages
8800
+ // control previews via entity detection) — drop it for the rich path.
8801
+ const richOpts = { ...opts }
8802
+ delete (richOpts as { link_preview_options?: unknown }).link_preview_options
8803
+ // allow-raw-bot-api: sendRichMessage is not in the THREAD_NOT_FOUND blast pattern; thread fallback handled in the catch below
8804
+ return lockedBot.api.sendRichMessage(chat_id, richMessage(chunks[i]), richOpts as never)
8805
+ }
8806
+
8807
+ // Length-error recovery: a single pre-computed chunk can still exceed the
8808
+ // wire cap when splitMarkdownChunks hit an indivisible region and emitted
8809
+ // it whole (a giant fenced block, a no-boundary blob). Telegram answers
8810
+ // with RICH_MESSAGE_TEXT_TOO_LONG / MESSAGE_TOO_LONG. Re-split this chunk
8811
+ // at a harder boundary and send each piece, rather than misclassifying it
8812
+ // as a parse-reject (which would resend the same oversized payload as
8813
+ // plain text) or surfacing the raw 400.
8814
+ const sendChunkResplit = async (opts: Record<string, unknown>): Promise<void> => {
8815
+ // Re-split at the same cap; for a truly indivisible block this still
8816
+ // yields one oversized piece, but a hard character-cut on the rendered
8817
+ // markdown at least keeps each delivered piece under the wire cap.
8818
+ const subPieces = splitMarkdownChunks(chunks[i], RICH_MESSAGE_MAX_CHARS)
8819
+ const pieces =
8820
+ subPieces.length > 1
8821
+ ? subPieces
8822
+ : hardSliceToCap(chunks[i], RICH_MESSAGE_MAX_CHARS)
8823
+ for (let p = 0; p < pieces.length; p++) {
8824
+ let sent: { message_id: number }
8825
+ if (literalText) {
8826
+ // allow-raw-bot-api: length-error re-split last resort (literal text); wrapping would re-enter the chunk-loop's own classification on an already-classified length failure.
8827
+ sent = await lockedBot.api.sendMessage(chat_id, pieces[p], opts as never)
8828
+ } else {
8829
+ const ro = { ...opts }
8830
+ delete (ro as { link_preview_options?: unknown }).link_preview_options
8831
+ // allow-raw-bot-api: length-error re-split last resort (rich); wrapping would re-enter the chunk-loop's own classification on an already-classified length failure.
8832
+ sent = await lockedBot.api.sendRichMessage(chat_id, richMessage(pieces[p]), ro as never)
8833
+ }
8834
+ sentIds.push(sent.message_id)
8835
+ logOutbound('reply', chat_id, sent.message_id, pieces[p].length, `chunk=${i + 1}/${chunks.length} resplit=${p + 1}/${pieces.length}`)
8836
+ }
8837
+ process.stderr.write(
8838
+ `telegram gateway: rich body too long — re-split chunk ${i + 1}/${chunks.length} into ${pieces.length} piece(s)\n`,
8658
8839
  )
8840
+ }
8841
+
8842
+ try {
8843
+ const sent = await robustApiCall(() => sendChunk(sendOpts), { threadId, chat_id })
8659
8844
  sentIds.push(sent.message_id)
8660
8845
  logOutbound('reply', chat_id, sent.message_id, chunks[i].length, `chunk=${i + 1}/${chunks.length}`)
8661
8846
  } catch (err) {
@@ -8664,14 +8849,17 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8664
8849
  const retryOpts = { ...sendOpts }
8665
8850
  delete (retryOpts as any).message_thread_id
8666
8851
  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)
8852
+ const sent = await sendChunk(retryOpts)
8669
8853
  sentIds.push(sent.message_id)
8670
8854
  } catch (retryErr) {
8671
- // Thread dropped, but the HTML is also unparseable — go plain.
8672
- if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
8855
+ // Thread dropped, AND another failure: length → re-split,
8856
+ // parse-reject → plain text, else propagate.
8857
+ if (isMessageTooLongError(retryErr)) await sendChunkResplit(retryOpts)
8858
+ else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
8673
8859
  else throw retryErr
8674
8860
  }
8861
+ } else if (isMessageTooLongError(err)) {
8862
+ await sendChunkResplit(sendOpts)
8675
8863
  } else if (isHtmlParseRejectError(err)) {
8676
8864
  await sendChunkPlainText(sendOpts)
8677
8865
  } else {
@@ -8716,7 +8904,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
8716
8904
  pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
8717
8905
  messageId: anchorMsgId,
8718
8906
  text: chunks[chunks.length - 1],
8719
- parseMode,
8907
+ literalText,
8720
8908
  })
8721
8909
  }
8722
8910
  }
@@ -9161,7 +9349,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9161
9349
  ...(streamReplyMarkup != null ? { reply_markup: streamReplyMarkup } : {}),
9162
9350
  ...(args.disable_notification === true ? { disable_notification: true } : {}),
9163
9351
  },
9164
- { activeDraftStreams, activeDraftParseModes, suppressPtyPreview },
9352
+ { activeDraftStreams, suppressPtyPreview },
9165
9353
  {
9166
9354
  // grammy's Bot<Context, Api<RawApi>> has a wider api shape than the
9167
9355
  // local StreamBotApi interface, but is structurally compatible at
@@ -9169,9 +9357,8 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9169
9357
  // bypass the structural-typing strictness.
9170
9358
  bot: lockedBot as unknown as { api: import('../stream-controller.js').StreamBotApi },
9171
9359
  retry: robustApiCall,
9172
- markdownToHtml,
9173
- escapeMarkdownV2,
9174
9360
  repairEscapedWhitespace,
9361
+ normalizeParagraphBreaks,
9175
9362
  assertAllowedChat,
9176
9363
  resolveThreadId,
9177
9364
  disableLinkPreview: access.disableLinkPreview !== false,
@@ -9257,16 +9444,11 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9257
9444
  // async dispatch, the framework edits THIS message in place at
9258
9445
  // intervals.
9259
9446
  //
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.
9447
+ // #2669 — capture whether the stream-reply-handler sent literally
9448
+ // (format:'text') so the cross-turn edit tick re-edits with the same
9449
+ // shape. The default rich-markdown path re-edits via the rich path.
9265
9450
  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
9451
+ const streamLiteralText = streamFormat === 'text'
9270
9452
  // #1760 primary fix — clear any stale prior-turn ticker before
9271
9453
  // re-anchoring on stream_reply done. See the matching comment at
9272
9454
  // the executeReply finalize site.
@@ -9274,7 +9456,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
9274
9456
  pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
9275
9457
  messageId: result.messageId,
9276
9458
  text: args.text as string,
9277
- parseMode: streamParseMode,
9459
+ literalText: streamLiteralText,
9278
9460
  })
9279
9461
  }
9280
9462
  // #1664 — mark the turn's final answer as delivered. For stream_reply a
@@ -9434,19 +9616,22 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
9434
9616
  // Otherwise fall through to message-send below.
9435
9617
  }
9436
9618
 
9437
- // Send plain message (no quote-reply)
9619
+ // Send plain message (no quote-reply). Single rich-markdown path (#2669):
9620
+ // `parseMode:'text'` config opts into a literal plain send; everything
9621
+ // else ships raw GFM markdown via sendRichMessage.
9438
9622
  const access = loadAccess()
9439
- const configParseMode = access.parseMode ?? 'html'
9440
- const parseMode = configParseMode === 'html' ? 'HTML' : undefined
9441
- const effectiveText = configParseMode === 'html' ? markdownToHtml(text) : text
9623
+ const literalText = (access.parseMode ?? 'html') === 'text'
9442
9624
 
9443
9625
  const sendOpts = {
9444
- ...(parseMode ? { parse_mode: parseMode } : {}),
9445
9626
  ...(threadId != null ? { message_thread_id: threadId } : {}),
9446
9627
  }
9447
9628
 
9448
9629
  const sent = await robustApiCall(
9449
- () => lockedBot.api.sendMessage(chat_id, effectiveText, sendOpts as never),
9630
+ (): Promise<{ message_id: number }> =>
9631
+ literalText
9632
+ // allow-raw-bot-api: literal progress-update send routed through robustApiCall
9633
+ ? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
9634
+ : lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
9450
9635
  { verb: 'sendMessage', chat_id, threadId },
9451
9636
  )
9452
9637
 
@@ -9537,7 +9722,6 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
9537
9722
  // alive (button tap turns into "✅ <choice>" inline) rather than a
9538
9723
  // graveyard of unanswered questions in the chat history.
9539
9724
  const sendOpts: Record<string, unknown> = {
9540
- parse_mode: 'HTML',
9541
9725
  reply_markup: keyboard,
9542
9726
  }
9543
9727
  if (threadId != null) sendOpts.message_thread_id = threadId
@@ -9553,7 +9737,7 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
9553
9737
  const opts = { ...sendOpts }
9554
9738
  if (tid == null) delete opts.message_thread_id
9555
9739
  else opts.message_thread_id = tid
9556
- return lockedBot.api.sendMessage(args.chatId, args.question, opts)
9740
+ return lockedBot.api.sendRichMessage(args.chatId, richMessage(args.question), opts)
9557
9741
  },
9558
9742
  { threadId, chat_id: args.chatId, verb: 'ask_user.sendMessage' },
9559
9743
  )
@@ -9581,8 +9765,7 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
9581
9765
  lockedBot.api.editMessageText(
9582
9766
  args.chatId,
9583
9767
  entry.messageId!,
9584
- `${args.question}\n\n<i>⏱ no response within ${Math.round(args.timeoutMs / 1000)}s</i>`,
9585
- { parse_mode: 'HTML' },
9768
+ richMessage(`${args.question}\n\n_⏱ no response within ${Math.round(args.timeoutMs / 1000)}s_`),
9586
9769
  ),
9587
9770
  {
9588
9771
  chat_id: args.chatId,
@@ -9816,13 +9999,13 @@ function buildVaultRequestSaveKeyboard(stageId: string): { inline_keyboard: Arra
9816
9999
 
9817
10000
  function renderVaultRequestSaveCard(req: PendingVaultRequestSave, agentSlug: string): string {
9818
10001
  const lines: string[] = []
9819
- lines.push(`🔐 <b>${escapeHtmlForTg(agentSlug)}</b> wants to save a secret`)
9820
- lines.push(`key: <code>${escapeHtmlForTg(req.key)}</code>`)
10002
+ lines.push(`🔐 **${escapeHtmlForTg(agentSlug)}** wants to save a secret`)
10003
+ lines.push(`key: \`${req.key}\``)
9821
10004
  if (req.why && req.why.length > 0) {
9822
- lines.push(`why: <i>${escapeHtmlForTg(req.why)}</i>`)
10005
+ lines.push(`why: _${escapeHtmlForTg(req.why)}_`)
9823
10006
  }
9824
10007
  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>`)
10008
+ 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
10009
  return lines.join('\n')
9827
10010
  }
9828
10011
 
@@ -9885,8 +10068,7 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
9885
10068
  const sent = await retryWithThreadFallback<{ message_id: number }>(
9886
10069
  robustApiCall,
9887
10070
  (tid) =>
9888
- lockedBot.api.sendMessage(chat_id, text, {
9889
- parse_mode: 'HTML',
10071
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
9890
10072
  reply_markup: buildVaultRequestSaveKeyboard(stageId),
9891
10073
  ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
9892
10074
  }),
@@ -9959,13 +10141,13 @@ function buildSecretRequestKeyboard(stageId: string): { inline_keyboard: Array<A
9959
10141
 
9960
10142
  function renderSecretRequestCard(req: PendingSecretRequest): string {
9961
10143
  const lines: string[] = [
9962
- `🔒 <b>${escapeHtmlForTg(req.agent)}</b> needs a secret:`,
9963
- `<code>${escapeHtmlForTg(req.key)}</code>`,
10144
+ `🔒 **${escapeHtmlForTg(req.agent)}** needs a secret:`,
10145
+ `\`${req.key}\``,
9964
10146
  ]
9965
- if (req.reason) lines.push(`<i>${escapeHtmlForTg(req.reason)}</i>`)
10147
+ if (req.reason) lines.push(`_${escapeHtmlForTg(req.reason)}_`)
9966
10148
  lines.push(
9967
10149
  '',
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.',
10150
+ '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
10151
  )
9970
10152
  return lines.join('\n')
9971
10153
  }
@@ -10005,8 +10187,7 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
10005
10187
  const sent = await retryWithThreadFallback<{ message_id: number }>(
10006
10188
  robustApiCall,
10007
10189
  (tid) =>
10008
- lockedBot.api.sendMessage(chat_id, text, {
10009
- parse_mode: 'HTML',
10190
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
10010
10191
  reply_markup: buildSecretRequestKeyboard(stageId),
10011
10192
  ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
10012
10193
  }),
@@ -10069,8 +10250,8 @@ async function captureProvidedSecret(
10069
10250
  if (!write.ok) {
10070
10251
  const parsed = parseVaultCliError(write.output)
10071
10252
  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 })
10253
+ const body = rendered.suppressRaw ? rendered.html : `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
10254
+ await switchroomReply(ctx, `${body}\n\n_The secret was NOT saved. The agent can re-request with \`request_secret\`._`, { html: true })
10074
10255
  // Wake the agent too (review #2047 finding #3): it ended its turn
10075
10256
  // waiting for a synthetic inbound; without this it stalls silently.
10076
10257
  const fts = Date.now()
@@ -10097,7 +10278,7 @@ async function captureProvidedSecret(
10097
10278
 
10098
10279
  await switchroomReply(
10099
10280
  ctx,
10100
- `✅ saved as <code>vault:${escapeHtmlForTg(armed.key)}</code> (masked: <code>${escapeHtmlForTg(maskToken(value))}</code>). The agent can now reference it.`,
10281
+ `✅ saved as \`vault:${escapeHtmlForTg(armed.key)}\` (masked: \`${escapeHtmlForTg(maskToken(value))}\`). The agent can now reference it.`,
10101
10282
  { html: true },
10102
10283
  )
10103
10284
 
@@ -10166,8 +10347,8 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
10166
10347
  .editMessageText(
10167
10348
  pending.chat_id,
10168
10349
  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: [] } },
10350
+ 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.`),
10351
+ { reply_markup: { inline_keyboard: [] } },
10171
10352
  )
10172
10353
  .catch(() => {})
10173
10354
  }
@@ -10180,8 +10361,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
10180
10361
  await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
10181
10362
  if (pending.card_message_id != null) {
10182
10363
  await ctx.api
10183
- .editMessageText(pending.chat_id, pending.card_message_id, `🚫 Declined — <code>${escapeHtmlForTg(pending.key)}</code> not provided.`, {
10184
- parse_mode: 'HTML',
10364
+ .editMessageText(pending.chat_id, pending.card_message_id, richMessage(`🚫 Declined — \`${pending.key}\` not provided.`), {
10185
10365
  reply_markup: { inline_keyboard: [] },
10186
10366
  })
10187
10367
  .catch(() => {})
@@ -10233,9 +10413,9 @@ function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
10233
10413
  const scopeLabel = req.scope === 'write' ? 'write' : 'read'
10234
10414
  const days = Math.round(req.ttl_seconds / 86400)
10235
10415
  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>`)
10416
+ lines.push(`🔐 **${escapeHtmlForTg(req.agent)}** wants vault access`)
10417
+ lines.push(`key: \`${req.key}\``)
10418
+ lines.push(`scope: \`${scopeLabel}\` · duration: \`${durationLabel}\``)
10239
10419
  // #1790 — always render the why-line, even when the agent omitted
10240
10420
  // `reason`. Rendering "not provided" makes a missing rationale
10241
10421
  // visibly an agent-side failure (the tool description nudges the
@@ -10244,12 +10424,12 @@ function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
10244
10424
  // template choice, which the operator couldn't tell apart from a
10245
10425
  // legitimate "no reason needed" case.
10246
10426
  if (req.reason && req.reason.length > 0) {
10247
- lines.push(`why: <i>${escapeHtmlForTg(req.reason)}</i>`)
10427
+ lines.push(`why: _${escapeHtmlForTg(req.reason)}_`)
10248
10428
  } else {
10249
- lines.push(`why: <i>not provided</i>`)
10429
+ lines.push(`why: _not provided_`)
10250
10430
  }
10251
10431
  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>`)
10432
+ 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
10433
  return lines.join('\n')
10254
10434
  }
10255
10435
 
@@ -10355,8 +10535,7 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
10355
10535
  const sent = await retryWithThreadFallback<{ message_id: number }>(
10356
10536
  robustApiCall,
10357
10537
  (tid) =>
10358
- lockedBot.api.sendMessage(chat_id, text, {
10359
- parse_mode: 'HTML',
10538
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
10360
10539
  reply_markup: buildVaultRequestAccessKeyboard(stageId),
10361
10540
  ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
10362
10541
  }),
@@ -10425,9 +10604,15 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10425
10604
  if (args.text == null || args.text === '') throw new Error('edit_message: text is required and cannot be empty')
10426
10605
  assertAllowedChat(String(args.chat_id ?? ''))
10427
10606
  const editAccess = loadAccess()
10428
- const editConfigMode = editAccess.parseMode ?? 'html'
10429
- const editFormat = (args.format as string | undefined) ?? editConfigMode
10607
+ const editFormat = (args.format as string | undefined) ?? (editAccess.parseMode ?? 'html')
10608
+ // Single rich-markdown path (#2669): `format:'text'` edits as a literal
10609
+ // plain string; everything else edits via the rich-markdown path.
10610
+ const editLiteralText = editFormat === 'text'
10611
+ // Match the reply path: repair JSON-escape bungles, then promote lone prose
10612
+ // paragraph breaks for the rich path. A literal-text edit (`format:'text'`)
10613
+ // skips paragraph normalization — it must edit byte-for-byte as given.
10430
10614
  let editRawText = repairEscapedWhitespace(args.text as string)
10615
+ if (!editLiteralText) editRawText = normalizeParagraphBreaks(editRawText)
10431
10616
  // Outbound secret scrub (#2044): an edit must not re-introduce a raw
10432
10617
  // secret into a live bubble or the history row. Mask before scrub/send.
10433
10618
  editRawText = redactOutboundText(editRawText, 'edit_message')
@@ -10447,22 +10632,10 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
10447
10632
  })
10448
10633
  }
10449
10634
  }
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
10635
  const edited = await robustApiCall(
10463
10636
  () => lockedBot.api.editMessageText(
10464
- String(args.chat_id ?? ''), Number(args.message_id), editText,
10465
- ...(editParseMode ? [{ parse_mode: editParseMode }] : []),
10637
+ String(args.chat_id ?? ''), Number(args.message_id),
10638
+ editLiteralText ? editRawText : richMessage(editRawText),
10466
10639
  ),
10467
10640
  )
10468
10641
  const id = typeof edited === 'object' && edited ? (edited as any).message_id : args.message_id
@@ -10696,7 +10869,6 @@ function closeActivityLane(chatId: string, threadId: number | undefined): void {
10696
10869
  const stream = activeDraftStreams.get(key)
10697
10870
  if (stream == null) return
10698
10871
  activeDraftStreams.delete(key)
10699
- activeDraftParseModes.delete(key)
10700
10872
  void stream.finalize().catch(() => {})
10701
10873
  }
10702
10874
 
@@ -10708,7 +10880,6 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
10708
10880
  for (const [key, stream] of activeDraftStreams) {
10709
10881
  if (key.startsWith(prefix)) {
10710
10882
  activeDraftStreams.delete(key)
10711
- activeDraftParseModes.delete(key)
10712
10883
  void stream.finalize().catch(() => {})
10713
10884
  }
10714
10885
  }
@@ -10916,7 +11087,7 @@ async function drainActivitySummary(
10916
11087
  break
10917
11088
  }
10918
11089
  // `renderActivityFeed` already emitted ready Telegram HTML with per-line
10919
- // markup (<b>→ current</b> / <i>✓ done</i>) and escaped each label's
11090
+ // markup (**→ current** / _✓ done_) and escaped each label's
10920
11091
  // <,>,& itself (#1942 class) — send verbatim, do NOT re-escape or
10921
11092
  // re-wrap (double-escaping would surface literal tags).
10922
11093
  const html = target
@@ -10932,9 +11103,9 @@ async function drainActivitySummary(
10932
11103
  try {
10933
11104
  if (turn.activityMessageId == null) {
10934
11105
  const sent = await robustApiCall(
10935
- () => bot.api.sendMessage(chat, html, {
11106
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
11107
+ () => bot.api.sendRichMessage(chat, richMessage(html), {
10936
11108
  ...(thread != null ? { message_thread_id: thread } : {}),
10937
- parse_mode: 'HTML',
10938
11109
  disable_notification: true,
10939
11110
  ...replyAnchor,
10940
11111
  }),
@@ -10945,7 +11116,7 @@ async function drainActivitySummary(
10945
11116
  } else {
10946
11117
  const id = turn.activityMessageId
10947
11118
  await robustApiCall(
10948
- () => bot.api.editMessageText(chat, id, html, { parse_mode: 'HTML' }),
11119
+ () => bot.api.editMessageText(chat, id, richMessage(html), {}),
10949
11120
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
10950
11121
  )
10951
11122
  }
@@ -11257,7 +11428,7 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
11257
11428
  if (finalHtml == null) return
11258
11429
  try {
11259
11430
  await robustApiCall(
11260
- () => bot.api.editMessageText(chat, id, finalHtml, { parse_mode: 'HTML' }),
11431
+ () => bot.api.editMessageText(chat, id, richMessage(finalHtml), {}),
11261
11432
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
11262
11433
  )
11263
11434
  } catch (err) {
@@ -11781,15 +11952,10 @@ function handleSessionEvent(ev: SessionEvent): void {
11781
11952
  // (no flash). The draft transport is permanently retired — both modes
11782
11953
  // use sendMessage + editMessageText for any message that does open.
11783
11954
  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)),
11955
+ // #2669: the answer-stream ships the RAW transcript markdown; the
11956
+ // sendMessage/editMessageText wrappers below send it via the
11957
+ // rich-message path (`sendRichMessage` / `editMessageText({ markdown })`),
11958
+ // matching every other outbound lane. No render step needed.
11793
11959
  // #1075: route through robustApiCall so flood-wait,
11794
11960
  // benign-400, and THREAD_NOT_FOUND are handled uniformly
11795
11961
  // instead of crashing the answer-stream loop on a deleted
@@ -11825,13 +11991,11 @@ function handleSessionEvent(ev: SessionEvent): void {
11825
11991
  const silent = params?.purpose !== 'materialize'
11826
11992
  const msg = await robustApiCall(
11827
11993
  () =>
11828
- bot.api.sendMessage(chatId, text, {
11829
- parse_mode: params?.parse_mode,
11994
+ // allow-raw-bot-api: sendRichMessage is not in the THREAD_NOT_FOUND blast pattern; answer-stream tolerates failures via its own try/catch
11995
+ // sendRichMessage doesn't accept link_preview_options — omit it.
11996
+ bot.api.sendRichMessage(chatId, richMessage(text), {
11830
11997
  disable_notification: silent,
11831
11998
  ...(tid != null ? { message_thread_id: tid } : {}),
11832
- ...(params?.link_preview_options != null
11833
- ? { link_preview_options: params.link_preview_options }
11834
- : {}),
11835
11999
  ...(params?.reply_parameters != null
11836
12000
  ? { reply_parameters: params.reply_parameters }
11837
12001
  : {}),
@@ -11848,8 +12012,7 @@ function handleSessionEvent(ev: SessionEvent): void {
11848
12012
  const tid = params?.message_thread_id
11849
12013
  return robustApiCall(
11850
12014
  () =>
11851
- bot.api.editMessageText(chatId, messageId, text, {
11852
- parse_mode: params?.parse_mode,
12015
+ bot.api.editMessageText(chatId, messageId, richMessage(text), {
11853
12016
  ...(tid != null ? { message_thread_id: tid } : {}),
11854
12017
  ...(params?.link_preview_options != null
11855
12018
  ? { link_preview_options: params.link_preview_options }
@@ -11945,16 +12108,16 @@ function handleSessionEvent(ev: SessionEvent): void {
11945
12108
  lastContextExhaustionWarningAt = now
11946
12109
  process.stderr.write(`telegram gateway: context exhaustion detected — notifying user\n`)
11947
12110
  const warnOpts = {
11948
- parse_mode: 'HTML' as const,
11949
12111
  ...(threadId != null ? { message_thread_id: threadId } : {}),
11950
12112
  }
11951
12113
  // #1075: thread-id-bearing, fire-and-forget — swallow on
11952
12114
  // THREAD_NOT_FOUND so a deleted topic doesn't crash the gateway.
11953
12115
  void swallowingApiCall(
11954
12116
  () =>
11955
- bot.api.sendMessage(
12117
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
12118
+ bot.api.sendRichMessage(
11956
12119
  chatId,
11957
- '⚠️ <b>Context window full</b> — send <code>/restart</code> to start a fresh session.',
12120
+ richMessage('⚠️ **Context window full** — send \`/restart\` to start a fresh session.'),
11958
12121
  warnOpts,
11959
12122
  ),
11960
12123
  {
@@ -12313,7 +12476,6 @@ function handleSessionEvent(ev: SessionEvent): void {
12313
12476
  for (const [key] of activeDraftStreams) {
12314
12477
  if (key.startsWith(suppressPrefix)) {
12315
12478
  activeDraftStreams.delete(key)
12316
- activeDraftParseModes.delete(key)
12317
12479
  }
12318
12480
  }
12319
12481
  // Unpin without editing the message so no orphaned card lingers.
@@ -12414,9 +12576,9 @@ function handleSessionEvent(ev: SessionEvent): void {
12414
12576
  // that fires when the model emits raw transcript text WITHOUT
12415
12577
  // calling reply / stream_reply. That captured text bypasses
12416
12578
  // 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.
12579
+ // via the rich-message path directly. Scrub the capturedText on the
12580
+ // raw markdown so em-dashes never reach the wire. Kill switch:
12581
+ // SWITCHROOM_DISABLE_VOICE_SCRUB.
12420
12582
  {
12421
12583
  const scrub = scrubVoice(capturedText)
12422
12584
  if (scrub.replaced > 0) {
@@ -12517,13 +12679,11 @@ function handleSessionEvent(ev: SessionEvent): void {
12517
12679
  `(chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? 'none'})\n`,
12518
12680
  )
12519
12681
  const sendOpts = {
12520
- parse_mode: 'HTML' as const,
12521
12682
  ...(backstopThreadId != null ? { message_thread_id: backstopThreadId } : {}),
12522
12683
  link_preview_options: { is_disabled: true },
12523
12684
  }
12524
- const renderedText = markdownToHtml(capturedText)
12525
- const limit = 4000
12526
- const htmlChunks = splitHtmlChunks(renderedText, limit)
12685
+ const limit = RICH_MESSAGE_MAX_CHARS
12686
+ const htmlChunks = splitMarkdownChunks(capturedText, limit)
12527
12687
  const sentIds: number[] = []
12528
12688
  try {
12529
12689
  // #654 deterministic double-message fix. If the progress
@@ -12552,7 +12712,7 @@ function handleSessionEvent(ev: SessionEvent): void {
12552
12712
  bot.api.editMessageText(
12553
12713
  backstopChatId,
12554
12714
  backstopCardMessageId,
12555
- htmlChunks[0],
12715
+ richMessage(htmlChunks[0]),
12556
12716
  sendOpts,
12557
12717
  ),
12558
12718
  {
@@ -12578,11 +12738,10 @@ function handleSessionEvent(ev: SessionEvent): void {
12578
12738
  robustApiCall,
12579
12739
  (tid) => {
12580
12740
  const opts = {
12581
- parse_mode: 'HTML' as const,
12582
12741
  link_preview_options: { is_disabled: true },
12583
12742
  ...(tid != null ? { message_thread_id: tid } : {}),
12584
12743
  }
12585
- return bot.api.sendMessage(backstopChatId, c, opts)
12744
+ return bot.api.sendRichMessage(backstopChatId, richMessage(c), opts)
12586
12745
  },
12587
12746
  {
12588
12747
  threadId: liveThreadId,
@@ -12832,7 +12991,6 @@ function handlePtyPartial(text: string): void {
12832
12991
  currentSessionThreadId: turn?.sessionThreadId,
12833
12992
  pendingPtyPartial: pendingPtyPartial != null ? { text: pendingPtyPartial } : null,
12834
12993
  activeDraftStreams,
12835
- activeDraftParseModes,
12836
12994
  suppressPtyPreview,
12837
12995
  lastPtyPreviewByChat,
12838
12996
  }
@@ -12841,7 +12999,6 @@ function handlePtyPartial(text: string): void {
12841
12999
  // interface; runtime-compatible. Cast through never.
12842
13000
  bot: bot as never,
12843
13001
  retry: robustApiCall,
12844
- renderText: markdownToHtml,
12845
13002
  logEvent: logStreamingEvent,
12846
13003
  onStreamSend: (chatId, messageId, charCount) => {
12847
13004
  logOutbound('pty_preview', chatId, messageId, charCount, 'initial_send')
@@ -12876,13 +13033,11 @@ function handlePtyActivity(text: string): void {
12876
13033
  format: 'text',
12877
13034
  lane: 'activity',
12878
13035
  },
12879
- { activeDraftStreams, activeDraftParseModes },
13036
+ { activeDraftStreams },
12880
13037
  {
12881
13038
  // See StreamBotApi cast comment above (line ~2522 area).
12882
13039
  bot: bot as never,
12883
13040
  retry: robustApiCall,
12884
- markdownToHtml,
12885
- escapeMarkdownV2,
12886
13041
  repairEscapedWhitespace,
12887
13042
  assertAllowedChat,
12888
13043
  resolveThreadId,
@@ -13573,9 +13728,9 @@ async function handleInbound(
13573
13728
  cleanAuthAddScratchDir(pendingAdd.scratchDir)
13574
13729
  await switchroomReply(
13575
13730
  ctx,
13576
- `✓ Account <code>${escapeHtmlForTg(pendingAdd.label)}</code> added.\n` +
13731
+ `✓ Account \`${pendingAdd.label}\` added.\n` +
13577
13732
  `The fleet's active account hasn't changed. Send ` +
13578
- `<code>/auth use ${escapeHtmlForTg(pendingAdd.label)}</code> to switch to it.`,
13733
+ `\`/auth use ${escapeHtmlForTg(pendingAdd.label)}\` to switch to it.`,
13579
13734
  { html: true },
13580
13735
  )
13581
13736
  } catch (brokerErr) {
@@ -13585,7 +13740,7 @@ async function handleInbound(
13585
13740
  cleanAuthAddScratchDir(pendingAdd.scratchDir)
13586
13741
  await switchroomReply(
13587
13742
  ctx,
13588
- `<b>/auth add failed at broker:</b> ${escapeHtmlForTg((brokerErr as Error)?.message ?? String(brokerErr))}`,
13743
+ `**/auth add failed at broker:** ${escapeHtmlForTg((brokerErr as Error)?.message ?? String(brokerErr))}`,
13589
13744
  { html: true },
13590
13745
  )
13591
13746
  }
@@ -13594,7 +13749,7 @@ async function handleInbound(
13594
13749
  // failure paths (timeout, child exit, stdin broken).
13595
13750
  await switchroomReply(
13596
13751
  ctx,
13597
- `<b>/auth add code failed:</b> ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
13752
+ `**/auth add code failed:** ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
13598
13753
  { html: true },
13599
13754
  )
13600
13755
  }
@@ -13616,7 +13771,7 @@ async function handleInbound(
13616
13771
  pendingReauthFlows.delete(interceptKey)
13617
13772
  const { result, errorText } = execAuthCode(pendingReauth.agent, text.trim())
13618
13773
  if (errorText) {
13619
- await switchroomReply(ctx, `<b>auth code failed:</b>\n${preBlock(formatSwitchroomOutput(errorText))}`, { html: true })
13774
+ await switchroomReply(ctx, `**auth code failed:**\n${preBlock(formatSwitchroomOutput(errorText))}`, { html: true })
13620
13775
  } else if (result) {
13621
13776
  const outcomeMsg = renderAuthCodeOutcome(result.outcome)
13622
13777
  if (outcomeMsg) {
@@ -13668,7 +13823,7 @@ async function handleInbound(
13668
13823
  if (result.ok) {
13669
13824
  await switchroomReply(ctx, '🔓 Vault broker unlocked.', { html: true })
13670
13825
  } else {
13671
- await switchroomReply(ctx, `<b>vault unlock failed:</b> ${escapeHtmlForTg(result.msg ?? 'unknown error')}`, { html: true })
13826
+ await switchroomReply(ctx, `**vault unlock failed:** ${escapeHtmlForTg(result.msg ?? 'unknown error')}`, { html: true })
13672
13827
  }
13673
13828
  } else if (pendingVault.kind === 'passphrase-for-deferred') {
13674
13829
  // Issue #44: passphrase entered after tapping "🔓 Unlock vault &
@@ -13717,8 +13872,8 @@ async function handleInbound(
13717
13872
  .editMessageText(
13718
13873
  item.cardChatId,
13719
13874
  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: [] } },
13875
+ richMessage(`⌛ _Vault unlocked, but this access-request card expired or was denied before you replied. Ask the agent to re-issue if still needed._`),
13876
+ { reply_markup: { inline_keyboard: [] } },
13722
13877
  )
13723
13878
  .catch(() => {})
13724
13879
  continue
@@ -13732,7 +13887,7 @@ async function handleInbound(
13732
13887
  if (ttlSeconds === null) {
13733
13888
  // Re-set state so user can try again
13734
13889
  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 })
13890
+ await switchroomReply(ctx, '⚠️ Invalid duration. Use \`Nd\` (days) or \`Nh\` (hours), e.g. \`30d\` or \`12h\`.', { html: true })
13736
13891
  return
13737
13892
  }
13738
13893
  const newState = { ...pendingVault, ttlSeconds, awaitingCustomDuration: false }
@@ -13759,7 +13914,7 @@ async function handleInbound(
13759
13914
  if (!VAULT_KEY_REGEX.test(newKey)) {
13760
13915
  // Re-arm the pending state so the user can try again.
13761
13916
  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 })
13917
+ await switchroomReply(ctx, `⚠️ Key must match \`${VAULT_KEY_REGEX_LABEL}\`. Send a different name.`, { html: true })
13763
13918
  return
13764
13919
  }
13765
13920
  staged.key = newKey
@@ -13770,8 +13925,8 @@ async function handleInbound(
13770
13925
  .editMessageText(
13771
13926
  staged.chat_id,
13772
13927
  staged.card_message_id,
13773
- renderVaultRequestSaveCard(staged, staged.agent),
13774
- { parse_mode: 'HTML', reply_markup: buildVaultRequestSaveKeyboard(pendingVault.stageId) },
13928
+ richMessage(renderVaultRequestSaveCard(staged, staged.agent)),
13929
+ { reply_markup: buildVaultRequestSaveKeyboard(pendingVault.stageId) },
13775
13930
  )
13776
13931
  .catch(() => {})
13777
13932
  }
@@ -13806,7 +13961,7 @@ async function handleInbound(
13806
13961
  if (verb === 'stash' || verb === 'rename') {
13807
13962
  const cached = vaultPassphraseCache.get(chat_id)
13808
13963
  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 })
13964
+ 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
13965
  return
13811
13966
  }
13812
13967
  const slugBase = arg && arg.length > 0 ? arg : staged.detection.suggested_slug
@@ -13824,14 +13979,14 @@ async function handleInbound(
13824
13979
  const rendered = renderVaultCliError(parsed, { verb: 'save', key: slug })
13825
13980
  const body = rendered.suppressRaw
13826
13981
  ? rendered.html
13827
- : `<b>vault write failed:</b>\n${preBlock(write.output)}`
13982
+ : `**vault write failed:**\n${preBlock(write.output)}`
13828
13983
  await switchroomReply(ctx, body, { html: true })
13829
13984
  return
13830
13985
  }
13831
13986
  secretStaging.delete(staged.chat_id, staged.message_id)
13832
13987
  if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'stash command')
13833
13988
  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 })
13989
+ await switchroomReply(ctx, `✅ stored as \`vault:${slug}\` (masked: \`${maskToken(staged.detection.matched_text)}\`)`, { html: true })
13835
13990
  return
13836
13991
  }
13837
13992
  }
@@ -13921,11 +14076,11 @@ async function handleInbound(
13921
14076
  await deleteSensitiveMessage(chat_id, msgId, 'detected secret')
13922
14077
  }
13923
14078
  const lines = pipeRes.stored.map((s) =>
13924
- `• <code>${s.masked}</code> → <code>vault:${s.actual_slug}</code>`,
14079
+ `• \`${s.masked}\` → \`vault:${s.actual_slug}\``,
13925
14080
  )
13926
14081
  await switchroomReply(
13927
14082
  ctx,
13928
- [`🔒 captured ${pipeRes.stored.length} secret${pipeRes.stored.length === 1 ? '' : 's'}:`, ...lines, '', 'reply <code>rename X</code> or <code>forget</code>.'].join('\n'),
14083
+ [`🔒 captured ${pipeRes.stored.length} secret${pipeRes.stored.length === 1 ? '' : 's'}:`, ...lines, '', 'reply \`rename X\` or \`forget\`.'].join('\n'),
13929
14084
  { html: true },
13930
14085
  )
13931
14086
  for (const s of pipeRes.stored) {
@@ -13978,7 +14133,7 @@ async function handleInbound(
13978
14133
  const top = pipeRes.ambiguous[0]!
13979
14134
  await switchroomReply(
13980
14135
  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>.`,
14136
+ `👀 looks like a high-entropy string (rule: \`${top.rule_id}\`). reply \`stash NAME\` to store in vault, or \`ignore\`.`,
13982
14137
  { html: true },
13983
14138
  )
13984
14139
  // For ambiguous, we do NOT delete the message or rewrite — let the
@@ -14127,7 +14282,6 @@ async function handleInbound(
14127
14282
  // content bug. Delete from the map FIRST so any concurrent
14128
14283
  // reads can't see the stale stream while we await.
14129
14284
  activeDraftStreams.delete(sKey)
14130
- activeDraftParseModes.delete(sKey)
14131
14285
  await priorStream.finalize().catch(() => {})
14132
14286
  }
14133
14287
  suppressPtyPreview.delete(sKey)
@@ -14637,7 +14791,10 @@ function switchroomExecCombined(args: string[], timeoutMs = 15000): string {
14637
14791
  })
14638
14792
  }
14639
14793
 
14640
- function formatSwitchroomOutput(output: string, maxLen = 4000): string {
14794
+ // Default truncation budget for CLI output bound for Telegram. The rich-message
14795
+ // wire cap is RICH_MESSAGE_MAX_CHARS (32768) post-#2669, not the legacy 4096
14796
+ // plain-text limit. Mirrors shared/bot-runtime.ts formatSwitchroomOutput.
14797
+ function formatSwitchroomOutput(output: string, maxLen = RICH_MESSAGE_MAX_CHARS): string {
14641
14798
  const trimmed = output.trim()
14642
14799
  if (trimmed.length <= maxLen) return trimmed
14643
14800
  return trimmed.slice(0, maxLen - 20) + '\n... (truncated)'
@@ -14647,12 +14804,15 @@ function stripAnsi(text: string): string {
14647
14804
  return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
14648
14805
  }
14649
14806
 
14807
+ // #2669: escape GFM-markdown specials in dynamic values interpolated into
14808
+ // rich-message bodies (kept under the legacy name to avoid churn).
14650
14809
  function escapeHtmlForTg(text: string): string {
14651
- return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
14810
+ return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
14652
14811
  }
14653
14812
 
14813
+ // Wrap CLI/command output in a fenced code block (content is literal there).
14654
14814
  function preBlock(text: string): string {
14655
- return '<pre>' + escapeHtmlForTg(text) + '</pre>'
14815
+ return '```\n' + text.replace(/```/g, '`​``') + '\n```'
14656
14816
  }
14657
14817
 
14658
14818
  type SwitchroomReplyMarkup =
@@ -14682,11 +14842,17 @@ async function switchroomReply(
14682
14842
  ? slashCommandReplyOpts(ctx, options.classification)
14683
14843
  : {}
14684
14844
  const threadId = routedOpts.message_thread_id ?? baseThreadId
14685
- await ctx.reply(text, {
14845
+ const replyOpts = {
14686
14846
  ...(threadId != null ? { message_thread_id: threadId } : {}),
14687
- ...(options.html ? { parse_mode: 'HTML' as const, link_preview_options: { is_disabled: true } } : {}),
14688
14847
  ...(options.reply_markup ? { reply_markup: options.reply_markup } : {}),
14689
- })
14848
+ }
14849
+ // #2669: `options.html` now means "render `text` as GFM markdown via the
14850
+ // rich-message path" (legacy field name kept). Plain otherwise.
14851
+ if (options.html) {
14852
+ await ctx.replyWithRichMessage(richMessage(text), replyOpts)
14853
+ } else {
14854
+ await ctx.reply(text, replyOpts)
14855
+ }
14690
14856
  }
14691
14857
 
14692
14858
  /**
@@ -14725,10 +14891,11 @@ async function deleteSensitiveMessage(
14725
14891
  // the stderr log is the audit trail.
14726
14892
  await swallowingApiCall(
14727
14893
  () =>
14728
- bot.api.sendMessage(
14894
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
14895
+ bot.api.sendRichMessage(
14729
14896
  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' },
14897
+ 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)}_`),
14898
+ {},
14732
14899
  ),
14733
14900
  { chat_id: chatId, verb: 'deleteSensitiveMessage.warning' },
14734
14901
  )
@@ -15233,7 +15400,7 @@ function notifyDetachedFailure(
15233
15400
  clearRestartMarker()
15234
15401
  const snippet = tail ? tail.slice(-800) : '(no output captured)'
15235
15402
  const text =
15236
- `❌ <b>${escapeHtmlForTg(label)} failed</b> (exit ${code}):\n` +
15403
+ `❌ **${escapeHtmlForTg(label)} failed** (exit ${code}):\n` +
15237
15404
  preBlock(snippet)
15238
15405
  // Fire-and-forget — we're off the command-handler context and don't
15239
15406
  // have an await to block on anyway.
@@ -15242,9 +15409,7 @@ function notifyDetachedFailure(
15242
15409
  // failure handler.
15243
15410
  void swallowingApiCall(
15244
15411
  () =>
15245
- lockedBot.api.sendMessage(chatId, text, {
15246
- parse_mode: 'HTML',
15247
- link_preview_options: { is_disabled: true },
15412
+ lockedBot.api.sendRichMessage(chatId, richMessage(text), {
15248
15413
  // Detached restart/update child-failure notice — status, not
15249
15414
  // the user's answer. Silence the open ping.
15250
15415
  disable_notification: true,
@@ -15353,8 +15518,8 @@ function formatAuthOutputForTelegram(output: string): { text: string; url: strin
15353
15518
  return true
15354
15519
  })
15355
15520
  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>`
15521
+ if (line.startsWith('Started Claude auth') || line.startsWith('Auth session already running')) return `**${escapeHtmlForTg(line)}**`
15522
+ if (line.startsWith('Open this URL')) return `_${escapeHtmlForTg(line)}_`
15358
15523
  return escapeHtmlForTg(line)
15359
15524
  })
15360
15525
  // Mobile-native post-script. Two paths depending on which Anthropic
@@ -15378,11 +15543,11 @@ function formatAuthOutputForTelegram(output: string): { text: string; url: strin
15378
15543
  // the same outcome with no API constraint. See PR #30.
15379
15544
  rendered.push(
15380
15545
  '',
15381
- '👇 Tap <b>🔐 Open Claude auth</b> below, then <b>reply with the browser code</b>.',
15546
+ '👇 Tap **🔐 Open Claude auth** below, then **reply with the browser code**.',
15382
15547
  '',
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>',
15548
+ '_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
15549
  '',
15385
- `<a href="${escapeHtmlForTg(url)}">${escapeHtmlForTg(url)}</a>`,
15550
+ url,
15386
15551
  )
15387
15552
  return { text: rendered.join('\n'), url }
15388
15553
  }
@@ -15474,7 +15639,7 @@ async function runSwitchroomAuthCommand(ctx: Context, args: string[], label: str
15474
15639
  if (error.message?.includes('ENOENT')) { await switchroomReply(ctx, 'switchroom CLI not found.', { html: true }); return }
15475
15640
  if (error.message?.includes('ETIMEDOUT') || error.message?.includes('timed out')) { await switchroomReply(ctx, `${label}: timed out`); return }
15476
15641
  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 })
15642
+ await switchroomReply(ctx, `**${escapeHtmlForTg(label)} failed:**\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true })
15478
15643
  }
15479
15644
  }
15480
15645
 
@@ -15521,7 +15686,7 @@ function renderVaultOpFailure(
15521
15686
  const verb = verbLabel === 'delete' ? 'remove' : verbLabel
15522
15687
  const rendered = renderVaultCliError(parsed, { verb, key })
15523
15688
  if (rendered.suppressRaw) return rendered.html
15524
- return `<b>vault ${verbLabel} failed:</b>\n${preBlock(cliOutput)}`
15689
+ return `**vault ${verbLabel} failed:**\n${preBlock(cliOutput)}`
15525
15690
  }
15526
15691
 
15527
15692
  async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' | 'set' | 'delete', key: string | undefined, passphrase: string, setValue: string | undefined): Promise<void> {
@@ -15530,24 +15695,24 @@ async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' |
15530
15695
  if (!r.ok) { await switchroomReply(ctx, renderVaultOpFailure('list', r.output, undefined), { html: true }); return }
15531
15696
  const keys = r.output.split('\n').filter(Boolean)
15532
15697
  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 }) }
15698
+ else { await switchroomReply(ctx, `**Vault keys (${keys.length}):**\n${keys.map(k => `• \`${k}\``).join('\n')}`, { html: true }) }
15534
15699
  } else if (op === 'get') {
15535
15700
  const r = runVaultCli(['get', key!], passphrase)
15536
15701
  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 })
15702
+ await switchroomReply(ctx, `\`${key!}\` =\n\`${r.output}\``, { html: true })
15538
15703
  } else if (op === 'set') {
15539
15704
  if (setValue === undefined) {
15540
15705
  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 })
15706
+ await switchroomReply(ctx, `Send the value for \`${key!}\`.`, { html: true })
15542
15707
  return
15543
15708
  }
15544
15709
  const r = runVaultCli(['set', key!], passphrase, setValue)
15545
15710
  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 }) }
15711
+ else { await switchroomReply(ctx, `✅ \`${key!}\` saved to vault.`, { html: true }) }
15547
15712
  } else if (op === 'delete') {
15548
15713
  const r = runVaultCli(['remove', key!], passphrase)
15549
15714
  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 }) }
15715
+ else { await switchroomReply(ctx, `✅ \`${key!}\` removed from vault.`, { html: true }) }
15551
15716
  }
15552
15717
  }
15553
15718
 
@@ -15598,8 +15763,8 @@ async function dispatchShortVerbViaHostd(
15598
15763
  if (hostdResp.result === 'started') {
15599
15764
  await switchroomReply(
15600
15765
  ctx,
15601
- `🔄 <b>${escapeHtmlForTg(label)}</b> dispatched via hostd ` +
15602
- `(request_id=<code>${escapeHtmlForTg(hostdResp.request_id)}</code>). ` +
15766
+ `🔄 **${escapeHtmlForTg(label)}** dispatched via hostd ` +
15767
+ `(request_id=\`${hostdResp.request_id}\`). ` +
15603
15768
  `Check audit log for completion.`,
15604
15769
  { html: true },
15605
15770
  )
@@ -15613,7 +15778,7 @@ async function dispatchShortVerbViaHostd(
15613
15778
  '(no error tail returned)'
15614
15779
  await switchroomReply(
15615
15780
  ctx,
15616
- `❌ <b>${escapeHtmlForTg(label)} failed via hostd</b> ` +
15781
+ `❌ **${escapeHtmlForTg(label)} failed via hostd** ` +
15617
15782
  `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
15618
15783
  preBlock(stripAnsi(errBody)),
15619
15784
  { html: true },
@@ -15640,7 +15805,7 @@ async function runSwitchroomCommand(
15640
15805
  if (error.message?.includes('ENOENT')) { await switchroomReply(ctx, 'switchroom CLI not found.', { html: true, classification }); return }
15641
15806
  if (error.message?.includes('ETIMEDOUT') || error.message?.includes('timed out')) { await switchroomReply(ctx, `${label}: timed out`, { classification }); return }
15642
15807
  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 })
15808
+ await switchroomReply(ctx, `**${escapeHtmlForTg(label)} failed:**\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true, classification })
15644
15809
  }
15645
15810
  }
15646
15811
 
@@ -15666,16 +15831,16 @@ function statusIcon(status: string): string {
15666
15831
  function renderAuthCodeOutcome(outcome: AuthCodeOutcome | null | undefined): string | null {
15667
15832
  if (!outcome || outcome.kind === 'success') return null
15668
15833
  const tail = outcome.paneTailText
15669
- ? `\n<i>${escapeHtmlForTg(outcome.paneTailText)}</i>`
15834
+ ? `\n_${escapeHtmlForTg(outcome.paneTailText)}_`
15670
15835
  : ''
15671
15836
  switch (outcome.kind) {
15672
15837
  case 'invalid-code':
15673
15838
  case 'expired-code':
15674
- return `Code rejected by Claude — tap <b>Restart flow</b> for a fresh URL.${tail}`
15839
+ return `Code rejected by Claude — tap **Restart flow** for a fresh URL.${tail}`
15675
15840
  case 'pane-not-ready':
15676
- return `Auth pane not ready — tap <b>Retry</b>.`
15841
+ return `Auth pane not ready — tap **Retry**.`
15677
15842
  case 'timeout':
15678
- return `Still waiting after 2 min — tap <b>Retry</b> or check <code>switchroom auth list</code>.${tail}`
15843
+ return `Still waiting after 2 min — tap **Retry** or check \`switchroom auth list\`.${tail}`
15679
15844
  }
15680
15845
  }
15681
15846
 
@@ -15727,7 +15892,7 @@ async function runSwitchroomCommandFormatted(ctx: Context, args: string[], label
15727
15892
  } catch (err: unknown) {
15728
15893
  const error = err as { stderr?: string; message?: string }
15729
15894
  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 })
15895
+ await switchroomReply(ctx, `**${escapeHtmlForTg(label)} failed:**\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true })
15731
15896
  }
15732
15897
  }
15733
15898
 
@@ -15762,8 +15927,8 @@ bot.use(async (ctx, next) => {
15762
15927
  const nameHtml = escapeHtmlForTg(myName)
15763
15928
  const notFlagged =
15764
15929
  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.`
15930
+ ? `⚠️ \`${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.)`
15931
+ : `⚠️ \`${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
15932
  if (!AGENT_ADMIN) {
15768
15933
  // Unchanged behaviour: a non-admin agent never executes admin
15769
15934
  // verbs locally and must not forward them to Claude.
@@ -15793,7 +15958,7 @@ bot.use(async (ctx, next) => {
15793
15958
  )
15794
15959
  await switchroomReply(
15795
15960
  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.`,
15961
+ `⚠️ \`${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
15962
  { html: true },
15798
15963
  )
15799
15964
  return
@@ -16018,12 +16183,12 @@ bot.command('start', async ctx => {
16018
16183
  const gated = dmCommandGate(ctx)
16019
16184
  if (!gated) return
16020
16185
  const disabled = gated.access.dmPolicy === 'disabled'
16021
- await ctx.reply(buildStartText(getMyAgentName(), disabled), { parse_mode: 'HTML' })
16186
+ await ctx.replyWithRichMessage(richMessage(buildStartText(getMyAgentName(), disabled)))
16022
16187
  })
16023
16188
 
16024
16189
  bot.command('help', async ctx => {
16025
16190
  if (!dmCommandGate(ctx)) return
16026
- await ctx.reply(buildHelpText(getMyAgentName()), { parse_mode: 'HTML' })
16191
+ await ctx.replyWithRichMessage(richMessage(buildHelpText(getMyAgentName())))
16027
16192
  })
16028
16193
 
16029
16194
  bot.command('status', async ctx => {
@@ -16038,12 +16203,12 @@ bot.command('status', async ctx => {
16038
16203
  const demo = hasDemoFlag(getCommandArgs(ctx))
16039
16204
  const userTag = from.username ? `@${from.username}` : senderId
16040
16205
  const meta = await buildAgentMetadata(getMyAgentName())
16041
- await ctx.reply(buildStatusPairedText({ user: userTag, meta, demo }), { parse_mode: 'HTML' })
16206
+ await ctx.replyWithRichMessage(richMessage(buildStatusPairedText({ user: userTag, meta, demo })))
16042
16207
  return
16043
16208
  }
16044
16209
  for (const [code, p] of Object.entries(access.pending)) {
16045
16210
  if (p.senderId === senderId) {
16046
- await ctx.reply(buildStatusPendingText(code), { parse_mode: 'HTML' })
16211
+ await ctx.replyWithRichMessage(richMessage(buildStatusPendingText(code)))
16047
16212
  return
16048
16213
  }
16049
16214
  }
@@ -16056,11 +16221,11 @@ bot.command('agents', async ctx => {
16056
16221
  type AgentListResp = { agents: Array<{ name: string; status: string; uptime: string; template: string; topic_name: string; topic_emoji?: string }> }
16057
16222
  const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
16058
16223
  if (!data) return null
16059
- if (data.agents.length === 0) return '<i>No agents defined</i>'
16060
- const lines = ['<b>Agents</b>']
16224
+ if (data.agents.length === 0) return '_No agents defined_'
16225
+ const lines = ['**Agents**']
16061
16226
  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>`)
16227
+ lines.push(`${statusIcon(a.status)} **${escapeHtmlForTg(a.name)}** · ${escapeHtmlForTg(a.status)} · ${escapeHtmlForTg(a.uptime)}`)
16228
+ lines.push(` _${escapeHtmlForTg(a.template)} → ${escapeHtmlForTg(a.topic_name)}${a.topic_emoji ? ' ' + a.topic_emoji : ''}_`)
16064
16229
  }
16065
16230
  return lines.join('\n')
16066
16231
  })
@@ -16335,8 +16500,7 @@ bot.command('restart', async ctx => {
16335
16500
  const sent = await retryWithThreadFallback<{ message_id: number }>(
16336
16501
  robustApiCall,
16337
16502
  (tid) =>
16338
- lockedBot.api.sendMessage(chatId, ackText, {
16339
- parse_mode: 'HTML', link_preview_options: { is_disabled: true },
16503
+ lockedBot.api.sendRichMessage(chatId, richMessage(ackText), {
16340
16504
  // Restart acknowledgement is a status notice — silence the
16341
16505
  // open ping (the "restarted — ready" follow-up is what matters).
16342
16506
  disable_notification: true,
@@ -16378,7 +16542,7 @@ bot.command('restart', async ctx => {
16378
16542
  clearRestartMarker()
16379
16543
  await switchroomReply(
16380
16544
  ctx,
16381
- `❌ <b>restart ${escapeHtmlForTg(name)} failed via hostd</b> ` +
16545
+ `❌ **restart ${escapeHtmlForTg(name)} failed via hostd** ` +
16382
16546
  `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
16383
16547
  preBlock(hostdResp.error ?? '(no error message)'),
16384
16548
  { html: true },
@@ -16435,7 +16599,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
16435
16599
  await switchroomReply(
16436
16600
  ctx,
16437
16601
  `/${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.`,
16602
+ `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
16603
  { html: true },
16440
16604
  )
16441
16605
  return
@@ -16446,9 +16610,9 @@ async function handleNewCommand(ctx: Context): Promise<void> {
16446
16610
  if (!isSelfTargetingCommand(name)) {
16447
16611
  await switchroomReply(
16448
16612
  ctx,
16449
- `/${kind} only supports the current agent (<b>${escapeHtmlForTg(getMyAgentName())}</b>). ` +
16613
+ `/${kind} only supports the current agent (**${escapeHtmlForTg(getMyAgentName())}**). ` +
16450
16614
  `To restart another agent with a fresh session, run /${kind} from its own topic, ` +
16451
- `or use <code>switchroom agent restart ${escapeHtmlForTg(name)}</code>.`,
16615
+ `or use \`switchroom agent restart ${escapeHtmlForTg(name)}\`.`,
16452
16616
  { html: true },
16453
16617
  )
16454
16618
  return
@@ -16460,7 +16624,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
16460
16624
  if (existing && Date.now() - existing.ts < 15_000) {
16461
16625
  await switchroomReply(
16462
16626
  ctx,
16463
- `⏳ Restart of <b>${escapeHtmlForTg(name)}</b> already in progress (${Math.round((Date.now() - existing.ts) / 1000)}s ago) — ignoring duplicate /${kind}.`,
16627
+ `⏳ Restart of **${escapeHtmlForTg(name)}** already in progress (${Math.round((Date.now() - existing.ts) / 1000)}s ago) — ignoring duplicate /${kind}.`,
16464
16628
  { html: true },
16465
16629
  )
16466
16630
  return
@@ -16479,8 +16643,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
16479
16643
  const sent = await retryWithThreadFallback<{ message_id: number }>(
16480
16644
  robustApiCall,
16481
16645
  (tid) =>
16482
- lockedBot.api.sendMessage(chatId, ackText, {
16483
- parse_mode: 'HTML', link_preview_options: { is_disabled: true },
16646
+ lockedBot.api.sendRichMessage(chatId, richMessage(ackText), {
16484
16647
  // /new /reset acknowledgement is a status notice — silence the
16485
16648
  // open ping (the post-restart greeting card is what matters).
16486
16649
  disable_notification: true,
@@ -16534,7 +16697,7 @@ async function handleNewCommand(ctx: Context): Promise<void> {
16534
16697
  clearRestartMarker()
16535
16698
  await switchroomReply(
16536
16699
  ctx,
16537
- `❌ <b>${escapeHtmlForTg(kind)} ${escapeHtmlForTg(name)} failed via hostd</b> ` +
16700
+ `❌ **${escapeHtmlForTg(kind)} ${escapeHtmlForTg(name)} failed via hostd** ` +
16538
16701
  `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
16539
16702
  preBlock(hostdResp.error ?? '(no error message)'),
16540
16703
  { html: true },
@@ -16555,7 +16718,7 @@ bot.command('update', async ctx => {
16555
16718
  await runSwitchroomCommand(ctx, ['update', '--check'], 'update --check')
16556
16719
  await switchroomReply(
16557
16720
  ctx,
16558
- 'Reply with <code>/update apply</code> to execute, or <code>/update apply --skip-images</code> to skip the image pull.',
16721
+ 'Reply with \`/update apply\` to execute, or \`/update apply --skip-images\` to skip the image pull.',
16559
16722
  { html: true },
16560
16723
  )
16561
16724
  return
@@ -16567,7 +16730,7 @@ bot.command('update', async ctx => {
16567
16730
  if (tokens[0] !== 'apply' && tokens[0] !== '--apply') {
16568
16731
  await switchroomReply(
16569
16732
  ctx,
16570
- 'Usage: <code>/update</code> (dry-run) or <code>/update apply [--skip-images] [--rebuild]</code>',
16733
+ 'Usage: \`/update\` (dry-run) or \`/update apply [--skip-images] [--rebuild]\`',
16571
16734
  { html: true },
16572
16735
  )
16573
16736
  return
@@ -16581,8 +16744,8 @@ bot.command('update', async ctx => {
16581
16744
  if (!ALLOWED_FLAGS.has(tok)) {
16582
16745
  await switchroomReply(
16583
16746
  ctx,
16584
- `Refusing to pass unknown flag: <code>${escapeHtmlForTg(tok)}</code>. ` +
16585
- `Allowed: <code>--skip-images</code>, <code>--rebuild</code>.`,
16747
+ `Refusing to pass unknown flag: \`${tok}\`. ` +
16748
+ `Allowed: \`--skip-images\`, \`--rebuild\`.`,
16586
16749
  { html: true },
16587
16750
  )
16588
16751
  return
@@ -16615,16 +16778,16 @@ bot.command('update', async ctx => {
16615
16778
  // hostd hasn't been installed yet, or its daemon is down.
16616
16779
  await switchroomReply(
16617
16780
  ctx,
16618
- `❌ <b>/update apply</b> needs <code>hostd</code>, but its socket ` +
16619
- `for agent <code>${escapeHtmlForTg(myAgentName)}</code> isn't ` +
16781
+ `❌ **/update apply** needs \`hostd\`, but its socket ` +
16782
+ `for agent \`${myAgentName}\` isn't ` +
16620
16783
  `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>, ` +
16784
+ `\`host_control.enabled\` is on in \`switchroom.yaml\`, ` +
16622
16785
  `so hostd is the expected dispatch path — but no socket at ` +
16623
- `<code>/run/switchroom/hostd/${escapeHtmlForTg(myAgentName)}/sock</code>. ` +
16786
+ `\`/run/switchroom/hostd/${escapeHtmlForTg(myAgentName)}/sock\`. ` +
16624
16787
  `On a fresh docker install this usually means hostd hasn't been ` +
16625
16788
  `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 ` +
16789
+ `Run \`switchroom hostd install\` on the host to install + ` +
16790
+ `start the daemon, then retry. Send \`/upgradestatus\` to ` +
16628
16791
  `re-check the daemon state from here.`,
16629
16792
  { html: true },
16630
16793
  )
@@ -16634,14 +16797,14 @@ bot.command('update', async ctx => {
16634
16797
  // pick one of: host CLI, or enable hostd.
16635
16798
  await switchroomReply(
16636
16799
  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 ` +
16800
+ `❌ **/update apply** needs docker access from inside the agent ` +
16801
+ `container, but it's not available (no \`docker\` binary on ` +
16802
+ `PATH, no \`/var/run/docker.sock\` mount) and ` +
16803
+ `\`host_control.enabled\` is off.\n\n` +
16804
+ `Either run \`switchroom update\` from the host shell, or ` +
16805
+ `set \`host_control.enabled: true\` in ` +
16806
+ `\`switchroom.yaml\` and run ` +
16807
+ `\`switchroom hostd install\` on the host so this verb ` +
16645
16808
  `can dispatch through the host-side daemon.`,
16646
16809
  { html: true },
16647
16810
  )
@@ -16671,9 +16834,9 @@ bot.command('update', async ctx => {
16671
16834
  // /restart handler (gateway.ts ~6273) so the boot-card lookup
16672
16835
  // (gateway.ts ~10393) finds chat_id + ack_message_id in the marker.
16673
16836
  const ackText =
16674
- `🚀 <b>update started</b> — running ${[
16675
- '<code>switchroom update</code>',
16676
- ...passthrough.map((t) => `<code>${escapeHtmlForTg(t)}</code>`),
16837
+ `🚀 **update started** — running ${[
16838
+ '\`switchroom update\`',
16839
+ ...passthrough.map((t) => `\`${t}\``),
16677
16840
  ].join(' ')}\n` +
16678
16841
  `\nThe gateway will restart as part of the recreate step; watch ` +
16679
16842
  `for the post-restart greeting card to confirm completion.`
@@ -16683,9 +16846,7 @@ bot.command('update', async ctx => {
16683
16846
  const sent = await retryWithThreadFallback<{ message_id: number }>(
16684
16847
  robustApiCall,
16685
16848
  (tid) =>
16686
- lockedBot.api.sendMessage(chatId, ackText, {
16687
- parse_mode: 'HTML',
16688
- link_preview_options: { is_disabled: true },
16849
+ lockedBot.api.sendRichMessage(chatId, richMessage(ackText), {
16689
16850
  // "update started" acknowledgement is a status notice — silence
16690
16851
  // the open ping (the post-restart greeting card is what matters).
16691
16852
  disable_notification: true,
@@ -16778,15 +16939,14 @@ bot.command('update', async ctx => {
16778
16939
  terminal.stdout_tail ??
16779
16940
  '(no error tail returned)'
16780
16941
  const editedText =
16781
- `🚀 <b>update started</b> — <b>FAILED</b> via hostd ` +
16942
+ `🚀 **update started** — **FAILED** via hostd ` +
16782
16943
  `(result=${escapeHtmlForTg(terminal.result)}):\n` +
16783
16944
  preBlock(errBody)
16784
16945
  if (ackId != null) {
16785
16946
  try {
16786
16947
  await robustApiCall(
16787
16948
  () =>
16788
- lockedBot.api.editMessageText(chatId, ackId!, editedText, {
16789
- parse_mode: 'HTML',
16949
+ lockedBot.api.editMessageText(chatId, ackId!, richMessage(editedText), {
16790
16950
  link_preview_options: { is_disabled: true },
16791
16951
  }),
16792
16952
  { verb: 'update.poll.editAck' },
@@ -16814,7 +16974,7 @@ bot.command('update', async ctx => {
16814
16974
  clearRestartMarker()
16815
16975
  await switchroomReply(
16816
16976
  ctx,
16817
- `❌ <b>/update apply failed via hostd</b> ` +
16977
+ `❌ **/update apply failed via hostd** ` +
16818
16978
  `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
16819
16979
  preBlock(hostdResp.error ?? '(no error message)'),
16820
16980
  { html: true },
@@ -16841,8 +17001,8 @@ bot.command('upgrade', async ctx => {
16841
17001
  if (!isAuthorizedSender(ctx)) return
16842
17002
  await switchroomReply(
16843
17003
  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.',
17004
+ 'Did you mean \`/upgradestatus\` (no hyphen — Telegram slash-command grammar)? ' +
17005
+ 'Or \`/update\` to plan, \`/update apply\` to execute.',
16846
17006
  { html: true },
16847
17007
  )
16848
17008
  })
@@ -16858,7 +17018,7 @@ bot.command('audit', async ctx => {
16858
17018
  if (arg === '' || arg === 'help' || arg === '--help') {
16859
17019
  await switchroomReply(
16860
17020
  ctx,
16861
- 'Usage: <code>/audit hostd [--tail N] [--agent &lt;name&gt;] [--op &lt;verb&gt;] [--error] [--verbose]</code>',
17021
+ 'Usage: \`/audit hostd [--tail N] [--agent <name>] [--op <verb>] [--error] [--verbose]\`',
16862
17022
  { html: true },
16863
17023
  )
16864
17024
  return
@@ -16868,8 +17028,8 @@ bot.command('audit', async ctx => {
16868
17028
  if (sub !== 'hostd') {
16869
17029
  await switchroomReply(
16870
17030
  ctx,
16871
- `Unknown audit target <code>${escapeHtmlForTg(sub ?? '')}</code>. ` +
16872
- `Supported: <code>hostd</code>.`,
17031
+ `Unknown audit target \`${sub ?? ''}\`. ` +
17032
+ `Supported: \`hostd\`.`,
16873
17033
  { html: true },
16874
17034
  )
16875
17035
  return
@@ -16889,22 +17049,22 @@ bot.command('audit', async ctx => {
16889
17049
  if (t === '--tail' || t === '--agent' || t === '--op') {
16890
17050
  const v = tokens[++i]
16891
17051
  if (v == null) {
16892
- await switchroomReply(ctx, `Flag <code>${t}</code> requires a value.`, { html: true })
17052
+ await switchroomReply(ctx, `Flag \`${t}\` requires a value.`, { html: true })
16893
17053
  return
16894
17054
  }
16895
17055
  if (t === '--tail' && !/^[0-9]{1,4}$/.test(v)) {
16896
- await switchroomReply(ctx, `<code>--tail</code> must be an integer (1-9999).`, { html: true })
17056
+ await switchroomReply(ctx, `\`--tail\` must be an integer (1-9999).`, { html: true })
16897
17057
  return
16898
17058
  }
16899
17059
  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 })
17060
+ await switchroomReply(ctx, `\`--agent\` name has an invalid shape.`, { html: true })
16901
17061
  return
16902
17062
  }
16903
17063
  if (t === '--op' && !ALLOWED_OPS.has(v)) {
16904
17064
  await switchroomReply(
16905
17065
  ctx,
16906
- `Unknown hostd verb <code>${escapeHtmlForTg(v)}</code>. ` +
16907
- `Known: ${[...ALLOWED_OPS].sort().map(o => `<code>${o}</code>`).join(', ')}.`,
17066
+ `Unknown hostd verb \`${v}\`. ` +
17067
+ `Known: ${[...ALLOWED_OPS].sort().map(o => `\`${o}\``).join(', ')}.`,
16908
17068
  { html: true },
16909
17069
  )
16910
17070
  return
@@ -16914,8 +17074,8 @@ bot.command('audit', async ctx => {
16914
17074
  }
16915
17075
  await switchroomReply(
16916
17076
  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>.`,
17077
+ `Unknown flag \`${t}\`. ` +
17078
+ `Allowed: \`--tail\`, \`--agent\`, \`--op\`, \`--error\`, \`--verbose\`.`,
16919
17079
  { html: true },
16920
17080
  )
16921
17081
  return
@@ -16964,7 +17124,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
16964
17124
  if (!details) {
16965
17125
  await switchroomReply(
16966
17126
  ctx,
16967
- `No pending permission for id <code>${escapeHtmlForTg(request_id)}</code>. It may have already been answered or timed out.`,
17127
+ `No pending permission for id \`${request_id}\`. It may have already been answered or timed out.`,
16968
17128
  { html: true },
16969
17129
  )
16970
17130
  return
@@ -16983,10 +17143,10 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
16983
17143
  `[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
16984
17144
  )
16985
17145
  const lbl = behavior === 'allow' ? '✅ Allowed' : '❌ Denied'
16986
- const suffix = details.tool_name ? ` (<code>${escapeHtmlForTg(details.tool_name)}</code>)` : ''
17146
+ const suffix = details.tool_name ? ` (\`${details.tool_name}\`)` : ''
16987
17147
  await switchroomReply(
16988
17148
  ctx,
16989
- `${lbl}${suffix} via /${behavior} <code>${escapeHtmlForTg(request_id)}</code>`,
17149
+ `${lbl}${suffix} via /${behavior} \`${request_id}\``,
16990
17150
  { html: true },
16991
17151
  )
16992
17152
  }
@@ -17064,9 +17224,9 @@ bot.command('pending', async ctx => {
17064
17224
  await switchroomReply(ctx, 'No pending permission prompts.')
17065
17225
  return
17066
17226
  }
17067
- const lines: string[] = ['<b>Pending permission prompts</b>']
17227
+ const lines: string[] = ['**Pending permission prompts**']
17068
17228
  for (const [id, details] of pendingPermissions.entries()) {
17069
- lines.push(`• <code>${escapeHtmlForTg(id)}</code> — ${escapeHtmlForTg(details.tool_name)}`)
17229
+ lines.push(`• \`${id}\` — ${escapeHtmlForTg(details.tool_name)}`)
17070
17230
  }
17071
17231
  await switchroomReply(ctx, lines.join('\n'), { html: true })
17072
17232
  })
@@ -17266,7 +17426,8 @@ function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): vo
17266
17426
  const html = renderFallbackFailureNotice(triggerAgent, reason)
17267
17427
  for (const chat_id of access.allowFrom) {
17268
17428
  void swallowingApiCall(
17269
- () => bot.api.sendMessage(chat_id, html, { parse_mode: 'HTML' as const }),
17429
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
17430
+ () => bot.api.sendRichMessage(chat_id, richMessage(html), {}),
17270
17431
  { chat_id, verb: 'fleet-fallback:failure-notify' },
17271
17432
  )
17272
17433
  }
@@ -17356,10 +17517,11 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
17356
17517
  if (access.allowFrom.length === 0) return outcome.kind === 'switched'
17357
17518
  // Account-switch / all-blocked announcement is a system status notice,
17358
17519
  // not the user's answer — silence the open ping.
17359
- const opts = { parse_mode: 'HTML' as const, disable_notification: true }
17520
+ const opts = { disable_notification: true }
17360
17521
  for (const chat_id of access.allowFrom) {
17361
17522
  void swallowingApiCall(
17362
- () => bot.api.sendMessage(chat_id, outcome.announcement, opts),
17523
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
17524
+ () => bot.api.sendRichMessage(chat_id, richMessage(outcome.announcement), opts),
17363
17525
  { chat_id, verb: 'fleet-fallback:notify' },
17364
17526
  )
17365
17527
  }
@@ -17447,9 +17609,7 @@ async function runCreditWatch(): Promise<void> {
17447
17609
  // contract enforced by scripts/check-bot-api-wrapping.sh (#1075).
17448
17610
  await swallowingApiCall(
17449
17611
  () =>
17450
- bot.api.sendMessage(chat_id, decision.message, {
17451
- parse_mode: 'HTML',
17452
- link_preview_options: { is_disabled: true },
17612
+ bot.api.sendRichMessage(chat_id, richMessage(decision.message), {
17453
17613
  // Credit/quota warning is a system status notice — silence the
17454
17614
  // open ping (the user isn't waiting to tap anything).
17455
17615
  disable_notification: true,
@@ -17589,9 +17749,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
17589
17749
  }
17590
17750
  await swallowingApiCall(
17591
17751
  () =>
17592
- bot.api.sendMessage(chat_id, fleetDecision.message, {
17593
- parse_mode: 'HTML',
17594
- link_preview_options: { is_disabled: true },
17752
+ bot.api.sendRichMessage(chat_id, richMessage(fleetDecision.message), {
17595
17753
  // Quota status notice — silence the open ping.
17596
17754
  disable_notification: true,
17597
17755
  }),
@@ -17812,9 +17970,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
17812
17970
  // contract enforced by scripts/check-bot-api-wrapping.sh (#1075).
17813
17971
  await swallowingApiCall(
17814
17972
  () =>
17815
- bot.api.sendMessage(chat_id, message, {
17816
- parse_mode: 'HTML',
17817
- link_preview_options: { is_disabled: true },
17973
+ bot.api.sendRichMessage(chat_id, richMessage(message), {
17818
17974
  // Quota throttling status notice — silence the open ping.
17819
17975
  disable_notification: true,
17820
17976
  }),
@@ -17845,8 +18001,7 @@ async function editMicrosoftConnectCard(
17845
18001
  ): Promise<void> {
17846
18002
  await robustApiCall(
17847
18003
  // 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',
18004
+ () => bot.api.editMessageText(chatId, messageId, richMessage(html), {
17850
18005
  link_preview_options: { is_disabled: true },
17851
18006
  }),
17852
18007
  { chat_id: String(chatId), verb: 'microsoftConnectCard' },
@@ -17874,21 +18029,21 @@ async function finalizeMicrosoftConnect(key: string): Promise<void> {
17874
18029
  let html: string
17875
18030
  if (result.kind === 'connected') {
17876
18031
  html =
17877
- `✅ <b>Connected</b> <code>${escapeHtmlForTg(result.account)}</code> ` +
18032
+ `✅ **Connected** \`${result.account}\` ` +
17878
18033
  `(${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` +
18034
+ `To let **${escapeHtmlForTg(agentName)}** use it, run on the host:\n` +
18035
+ `\`switchroom auth microsoft enable ${escapeHtmlForTg(result.account)} ${escapeHtmlForTg(agentName)}\`\n` +
17881
18036
  `then restart ${escapeHtmlForTg(agentName)}.`
17882
18037
  } else if (result.kind === 'no-refresh-token') {
17883
18038
  html =
17884
18039
  `⚠ Microsoft returned no refresh token (the account would expire in ~1h), ` +
17885
- `so it was not registered. Try <code>/connect microsoft</code> again, or ` +
18040
+ `so it was not registered. Try \`/connect microsoft\` again, or ` +
17886
18041
  `connect from the host CLI.`
17887
18042
  } else {
17888
18043
  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>`
18044
+ `❌ **Connect failed:** ${escapeHtmlForTg(result.message)}\n\n` +
18045
+ `_Work/school accounts can't use the phone flow at /common — connect those ` +
18046
+ `from the host: \`switchroom auth microsoft account add <email>\`._`
17892
18047
  }
17893
18048
  await editMicrosoftConnectCard(flow.cardChatId, flow.cardMessageId, html)
17894
18049
  }
@@ -17918,7 +18073,7 @@ bot.command('connect', async ctx => {
17918
18073
  )
17919
18074
  await switchroomReply(
17920
18075
  ctx,
17921
- `⚠️ <code>/connect</code> links account credentials — it is <b>operator-private</b>. ` +
18076
+ `⚠️ \`/connect\` links account credentials — it is **operator-private**. ` +
17922
18077
  `Send it as a direct message to me from your operator account (a private chat where ` +
17923
18078
  `your Telegram ID is on the access allowlist), not in a group or forum.`,
17924
18079
  { html: true },
@@ -17945,8 +18100,8 @@ bot.command('connect', async ctx => {
17945
18100
  if (!isAuthAdmin({ isAdmin })) {
17946
18101
  await switchroomReply(
17947
18102
  ctx,
17948
- `<b>Not authorized.</b> <code>/connect</code> requires this agent to have ` +
17949
- `<code>admin: true</code> in switchroom.yaml.`,
18103
+ `**Not authorized.** \`/connect\` requires this agent to have ` +
18104
+ `\`admin: true\` in switchroom.yaml.`,
17950
18105
  { html: true },
17951
18106
  )
17952
18107
  return
@@ -17955,7 +18110,7 @@ bot.command('connect', async ctx => {
17955
18110
  if (arg === 'cancel') {
17956
18111
  const existing = pendingMicrosoftConnectFlows.get(key)
17957
18112
  if (!existing) {
17958
- await switchroomReply(ctx, '<i>No pending connect flow in this chat.</i>', { html: true })
18113
+ await switchroomReply(ctx, '_No pending connect flow in this chat._', { html: true })
17959
18114
  return
17960
18115
  }
17961
18116
  existing.cancelled = true
@@ -17967,9 +18122,9 @@ bot.command('connect', async ctx => {
17967
18122
  if (arg !== '' && arg !== 'microsoft' && arg !== 'ms') {
17968
18123
  await switchroomReply(
17969
18124
  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>`,
18125
+ `**Usage:** \`/connect microsoft\` to link a Microsoft account ` +
18126
+ `(Outlook / Office 365), or \`/connect cancel\`.\n` +
18127
+ `_Google accounts are connected from the host CLI._`,
17973
18128
  { html: true },
17974
18129
  )
17975
18130
  return
@@ -17978,8 +18133,8 @@ bot.command('connect', async ctx => {
17978
18133
  if (pendingMicrosoftConnectFlows.has(key)) {
17979
18134
  await switchroomReply(
17980
18135
  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>',
18136
+ '_A Microsoft connect flow is already in progress here. Finish it on your phone, ' +
18137
+ 'or send \`/connect cancel\`._',
17983
18138
  { html: true },
17984
18139
  )
17985
18140
  return
@@ -17999,9 +18154,9 @@ bot.command('connect', async ctx => {
17999
18154
  if (started.kind === 'byo-vault') {
18000
18155
  await switchroomReply(
18001
18156
  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.`,
18157
+ `**Can't connect from chat:** this install uses a vaulted custom Microsoft ` +
18158
+ `app (\`${started.ref}\`) only the host CLI can read. ` +
18159
+ `Run \`switchroom auth microsoft account add <email>\` on the host.`,
18005
18160
  { html: true },
18006
18161
  )
18007
18162
  return
@@ -18009,7 +18164,7 @@ bot.command('connect', async ctx => {
18009
18164
  if (started.kind === 'error') {
18010
18165
  await switchroomReply(
18011
18166
  ctx,
18012
- `<b>/connect failed:</b> ${escapeHtmlForTg(started.message)}`,
18167
+ `**/connect failed:** ${escapeHtmlForTg(started.message)}`,
18013
18168
  { html: true },
18014
18169
  )
18015
18170
  return
@@ -18017,21 +18172,21 @@ bot.command('connect', async ctx => {
18017
18172
 
18018
18173
  const appNote =
18019
18174
  started.source === 'default'
18020
- ? "<i>Using switchroom's shipped Microsoft app.</i>"
18021
- : '<i>Using your configured Microsoft app.</i>'
18175
+ ? "_Using switchroom's shipped Microsoft app._"
18176
+ : '_Using your configured Microsoft app._'
18022
18177
  const keyboard = new InlineKeyboard()
18023
18178
  .url('🔐 Sign in to Microsoft', started.device.verification_uri)
18024
18179
  .row()
18025
18180
  .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` +
18181
+ const sent = await ctx.replyWithRichMessage(
18182
+ richMessage(
18183
+ `🔗 **Connect a Microsoft account**\n\n` +
18184
+ `1. Tap **Sign in to Microsoft** below.\n` +
18185
+ `2. Enter this code: \`${started.device.user_code}\`\n` +
18030
18186
  `3. Approve the requested permissions (Mail, Calendar, Files).\n\n` +
18031
18187
  `I'll confirm here once it's connected (within ~15 min).\n${appNote}`,
18188
+ ),
18032
18189
  {
18033
- parse_mode: 'HTML',
18034
- link_preview_options: { is_disabled: true },
18035
18190
  reply_markup: keyboard,
18036
18191
  ...(ctx.message?.message_thread_id != null
18037
18192
  ? { message_thread_id: ctx.message.message_thread_id }
@@ -18082,7 +18237,7 @@ bot.command("auth", async ctx => {
18082
18237
  )
18083
18238
  await switchroomReply(
18084
18239
  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.`,
18240
+ `⚠️ \`/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
18241
  { html: true },
18087
18242
  ).catch(() => {})
18088
18243
  }
@@ -18121,10 +18276,10 @@ bot.command("auth", async ctx => {
18121
18276
  if (!isAuthAdmin({ isAdmin })) {
18122
18277
  await switchroomReply(
18123
18278
  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.).`,
18279
+ `**Not authorized.** \`/auth ${parsed.kind}\` is admin-only.\n` +
18280
+ `Set \`admin: true\` on this agent in switchroom.yaml to unlock ` +
18281
+ `(the same flag that gates \`/agents\`, \`/restart\`, ` +
18282
+ `\`/update\` etc.).`,
18128
18283
  { html: true },
18129
18284
  )
18130
18285
  return
@@ -18137,7 +18292,7 @@ bot.command("auth", async ctx => {
18137
18292
  if (parsed.kind === 'cancel') {
18138
18293
  const existing = pendingAuthAddFlows.get(authAddKey)
18139
18294
  if (!existing) {
18140
- await switchroomReply(ctx, "<i>No pending <code>/auth add</code> flow in this chat.</i>", { html: true })
18295
+ await switchroomReply(ctx, "_No pending \`/auth add\` flow in this chat._", { html: true })
18141
18296
  return
18142
18297
  }
18143
18298
  cancelAccountAuthSession(existing)
@@ -18149,8 +18304,8 @@ bot.command("auth", async ctx => {
18149
18304
  if (pendingAuthAddFlows.has(authAddKey)) {
18150
18305
  await switchroomReply(
18151
18306
  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>",
18307
+ "_An \`/auth add\` flow is already in progress for this chat. " +
18308
+ "Finish the paste, or send \`/auth cancel\` to abort._",
18154
18309
  { html: true },
18155
18310
  )
18156
18311
  return
@@ -18166,17 +18321,17 @@ bot.command("auth", async ctx => {
18166
18321
  })
18167
18322
  await switchroomReply(
18168
18323
  ctx,
18169
- `<b>Adding account</b> <code>${escapeHtmlForTg(parsed.label)}</code>\n\n` +
18324
+ `**Adding account** \`${parsed.label}\`\n\n` +
18170
18325
  `1. Open this URL on your phone:\n${loginUrl}\n\n` +
18171
18326
  `2. Log into Anthropic, copy the code Claude shows.\n` +
18172
18327
  `3. Paste it back here.\n\n` +
18173
- `Send <code>/auth cancel</code> to abort.`,
18328
+ `Send \`/auth cancel\` to abort.`,
18174
18329
  { html: true },
18175
18330
  )
18176
18331
  } catch (err) {
18177
18332
  await switchroomReply(
18178
18333
  ctx,
18179
- `<b>/auth add failed:</b> ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
18334
+ `**/auth add failed:** ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
18180
18335
  { html: true },
18181
18336
  )
18182
18337
  }
@@ -18185,7 +18340,7 @@ bot.command("auth", async ctx => {
18185
18340
 
18186
18341
  const client = await getAuthBrokerClient(currentAgent)
18187
18342
  if (!client) {
18188
- await switchroomReply(ctx, "<b>/auth unavailable:</b> auth-broker client is not loaded (post-RFC-H rewire in progress?).", { html: true })
18343
+ await switchroomReply(ctx, "**/auth unavailable:** auth-broker client is not loaded (post-RFC-H rewire in progress?).", { html: true })
18189
18344
  return
18190
18345
  }
18191
18346
  const reply = await handleAuthCommand(parsed, {
@@ -18437,7 +18592,7 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
18437
18592
  return
18438
18593
  }
18439
18594
  if (result.kind === 'error') {
18440
- await switchroomReply(ctx, `<b>mint_grant failed:</b> ${escapeHtmlForTg(result.msg)}`, { html: true })
18595
+ await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
18441
18596
  return
18442
18597
  }
18443
18598
  // Write the token to the agent's .vault-token file — same flow as the
@@ -18451,10 +18606,10 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
18451
18606
  } catch (err) {
18452
18607
  await switchroomReply(
18453
18608
  ctx,
18454
- `<b>Grant created (${escapeHtmlForTg(id)}) but token write failed:</b> ` +
18609
+ `**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
18455
18610
  `${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>`,
18611
+ `_Recover with: \`switchroom vault grant ${escapeHtmlForTg(agentName)} ` +
18612
+ `--keys ${escapeHtmlForTg(keyName)} --duration 30d\` on the host._`,
18458
18613
  { html: true },
18459
18614
  )
18460
18615
  return
@@ -18479,14 +18634,13 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
18479
18634
  ? escapeHtmlForTg(sourceMsg.text)
18480
18635
  : ''
18481
18636
  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>). ` +
18637
+ `\n\n✅ **${escapeHtmlForTg(agentName)}** granted read access to ` +
18638
+ `\`${keyName}\` for 30 days ` +
18639
+ `(grant \`${id}\`). ` +
18485
18640
  `Re-run /vault audit to act on remaining denials.`
18486
18641
  await finalizeCallback(ctx, {
18487
18642
  ackText: '✅ Grant minted',
18488
18643
  newText: baseText ? `${baseText}${statusLine}` : statusLine,
18489
- parseMode: 'HTML',
18490
18644
  // No synthInbound — operator-only flow. The granted agent picks
18491
18645
  // up the token via .vault-token file on next CLI invocation; no
18492
18646
  // turn-wake needed.
@@ -18553,11 +18707,11 @@ async function performVaultAccessApproval(
18553
18707
  .editMessageText(
18554
18708
  pending.chat_id,
18555
18709
  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: [] } },
18710
+ `ℹ️ **${escapeHtmlForTg(pending.agent)}** already has standing-ACL access to ` +
18711
+ `\`${pending.key}\` (schedule.secrets[]). ` +
18712
+ `**No grant minted** — a token would shadow the standing ACL. ` +
18713
+ richMessage(`The agent can read it directly.`),
18714
+ { reply_markup: { inline_keyboard: [] } },
18561
18715
  )
18562
18716
  .catch(() => {})
18563
18717
  }
@@ -18651,8 +18805,8 @@ async function performVaultAccessApproval(
18651
18805
  .editMessageText(
18652
18806
  pending.chat_id,
18653
18807
  pending.card_message_id,
18654
- `<b>mint_grant failed:</b> ${escapeHtmlForTg(result.msg)}`,
18655
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18808
+ richMessage(`**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`),
18809
+ { reply_markup: { inline_keyboard: [] } },
18656
18810
  )
18657
18811
  .catch(() => {})
18658
18812
  }
@@ -18667,10 +18821,10 @@ async function performVaultAccessApproval(
18667
18821
  } catch (err) {
18668
18822
  await switchroomReply(
18669
18823
  ctx,
18670
- `<b>Grant created (${escapeHtmlForTg(id)}) but token write failed:</b> ` +
18824
+ `**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
18671
18825
  `${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>`,
18826
+ `_Recover with: \`switchroom vault grant ${escapeHtmlForTg(pending.agent)} ` +
18827
+ `--keys ${escapeHtmlForTg(pending.key)} --duration ${Math.round(pending.ttl_seconds / 86400)}d\` on the host._`,
18674
18828
  { html: true },
18675
18829
  )
18676
18830
  return
@@ -18681,16 +18835,16 @@ async function performVaultAccessApproval(
18681
18835
  const days = Math.round(pending.ttl_seconds / 86400)
18682
18836
  const footer =
18683
18837
  VAULT_APPROVAL_AUTH_MODE === 'telegram-id'
18684
- ? `\n<i>Approver verified by Telegram identity — broker auto-unlocked at startup.</i>`
18838
+ ? `\n_Approver verified by Telegram identity — broker auto-unlocked at startup._`
18685
18839
  : ''
18686
18840
  await ctx.api
18687
18841
  .editMessageText(
18688
18842
  pending.chat_id,
18689
18843
  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: [] } },
18844
+ `✅ Granted **${escapeHtmlForTg(pending.agent)}** ${pending.scope} access to ` +
18845
+ `\`${pending.key}\` for ${days}d. ` +
18846
+ richMessage(`(grant \`${id}\`)` + footer),
18847
+ { reply_markup: { inline_keyboard: [] } },
18694
18848
  )
18695
18849
  .catch(() => {})
18696
18850
  }
@@ -18734,6 +18888,97 @@ async function performVaultAccessApproval(
18734
18888
  )
18735
18889
  }
18736
18890
 
18891
+ /**
18892
+ * #2670 — handle an Approve / Dismiss tap on a one-tap skill-improvement
18893
+ * proposal card.
18894
+ *
18895
+ * Approve → mark the proposal approved + inject a synthetic
18896
+ * `skill_proposal_apply` turn instructing the live agent to
18897
+ * write the stored draft through `skill_*_personal` (so the
18898
+ * secret-scan pipeline runs; agent never self-applies).
18899
+ * Dismiss → mark rejected + write a rejection fingerprint so the weekly
18900
+ * synthesis cron doesn't re-propose it.
18901
+ */
18902
+ async function handleSkillProposalCallback(ctx: Context, data: string): Promise<void> {
18903
+ const senderId = String(ctx.from?.id ?? '')
18904
+ const access = loadAccess()
18905
+ if (!access.allowFrom.includes(senderId)) {
18906
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
18907
+ return
18908
+ }
18909
+ const parsed = parseSkillProposalCallback(data)
18910
+ if (parsed == null) {
18911
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
18912
+ return
18913
+ }
18914
+ const stateDir = process.env.TELEGRAM_STATE_DIR
18915
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
18916
+ if (stateDir == null || stateDir.length === 0) {
18917
+ await ctx.answerCallbackQuery({ text: 'State dir unset — cannot apply.' }).catch(() => {})
18918
+ return
18919
+ }
18920
+ const proposal = getSkillProposal(stateDir, parsed.id)
18921
+ if (proposal == null) {
18922
+ await ctx.answerCallbackQuery({ text: 'Proposal expired or already actioned.' }).catch(() => {})
18923
+ if (ctx.callbackQuery?.message) {
18924
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
18925
+ }
18926
+ return
18927
+ }
18928
+ if (proposal.status !== 'pending') {
18929
+ await ctx.answerCallbackQuery({ text: `Already ${proposal.status}.` }).catch(() => {})
18930
+ if (ctx.callbackQuery?.message) {
18931
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
18932
+ }
18933
+ return
18934
+ }
18935
+
18936
+ const cbChatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
18937
+ const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
18938
+
18939
+ if (parsed.action === 'deny') {
18940
+ setSkillProposalStatus(stateDir, parsed.id, 'rejected')
18941
+ await ctx.answerCallbackQuery({ text: '🚫 Dismissed — won’t be proposed again.' }).catch(() => {})
18942
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
18943
+ await ctx
18944
+ .editMessageText(
18945
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
18946
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18947
+ )
18948
+ .catch(() => {})
18949
+ }
18950
+ return
18951
+ }
18952
+
18953
+ // Approve.
18954
+ setSkillProposalStatus(stateDir, parsed.id, 'approved')
18955
+ const synthetic = buildSkillProposalApplyInbound({
18956
+ ctx: {
18957
+ agent,
18958
+ chat_id: cbChatId,
18959
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
18960
+ },
18961
+ proposalId: proposal.id,
18962
+ skillSlug: proposal.skill_slug,
18963
+ isNew: proposal.is_new,
18964
+ operatorId: senderId,
18965
+ })
18966
+ const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
18967
+ await ctx.answerCallbackQuery({ text: '✅ Applying the skill…' }).catch(() => {})
18968
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
18969
+ await ctx
18970
+ .editMessageText(
18971
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n✅ <i>Approved — applying.</i>`,
18972
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
18973
+ )
18974
+ .catch(() => {})
18975
+ }
18976
+ process.stderr.write(
18977
+ `telegram gateway: skill_proposal_apply injection agent=${agent} ` +
18978
+ `proposal=${proposal.id} slug=${proposal.skill_slug} delivered=${delivered}\n`,
18979
+ )
18980
+ }
18981
+
18737
18982
  async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
18738
18983
  const senderId = String(ctx.from?.id ?? '')
18739
18984
  const access = loadAccess()
@@ -18756,8 +19001,8 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
18756
19001
  .editMessageText(
18757
19002
  ctx.callbackQuery.message.chat.id,
18758
19003
  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: [] } },
19004
+ richMessage('⌛ _This access-request card expired before you tapped. Ask the agent to re-issue if the need still stands._'),
19005
+ { reply_markup: { inline_keyboard: [] } },
18761
19006
  )
18762
19007
  .catch(() => {})
18763
19008
  }
@@ -18772,8 +19017,8 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
18772
19017
  .editMessageText(
18773
19018
  pending.chat_id,
18774
19019
  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: [] } },
19020
+ richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}** will not get access to \`${pending.key}\`._`),
19021
+ { reply_markup: { inline_keyboard: [] } },
18777
19022
  )
18778
19023
  .catch(() => {})
18779
19024
  }
@@ -18837,8 +19082,8 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
18837
19082
  .editMessageText(
18838
19083
  pending.chat_id,
18839
19084
  pending.card_message_id,
18840
- `✅ Approved by @${escapeHtmlForTg(username)} — minting…`,
18841
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19085
+ richMessage(`✅ Approved by @${escapeHtmlForTg(username)} — minting…`),
19086
+ { reply_markup: { inline_keyboard: [] } },
18842
19087
  )
18843
19088
  .catch(() => {})
18844
19089
  }
@@ -18894,13 +19139,13 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
18894
19139
  pending.chat_id,
18895
19140
  pending.card_message_id,
18896
19141
  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).`
19142
+ ? `🔐 **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
19143
  : 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: [] } },
19144
+ ? `🔒 **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` +
19145
+ `_The passphrase is what proves it's you: an agent can never mint this key on its own._`
19146
+ : `🔐 **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` +
19147
+ richMessage(`_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches._`),
19148
+ { reply_markup: { inline_keyboard: [] } },
18904
19149
  )
18905
19150
  .catch(() => {})
18906
19151
  return
@@ -18937,8 +19182,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
18937
19182
  .editMessageText(
18938
19183
  ctx.callbackQuery.message.chat.id,
18939
19184
  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: [] } },
19185
+ richMessage('⌛ _This vault-save card expired before you tapped. Ask the agent to re-issue if you still want to save._'),
19186
+ { reply_markup: { inline_keyboard: [] } },
18942
19187
  )
18943
19188
  .catch(() => {})
18944
19189
  }
@@ -18953,8 +19198,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
18953
19198
  .editMessageText(
18954
19199
  pending.chat_id,
18955
19200
  pending.card_message_id,
18956
- `🚫 <i>Discarded. The secret was not written to the vault.</i>`,
18957
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19201
+ richMessage(`🚫 _Discarded. The secret was not written to the vault._`),
19202
+ { reply_markup: { inline_keyboard: [] } },
18958
19203
  )
18959
19204
  .catch(() => {})
18960
19205
  }
@@ -19003,12 +19248,11 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
19003
19248
  ? escapeHtmlForTg(sourceMsg.text)
19004
19249
  : ''
19005
19250
  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>.`
19251
+ `\n\n✏️ **Rename mode** — send the new key name as your next message. ` +
19252
+ `The current proposed key is \`${pending.key}\`.`
19008
19253
  await finalizeCallback(ctx, {
19009
19254
  ackText: 'Send the new key name as your next message.',
19010
19255
  newText: baseText ? `${baseText}${statusLine}` : statusLine,
19011
- parseMode: 'HTML',
19012
19256
  })
19013
19257
  return
19014
19258
  }
@@ -19047,8 +19291,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
19047
19291
  .editMessageText(
19048
19292
  pending.chat_id,
19049
19293
  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: [] } },
19294
+ 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.`),
19295
+ { reply_markup: { inline_keyboard: [] } },
19052
19296
  )
19053
19297
  .catch(() => {})
19054
19298
  }
@@ -19069,14 +19313,14 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
19069
19313
  const rendered = renderVaultCliError(parsed, { verb: 'save', key: pending.key })
19070
19314
  const body = rendered.suppressRaw
19071
19315
  ? rendered.html
19072
- : `⚠️ vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
19316
+ : `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
19073
19317
  if (pending.card_message_id != null) {
19074
19318
  await ctx.api
19075
19319
  .editMessageText(
19076
19320
  pending.chat_id,
19077
19321
  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: [] } },
19322
+ richMessage(`${body}\n\n_Tap a fresh card after fixing the underlying issue._`),
19323
+ { reply_markup: { inline_keyboard: [] } },
19080
19324
  )
19081
19325
  .catch(() => {})
19082
19326
  }
@@ -19114,8 +19358,8 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
19114
19358
  .editMessageText(
19115
19359
  pending.chat_id,
19116
19360
  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: [] } },
19361
+ richMessage(`✅ saved as \`vault:${escapeHtmlForTg(pending.key)}\` (masked: \`${escapeHtmlForTg(maskToken(pending.value))}\`)\n_The agent can now reference this as \`vault:${escapeHtmlForTg(pending.key)}\`._`),
19362
+ { reply_markup: { inline_keyboard: [] } },
19119
19363
  )
19120
19364
  .catch(() => {})
19121
19365
  }
@@ -19238,8 +19482,8 @@ async function handleVaultDeferCallback(ctx: Context, data: string): Promise<voi
19238
19482
  await ctx.answerCallbackQuery({ text: 'Send your passphrase…' }).catch(() => {})
19239
19483
  await ctx
19240
19484
  .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: [] } },
19485
+ richMessage('🔐 Send your vault passphrase as your next message — we\'ll save the held secret automatically and delete the passphrase message.'),
19486
+ { reply_markup: { inline_keyboard: [] } },
19243
19487
  )
19244
19488
  .catch(() => {})
19245
19489
  return
@@ -19324,7 +19568,7 @@ async function startGrantWizardStep1(ctx: Context, chatId: string): Promise<void
19324
19568
  return
19325
19569
  }
19326
19570
  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 })
19571
+ const sent = await switchroomReply(ctx, '**Grant capability token — Step 1/3**\n\nWhich agent?', { html: true, reply_markup: kb })
19328
19572
  const wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
19329
19573
  pendingVaultOps.set(chatId, {
19330
19574
  kind: 'grant-wizard',
@@ -19343,16 +19587,16 @@ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wiz
19343
19587
  return
19344
19588
  }
19345
19589
  if (keys.length === 0) {
19346
- await switchroomReply(ctx, '⚠️ No vault keys found. Add secrets first with <code>/vault set</code>.', { html: true })
19590
+ await switchroomReply(ctx, '⚠️ No vault keys found. Add secrets first with \`/vault set\`.', { html: true })
19347
19591
  pendingVaultOps.delete(chatId)
19348
19592
  return
19349
19593
  }
19350
19594
  const selected = new Set<string>()
19351
19595
  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>`
19596
+ const text = `**Grant capability token — Step 2/3**\n\nWhich keys for \`${agent}\`?\n_Tap to toggle; tap Continue when done._`
19353
19597
  if (wizardMsgId != null) {
19354
19598
  // 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(() => {})
19599
+ await ctx.api.editMessageText(chatId, wizardMsgId, richMessage(text), { reply_markup: kb }).catch(() => {})
19356
19600
  } else {
19357
19601
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
19358
19602
  wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
@@ -19371,12 +19615,12 @@ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wiz
19371
19615
  /** Advance grant wizard to step 3 (pick duration). */
19372
19616
  async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
19373
19617
  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?`
19618
+ const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
19619
+ const text = `**Grant capability token — Step 3/3**\n\nKeys for \`${state.agent!}\`:\n${keyList}\n\nHow long should this grant be valid?`
19376
19620
  const msgId = state.wizardMsgId
19377
19621
  if (msgId != null) {
19378
19622
  // 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(() => {})
19623
+ await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
19380
19624
  } else {
19381
19625
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
19382
19626
  state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
@@ -19388,20 +19632,20 @@ async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<Pen
19388
19632
  async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
19389
19633
  const kb = buildGrantConfirmKeyboard()
19390
19634
  const expiresLabel = formatGrantExpiry(state.ttlSeconds!)
19391
- const keyList = state.selectedKeys!.map(k => `• <code>${escapeHtmlForTg(k)}</code>`).join('\n')
19635
+ const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
19392
19636
  const text = [
19393
- '<b>Confirm grant</b>',
19637
+ '**Confirm grant**',
19394
19638
  '',
19395
- `Agent: <code>${escapeHtmlForTg(state.agent!)}</code>`,
19639
+ `Agent: \`${state.agent!}\``,
19396
19640
  `Keys:\n${keyList}`,
19397
- `Expires: <b>${escapeHtmlForTg(expiresLabel)}</b>`,
19641
+ `Expires: **${escapeHtmlForTg(expiresLabel)}**`,
19398
19642
  '',
19399
- 'Tap <b>Generate</b> to mint the token.',
19643
+ 'Tap **Generate** to mint the token.',
19400
19644
  ].join('\n')
19401
19645
  const msgId = state.wizardMsgId
19402
19646
  if (msgId != null) {
19403
19647
  // 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(() => {})
19648
+ await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
19405
19649
  } else {
19406
19650
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
19407
19651
  state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
@@ -19460,7 +19704,7 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
19460
19704
  return
19461
19705
  }
19462
19706
  if (result.kind === 'error') {
19463
- await switchroomReply(ctx, `<b>mint_grant failed:</b> ${escapeHtmlForTg(result.msg)}`, { html: true })
19707
+ await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
19464
19708
  return
19465
19709
  }
19466
19710
  // Write token to the agent's .vault-token file
@@ -19470,7 +19714,7 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
19470
19714
  mkdirSync(join(homedir(), '.switchroom', 'agents', state.agent!), { recursive: true })
19471
19715
  writeFileSync(tokenPath, token, { mode: 0o600 })
19472
19716
  } catch (err) {
19473
- await switchroomReply(ctx, `<b>Grant created but token write failed:</b> ${escapeHtmlForTg(String(err))}`, { html: true })
19717
+ await switchroomReply(ctx, `**Grant created but token write failed:** ${escapeHtmlForTg(String(err))}`, { html: true })
19474
19718
  return
19475
19719
  }
19476
19720
  // Collapse wizard message to just the outcome.
@@ -19481,12 +19725,11 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
19481
19725
  // Strip the keyboard atomically with the success text via the
19482
19726
  // finalizeCallback helper.
19483
19727
  const msgId = state.wizardMsgId
19484
- const successText = `✅ Grant <code>${escapeHtmlForTg(id)}</code> created. Written to <code>~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token</code>`
19728
+ const successText = `✅ Grant \`${id}\` created. Written to \`~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token\``
19485
19729
  if (msgId != null) {
19486
19730
  await finalizeCallback(ctx, {
19487
19731
  ackText: '✅ Grant created',
19488
19732
  newText: successText,
19489
- parseMode: 'HTML',
19490
19733
  // No synthInbound — operator-only flow.
19491
19734
  })
19492
19735
  } else {
@@ -19537,15 +19780,14 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
19537
19780
  return
19538
19781
  }
19539
19782
  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>`
19783
+ `🗑 Revoke \`${grantId}\`?\n` +
19784
+ `Agent: **${escapeHtmlForTg(grant.agent_slug)}**\n` +
19785
+ `Keys: \`${escapeHtmlForTg(grant.key_allow.join(', '))}\``
19543
19786
  const confirmKeyboard = new InlineKeyboard()
19544
19787
  .text('✅ Confirm Revoke', `vg:confirm:${grantId}`)
19545
19788
  .text('❌ Cancel', `vg:cancel:${grantId}`)
19546
19789
  await ctx.answerCallbackQuery().catch(() => {})
19547
- await ctx.editMessageText(cardText, {
19548
- parse_mode: 'HTML',
19790
+ await ctx.editMessageText(richMessage(cardText), {
19549
19791
  reply_markup: confirmKeyboard,
19550
19792
  }).catch(async () => {
19551
19793
  const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
@@ -19554,8 +19796,7 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
19554
19796
  // #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND.
19555
19797
  await swallowingApiCall(
19556
19798
  () =>
19557
- bot.api.sendMessage(chatId, cardText, {
19558
- parse_mode: 'HTML',
19799
+ bot.api.sendRichMessage(chatId, richMessage(cardText), {
19559
19800
  reply_markup: confirmKeyboard,
19560
19801
  ...(threadId != null ? { message_thread_id: threadId } : {}),
19561
19802
  }),
@@ -19584,8 +19825,8 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
19584
19825
  }
19585
19826
  await ctx.answerCallbackQuery({ text: '✅ Revoked' }).catch(() => {})
19586
19827
  await ctx.editMessageText(
19587
- `✅ Grant <code>${escapeHtmlForTg(grantId)}</code> revoked. Token file removed.`,
19588
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
19828
+ richMessage(`✅ Grant \`${grantId}\` revoked. Token file removed.`),
19829
+ { reply_markup: { inline_keyboard: [] } },
19589
19830
  ).catch(() => {})
19590
19831
  return
19591
19832
  }
@@ -19700,8 +19941,8 @@ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<voi
19700
19941
  // `.catch(() => {})` would swallow the failure silently — same
19701
19942
  // hazard PR #1158 caught on the operator-event card.
19702
19943
  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() },
19944
+ richMessage(escapeHtmlForTg(msg.text) + '\n\n_Send a duration like \`30d\` or \`12h\`:_'),
19945
+ { reply_markup: buildGrantDurationKeyboard() },
19705
19946
  ).catch(() => {})
19706
19947
  }
19707
19948
  await ackSilently()
@@ -19803,15 +20044,14 @@ async function executeDeferredSecretSave(
19803
20044
  const rendered = renderVaultCliError(parsed, { verb: "save", key: slug })
19804
20045
  const body = rendered.suppressRaw
19805
20046
  ? rendered.html
19806
- : `⚠️ vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
20047
+ : `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
19807
20048
  if (cardMessageId != null) {
19808
20049
  await ctx.api
19809
20050
  .editMessageText(
19810
20051
  deferred.chat_id,
19811
20052
  cardMessageId,
19812
- `${body}\n\nRe-tap to retry.`,
20053
+ richMessage(`${body}\n\nRe-tap to retry.`),
19813
20054
  {
19814
- parse_mode: 'HTML',
19815
20055
  reply_markup: buildDeferredSecretKeyboard(deferKey).inline_keyboard.length > 0
19816
20056
  ? buildDeferredSecretKeyboard(deferKey)
19817
20057
  : undefined,
@@ -19829,8 +20069,8 @@ async function executeDeferredSecretSave(
19829
20069
  .editMessageText(
19830
20070
  deferred.chat_id,
19831
20071
  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: [] } },
20072
+ richMessage(`✅ stored as \`vault:${slug}\` (masked: \`${masked}\`)\n\nReply \`rename NEW_NAME\` to relabel.`),
20073
+ { reply_markup: { inline_keyboard: [] } },
19834
20074
  )
19835
20075
  .catch(() => {})
19836
20076
  }
@@ -19907,11 +20147,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19907
20147
  case 'dismiss': {
19908
20148
  // #1150 audit P1: was strip-only. Now appends a status line so
19909
20149
  // scrollback shows the dismissal.
19910
- const status = `\n\n✗ <i>Dismissed by operator.</i>`
20150
+ const status = `\n\n✗ _Dismissed by operator._`
19911
20151
  await finalizeCallback(ctx, {
19912
20152
  ackText: 'Dismissed',
19913
20153
  newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
19914
- parseMode: 'HTML',
19915
20154
  // No synthInbound — dismiss is operator-only, no model in loop.
19916
20155
  })
19917
20156
  return
@@ -19922,11 +20161,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19922
20161
  // #1150 audit P1: was reply + editMessageReplyMarkup (two
19923
20162
  // separate edits). Atomic via finalizeCallback now — the
19924
20163
  // status line is the announcement, no separate reply needed.
19925
- const status = `\n\n🔄 <i><b>${escapeHtmlForTg(agent)}</b> restart requested by operator.</i>`
20164
+ const status = `\n\n🔄 _**${escapeHtmlForTg(agent)}** restart requested by operator._`
19926
20165
  await finalizeCallback(ctx, {
19927
20166
  ackText: `Restarting ${agent}…`,
19928
20167
  newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
19929
- parseMode: 'HTML',
19930
20168
  })
19931
20169
  } else {
19932
20170
  // Failure-path: leave the keyboard tappable so the operator
@@ -19936,11 +20174,9 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19936
20174
  const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
19937
20175
  const detail = isDocker
19938
20176
  ? `cross-agent restart is not supported under docker. ` +
19939
- `Restart from the host: <code>docker compose -p switchroom restart agent-${agent}</code>.`
20177
+ `Restart from the host: \`docker compose -p switchroom restart agent-${agent}\`.`
19940
20178
  : 'restart trigger failed'
19941
- await ctx.reply(`<b>Restart failed for ${agent}:</b> ${detail}`, {
19942
- parse_mode: 'HTML',
19943
- })
20179
+ await ctx.replyWithRichMessage(richMessage(`**Restart failed for ${agent}:** ${detail}`))
19944
20180
  }
19945
20181
  return
19946
20182
  }
@@ -19951,11 +20187,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19951
20187
  // for the login URL state. Strip the keyboard and append a status
19952
20188
  // line; the new reauth-flow's own messages appear below the
19953
20189
  // collapsed card.
19954
- const status = `\n\n🔐 <i>Reauth started for <b>${escapeHtmlForTg(agent)}</b> — follow the login URL below.</i>`
20190
+ const status = `\n\n🔐 _Reauth started for **${escapeHtmlForTg(agent)}** — follow the login URL below._`
19955
20191
  await finalizeCallback(ctx, {
19956
20192
  ackText: `Starting reauth for ${agent}…`,
19957
20193
  newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
19958
- parseMode: 'HTML',
19959
20194
  synthInbound: async () => {
19960
20195
  await runSwitchroomAuthCommand(ctx, ['auth', 'reauth', agent], `auth reauth ${agent}`)
19961
20196
  // PR3 supergroup-mode: key by (chat, thread) so an OAuth code
@@ -19980,11 +20215,10 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19980
20215
  // path systemd-only.
19981
20216
  const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
19982
20217
  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
- )
20218
+ await ctx.replyWithRichMessage(richMessage(
20219
+ `_Inline log fetch is not available under docker mode (no docker.sock in agent containers). ` +
20220
+ `Run from the host: \`docker logs --since 30m --tail 30 switchroom-${agent}\`_`,
20221
+ ))
19988
20222
  return
19989
20223
  }
19990
20224
  try {
@@ -19994,17 +20228,15 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
19994
20228
  { encoding: 'utf-8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] },
19995
20229
  ) as string
19996
20230
  const trimmed = out.trim().slice(-3500)
19997
- await ctx.reply(
20231
+ await ctx.replyWithRichMessage(richMessage(
19998
20232
  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
- )
20233
+ ? `\`\`\`\n${trimmed.replace(/```/g, '`​``')}\n\`\`\``
20234
+ : `_No logs for ${agent}._`,
20235
+ ))
20003
20236
  } catch (err) {
20004
- await ctx.reply(
20005
- `<b>logs failed:</b> ${escapeHtmlForTg((err as Error).message)}`,
20006
- { parse_mode: 'HTML' },
20007
- )
20237
+ await ctx.replyWithRichMessage(richMessage(
20238
+ `**logs failed:** ${escapeHtmlForTg((err as Error).message)}`,
20239
+ ))
20008
20240
  }
20009
20241
  return
20010
20242
  }
@@ -20058,10 +20290,12 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
20058
20290
  bot.api.editMessageText(
20059
20291
  msg.chat.id,
20060
20292
  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' },
20293
+ richMessage(
20294
+ `**Active account →** \`${result.active}\`\n` +
20295
+ `_Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}._\n\n` +
20296
+ `_Tap /auth to see updated quota for the new active account._`,
20297
+ ),
20298
+ {},
20065
20299
  ),
20066
20300
  { chat_id: String(msg.chat.id), verb: 'auth:use:edit' },
20067
20301
  )
@@ -20140,8 +20374,7 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
20140
20374
  if (msg) {
20141
20375
  await swallowingApiCall(
20142
20376
  () =>
20143
- bot.api.editMessageText(msg.chat.id, msg.message_id, text, {
20144
- parse_mode: 'HTML',
20377
+ bot.api.editMessageText(msg.chat.id, msg.message_id, richMessage(text), {
20145
20378
  reply_markup: { inline_keyboard },
20146
20379
  }),
20147
20380
  { chat_id: String(msg.chat.id), verb: 'auth:refresh:edit' },
@@ -20189,17 +20422,17 @@ bot.command('vault', async ctx => {
20189
20422
  const key = args[1]
20190
20423
  if (!sub || sub === 'help') {
20191
20424
  await switchroomReply(ctx, [
20192
- '<b>Vault commands</b>',
20425
+ '**Vault commands**',
20193
20426
  '/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',
20427
+ '/vault get <key> — read a secret value',
20428
+ '/vault set <key> — set a secret',
20429
+ '/vault delete <key> — remove a secret',
20197
20430
  '/vault status — show broker state',
20198
20431
  '/vault unlock — unlock the broker (prompts for passphrase)',
20199
20432
  '/vault lock — lock the broker',
20200
20433
  '/vault grant — mint a capability token (inline wizard)',
20201
20434
  '/vault grants [agent] — list active capability grants (tap to revoke)',
20202
- '/vault audit &lt;agent&gt; — unified view of an agent\'s vault access',
20435
+ '/vault audit <agent> — unified view of an agent\'s vault access',
20203
20436
  '',
20204
20437
  'Your passphrase is cached in memory for 30 min after first use.',
20205
20438
  ].join('\n'), { html: true })
@@ -20212,7 +20445,7 @@ bot.command('vault', async ctx => {
20212
20445
  if (sub === 'audit') {
20213
20446
  const targetAgent = args[1]
20214
20447
  if (!targetAgent) {
20215
- await switchroomReply(ctx, 'Usage: /vault audit &lt;agent&gt;', { html: true })
20448
+ await switchroomReply(ctx, 'Usage: /vault audit <agent>', { html: true })
20216
20449
  return
20217
20450
  }
20218
20451
  // Sanity-check the agent name shape — same charset constraint as
@@ -20270,48 +20503,48 @@ bot.command('vault', async ctx => {
20270
20503
  }
20271
20504
 
20272
20505
  // 3. Compose the rendered audit view.
20273
- const lines: string[] = [`<b>🔐 ${escapeHtmlForTg(targetAgent)} — vault access</b>`, '']
20506
+ const lines: string[] = [`**🔐 ${escapeHtmlForTg(targetAgent)} — vault access**`, '']
20274
20507
 
20275
20508
  if (grantsError) {
20276
- lines.push(`<i>Grants: ⚠️ ${escapeHtmlForTg(grantsError)}</i>`)
20509
+ lines.push(`_Grants: ⚠️ ${escapeHtmlForTg(grantsError)}_`)
20277
20510
  lines.push('')
20278
20511
  } else {
20279
- lines.push('<b>Read grants:</b>')
20512
+ lines.push('**Read grants:**')
20280
20513
  if (readGrants.length === 0) {
20281
- lines.push(' <i>none</i>')
20514
+ lines.push(' _none_')
20282
20515
  } else {
20283
20516
  for (const g of readGrants) {
20284
20517
  const expiry = g.expiresAt
20285
20518
  ? new Date(g.expiresAt * 1000).toISOString().slice(0, 10)
20286
20519
  : 'no expiry'
20287
20520
  const keysJoined = g.keys.join(', ')
20288
- lines.push(` <code>${escapeHtmlForTg(g.id)}</code> · ${escapeHtmlForTg(keysJoined)} · expires ${expiry}`)
20521
+ lines.push(` \`${g.id}\` · ${escapeHtmlForTg(keysJoined)} · expires ${expiry}`)
20289
20522
  }
20290
20523
  }
20291
20524
  lines.push('')
20292
- lines.push('<b>Write grants:</b>')
20525
+ lines.push('**Write grants:**')
20293
20526
  if (writeGrants.length === 0) {
20294
- lines.push(' <i>none</i>')
20527
+ lines.push(' _none_')
20295
20528
  } else {
20296
20529
  for (const g of writeGrants) {
20297
20530
  const expiry = g.expiresAt
20298
20531
  ? new Date(g.expiresAt * 1000).toISOString().slice(0, 10)
20299
20532
  : 'no expiry'
20300
20533
  const keysJoined = g.keys.join(', ')
20301
- lines.push(` <code>${escapeHtmlForTg(g.id)}</code> · ${escapeHtmlForTg(keysJoined)} · expires ${expiry}`)
20534
+ lines.push(` \`${g.id}\` · ${escapeHtmlForTg(keysJoined)} · expires ${expiry}`)
20302
20535
  }
20303
20536
  }
20304
20537
  lines.push('')
20305
20538
  }
20306
20539
 
20307
- lines.push('<b>Cron-declared secrets:</b>')
20540
+ lines.push('**Cron-declared secrets:**')
20308
20541
  if (configError) {
20309
- lines.push(` <i>⚠️ ${escapeHtmlForTg(configError)}</i>`)
20542
+ lines.push(` _⚠️ ${escapeHtmlForTg(configError)}_`)
20310
20543
  } else if (cronEntries.length === 0) {
20311
- lines.push(' <i>none</i>')
20544
+ lines.push(' _none_')
20312
20545
  } else {
20313
20546
  for (const entry of cronEntries) {
20314
- const cronLabel = entry.cron ? ` (<code>${escapeHtmlForTg(entry.cron)}</code>)` : ''
20547
+ const cronLabel = entry.cron ? ` (\`${entry.cron}\`)` : ''
20315
20548
  const keysJoined = entry.secrets.join(', ')
20316
20549
  lines.push(` schedule[${entry.index}]${cronLabel}: ${escapeHtmlForTg(keysJoined)}`)
20317
20550
  }
@@ -20326,11 +20559,11 @@ bot.command('vault', async ctx => {
20326
20559
  const denials = readRecentDenialsForAgent(targetAgent, 7 * 24 * 60 * 60 * 1000, 5)
20327
20560
  const denialKeyboard = new InlineKeyboard()
20328
20561
  if (denials.length > 0) {
20329
- lines.push('<b>Recent denials (last 7d):</b>')
20562
+ lines.push('**Recent denials (last 7d):**')
20330
20563
  for (const d of denials) {
20331
20564
  const when = new Date(d.lastSeenMs).toISOString().slice(0, 16).replace('T', ' ')
20332
20565
  lines.push(
20333
- ` <code>${escapeHtmlForTg(d.key)}</code> · ${d.count}× · last ${when}`,
20566
+ ` \`${d.key}\` · ${d.count}× · last ${when}`,
20334
20567
  )
20335
20568
  // callback_data must stay ≤ 64 bytes. Format:
20336
20569
  // vrd:<agent>:<key>
@@ -20348,17 +20581,15 @@ bot.command('vault', async ctx => {
20348
20581
  }
20349
20582
 
20350
20583
  lines.push(
20351
- `<i>Summary: ${readGrants.length} read grant${readGrants.length === 1 ? '' : 's'}, ` +
20584
+ `_Summary: ${readGrants.length} read grant${readGrants.length === 1 ? '' : 's'}, ` +
20352
20585
  `${writeGrants.length} write grant${writeGrants.length === 1 ? '' : 's'}, ` +
20353
20586
  `${cronEntries.length} cron entr${cronEntries.length === 1 ? 'y' : 'ies'}, ` +
20354
- `${denials.length} recent denial${denials.length === 1 ? '' : 's'}.</i>`,
20587
+ `${denials.length} recent denial${denials.length === 1 ? '' : 's'}._`,
20355
20588
  )
20356
20589
 
20357
20590
  const chatIdAudit = String(ctx.chat!.id)
20358
20591
  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 },
20592
+ await ctx.replyWithRichMessage(richMessage(lines.join('\n')), {
20362
20593
  ...(denials.length > 0 ? { reply_markup: denialKeyboard } : {}),
20363
20594
  ...(threadIdAudit != null ? { message_thread_id: threadIdAudit } : {}),
20364
20595
  })
@@ -20379,7 +20610,7 @@ bot.command('vault', async ctx => {
20379
20610
  }
20380
20611
  const { grants } = result
20381
20612
  if (grants.length === 0) {
20382
- const filterNote = agentFilter ? ` for <code>${escapeHtmlForTg(agentFilter)}</code>` : ''
20613
+ const filterNote = agentFilter ? ` for \`${agentFilter}\`` : ''
20383
20614
  await switchroomReply(ctx, `📜 No active grants${filterNote}.`, { html: true })
20384
20615
  return
20385
20616
  }
@@ -20391,16 +20622,16 @@ bot.command('vault', async ctx => {
20391
20622
  byAgent.set(g.agent_slug, list)
20392
20623
  }
20393
20624
  // Build message text (grouped) + inline keyboard (one [Revoke] per grant per row)
20394
- const lines: string[] = ['<b>📜 Active grants</b>', '']
20625
+ const lines: string[] = ['**📜 Active grants**', '']
20395
20626
  const keyboard = new InlineKeyboard()
20396
20627
  for (const [agentName, agentGrants] of byAgent) {
20397
- lines.push(`<b>${escapeHtmlForTg(agentName)}:</b>`)
20628
+ lines.push(`**${escapeHtmlForTg(agentName)}:**`)
20398
20629
  for (const g of agentGrants) {
20399
20630
  const keys = g.key_allow.join(', ')
20400
20631
  const expiry = g.expires_at
20401
20632
  ? new Date(g.expires_at * 1000).toISOString().slice(0, 10)
20402
20633
  : 'no expiry'
20403
- lines.push(`• <code>${escapeHtmlForTg(g.id)}</code> — ${escapeHtmlForTg(keys)}, expires ${expiry}`)
20634
+ lines.push(`• \`${g.id}\` — ${escapeHtmlForTg(keys)}, expires ${expiry}`)
20404
20635
  // callback_data: vg:revoke:<id> — max 64 bytes; grant IDs are "vg_" + 6 chars = 9 chars total → well within limit
20405
20636
  keyboard.text(`🗑 Revoke ${g.id}`, `vg:revoke:${g.id}`).row()
20406
20637
  }
@@ -20408,9 +20639,7 @@ bot.command('vault', async ctx => {
20408
20639
  }
20409
20640
  const chatId2 = String(ctx.chat!.id)
20410
20641
  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 },
20642
+ await ctx.replyWithRichMessage(richMessage(lines.join('\n')), {
20414
20643
  reply_markup: keyboard,
20415
20644
  ...(threadId2 != null ? { message_thread_id: threadId2 } : {}),
20416
20645
  })
@@ -20460,11 +20689,11 @@ bot.command('vault', async ctx => {
20460
20689
  }
20461
20690
 
20462
20691
  if (!['list', 'get', 'set', 'delete', 'remove'].includes(sub)) {
20463
- await switchroomReply(ctx, `Unknown vault subcommand: <code>${escapeHtmlForTg(sub)}</code>. Try /vault help`, { html: true })
20692
+ await switchroomReply(ctx, `Unknown vault subcommand: \`${sub}\`. Try /vault help`, { html: true })
20464
20693
  return
20465
20694
  }
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 }
20695
+ if ((sub === 'get' || sub === 'delete' || sub === 'remove') && !key) { await switchroomReply(ctx, `Usage: /vault ${sub} <key>`, { html: true }); return }
20696
+ if (sub === 'set' && !key) { await switchroomReply(ctx, 'Usage: /vault set <key>', { html: true }); return }
20468
20697
 
20469
20698
  const cached = vaultPassphraseCache.get(chatId)
20470
20699
  const passphrase = cached && cached.expiresAt > Date.now() ? cached.passphrase : undefined
@@ -20551,9 +20780,9 @@ bot.command('issues', async ctx => {
20551
20780
  await switchroomReply(
20552
20781
  ctx,
20553
20782
  [
20554
- `This will resolve <b>${n} ${noun}</b>. To confirm, run:`,
20783
+ `This will resolve **${n} ${noun}**. To confirm, run:`,
20555
20784
  '',
20556
- '<code>/issues clear --confirm</code>',
20785
+ '\`/issues clear --confirm\`',
20557
20786
  ].join('\n'),
20558
20787
  { html: true },
20559
20788
  )
@@ -20573,7 +20802,7 @@ bot.command('issues', async ctx => {
20573
20802
  }
20574
20803
  await switchroomReply(
20575
20804
  ctx,
20576
- 'Usage: <code>/issues</code> | <code>/issues resolve &lt;fingerprint&gt;</code> | <code>/issues clear [--confirm]</code>',
20805
+ 'Usage: \`/issues\` | \`/issues resolve <fingerprint>\` | \`/issues clear [--confirm]\`',
20577
20806
  { html: true },
20578
20807
  )
20579
20808
  })
@@ -20633,12 +20862,12 @@ bot.command('usage', async ctx => {
20633
20862
  // socket bind, etc.). Same shape /usage shipped with originally.
20634
20863
  const agentDir = resolveAgentDirFromEnv()
20635
20864
  if (!agentDir) {
20636
- await switchroomReply(ctx, '<b>/usage:</b> cannot resolve agent dir.', { html: true })
20865
+ await switchroomReply(ctx, '**/usage:** cannot resolve agent dir.', { html: true })
20637
20866
  return
20638
20867
  }
20639
20868
  const result = await fetchQuota({ claudeConfigDir: join(agentDir, '.claude') })
20640
20869
  if (!result.ok) {
20641
- await switchroomReply(ctx, `<b>/usage:</b> ${escapeHtmlForTg(result.reason)}`, { html: true })
20870
+ await switchroomReply(ctx, `**/usage:** ${escapeHtmlForTg(result.reason)}`, { html: true })
20642
20871
  return
20643
20872
  }
20644
20873
  await switchroomReply(ctx, formatQuotaBlock(result.data), { html: true })
@@ -20694,7 +20923,7 @@ async function renderFleetDoctor(ctx: Context): Promise<void> {
20694
20923
  return
20695
20924
  }
20696
20925
  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 })
20926
+ await switchroomReply(ctx, `🩺 **Whole-fleet doctor denied by hostd:**\n${preBlock(formatSwitchroomOutput(resp.error ?? 'admin required'))}`, { html: true })
20698
20927
  return
20699
20928
  }
20700
20929
  // `completed` (including `switchroom doctor` exit 1 = "found
@@ -20712,7 +20941,7 @@ bot.command('doctor', async ctx => {
20712
20941
  // approval card — doctor is read-only). Everyone else keeps the
20713
20942
  // original zero-extra-tap in-container behaviour.
20714
20943
  if (AGENT_ADMIN && hostdWillBeUsed(getMyAgentName())) {
20715
- await switchroomReply(ctx, '🩺 <b>Doctor</b> — which scope?', {
20944
+ await switchroomReply(ctx, '🩺 **Doctor** — which scope?', {
20716
20945
  html: true,
20717
20946
  reply_markup: buildDoctorScopeKeyboard(),
20718
20947
  })
@@ -20720,7 +20949,7 @@ bot.command('doctor', async ctx => {
20720
20949
  }
20721
20950
  await renderSelfDoctor(ctx)
20722
20951
  } catch (err: unknown) {
20723
- await switchroomReply(ctx, `<b>doctor failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
20952
+ await switchroomReply(ctx, `**doctor failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
20724
20953
  }
20725
20954
  })
20726
20955
 
@@ -20770,7 +20999,7 @@ bot.command('version', async ctx => {
20770
20999
  if (!trimmed) { await switchroomReply(ctx, 'version: no output'); return }
20771
21000
  await switchroomReply(ctx, preBlock(formatSwitchroomOutput(trimmed)), { html: true })
20772
21001
  } catch (err: unknown) {
20773
- await switchroomReply(ctx, `<b>version failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
21002
+ await switchroomReply(ctx, `**version failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
20774
21003
  }
20775
21004
  })
20776
21005
 
@@ -20793,7 +21022,7 @@ bot.command('whoami', async ctx => {
20793
21022
  catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
20794
21023
  await switchroomReply(ctx, card, { html: true })
20795
21024
  } catch (err: unknown) {
20796
- await switchroomReply(ctx, `<b>whoami failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
21025
+ await switchroomReply(ctx, `**whoami failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
20797
21026
  }
20798
21027
  })
20799
21028
 
@@ -20811,7 +21040,7 @@ function formatWhoamiCard(v: {
20811
21040
  const esc = escapeHtmlForTg
20812
21041
  const yn = (b?: boolean) => (b ? '✓' : '✗')
20813
21042
  const lines: string[] = []
20814
- lines.push(`👤 <b>${esc(v.name ?? '?')}</b> · ${esc(v.tier ?? 'standard')}`)
21043
+ lines.push(`👤 **${esc(v.name ?? '?')}** · ${esc(v.tier ?? 'standard')}`)
20815
21044
  if (v.persona) lines.push(esc(v.persona))
20816
21045
  if (v.model) lines.push(`Model: ${esc(v.model)}`)
20817
21046
  const allow = v.tools?.allow ?? []
@@ -20882,8 +21111,7 @@ bot.on('callback_query:data', async ctx => {
20882
21111
  try {
20883
21112
  const outcome = await handleEffortMenuCallback(data, buildEffortDeps())
20884
21113
  await ctx
20885
- .editMessageText(outcome.reply.text, {
20886
- parse_mode: 'HTML',
21114
+ .editMessageText(richMessage(outcome.reply.text), {
20887
21115
  reply_markup: effortMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
20888
21116
  })
20889
21117
  .catch(() => {})
@@ -20908,8 +21136,7 @@ bot.on('callback_query:data', async ctx => {
20908
21136
  if (process.env.SWITCHROOM_MODEL_MENU === '0') {
20909
21137
  await ctx.answerCallbackQuery({ text: 'Model menu is disabled (SWITCHROOM_MODEL_MENU=0).' }).catch(() => {})
20910
21138
  await ctx
20911
- .editMessageText('Model menu is disabled on this agent. Use <code>/model &lt;name&gt;</code>.', {
20912
- parse_mode: 'HTML',
21139
+ .editMessageText(richMessage('Model menu is disabled on this agent. Use \`/model <name>\`.'), {
20913
21140
  reply_markup: { inline_keyboard: [] },
20914
21141
  })
20915
21142
  .catch(() => {})
@@ -20953,7 +21180,7 @@ bot.on('callback_query:data', async ctx => {
20953
21180
  if (data.startsWith(MODEL_CALLBACK_SR)) {
20954
21181
  const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)))
20955
21182
  await ctx
20956
- .editMessageText(`⏳ Switching session to <b>${srLabel}</b>…`, { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } })
21183
+ .editMessageText(richMessage(`⏳ Switching session to **${srLabel}**…`), { reply_markup: { inline_keyboard: [] } })
20957
21184
  .catch(() => {})
20958
21185
  didInterimSrEdit = true
20959
21186
  }
@@ -20982,8 +21209,8 @@ bot.on('callback_query:data', async ctx => {
20982
21209
  // Replace the menu with a restart notice (no buttons — session is ending).
20983
21210
  await ctx
20984
21211
  .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: [] } },
21212
+ richMessage(`🔄 Switching from **${escapeHtmlForTg(prevSessionModel!)}** back to Claude — restarting session cleanly. Claude will be ready in ~30s.`),
21213
+ { reply_markup: { inline_keyboard: [] } },
20987
21214
  )
20988
21215
  .catch(() => {})
20989
21216
  // Write the restart marker so the post-restart boot card edits into this chat.
@@ -21001,8 +21228,7 @@ bot.on('callback_query:data', async ctx => {
21001
21228
  }
21002
21229
 
21003
21230
  await ctx
21004
- .editMessageText(outcome.reply.text, {
21005
- parse_mode: 'HTML',
21231
+ .editMessageText(richMessage(outcome.reply.text), {
21006
21232
  reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
21007
21233
  })
21008
21234
  .catch(() => {})
@@ -21099,8 +21325,7 @@ bot.on('callback_query:data', async ctx => {
21099
21325
  editCard: async (args) => {
21100
21326
  try {
21101
21327
  await robustApiCall(() =>
21102
- bot.api.editMessageText(args.chatId, args.messageId, args.text, {
21103
- parse_mode: 'HTML',
21328
+ bot.api.editMessageText(args.chatId, args.messageId, richMessage(args.text), {
21104
21329
  // Resolved on tap — strip the keyboard so it can't be re-tapped.
21105
21330
  ...(args.stripKeyboard ? { reply_markup: { inline_keyboard: [] } } : {}),
21106
21331
  }),
@@ -21185,7 +21410,7 @@ bot.on('callback_query:data', async ctx => {
21185
21410
  if (scope === 'fleet') await renderFleetDoctor(ctx)
21186
21411
  else await renderSelfDoctor(ctx)
21187
21412
  } catch (err: unknown) {
21188
- await switchroomReply(ctx, `<b>doctor failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
21413
+ await switchroomReply(ctx, `**doctor failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
21189
21414
  }
21190
21415
  return
21191
21416
  }
@@ -21224,6 +21449,16 @@ bot.on('callback_query:data', async ctx => {
21224
21449
  return
21225
21450
  }
21226
21451
 
21452
+ // #2670: one-tap skill-improvement proposal card.
21453
+ // skprop:approve:<id> — apply the stored draft via the personal-skill
21454
+ // write pipeline (inject a turn; agent writes it)
21455
+ // skprop:deny:<id> — dismiss + record a rejection fingerprint so it
21456
+ // isn't re-proposed
21457
+ if (data.startsWith('skprop:')) {
21458
+ await handleSkillProposalCallback(ctx, data)
21459
+ return
21460
+ }
21461
+
21227
21462
  // Issue #969 P2b: vault recent-denial one-tap approval.
21228
21463
  // vrd:<agent>:<key> — mint a 30-day read-grant for the agent + key
21229
21464
  // Posted by /vault audit <agent> in the "Recent denials" section.
@@ -21279,8 +21514,7 @@ bot.on('callback_query:data', async ctx => {
21279
21514
  if (sourceMsg && 'text' in sourceMsg && sourceMsg.text != null) {
21280
21515
  try {
21281
21516
  await ctx.editMessageText(
21282
- `${escapeHtmlForTg(sourceMsg.text)}\n\n✅ <b>${escapeHtmlForTg(choice)}</b>`,
21283
- { parse_mode: 'HTML' },
21517
+ richMessage(`${escapeHtmlForTg(sourceMsg.text)}\n\n✅ **${escapeHtmlForTg(choice)}**`),
21284
21518
  )
21285
21519
  } catch {
21286
21520
  /* edit-failed is fine — the answer-callback below still acks */
@@ -21658,13 +21892,13 @@ bot.on('callback_query:data', async ctx => {
21658
21892
  : ''
21659
21893
  const editLabel = ok
21660
21894
  ? (legacyNote
21661
- ? `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking (legacy path); restart agent for full effect`
21895
+ ? `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking (legacy path); restart agent for full effect`
21662
21896
  : 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`)
21897
+ ? `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking — applying now (restarting to take effect).`
21898
+ : `✅ **${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}** without asking; restart agent for full effect`)
21665
21899
  : (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.`)
21900
+ ? `⚠️ **Allowed for now — "always" did NOT save.** Config edits are locked; enable \`hostd.config_edit_enabled\`.`
21901
+ : `⚠️ **Allowed for now — "always" did NOT save.** It will ask again after restart. Check gateway log.`)
21668
21902
  // #1150 audit: route through finalizeCallback so the keyboard
21669
21903
  // strips alongside the status-line edit. The in-flight verdict was
21670
21904
  // ALREADY dispatched above (independently of this host round-trip)
@@ -21673,7 +21907,6 @@ bot.on('callback_query:data', async ctx => {
21673
21907
  await finalizeCallback(ctx, {
21674
21908
  ackText: ackText.slice(0, 200),
21675
21909
  newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
21676
- parseMode: 'HTML',
21677
21910
  })
21678
21911
  return
21679
21912
  }
@@ -21686,6 +21919,20 @@ bot.on('callback_query:data', async ctx => {
21686
21919
  // scopes (resolveTimeBox → null) and the disabled tier (ttl<=0) stay truly
21687
21920
  // once. The verdict is still dispatched WITHOUT a `rule` (below), so the
21688
21921
  // bridge never caches it untimed — the window lives only in scopedGrants.
21922
+ // Stale-id tap (#2469 follow-up): the pendingStateReaper auto-DENIES a
21923
+ // permission whose TTL expired and DELETES its pending entry — but a late
21924
+ // operator tap on the (now keyboard-stripped, but possibly still-cached)
21925
+ // card would otherwise dispatch a verdict for an already-resolved/dead
21926
+ // request_id, which Claude Code silently ignores. Net: operator THINKS
21927
+ // they approved, but the work was auto-denied and never continues. Be
21928
+ // honest: tell the operator the request already resolved and do NOT
21929
+ // dispatch a verdict for the dead id (no buffering, no resume message).
21930
+ // LIVE ids fall through to the normal dispatch path below, preserving the
21931
+ // dispatchPermissionVerdict buffering/offline-redelivery contract.
21932
+ if (!pendingPermissions.has(request_id)) {
21933
+ await ctx.answerCallbackQuery({ text: STALE_TAP_NOTICE }).catch(() => {})
21934
+ return
21935
+ }
21689
21936
  // Operator tapped a verdict ⇒ they are present; reset no-repeat suppression
21690
21937
  // so a later identical ask is shown fresh rather than silently short-circuited.
21691
21938
  clearPermissionTimeoutSuppression('operator answered a permission card')
@@ -21718,10 +21965,10 @@ bot.on('callback_query:data', async ctx => {
21718
21965
  ? '❌ Denied'
21719
21966
  : (windowGranted ? `✅ Allowed (${scopedMins} min)` : '✅ Allowed once')
21720
21967
  const htmlLabel = behavior === 'deny'
21721
- ? '❌ <b>Denied</b>'
21968
+ ? '❌ **Denied**'
21722
21969
  : (windowGranted
21723
- ? `✅ <b>Allowed — won't ask again about ${escapeHtmlForTg(timeBox!.breadth)} for ${scopedMins} min</b>`
21724
- : '✅ <b>Allowed once</b>')
21970
+ ? `✅ **Allowed — won't ask again about ${escapeHtmlForTg(timeBox!.breadth)} for ${scopedMins} min**`
21971
+ : '✅ **Allowed once**')
21725
21972
  // HTML-escape the source text — same hazard as the scope-commit and
21726
21973
  // recent-denial paths above. The permission card body
21727
21974
  // (formatPermissionCardBody) appends claude-supplied `description`
@@ -21742,7 +21989,6 @@ bot.on('callback_query:data', async ctx => {
21742
21989
  await finalizeCallback(ctx, {
21743
21990
  ackText: ackText.slice(0, 200),
21744
21991
  newText: baseText ? `${baseText}\n\n${htmlLabel}` : htmlLabel,
21745
- parseMode: 'HTML',
21746
21992
  synthInbound: () => {
21747
21993
  // No `rule` → the bridge does NOT cache this (truly once on the bridge);
21748
21994
  // any 30-min stickiness lives only in scopedGrants (recorded above).
@@ -23633,14 +23879,16 @@ void (async () => {
23633
23879
  const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS)
23634
23880
  workerActivityFeed?.stop()
23635
23881
  workerActivityFeed = createWorkerActivityFeed({
23882
+ // #2669: worker-feed body is raw GFM markdown — send via rich.
23636
23883
  bot: {
23637
23884
  sendMessage: async (cid, text, sendOpts) => {
23638
23885
  const sent = await robustApiCall(
23639
23886
  () =>
23640
- lockedBot.api.sendMessage(
23887
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
23888
+ lockedBot.api.sendRichMessage(
23641
23889
  cid,
23642
- text,
23643
- sendOpts as Parameters<typeof lockedBot.api.sendMessage>[2],
23890
+ richMessage(text),
23891
+ sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
23644
23892
  ),
23645
23893
  { chat_id: cid, verb: 'worker-feed' },
23646
23894
  )
@@ -23652,7 +23900,7 @@ void (async () => {
23652
23900
  lockedBot.api.editMessageText(
23653
23901
  cid,
23654
23902
  mid,
23655
- text,
23903
+ richMessage(text),
23656
23904
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
23657
23905
  ),
23658
23906
  { chat_id: cid, verb: 'worker-feed' },