switchroom 0.16.23 → 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 (127) 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 +671 -430
  9. package/dist/host-control/main.js +156 -156
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +5 -5
  13. package/telegram-plugin/answer-stream.ts +13 -43
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/card-format.ts +28 -25
  17. package/telegram-plugin/credits-watch.ts +5 -10
  18. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  19. package/telegram-plugin/dist/gateway/gateway.js +1855 -2077
  20. package/telegram-plugin/dist/server.js +160 -160
  21. package/telegram-plugin/draft-stream.ts +4 -4
  22. package/telegram-plugin/format.ts +101 -662
  23. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  24. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  25. package/telegram-plugin/gateway/approval-card.ts +16 -6
  26. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  27. package/telegram-plugin/gateway/auth-command.ts +74 -74
  28. package/telegram-plugin/gateway/auth-line.ts +5 -15
  29. package/telegram-plugin/gateway/boot-card.ts +20 -22
  30. package/telegram-plugin/gateway/boot-version.ts +3 -2
  31. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  32. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  33. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  34. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  35. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  36. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  37. package/telegram-plugin/gateway/effort-command.ts +17 -17
  38. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  39. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  40. package/telegram-plugin/gateway/gateway.ts +751 -624
  41. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  42. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  43. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  44. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  45. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  46. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  47. package/telegram-plugin/gateway/model-command.ts +25 -25
  48. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  49. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  50. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  51. package/telegram-plugin/issues-card.ts +6 -7
  52. package/telegram-plugin/model-unavailable.ts +8 -12
  53. package/telegram-plugin/operator-events-history.ts +1 -1
  54. package/telegram-plugin/operator-events.ts +24 -28
  55. package/telegram-plugin/package.json +1 -1
  56. package/telegram-plugin/pending-work-progress.ts +36 -36
  57. package/telegram-plugin/permission-title.ts +36 -20
  58. package/telegram-plugin/pty-partial-handler.ts +5 -13
  59. package/telegram-plugin/quota-check.ts +5 -5
  60. package/telegram-plugin/quota-watch.ts +13 -18
  61. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  62. package/telegram-plugin/registry/turns-schema.ts +43 -3
  63. package/telegram-plugin/retry-api-call.ts +15 -7
  64. package/telegram-plugin/rich-send.ts +57 -0
  65. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  66. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  67. package/telegram-plugin/shared/bot-runtime.ts +23 -6
  68. package/telegram-plugin/silence-poke.ts +26 -69
  69. package/telegram-plugin/slot-banner-driver.ts +9 -6
  70. package/telegram-plugin/slot-banner.ts +5 -8
  71. package/telegram-plugin/steering.ts +0 -4
  72. package/telegram-plugin/stream-controller.ts +59 -62
  73. package/telegram-plugin/stream-reply-handler.ts +40 -97
  74. package/telegram-plugin/subagent-watcher.ts +2 -2
  75. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  76. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  77. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  78. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  79. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  80. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  81. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  82. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  83. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  84. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  85. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  86. package/telegram-plugin/tests/card-format.test.ts +6 -4
  87. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  88. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  89. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  90. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  91. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  92. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  93. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  94. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  95. package/telegram-plugin/tests/model-command.test.ts +2 -2
  96. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  97. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  98. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  99. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  100. package/telegram-plugin/tests/permission-title.test.ts +45 -41
  101. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  102. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  103. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  104. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  105. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  106. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  107. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  108. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  109. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  110. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  111. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  112. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  113. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  114. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  115. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  116. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  117. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  118. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  119. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  120. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  121. package/telegram-plugin/text-voice-scrub.ts +8 -11
  122. package/telegram-plugin/tool-activity-summary.ts +29 -29
  123. package/telegram-plugin/welcome-text.ts +82 -83
  124. package/telegram-plugin/worker-activity-feed.ts +2 -3
  125. package/telegram-plugin/html-sanitize.ts +0 -244
  126. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  127. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -200,7 +200,10 @@ export interface FinalizeCallbackContext {
200
200
  answerCallbackQuery: (
201
201
  opts?: { text?: string; show_alert?: boolean },
202
202
  ) => Promise<unknown>
203
- editMessageText: (text: string, opts?: Record<string, unknown>) => Promise<unknown>
203
+ editMessageText: (
204
+ text: string | { markdown: string },
205
+ opts?: Record<string, unknown>,
206
+ ) => Promise<unknown>
204
207
  }
205
208
 
206
209
  export interface FinalizeCallbackOptions {
@@ -225,11 +228,12 @@ export interface FinalizeCallbackOptions {
225
228
  */
226
229
  newText: string
227
230
  /**
228
- * Parse mode for `newText`. Match the original message's parse mode —
229
- * mixing modes mid-edit silently breaks formatting. Optional; omitted
230
- * means plain text.
231
+ * When true, `newText` is edited as a LITERAL plain string (no markdown
232
+ * parsing) — match the original message's send shape. Default (false) →
233
+ * the rich-markdown path, so `newText` is rendered as GFM markdown
234
+ * (#2669, successor to the old `parseMode` option).
231
235
  */
232
- parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2'
236
+ literalText?: boolean
233
237
  /**
234
238
  * Side effect invoked AFTER `editMessageText` resolves. Use for
235
239
  * synthesizing the `<channel source="...">` inbound that wakes the
@@ -274,14 +278,16 @@ export async function finalizeCallback(
274
278
  })
275
279
  // Invariant 2 — strip keyboard + append status line, atomic edit.
276
280
  try {
277
- await ctx.editMessageText(opts.newText, {
278
- reply_markup: { inline_keyboard: [] },
279
- ...(opts.parseMode ? { parse_mode: opts.parseMode } : {}),
280
- // Default link_preview_options off — most finalized cards don't
281
- // benefit from preview cards, and a stale preview survives the
282
- // edit otherwise.
283
- link_preview_options: { is_disabled: true },
284
- })
281
+ await ctx.editMessageText(
282
+ opts.literalText ? opts.newText : { markdown: opts.newText },
283
+ {
284
+ reply_markup: { inline_keyboard: [] },
285
+ // Default link_preview_options off — most finalized cards don't
286
+ // benefit from preview cards, and a stale preview survives the
287
+ // edit otherwise.
288
+ link_preview_options: { is_disabled: true },
289
+ },
290
+ )
285
291
  } catch (err) {
286
292
  // MESSAGE_NOT_MODIFIED (text didn't change) and MESSAGE_TO_EDIT_NOT_FOUND
287
293
  // (operator already deleted the card) are both benign. Other failures
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { readFileSync, writeFileSync } from "node:fs";
20
- import { escapeHtml } from "./card-format.js";
20
+ import { escapeMarkdown } from "./card-format.js";
21
21
  import type { IssueEvent, IssueSeverity } from "../src/issues/index.js";
22
22
 
23
23
  export interface BotApiForIssuesCard {
@@ -92,7 +92,7 @@ export function renderIssuesCard(opts: RenderIssuesCardOpts): string | null {
92
92
  const maxSeverity = sorted[0].severity;
93
93
  const headerEmoji = SEVERITY_EMOJI[maxSeverity];
94
94
  const count = sorted.length;
95
- const header = `${headerEmoji} <b>${escapeHtml(opts.agentName)}</b> · ${count} ${count === 1 ? "issue" : "issues"}`;
95
+ const header = `${headerEmoji} **${escapeMarkdown(opts.agentName)}** · ${count} ${count === 1 ? "issue" : "issues"}`;
96
96
 
97
97
  const maxRows = opts.maxRows ?? DEFAULT_MAX_ROWS;
98
98
  const visible = sorted.slice(0, maxRows);
@@ -101,22 +101,22 @@ export function renderIssuesCard(opts: RenderIssuesCardOpts): string | null {
101
101
  const now = opts.now ?? Date.now();
102
102
  const rows = visible.map((e) => {
103
103
  const emoji = SEVERITY_EMOJI[e.severity];
104
- const occ = e.occurrences > 1 ? ` <i>(×${e.occurrences})</i>` : "";
104
+ const occ = e.occurrences > 1 ? ` _(×${e.occurrences})_` : "";
105
105
  const ago = relTime(now - e.last_seen);
106
- const head = `${emoji} <code>${escapeHtml(e.fingerprint)}</code> ${escapeHtml(e.summary)}${occ} — <i>${ago}</i>`;
106
+ const head = `${emoji} \`${e.fingerprint}\` ${escapeMarkdown(e.summary)}${occ} — _${ago}_`;
107
107
  // Render the `detail` line below the summary when present and short
108
108
  // enough to be a remediation hint (not a multi-line stderr tail).
109
109
  // Convention from the cron prompt template: agents put "Fix: <cmd>"
110
110
  // or "→ <cmd>" in detail. Long stderr details are omitted from the
111
111
  // card to keep the layout tight; users can run /issues to see them.
112
112
  const remediation = formatRemediation(e.detail);
113
- return remediation == null ? head : `${head}\n → <i>${escapeHtml(remediation)}</i>`;
113
+ return remediation == null ? head : `${head}\n → _${escapeMarkdown(remediation)}_`;
114
114
  });
115
115
 
116
116
  const lines = [header, "", ...rows];
117
117
  if (overflow > 0) {
118
118
  lines.push("");
119
- lines.push(`<i>+${overflow} more not shown — run <code>/issues</code></i>`);
119
+ lines.push(`_+${overflow} more not shown — run \`/issues\`_`);
120
120
  }
121
121
  return lines.join("\n");
122
122
  }
@@ -326,7 +326,6 @@ export function createIssuesCardHandle(
326
326
  if (body === lastBody && messageId != null) return;
327
327
 
328
328
  const sendOpts: Record<string, unknown> = {
329
- parse_mode: "HTML",
330
329
  disable_web_page_preview: true,
331
330
  // Status card, not the user's answer — silence the open ping.
332
331
  // (editMessageText ignores disable_notification, so the shared
@@ -366,10 +366,10 @@ export function formatModelUnavailableCard(
366
366
  opts: FormatCardOptions = {},
367
367
  ): string {
368
368
  const now = opts.now ?? new Date()
369
- const slotPart = opts.slot ? ` (slot <b>${escHtml(opts.slot)}</b>)` : ''
369
+ const slotPart = opts.slot ? ` (slot **${escHtml(opts.slot)}**)` : ''
370
370
  const reason = formatReason(detection, now)
371
371
  const lines = [
372
- `⚠️ <b>Model unavailable</b> on agent <b>${escHtml(agent)}</b>${slotPart}`,
372
+ `⚠️ **Model unavailable** on agent **${escHtml(agent)}**${slotPart}`,
373
373
  `Reason: ${reason}`,
374
374
  '',
375
375
  ]
@@ -378,7 +378,7 @@ export function formatModelUnavailableCard(
378
378
  // swap; a follow-up announcement (causal-shape) will land within
379
379
  // ~1s. Mention it explicitly so the user knows not to react.
380
380
  lines.push(
381
- '<i>Auto-failover in progress — see the announcement below.</i>',
381
+ '_Auto-failover in progress — see the announcement below._',
382
382
  )
383
383
  } else {
384
384
  // Default — kinds where auto-fallback can't help (network)
@@ -386,10 +386,10 @@ export function formatModelUnavailableCard(
386
386
  // a verb (post-RFC-H); `/auth use <label>` is the canonical
387
387
  // fleet-wide swap.
388
388
  lines.push(
389
- '<b>What to try</b>',
390
- '• <code>/auth use &lt;label&gt;</code> — switch the fleet to a healthy account',
391
- '• <code>/auth add</code> — attach another subscription',
392
- '• <code>/usage</code> — show quota breakdown',
389
+ '**What to try**',
390
+ '• \`/auth use <label>\` — switch the fleet to a healthy account',
391
+ '• \`/auth add\` — attach another subscription',
392
+ '• \`/usage\` — show quota breakdown',
393
393
  )
394
394
  }
395
395
  return lines.join('\n')
@@ -471,9 +471,5 @@ export function resolveModelUnavailableFromOperatorEvent(
471
471
  // ─── HTML escape (mirrors operator-events.ts) ────────────────────────────────
472
472
 
473
473
  function escHtml(text: string): string {
474
- return text
475
- .replace(/&/g, '&amp;')
476
- .replace(/</g, '&lt;')
477
- .replace(/>/g, '&gt;')
478
- .replace(/"/g, '&quot;')
474
+ return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
479
475
  }
@@ -78,7 +78,7 @@ export function formatLastEventLine(
78
78
  }
79
79
 
80
80
  const kindIcon = EVENT_KIND_ICON[ev.kind] ?? '⚪'
81
- return ` <i>last: ${kindIcon} ${ev.kind} (${age})</i>`
81
+ return ` _last: ${kindIcon} ${ev.kind} (${age})_`
82
82
  }
83
83
 
84
84
  const EVENT_KIND_ICON: Record<string, string> = {
@@ -217,9 +217,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
217
217
  case 'credentials-expired':
218
218
  return {
219
219
  text: [
220
- `🔑 <b>Claude login expired</b> for <b>${agent}</b>.`,
221
- detail ? `<i>${detail}</i>` : '',
222
- `Tap <b>Reauth now</b> to refresh credentials.`,
220
+ `🔑 **Claude login expired** for **${agent}**.`,
221
+ detail ? `_${detail}_` : '',
222
+ `Tap **Reauth now** to refresh credentials.`,
223
223
  ]
224
224
  .filter(Boolean)
225
225
  .join('\n'),
@@ -236,9 +236,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
236
236
  case 'credentials-invalid':
237
237
  return {
238
238
  text: [
239
- `🔑 <b>Invalid Claude credentials</b> for <b>${agent}</b>.`,
240
- detail ? `<i>${detail}</i>` : '',
241
- `Run <code>/auth reauth ${agent}</code> or tap below.`,
239
+ `🔑 **Invalid Claude credentials** for **${agent}**.`,
240
+ detail ? `_${detail}_` : '',
241
+ `Run \`/auth reauth ${agent}\` or tap below.`,
242
242
  ]
243
243
  .filter(Boolean)
244
244
  .join('\n'),
@@ -255,9 +255,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
255
255
  case 'credit-exhausted':
256
256
  return {
257
257
  text: [
258
- `💳 <b>Credit balance too low</b> for <b>${agent}</b>.`,
259
- detail ? `<i>${detail}</i>` : '',
260
- `Use <code>/auth use &lt;label&gt;</code> to switch account slot or <code>/auth add</code> to add one.`,
258
+ `💳 **Credit balance too low** for **${agent}**.`,
259
+ detail ? `_${detail}_` : '',
260
+ `Use \`/auth use <label>\` to switch account slot or \`/auth add\` to add one.`,
261
261
  ]
262
262
  .filter(Boolean)
263
263
  .join('\n'),
@@ -274,9 +274,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
274
274
  // are the historical source; this is now the single owner.
275
275
  return {
276
276
  text: [
277
- `⚠️ <b>Quota exhausted</b> for <b>${agent}</b>.`,
278
- detail ? `<i>${detail}</i>` : '',
279
- `All account slots are at the usage limit. Switchroom will auto-fallback when another slot is available. Use <code>/auth use &lt;label&gt;</code> to switch manually.`,
277
+ `⚠️ **Quota exhausted** for **${agent}**.`,
278
+ detail ? `_${detail}_` : '',
279
+ `All account slots are at the usage limit. Switchroom will auto-fallback when another slot is available. Use \`/auth use <label>\` to switch manually.`,
280
280
  ]
281
281
  .filter(Boolean)
282
282
  .join('\n'),
@@ -290,8 +290,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
290
290
  case 'rate-limited':
291
291
  return {
292
292
  text: [
293
- `🚦 <b>Rate limited</b> for <b>${agent}</b>.`,
294
- detail ? `<i>${detail}</i>` : '',
293
+ `🚦 **Rate limited** for **${agent}**.`,
294
+ detail ? `_${detail}_` : '',
295
295
  `Claude is temporarily rate-limiting requests. Will retry automatically.`,
296
296
  ]
297
297
  .filter(Boolean)
@@ -306,8 +306,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
306
306
  case 'agent-crashed':
307
307
  return {
308
308
  text: [
309
- `💥 <b>Agent crashed</b>: <b>${agent}</b>.`,
310
- detail ? `<i>${detail}</i>` : '',
309
+ `💥 **Agent crashed**: **${agent}**.`,
310
+ detail ? `_${detail}_` : '',
311
311
  ]
312
312
  .filter(Boolean)
313
313
  .join('\n'),
@@ -324,8 +324,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
324
324
  case 'agent-restarted-unexpectedly':
325
325
  return {
326
326
  text: [
327
- `🔄 <b>Agent restarted unexpectedly</b>: <b>${agent}</b>.`,
328
- detail ? `<i>${detail}</i>` : '',
327
+ `🔄 **Agent restarted unexpectedly**: **${agent}**.`,
328
+ detail ? `_${detail}_` : '',
329
329
  `This may indicate a crash-loop. Check logs if it happens again.`,
330
330
  ]
331
331
  .filter(Boolean)
@@ -343,8 +343,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
343
343
  case 'unknown-4xx':
344
344
  return {
345
345
  text: [
346
- `⚠️ <b>API error (4xx)</b> for <b>${agent}</b>.`,
347
- detail ? `<code>${detail}</code>` : '',
346
+ `⚠️ **API error (4xx)** for **${agent}**.`,
347
+ detail ? `\`${detail}\`` : '',
348
348
  ]
349
349
  .filter(Boolean)
350
350
  .join('\n'),
@@ -361,8 +361,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
361
361
  case 'unknown-5xx':
362
362
  return {
363
363
  text: [
364
- `🔥 <b>Server error (5xx)</b> for <b>${agent}</b>.`,
365
- detail ? `<code>${detail}</code>` : '',
364
+ `🔥 **Server error (5xx)** for **${agent}**.`,
365
+ detail ? `\`${detail}\`` : '',
366
366
  `Anthropic may be experiencing issues. Will retry automatically.`,
367
367
  ]
368
368
  .filter(Boolean)
@@ -411,12 +411,8 @@ export function resetAllCooldowns(): void {
411
411
  cooldownMap.clear()
412
412
  }
413
413
 
414
- // ─── HTML escape ─────────────────────────────────────────────────────────────
414
+ // ─── Markdown escape (#2669) ──────────────────────────────────────────────────
415
415
 
416
416
  function escHtml(text: string): string {
417
- return text
418
- .replace(/&/g, '&amp;')
419
- .replace(/</g, '&lt;')
420
- .replace(/>/g, '&gt;')
421
- .replace(/"/g, '&quot;')
417
+ return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
422
418
  }
@@ -32,7 +32,7 @@
32
32
  "@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
33
33
  "@secretlint/types": "^12.2.0",
34
34
  "@xterm/headless": "^6.0.0",
35
- "grammy": "^1.21.0",
35
+ "grammy": "^1.44",
36
36
  "posthog-node": "^5.29.2"
37
37
  },
38
38
  "engines": {
@@ -49,14 +49,13 @@
49
49
  * interval is short (5s) but edits are spaced at EDIT_INTERVAL_MS so
50
50
  * the Telegram bot.api editMessageText rate stays well under limits.
51
51
  *
52
- * Edits preserve the anchor's original `parse_mode` (issue #1698). The
53
- * anchor was sent through the reply tool, which defaults to HTML; an
54
- * earlier version of this module dropped parse_mode on edit, which made
55
- * the next "still working (Nm)" tick re-render `<b>` / `<code>` tags as
56
- * literal text. The suffix itself is plain text (no `<`/`>`/`&`) so it
57
- * is safe under any parse_mode. On subsequent edits the prior suffix is
58
- * stripped before re-appending so the message never accumulates duplicate
59
- * suffixes.
52
+ * Edits preserve the anchor's original send shape — rich-markdown vs the
53
+ * literal `format:'text'` path (#2669, successor to the #1698 parse-mode
54
+ * contract). The anchor was sent through the reply tool, which defaults to
55
+ * the rich-markdown path; the edit re-sends via the SAME path so the
56
+ * anchor body keeps rendering the way it first did. On subsequent edits
57
+ * the prior suffix is stripped before re-appending so the message never
58
+ * accumulates duplicate suffixes.
60
59
  *
61
60
  * Kill switch: `SWITCHROOM_DISABLE_PENDING_PROGRESS=1` disables the
62
61
  * whole subsystem. The conversational-pacing prompt is unaffected.
@@ -65,32 +64,36 @@
65
64
  export const EDIT_INTERVAL_MS = 60_000
66
65
  export const POLL_INTERVAL_MS = 5_000
67
66
  export const MAX_LIFETIME_MS = 30 * 60_000
68
- /** Telegram message length limit is 4096; budget headroom for the
67
+ /** Rich-message wire cap is 32768 (#2669); budget headroom for the
69
68
  * suffix and any escape expansion. If the anchor text plus suffix
70
69
  * would exceed this, we skip the edit (the user still sees the
71
70
  * original) rather than truncate the model's authored prose. */
72
- export const TELEGRAM_MSG_CAP = 4000
71
+ export const TELEGRAM_MSG_CAP = 32768
73
72
 
74
73
  /**
75
74
  * Regex matching the suffix we append. Used to strip a prior suffix
76
75
  * before appending the next one. The (\d+) covers "1m" / "12m" / etc.
77
76
  * The reachability clause is optional so anchors carrying a pre-v0.14.30
78
- * suffix (no clause) are still stripped during a rolling upgrade.
79
- * Kept anchored to end-of-string so it only matches OUR suffix, not
80
- * something the model happened to write.
77
+ * suffix (no clause) are still stripped during a rolling upgrade. Both the
78
+ * legacy em-dash prefix (`— still working`) and the rich-markdown italic
79
+ * form (`_still working … _`, #2669) are matched so a rolling upgrade
80
+ * strips either. Kept anchored to end-of-string so it only matches OUR
81
+ * suffix, not something the model happened to write.
81
82
  */
82
83
  const SUFFIX_RE =
83
- /\n\n— still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/
84
+ /\n\n(?:— |_)still working \(\d+m\)( · message me anytime, I'll keep you posted)?_?$/
84
85
 
85
86
  export interface PendingProgressEditCtx {
86
87
  chatId: string
87
88
  threadId: number | null
88
89
  messageId: number
89
90
  newText: string
90
- /** Telegram parse_mode the original anchor was sent with (#1698).
91
- * The edit must use the same mode or pre-rendered HTML / MarkdownV2
92
- * tags in `anchorOriginalText` re-render as literal text. */
93
- parseMode: 'HTML' | 'MarkdownV2' | undefined
91
+ /** True when the original anchor was a literal `format:'text'` send
92
+ * (plain `sendMessage`, no rich-message wrapper). The edit must match:
93
+ * a rich anchor re-edits via `editMessageText({ markdown })`, a literal
94
+ * anchor re-edits as a plain string (#2669 single-rich-path migration of
95
+ * the #1698 parse-mode-preservation contract). */
96
+ literalText: boolean
94
97
  }
95
98
 
96
99
  /**
@@ -145,11 +148,11 @@ interface State {
145
148
  /** The captured anchor text — what the model wrote, *minus* any
146
149
  * prior pending-progress suffix. Used as the base for every edit. */
147
150
  anchorOriginalText: string
148
- /** parse_mode the anchor was originally sent with. Edits must
149
- * reuse this or the rendered HTML / MarkdownV2 tags in
150
- * anchorOriginalText render as literal text on the next tick
151
- * (issue #1698). */
152
- anchorParseMode: 'HTML' | 'MarkdownV2' | undefined
151
+ /** True when the anchor was a literal `format:'text'` send. Edits must
152
+ * match the original send shape (rich vs literal) or the suffix re-edit
153
+ * changes how the anchor body renders (the #2669 single-rich-path
154
+ * successor to the #1698 parse-mode-preservation contract). */
155
+ anchorLiteralText: boolean
153
156
  /** Wall-clock ms when the cross-turn ambient state was *activated*
154
157
  * (at turn_end with pending+anchor). null before activation. */
155
158
  activatedAt: number | null
@@ -178,7 +181,7 @@ function ensure(key: string): State {
178
181
  pending: false,
179
182
  anchorMessageId: null,
180
183
  anchorOriginalText: '',
181
- anchorParseMode: undefined,
184
+ anchorLiteralText: false,
182
185
  activatedAt: null,
183
186
  lastEditAt: null,
184
187
  }
@@ -216,7 +219,7 @@ export function startTurn(key: string): void {
216
219
  s.pending = false
217
220
  s.anchorMessageId = null
218
221
  s.anchorOriginalText = ''
219
- s.anchorParseMode = undefined
222
+ s.anchorLiteralText = false
220
223
  }
221
224
 
222
225
  /**
@@ -241,21 +244,18 @@ export function noteOutbound(
241
244
  opts: {
242
245
  messageId: number
243
246
  text: string
244
- /** parse_mode the anchor was sent with. Captured so the
245
- * cross-turn edit tick can reuse it (#1698). Undefined or
246
- * omitted means the original send had no parse_mode (plain
247
- * text). Production callers MUST pass this — every reply path
248
- * knows its own parse_mode. Defaulted to undefined only so test
249
- * fixtures don't have to thread it through where they're
250
- * asserting other behaviour. */
251
- parseMode?: 'HTML' | 'MarkdownV2' | undefined
247
+ /** True when the anchor was a literal `format:'text'` send. Captured
248
+ * so the cross-turn edit tick re-edits with the same shape (#2669
249
+ * single-rich-path successor to the #1698 parse-mode contract).
250
+ * Omitted ⇒ false (the rich-markdown default). */
251
+ literalText?: boolean
252
252
  },
253
253
  ): void {
254
254
  if (!enabled()) return
255
255
  const s = ensure(key)
256
256
  s.anchorMessageId = opts.messageId
257
257
  s.anchorOriginalText = opts.text.replace(SUFFIX_RE, '')
258
- s.anchorParseMode = opts.parseMode
258
+ s.anchorLiteralText = opts.literalText ?? false
259
259
  }
260
260
 
261
261
  /**
@@ -399,7 +399,7 @@ function tick(now: number): void {
399
399
  // user-visible counter reads honestly (we only edit at intervals
400
400
  // ≥ EDIT_INTERVAL_MS = 60s).
401
401
  const minutes = Math.max(1, Math.round(elapsed / 60_000))
402
- const suffix = `\n\n— still working (${minutes}m) · message me anytime, I'll keep you posted`
402
+ const suffix = `\n\n_still working (${minutes}m) · message me anytime, I'll keep you posted_`
403
403
  const newText = s.anchorOriginalText + suffix
404
404
 
405
405
  if (newText.length > TELEGRAM_MSG_CAP) {
@@ -417,7 +417,7 @@ function tick(now: number): void {
417
417
  threadId,
418
418
  messageId: s.anchorMessageId,
419
419
  newText,
420
- parseMode: s.anchorParseMode,
420
+ literalText: s.anchorLiteralText,
421
421
  }
422
422
  // Fire-and-forget so a slow edit doesn't block the tick loop.
423
423
  // Errors are logged but never bubble (a 429 / "message not modified"
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { basename } from "node:path";
21
+ import { escapeMarkdown } from "./card-format.js";
21
22
  import { prettyMcpServer, type ScopeOption } from "./permission-rule.js";
22
23
  import { redact } from "./secret-detect/redact.js";
23
24
 
@@ -96,10 +97,10 @@ const HOSTD_AGENT_TARGET_VERBS = new Set([
96
97
  /**
97
98
  * Build the multi-line card body for an approval prompt.
98
99
  *
99
- * 🔐 <b>Gymbro</b> wants to edit: supplement-log.md
100
- * why: <i>logging today's lifts</i>
100
+ * 🔐 **Gymbro** wants to edit: supplement-log.md
101
+ * why: _logging today's lifts_
101
102
  *
102
- * Output is HTML-escaped for `parse_mode: 'HTML'`. The agent name is
103
+ * Output is GFM markdown for the rich-message path (#2669). The agent name is
103
104
  * capitalized for the sentence; dropped (with "wants to") when null —
104
105
  * the bridge client can be anonymous during early-boot edge cases.
105
106
  *
@@ -127,10 +128,10 @@ export function formatPermissionCardBody(opts: {
127
128
 
128
129
  if (opts.agentName && opts.agentName.length > 0) {
129
130
  lines.push(
130
- `🔐 <b>${escapeTgHtml(capFirst(opts.agentName))}</b> wants to ${escapeTgHtml(action)}`,
131
+ `🔐 **${escapeTgHtml(capFirst(opts.agentName))}** wants to ${escapeActionMarkdown(action)}`,
131
132
  );
132
133
  } else {
133
- lines.push(`🔐 ${escapeTgHtml(capFirst(action))}`);
134
+ lines.push(`🔐 ${escapeActionMarkdown(capFirst(action))}`);
134
135
  }
135
136
 
136
137
  // why: the caller-supplied rationale (`reason`/`why` arg), never the
@@ -143,8 +144,8 @@ export function formatPermissionCardBody(opts: {
143
144
  : rawWhy;
144
145
  lines.push(
145
146
  truncatedWhy.length > 0
146
- ? `why: <i>${escapeTgHtml(truncatedWhy)}</i>`
147
- : `why: <i>not provided</i>`,
147
+ ? `why: _${escapeTgHtml(truncatedWhy)}_`
148
+ : `why: _not provided_`,
148
149
  );
149
150
 
150
151
  // Third line (REST-wrapper MCP writes only): a redaction-safe summary of
@@ -152,7 +153,7 @@ export function formatPermissionCardBody(opts: {
152
153
  // endpoint — e.g. "↳ to: lisa@…, subject: Priority access…".
153
154
  const argSummary = mcpArgSummary(opts.toolName, opts.inputPreview);
154
155
  if (argSummary) {
155
- lines.push(`↳ <i>${escapeTgHtml(argSummary)}</i>`);
156
+ lines.push(`↳ _${escapeTgHtml(argSummary)}_`);
156
157
  }
157
158
 
158
159
  return lines.join("\n");
@@ -424,10 +425,10 @@ export function describeGrant(
424
425
  * the operator scrolls past — so this is the legible signal that the tap
425
426
  * landed and names the work being (re)started.
426
427
  *
427
- * Mirrors `formatPermissionCardBody`'s style ("🔐 <b>Gymbro</b> wants to
428
- * edit: log.md" → "▶️ <b>Gymbro</b> — got it, continuing: edit: log.md").
428
+ * Mirrors `formatPermissionCardBody`'s style ("🔐 **Gymbro** wants to
429
+ * edit: log.md" → "▶️ **Gymbro** — got it, continuing: edit: log.md").
429
430
  * `action` is a phrase from {@link naturalAction} (already operator-facing,
430
- * no tool ids). Output is HTML-escaped for `parse_mode: 'HTML'`.
431
+ * no tool ids). Output is GFM markdown for the rich-message path (#2669).
431
432
  *
432
433
  * `timeoutMinutes` marks the TTL auto-deny variant (no operator tapped —
433
434
  * the request aged out) so the wording reflects "no answer" rather than a
@@ -441,25 +442,25 @@ export function formatPermissionResumeMessage(opts: {
441
442
  }): string {
442
443
  const who =
443
444
  opts.agentName && opts.agentName.length > 0
444
- ? `<b>${escapeTgHtml(capFirst(opts.agentName))}</b>`
445
- : `<b>Agent</b>`;
445
+ ? `**${escapeTgHtml(capFirst(opts.agentName))}**`
446
+ : `**Agent**`;
446
447
  const act = (opts.action ?? "").trim();
447
448
  const hasAction = act.length > 0;
448
449
 
449
450
  if (opts.behavior === "allow") {
450
451
  return hasAction
451
- ? `▶️ ${who} — got it, continuing: <i>${escapeTgHtml(act)}</i>`
452
+ ? `▶️ ${who} — got it, continuing: _${escapeActionMarkdown(act)}_`
452
453
  : `▶️ ${who} — got it, back to work.`;
453
454
  }
454
455
 
455
456
  // deny
456
457
  if (opts.timeoutMinutes != null) {
457
458
  return hasAction
458
- ? `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it (<i>${escapeTgHtml(act)}</i>).`
459
+ ? `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it (_${escapeActionMarkdown(act)}_).`
459
460
  : `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it.`;
460
461
  }
461
462
  return hasAction
462
- ? `🚫 ${who} — noted, I won't ${escapeTgHtml(lowerFirst(act))}. Continuing without it.`
463
+ ? `🚫 ${who} — noted, I won't ${escapeActionMarkdown(lowerFirst(act))}. Continuing without it.`
463
464
  : `🚫 ${who} — noted, continuing without it.`;
464
465
  }
465
466
 
@@ -520,12 +521,27 @@ function capFirst(text: string): string {
520
521
  return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : text;
521
522
  }
522
523
 
523
- /** Minimal HTML escape for Telegram `parse_mode=HTML`. */
524
+ /** Markdown escape for the rich-message path (#2669). */
524
525
  function escapeTgHtml(text: string): string {
526
+ return escapeMarkdown(text);
527
+ }
528
+
529
+ /**
530
+ * Escape a phrase that may itself contain DELIBERATE `` `code spans` ``
531
+ * (e.g. the hostd fleet-verb phrases name the target agent in a code span:
532
+ * "restart agent `carrie` in the fleet"). The structural backticks must NOT
533
+ * be escaped — that would render literal `` \`carrie\` `` to the operator
534
+ * (#2669 regression). Everything OUTSIDE a backtick span is still escaped so
535
+ * a hostile Bash command / filename can't inject emphasis. Content INSIDE a
536
+ * span is left verbatim — code spans render literally, so there is no
537
+ * injection risk there.
538
+ */
539
+ function escapeActionMarkdown(text: string): string {
540
+ // Split on balanced single-backtick spans, keeping the delimiters.
525
541
  return text
526
- .replace(/&/g, "&amp;")
527
- .replace(/</g, "&lt;")
528
- .replace(/>/g, "&gt;");
542
+ .split(/(`[^`]*`)/g)
543
+ .map((seg) => (seg.startsWith("`") && seg.endsWith("`") && seg.length >= 2 ? seg : escapeMarkdown(seg)))
544
+ .join("");
529
545
  }
530
546
 
531
547
  function parseInput(raw: string | undefined): Record<string, unknown> | null {
@@ -48,13 +48,6 @@ export interface PtyHandlerState {
48
48
  pendingPtyPartial: { text: string } | null
49
49
  /** Active streams, keyed by `chat_id:thread_id`. */
50
50
  activeDraftStreams: Map<string, DraftStreamHandle>
51
- /**
52
- * Parallel to activeDraftStreams: parseMode baked into each controller.
53
- * Lets `handleStreamReply` detect and rotate streams whose parseMode no
54
- * longer matches the caller's resolved format (bug 1). Optional for
55
- * backwards compatibility.
56
- */
57
- activeDraftParseModes?: Map<string, 'HTML' | 'MarkdownV2' | undefined>
58
51
  /**
59
52
  * Chats whose PTY preview is claimed by an in-flight reply handler.
60
53
  * Partials for these chats are dropped to avoid duplicates.
@@ -67,8 +60,6 @@ export interface PtyHandlerState {
67
60
  export interface PtyHandlerDeps {
68
61
  bot: { api: StreamBotApi }
69
62
  retry?: RetryPolicy
70
- /** Markdown → HTML renderer applied to the text before stream.update. */
71
- renderText: (text: string) => string
72
63
  /** Optional structured event hook, called once per invocation. */
73
64
  logEvent?: (ev: {
74
65
  kind: 'pty_partial_received'
@@ -154,7 +145,10 @@ export function handlePtyPartialPure(
154
145
  bot: deps.bot,
155
146
  chatId,
156
147
  threadId,
157
- parseMode: 'HTML',
148
+ // PTY-tail previews are raw terminal/TUI output, not authored
149
+ // markdown — send them literally so control glyphs and box-drawing
150
+ // characters aren't misinterpreted as markdown syntax (#2669).
151
+ literalText: true,
158
152
  disableLinkPreview: true,
159
153
  throttleMs: 600,
160
154
  retry: deps.retry,
@@ -177,11 +171,9 @@ export function handlePtyPartialPure(
177
171
  : {}),
178
172
  })
179
173
  state.activeDraftStreams.set(sKey, stream)
180
- state.activeDraftParseModes?.set(sKey, 'HTML')
181
174
  }
182
175
 
183
- const rendered = deps.renderText(text)
184
- void stream.update(rendered).catch(() => { /* swallow — logged elsewhere */ })
176
+ void stream.update(text).catch(() => { /* swallow — logged elsewhere */ })
185
177
 
186
178
  return created ? 'update-new' : 'update-existing'
187
179
  }