switchroom 0.16.22 → 0.16.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  2. package/dist/cli/skill-validate-pretool.mjs +2865 -48
  3. package/dist/cli/switchroom.js +338 -92
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +5 -5
  6. package/telegram-plugin/answer-stream.ts +13 -43
  7. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  8. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  9. package/telegram-plugin/card-format.ts +28 -25
  10. package/telegram-plugin/credits-watch.ts +5 -10
  11. package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
  12. package/telegram-plugin/draft-stream.ts +4 -4
  13. package/telegram-plugin/format.ts +101 -662
  14. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  15. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  16. package/telegram-plugin/gateway/approval-card.ts +16 -6
  17. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  18. package/telegram-plugin/gateway/auth-command.ts +74 -74
  19. package/telegram-plugin/gateway/auth-line.ts +5 -15
  20. package/telegram-plugin/gateway/boot-card.ts +20 -22
  21. package/telegram-plugin/gateway/boot-version.ts +3 -2
  22. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  23. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  24. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  25. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  26. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  27. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  28. package/telegram-plugin/gateway/effort-command.ts +17 -17
  29. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  30. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  31. package/telegram-plugin/gateway/gateway.ts +751 -624
  32. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  33. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  34. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  35. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  36. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  37. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  38. package/telegram-plugin/gateway/model-command.ts +25 -25
  39. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  40. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  41. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  42. package/telegram-plugin/issues-card.ts +6 -7
  43. package/telegram-plugin/model-unavailable.ts +8 -12
  44. package/telegram-plugin/operator-events-history.ts +1 -1
  45. package/telegram-plugin/operator-events.ts +24 -28
  46. package/telegram-plugin/package.json +1 -1
  47. package/telegram-plugin/pending-work-progress.ts +36 -36
  48. package/telegram-plugin/permission-title.ts +36 -20
  49. package/telegram-plugin/pty-partial-handler.ts +5 -13
  50. package/telegram-plugin/quota-check.ts +5 -5
  51. package/telegram-plugin/quota-watch.ts +13 -18
  52. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  53. package/telegram-plugin/registry/turns-schema.ts +44 -2
  54. package/telegram-plugin/retry-api-call.ts +15 -7
  55. package/telegram-plugin/rich-send.ts +57 -0
  56. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  57. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  58. package/telegram-plugin/shared/bot-runtime.ts +23 -6
  59. package/telegram-plugin/silence-poke.ts +26 -69
  60. package/telegram-plugin/slot-banner-driver.ts +9 -6
  61. package/telegram-plugin/slot-banner.ts +5 -8
  62. package/telegram-plugin/steering.ts +0 -4
  63. package/telegram-plugin/stream-controller.ts +59 -62
  64. package/telegram-plugin/stream-reply-handler.ts +40 -97
  65. package/telegram-plugin/subagent-watcher.ts +2 -2
  66. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  67. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  68. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  69. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  70. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  71. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  72. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  73. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  74. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  75. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  76. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  77. package/telegram-plugin/tests/card-format.test.ts +6 -4
  78. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  79. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  80. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  81. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  82. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  83. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  84. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  85. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  86. package/telegram-plugin/tests/model-command.test.ts +2 -2
  87. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  88. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  89. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  90. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  91. package/telegram-plugin/tests/permission-title.test.ts +45 -41
  92. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  93. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  94. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  95. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  96. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  97. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  98. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  99. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  100. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  101. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  102. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  103. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  104. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  105. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  106. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  107. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  108. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  109. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  110. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  111. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  112. package/telegram-plugin/text-voice-scrub.ts +8 -11
  113. package/telegram-plugin/tool-activity-summary.ts +29 -29
  114. package/telegram-plugin/welcome-text.ts +82 -83
  115. package/telegram-plugin/worker-activity-feed.ts +2 -3
  116. package/telegram-plugin/html-sanitize.ts +0 -244
  117. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  118. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -22587,7 +22587,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
22587
22587
  import { dirname as dirname4, join as join2 } from "node:path";
22588
22588
 
22589
22589
  // src/build-info.ts
22590
- var VERSION = "0.16.22";
22590
+ var VERSION = "0.16.23";
22591
22591
 
22592
22592
  // src/cli/resolve-version.ts
22593
22593
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.16.22",
4
- "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
3
+ "version": "0.16.24",
4
+ "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw \u2014 no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "switchroom": "./dist/cli/switchroom.js"
@@ -23,9 +23,9 @@
23
23
  "build": "node scripts/build.mjs",
24
24
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
25
25
  "pretest": "npm run build",
26
- "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
26
+ "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
27
27
  "test:vitest": "vitest run",
28
- "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
28
+ "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
29
29
  "test:watch": "vitest",
30
30
  "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-web-subscription-honest.mjs",
31
31
  "lint:tsc": "tsc --noEmit",
@@ -47,7 +47,7 @@
47
47
  "bcryptjs": "^3.0.3",
48
48
  "chalk": "^5.4.0",
49
49
  "commander": "^13.1.0",
50
- "grammy": "^1.21.0",
50
+ "grammy": "^1.44",
51
51
  "handlebars": "^4.7.8",
52
52
  "posthog-node": "^5.29.2",
53
53
  "yaml": "^2.7.0",
@@ -34,7 +34,7 @@ import { isSilentFlushMarker } from './turn-flush-safety.js'
34
34
 
35
35
  export const MIN_INITIAL_CHARS = 50
36
36
  export const DEFAULT_THROTTLE_MS = 1000
37
- const TELEGRAM_MAX_CHARS = 4096
37
+ const TELEGRAM_MAX_CHARS = 32768
38
38
 
39
39
  /** Called when a late sendMessage/edit resolves after a new turn has started. */
40
40
  export type OnSupersededCallback = (params: {
@@ -55,11 +55,13 @@ export interface AnswerStreamConfig {
55
55
  replyToMessageId?: number
56
56
 
57
57
  // ── Transport callbacks ────────────────────────────────────────────────
58
+ // Both callbacks ship the RAW assistant transcript markdown — the gateway
59
+ // wrapper sends it via the rich-message path (`sendRichMessage` /
60
+ // `editMessageText({ markdown })`, #2669). No render step happens here.
58
61
  sendMessage: (
59
62
  chatId: string,
60
63
  text: string,
61
64
  params?: {
62
- parse_mode?: 'HTML'
63
65
  message_thread_id?: number
64
66
  link_preview_options?: { is_disabled: boolean }
65
67
  reply_parameters?: { message_id: number }
@@ -86,31 +88,12 @@ export interface AnswerStreamConfig {
86
88
  messageId: number,
87
89
  text: string,
88
90
  params?: {
89
- parse_mode?: 'HTML'
90
91
  message_thread_id?: number
91
92
  link_preview_options?: { is_disabled: boolean }
92
93
  },
93
94
  ) => Promise<unknown>
94
95
  deleteMessage?: (chatId: string, messageId: number) => Promise<unknown>
95
96
 
96
- /**
97
- * Render the raw assistant transcript text into the wire format used for
98
- * `parse_mode: 'HTML'` sends/edits. Every OTHER outbound lane in the gateway
99
- * (handleStreamReply, the reply handler, the turn-flush backstop, the PTY
100
- * partial handler) converts markdown → Telegram HTML via
101
- * `sanitizeTelegramHtml(markdownToHtml(...))` before sending. The
102
- * answer-stream lane historically shipped the RAW transcript under
103
- * `parse_mode: 'HTML'`, so `**bold**` reached the user as literal asterisks
104
- * and agent narration read as unformatted text.
105
- *
106
- * Injected as a dependency (mirroring `renderText` on the PTY partial
107
- * handler) rather than hard-importing `format`/`html-sanitize` here, so this
108
- * module stays free of a grammy/format dependency and remains fully
109
- * testable. When absent, text is sent verbatim — preserving the old
110
- * behaviour for callers (and tests) that don't wire it.
111
- */
112
- renderText?: (text: string) => string
113
-
114
97
  /** Called when a late edit/send resolves but this stream has been superseded. */
115
98
  onSuperseded?: OnSupersededCallback
116
99
  log?: (msg: string) => void
@@ -197,7 +180,6 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
197
180
  replyToMessageId,
198
181
  sendMessage,
199
182
  editMessageText,
200
- renderText,
201
183
  onSuperseded,
202
184
  log,
203
185
  warn,
@@ -207,13 +189,6 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
207
189
  recordOutbound,
208
190
  } = config
209
191
 
210
- /**
211
- * Convert raw transcript text to the wire format before any
212
- * `parse_mode: 'HTML'` send/edit. Falls back to the verbatim text when no
213
- * renderer is injected (old behaviour / unwired tests).
214
- */
215
- const render = (text: string): string => (renderText != null ? renderText(text) : text)
216
-
217
192
  const effectiveThrottle = Math.max(250, throttleMs)
218
193
 
219
194
  // Stream state
@@ -255,19 +230,17 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
255
230
  }
256
231
 
257
232
  async function sendOrEditViaMessage(trimmed: string, gen: number, prevText: string): Promise<void> {
258
- // Convert raw transcript markdown → Telegram HTML before sending under
259
- // parse_mode: 'HTML'. Without this, `**bold**` ships as literal asterisks
260
- // (the answer-stream-raw-markdown bug). Mirrors every other outbound lane.
261
- const rendered = render(trimmed)
233
+ // Ship the RAW transcript markdown — the gateway wrapper sends it via the
234
+ // rich-message path (#2669), so `**bold**` renders correctly without any
235
+ // render step here.
262
236
  if (typeof streamMsgId === 'number') {
263
237
  // Edit existing message
264
238
  const editParams: Parameters<typeof editMessageText>[3] = {
265
- parse_mode: 'HTML',
266
239
  link_preview_options: { is_disabled: true },
267
240
  }
268
241
  if (threadId != null) editParams.message_thread_id = threadId
269
242
  try {
270
- await editMessageText(chatId, streamMsgId, rendered, editParams)
243
+ await editMessageText(chatId, streamMsgId, trimmed, editParams)
271
244
  onMetric?.({ kind: 'answer_lane_update', chatId, messageId: streamMsgId, charCount: trimmed.length, transport: 'edit' })
272
245
  } catch (err) {
273
246
  const msg = err instanceof Error ? err.message : String(err)
@@ -290,13 +263,12 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
290
263
  // preview opener — silent (no device ping). The turn-end materialize
291
264
  // path uses 'materialize' to allow the platform-default ping.
292
265
  const sendParams: Parameters<typeof sendMessage>[2] = {
293
- parse_mode: 'HTML',
294
266
  link_preview_options: { is_disabled: true },
295
267
  purpose: 'stream',
296
268
  }
297
269
  if (threadId != null) sendParams.message_thread_id = threadId
298
270
  if (replyToMessageId != null) sendParams.reply_parameters = { message_id: replyToMessageId }
299
- const sent = await sendMessage(chatId, rendered, sendParams)
271
+ const sent = await sendMessage(chatId, trimmed, sendParams)
300
272
  const sentId = sent?.message_id
301
273
  if (typeof sentId !== 'number' || !Number.isFinite(sentId)) {
302
274
  warn?.('answer-stream: sendMessage returned no message_id')
@@ -444,7 +416,6 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
444
416
  // answer surface — beat 5 of the conversational-pacing contract
445
417
  // — and MUST ping so the user knows the answer landed.
446
418
  const sendParams: Parameters<typeof sendMessage>[2] = {
447
- parse_mode: 'HTML',
448
419
  link_preview_options: { is_disabled: true },
449
420
  purpose: 'materialize',
450
421
  }
@@ -454,11 +425,10 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
454
425
  // nested quote that looks wrong.
455
426
 
456
427
  try {
457
- // Render markdown → Telegram HTML for the wire send. The dedup /
458
- // silent-marker / history checks above all run on the raw textToSend
459
- // (so they match the comparisons the other lanes make on raw text);
460
- // only the actual outbound payload is converted.
461
- const sent = await sendMessage(chatId, render(textToSend), sendParams)
428
+ // Ship the raw transcript markdown — the gateway wrapper sends it via
429
+ // the rich-message path (#2669). The dedup / silent-marker / history
430
+ // checks above all run on the same raw textToSend.
431
+ const sent = await sendMessage(chatId, textToSend, sendParams)
462
432
  const sentId = sent?.message_id
463
433
  if (typeof sentId === 'number' && Number.isFinite(sentId)) {
464
434
  streamMsgId = sentId
@@ -243,7 +243,7 @@ function displayLabel(label: string, opts: SnapshotRenderOpts): string {
243
243
  function groupHeader(health: AccountHealth, count: number): string {
244
244
  const emoji = HEALTH_EMOJI[health];
245
245
  const title = HEALTH_TITLE[health];
246
- return `${emoji} <b>${title}</b> (${count})`;
246
+ return `${emoji} **${title}** (${count})`;
247
247
  }
248
248
 
249
249
  const HEALTH_EMOJI: Record<AccountHealth, string> = {
@@ -284,10 +284,10 @@ function renderAccountRow(
284
284
 
285
285
  if (!snap.quota) {
286
286
  lines.push(
287
- `${marker}<code>${escapeHtml(label)}</code> <i>quota probe failed</i>`,
287
+ `${marker}\`${escapeHtml(label)}\` _quota probe failed_`,
288
288
  );
289
289
  if (snap.quotaError) {
290
- lines.push(` <i>${escapeHtml(snap.quotaError)}</i>`);
290
+ lines.push(` _${escapeHtml(snap.quotaError)}_`);
291
291
  }
292
292
  return lines;
293
293
  }
@@ -297,7 +297,7 @@ function renderAccountRow(
297
297
  // it as a data-quality gap, never a confident "0% / 0%".
298
298
  if (isProbeThin(q)) {
299
299
  lines.push(
300
- `${marker}<code>${escapeHtml(label)}</code> <i>quota unknown (thin probe)</i>`,
300
+ `${marker}\`${escapeHtml(label)}\` _quota unknown (thin probe)_`,
301
301
  );
302
302
  return lines;
303
303
  }
@@ -307,7 +307,7 @@ function renderAccountRow(
307
307
  const fiveStr = fmtPct(norm.fiveHourUtilizationPct);
308
308
  const sevenStr = fmtPct(norm.sevenDayUtilizationPct);
309
309
  lines.push(
310
- `${marker}<code>${escapeHtml(label)}</code> ${fiveStr} / ${sevenStr}`,
310
+ `${marker}\`${escapeHtml(label)}\` ${fiveStr} / ${sevenStr}`,
311
311
  );
312
312
 
313
313
  const health = classifyHealth(snap, now);
@@ -318,7 +318,7 @@ function renderAccountRow(
318
318
  const reset = win === '5h' ? q.fiveHourResetAt : q.sevenDayResetAt;
319
319
  const winLabel = win === '5h' ? '5-hour' : '7-day';
320
320
  lines.push(
321
- ` <i>quota exhausted — back ${formatAbsolute(reset, tz)} (in ${formatRelative(reset, now)}, ${winLabel} cap)</i>`,
321
+ ` _quota exhausted — back ${formatAbsolute(reset, tz)} (in ${formatRelative(reset, now)}, ${winLabel} cap)_`,
322
322
  );
323
323
  return lines;
324
324
  }
@@ -337,13 +337,13 @@ function renderAccountRow(
337
337
  const sevenSeg = q.sevenDayResetAt
338
338
  ? `7d resets ${formatAbsolute(q.sevenDayResetAt, tz)} (in ${formatRelative(q.sevenDayResetAt, now)})`
339
339
  : '7d resets —';
340
- lines.push(` <i>${fiveFirst ? fiveSeg : sevenSeg}</i>`);
341
- lines.push(` <i>${fiveFirst ? sevenSeg : fiveSeg}</i>`);
340
+ lines.push(` _${fiveFirst ? fiveSeg : sevenSeg}_`);
341
+ lines.push(` _${fiveFirst ? sevenSeg : fiveSeg}_`);
342
342
  // Informational overage annotation: if out_of_credits (no overage headroom),
343
343
  // surface it as a sub-line on a healthy/throttling row — NOT a blocked badge.
344
344
  if (q.overageDisabledReason != null && OVERAGE_EXHAUSTED_REASONS.has(q.overageDisabledReason)) {
345
345
  lines.push(
346
- ` <i>overage off (${escapeHtml(q.overageDisabledReason)}) — serving from quota</i>`,
346
+ ` _overage off (${escapeHtml(q.overageDisabledReason)}) — serving from quota_`,
347
347
  );
348
348
  }
349
349
  return lines;
@@ -380,7 +380,7 @@ export function renderAuthSnapshotFormat2(
380
380
  ): string {
381
381
  const now = opts.now ?? new Date();
382
382
  const lines: string[] = [];
383
- lines.push('🔋 <b>Auth — fleet status</b>');
383
+ lines.push('🔋 **Auth — fleet status**');
384
384
 
385
385
  // Group by health. Render BLOCKED first (it's the urgent action),
386
386
  // then THROTTLING (potential next problem), then HEALTHY (good
@@ -410,16 +410,16 @@ export function renderAuthSnapshotFormat2(
410
410
 
411
411
  lines.push('');
412
412
  lines.push('────────────────────────────');
413
- lines.push(`<i>${recommendation(snapshots, now, opts.demo ?? false)}</i>`);
413
+ lines.push(`_${recommendation(snapshots, now, opts.demo ?? false)}_`);
414
414
  // #2495 Change 2 — a failed probe-on-open renders an explicit "cached Nm
415
415
  // ago" warning, never a false live stamp. The degraded variant takes
416
416
  // precedence over the live stamp.
417
417
  if (opts.staleCachedAtMs != null) {
418
- lines.push(`<i>⚠ cached ${formatAgeStamp(opts.staleCachedAtMs, now)}</i>`);
418
+ lines.push(`_⚠ cached ${formatAgeStamp(opts.staleCachedAtMs, now)}_`);
419
419
  } else if (opts.liveProbedAtMs != null) {
420
- lines.push(`<i>Live · refreshed ${formatAgeStamp(opts.liveProbedAtMs, now)}</i>`);
420
+ lines.push(`_Live · refreshed ${formatAgeStamp(opts.liveProbedAtMs, now)}_`);
421
421
  } else {
422
- lines.push('<i>Live</i>');
422
+ lines.push('_Live_');
423
423
  }
424
424
  return lines.join('\n');
425
425
  }
@@ -615,10 +615,10 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
615
615
  // same per-account row + earliest-recovery helpers the /auth table uses so
616
616
  // the formatting stays consistent with the rest of the auth surface.
617
617
  lines.push(
618
- `🔴 <b>All accounts blocked · ${headerLimit} on ${escapeHtml(input.oldLabel)}</b>`,
618
+ `🔴 **All accounts blocked · ${headerLimit} on ${escapeHtml(input.oldLabel)}**`,
619
619
  );
620
620
  lines.push('');
621
- lines.push(`Triggered by: agent <b>${escapeHtml(input.triggerAgent)}</b>`);
621
+ lines.push(`Triggered by: agent **${escapeHtml(input.triggerAgent)}**`);
622
622
 
623
623
  const fleet = input.fleetSnapshots ?? [];
624
624
  if (fleet.length > 0) {
@@ -639,7 +639,7 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
639
639
  if (earliest) {
640
640
  lines.push('');
641
641
  lines.push(
642
- `Earliest recovery: <code>${escapeHtml(earliest.label)}</code> ` +
642
+ `Earliest recovery: \`${escapeHtml(earliest.label)}\` ` +
643
643
  `${formatAbsolute(earliest.at, tz)} (in ${formatRelative(earliest.at, now)})`,
644
644
  );
645
645
  }
@@ -655,28 +655,28 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
655
655
  }
656
656
  lines.push('');
657
657
  lines.push(
658
- `Run <code>/auth add &lt;label&gt;</code> to attach another subscription, ` +
659
- `or <code>/auth refresh</code> to re-probe.`,
658
+ `Run \`/auth add <label>\` to attach another subscription, ` +
659
+ `or \`/auth refresh\` to re-probe.`,
660
660
  );
661
661
  return lines.join('\n');
662
662
  }
663
663
 
664
664
  // Successful swap.
665
665
  lines.push(
666
- `✓ <b>Switched fleet · ${headerLimit} on ${escapeHtml(input.oldLabel)}</b>`,
666
+ `✓ **Switched fleet · ${headerLimit} on ${escapeHtml(input.oldLabel)}**`,
667
667
  );
668
668
  lines.push('');
669
669
  lines.push(
670
- `<code>${escapeHtml(input.oldLabel)}</code> → <code>${escapeHtml(input.newLabel)}</code>`,
670
+ `\`${escapeHtml(input.oldLabel)}\` → \`${escapeHtml(input.newLabel)}\``,
671
671
  );
672
- lines.push(`Triggered by: agent <b>${escapeHtml(input.triggerAgent)}</b>`);
672
+ lines.push(`Triggered by: agent **${escapeHtml(input.triggerAgent)}**`);
673
673
  lines.push('');
674
674
 
675
675
  if (input.oldQuota) {
676
676
  const recovery = recoveryAtFor(input.oldQuota);
677
677
  if (recovery) {
678
678
  lines.push(
679
- `<code>${escapeHtml(input.oldLabel)}</code> recovers ` +
679
+ `\`${escapeHtml(input.oldLabel)}\` recovers ` +
680
680
  `${formatAbsolute(recovery, tz)} (in ${formatRelative(recovery, now)})`,
681
681
  );
682
682
  }
@@ -688,13 +688,13 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
688
688
  const hasHeadroom =
689
689
  input.newQuota.fiveHourUtilizationPct < THROTTLING_THRESHOLD_PCT &&
690
690
  input.newQuota.sevenDayUtilizationPct < THROTTLING_THRESHOLD_PCT;
691
- const headroomStr = hasHeadroom ? '<i>(plenty of headroom)</i>' : '<i>(near limit — watch this)</i>';
691
+ const headroomStr = hasHeadroom ? '_(plenty of headroom)_' : '_(near limit — watch this)_';
692
692
  lines.push(
693
- `<code>${escapeHtml(input.newLabel)}</code> now: ${fiveStr} of 5h · ${sevenStr} of 7d ${headroomStr}`,
693
+ `\`${escapeHtml(input.newLabel)}\` now: ${fiveStr} of 5h · ${sevenStr} of 7d ${headroomStr}`,
694
694
  );
695
695
  } else {
696
696
  lines.push(
697
- `<i>(quota probe for new account is pending — will reflect on next /auth)</i>`,
697
+ `_(quota probe for new account is pending — will reflect on next /auth)_`,
698
698
  );
699
699
  }
700
700
 
@@ -808,10 +808,7 @@ function switchPriority(s: AccountSnapshot, now: Date = new Date()): number {
808
808
  // ── shared HTML escape ───────────────────────────────────────────────
809
809
 
810
810
  function escapeHtml(s: string): string {
811
- return s
812
- .replace(/&/g, '&amp;')
813
- .replace(/</g, '&lt;')
814
- .replace(/>/g, '&gt;');
811
+ return s.replace(/([\\`*_~=\[\]|])/g, '\\$1');
815
812
  }
816
813
 
817
814
  // ── snapshot assembly helper ─────────────────────────────────────────
@@ -54,19 +54,14 @@ import {
54
54
  */
55
55
  export function renderFallbackFailureNotice(triggerAgent: string, reason: string): string {
56
56
  return (
57
- `⚠️ <b>Auto-failover could not run</b> (trigger: <b>${escFailureHtml(triggerAgent)}</b>)\n` +
57
+ `⚠️ **Auto-failover could not run** (trigger: **${escFailureHtml(triggerAgent)}**)\n` +
58
58
  `${escFailureHtml(reason)}\n\n` +
59
- `<i>Switch manually with <code>/auth use &lt;label&gt;</code>, or <code>/auth</code> for fleet status.</i>`
59
+ `_Switch manually with \`/auth use <label>\`, or \`/auth\` for fleet status._`
60
60
  );
61
61
  }
62
62
 
63
63
  function escFailureHtml(s: string): string {
64
- return s
65
- .replace(/&/g, '&amp;')
66
- .replace(/</g, '&lt;')
67
- .replace(/>/g, '&gt;')
68
- .replace(/"/g, '&quot;')
69
- .replace(/'/g, '&#39;');
64
+ return s.replace(/([\\`*_~=\[\]|])/g, '\\$1');
70
65
  }
71
66
 
72
67
  /**
@@ -214,7 +209,7 @@ export async function runFleetAutoFallback(
214
209
  if (!oldSnap) {
215
210
  return {
216
211
  kind: 'no-old-active',
217
- announcement: '<i>Auto-fallback skipped: no active account in broker state.</i>',
212
+ announcement: '_Auto-fallback skipped: no active account in broker state._',
218
213
  };
219
214
  }
220
215
 
@@ -232,8 +227,8 @@ export async function runFleetAutoFallback(
232
227
  oldLabel: oldSnap.label,
233
228
  oldQuota: oldSnap.quota,
234
229
  announcement:
235
- `<i>Auto-fallback skipped: ${oldSnap.label} probed healthy ` +
236
- `(${pctSummary(oldSnap.quota)}). Stale event?</i>`,
230
+ `_Auto-fallback skipped: ${oldSnap.label} probed healthy ` +
231
+ `(${pctSummary(oldSnap.quota)}). Stale event?_`,
237
232
  };
238
233
  }
239
234
 
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Shared formatters for Telegram status cards.
3
3
  *
4
+ * Since the Bot API 10.1 rich-message migration (#2669) the cards emit raw
5
+ * GFM markdown (sent via `sendRichMessage` / `editMessageText({ markdown })`),
6
+ * not HTML. `escapeMarkdown` (re-exported from `format.ts`) is the
7
+ * dynamic-value escaper cards use where they used to call `escapeHtml`.
8
+ *
4
9
  * Both the main progress card (rendered via `stream-reply-handler.ts`)
5
- * and the pinned worker card (`subagent-watcher.ts`) emit HTML to
6
- * Telegram; before issue #94 each had its own private copies with subtly
10
+ * and the pinned worker card (`subagent-watcher.ts`) emit to Telegram;
11
+ * before issue #94 each had its own private copies with subtly
7
12
  * different conventions:
8
13
  *
9
14
  * - `formatDuration(500)` → progress-card returned `500ms`, watcher
@@ -16,14 +21,24 @@
16
21
  * in both modules. Centralising removes one more piece of drift the
17
22
  * reviewer has to verify.
18
23
  *
19
- * Keep this module dependency-free. It's imported by every card-render
20
- * surface and must not pull in plugin or gateway state.
24
+ * Keep this module nearly dependency-free. It's imported by every
25
+ * card-render surface and must not pull in plugin or gateway state. The
26
+ * single exception is the `escapeMarkdown` re-export from `format.ts`,
27
+ * which is itself state-free.
28
+ */
29
+
30
+ import { escapeMarkdown } from './format.js'
31
+
32
+ /**
33
+ * Re-export so card surfaces have one import for the markdown escaper they
34
+ * use when interpolating dynamic values into a hand-built markdown card.
21
35
  */
36
+ export { escapeMarkdown }
22
37
 
23
38
  /**
24
39
  * Render a millisecond duration as `<n>ms` for sub-second values, or
25
- * `MM:SS` thereafter. Output is always HTML-safe (no `<` / `>` / `&`),
26
- * so callers can interpolate it into HTML without `escapeHtml`.
40
+ * `MM:SS` thereafter. Output contains no markdown-special characters, so
41
+ * callers can interpolate it into a markdown card without escaping.
27
42
  *
28
43
  * formatDuration(0) → "0ms"
29
44
  * formatDuration(500) → "500ms"
@@ -45,17 +60,6 @@ export function formatDuration(ms: number): string {
45
60
  return `${m.toString().padStart(2, '0')}:${r.toString().padStart(2, '0')}`;
46
61
  }
47
62
 
48
- /**
49
- * Escape `&`, `<`, `>` for safe interpolation into Telegram HTML
50
- * messages. Telegram's HTML parser is strict — an unescaped `<` is
51
- * read as a tag opener and rejects the message with
52
- * "can't parse entities" (see #101 for the cascade that crashed the
53
- * gateway into a restart loop).
54
- */
55
- export function escapeHtml(s: string): string {
56
- return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!);
57
- }
58
-
59
63
  /** Truncate to at most `n` characters, replacing the last char with `…`. */
60
64
  export function truncate(s: string, n: number): string {
61
65
  return s.length > n ? s.slice(0, n - 1) + '…' : s;
@@ -65,13 +69,12 @@ export function truncate(s: string, n: number): string {
65
69
  * Strip Markdown markup from a single line, leaving plain prose.
66
70
  *
67
71
  * Worker narration is authored as Markdown — the model writes `**bold**`,
68
- * `` `code` ``, `- bullets`, `# headings`. The status cards render Telegram
69
- * HTML, which does NOT interpret Markdown, so an unstripped `**` shows up as
70
- * two literal asterisks (the #94-class "half-done" look). Strip the markup
71
- * here so the card reads as clean prose.
72
+ * `` `code` ``, `- bullets`, `# headings`. The status cards have a fixed
73
+ * structure (steps, durations) and interleave narration as plain prose, so
74
+ * we strip the author's inline markup here so the narration reads as clean
75
+ * prose and never collides with the card's own markdown structure.
72
76
  *
73
- * Run this BEFORE `truncate` + `escapeHtml`: clean → measure → escape. (The
74
- * stripper never touches `<`/`>`/`&`, so escaping stays the last step.)
77
+ * Run this BEFORE `truncate` + `escapeMarkdown`: clean → measure → escape.
75
78
  */
76
79
  export function stripMarkdown(s: string): string {
77
80
  let out = s;
@@ -107,8 +110,8 @@ function isRuleLine(s: string): boolean {
107
110
  * Clean a worker's multi-line result/narration into a single plain-text
108
111
  * paragraph for a card's finished body. Drops fenced code blocks and
109
112
  * horizontal rules, strips per-line Markdown, then space-joins what's left.
110
- * Output is plain text — the caller still truncates + escapes before
111
- * interpolating into HTML.
113
+ * Output is plain text — the caller still truncates + escapes (escapeMarkdown)
114
+ * before interpolating into the card's markdown body.
112
115
  */
113
116
  export function cleanWorkerResultParagraph(s: string): string {
114
117
  const kept: string[] = [];
@@ -157,7 +157,7 @@ export function evaluateCreditState(args: {
157
157
  if (!currentIsFatal && prevIsFatal) {
158
158
  return {
159
159
  kind: "notify",
160
- message: `✅ <b>${escapeHtml(agentName)}</b>: credits restored — agent should resume normal operation.`,
160
+ message: `✅ **${escapeHtml(agentName)}**: credits restored — agent should resume normal operation.`,
161
161
  newState: { lastNotifiedReason: null, lastNotifiedAt: now },
162
162
  transition: "exited",
163
163
  };
@@ -193,12 +193,12 @@ export function evaluateCreditState(args: {
193
193
  function buildEntryMessage(agentName: string, reason: string): string {
194
194
  const desc = humanizeReason(reason);
195
195
  return [
196
- `⚠️ <b>${escapeHtml(agentName)}</b>: ${desc}`,
196
+ `⚠️ **${escapeHtml(agentName)}**: ${desc}`,
197
197
  ``,
198
198
  `Cron tasks and inbound replies will fail until this is resolved. Check`,
199
- `your subscription or pre-paid usage at <a href="https://console.anthropic.com">console.anthropic.com</a>.`,
199
+ `your subscription or pre-paid usage at [console.anthropic.com](https://console.anthropic.com).`,
200
200
  ``,
201
- `<i>Source: Claude CLI cache (cachedExtraUsageDisabledReason=${escapeHtml(reason)})</i>`,
201
+ `_Source: Claude CLI cache (cachedExtraUsageDisabledReason=${escapeHtml(reason)})_`,
202
202
  ].join("\n");
203
203
  }
204
204
 
@@ -218,12 +218,7 @@ function humanizeReason(reason: string): string {
218
218
  }
219
219
 
220
220
  function escapeHtml(s: string): string {
221
- return s
222
- .replace(/&/g, "&amp;")
223
- .replace(/</g, "&lt;")
224
- .replace(/>/g, "&gt;")
225
- .replace(/"/g, "&quot;")
226
- .replace(/'/g, "&#39;");
221
+ return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
227
222
  }
228
223
 
229
224
  // ─── State persistence ───────────────────────────────────────────────────────