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
@@ -250,21 +250,21 @@ export function formatResetRelative(target: Date | null, now: Date = new Date())
250
250
  */
251
251
  export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): string {
252
252
  const lines: string[] = [];
253
- lines.push("<b>Claude plan quota</b>");
253
+ lines.push("**Claude plan quota**");
254
254
  lines.push("");
255
255
  lines.push(
256
- `<b>5h window</b> ${Math.round(q.fiveHourUtilizationPct)}% · ${formatResetRelative(q.fiveHourResetAt, now)}`,
256
+ `**5h window** ${Math.round(q.fiveHourUtilizationPct)}% · ${formatResetRelative(q.fiveHourResetAt, now)}`,
257
257
  );
258
258
  lines.push(
259
- `<b>7d window</b> ${Math.round(q.sevenDayUtilizationPct)}% · ${formatResetRelative(q.sevenDayResetAt, now)}`,
259
+ `**7d window** ${Math.round(q.sevenDayUtilizationPct)}% · ${formatResetRelative(q.sevenDayResetAt, now)}`,
260
260
  );
261
261
  if (q.representativeClaim) {
262
262
  lines.push("");
263
- lines.push(`<i>Binding window: ${q.representativeClaim.replace(/_/g, " ")}</i>`);
263
+ lines.push(`_Binding window: ${q.representativeClaim.replace(/_/g, " ")}_`);
264
264
  }
265
265
  if (q.overageStatus && q.overageStatus !== "allowed") {
266
266
  const reason = q.overageDisabledReason ? ` (${q.overageDisabledReason})` : "";
267
- lines.push(`<i>Overage: ${q.overageStatus}${reason}</i>`);
267
+ lines.push(`_Overage: ${q.overageStatus}${reason}_`);
268
268
  }
269
269
  return lines.join("\n");
270
270
  }
@@ -498,12 +498,12 @@ function buildAllExhaustedMessage(
498
498
  ? `Earliest reset: ${formatRelative(new Date(earliest), new Date(now))}.`
499
499
  : `Reset time unknown (no window data).`;
500
500
  return [
501
- `🔴 <b>All accounts exhausted</b>`,
501
+ `🔴 **All accounts exhausted**`,
502
502
  ``,
503
503
  `Every Anthropic account (${accounts.length}) is quota-walled — there is no healthy account to fail over to.`,
504
504
  resetLine,
505
505
  ``,
506
- `<i>This is self-healing: agents resume and deferred scheduled jobs run automatically once a window resets. Nothing is lost. Add headroom with <code>/auth add</code> if this recurs.</i>`,
506
+ `_This is self-healing: agents resume and deferred scheduled jobs run automatically once a window resets. Nothing is lost. Add headroom with \`/auth add\` if this recurs._`,
507
507
  ].join("\n");
508
508
  }
509
509
 
@@ -511,11 +511,11 @@ function buildFleetRecoveredMessage(
511
511
  accounts: Array<{ label: string; exhausted: boolean }>,
512
512
  ): string {
513
513
  const healthy = accounts.filter((a) => !a.exhausted).map((a) => a.label);
514
- const which = healthy.length > 0 ? ` (<code>${escapeHtml(healthy[0]!)}</code>)` : "";
514
+ const which = healthy.length > 0 ? ` (\`${escapeHtml(healthy[0]!)}\`)` : "";
515
515
  return [
516
- `🟢 <b>Fleet recovered</b> — at least one account is healthy again${which}.`,
516
+ `🟢 **Fleet recovered** — at least one account is healthy again${which}.`,
517
517
  ``,
518
- `<i>Agents are back; any deferred scheduled jobs will run on their next occurrence.</i>`,
518
+ `_Agents are back; any deferred scheduled jobs will run on their next occurrence._`,
519
519
  ].join("\n");
520
520
  }
521
521
 
@@ -535,21 +535,21 @@ function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): strin
535
535
 
536
536
  const activeNote = snap.isActive
537
537
  ? ""
538
- : `\nThis is a non-active account. Consider <code>/auth use ${escapeHtml(snap.label)}</code> to switch, or keep it as a fallback reserve.`;
538
+ : `\nThis is a non-active account. Consider \`/auth use ${escapeHtml(snap.label)}\` to switch, or keep it as a fallback reserve.`;
539
539
 
540
540
  const altNote = snap.isActive
541
- ? `\nConsider <code>/auth use &lt;other-account&gt;</code> if you have a healthier account, or wait for the ${winLabel} window to refill${resetStr}.`
541
+ ? `\nConsider \`/auth use <other-account>\` if you have a healthier account, or wait for the ${winLabel} window to refill${resetStr}.`
542
542
  : "";
543
543
 
544
544
  return [
545
- `🟡 <b>Quota approaching limit</b> — <code>${escapeHtml(snap.label)}</code>`,
545
+ `🟡 **Quota approaching limit** — \`${escapeHtml(snap.label)}\``,
546
546
  ``,
547
547
  `${fiveStr} of 5h · ${sevenStr} of 7d`,
548
548
  `Binding window: ${winLabel}${resetStr}`,
549
549
  `${activeNote}${altNote}`,
550
550
  ``,
551
- `<i>Threshold: ${THROTTLING_THRESHOLD_PCT}% on either window. Live-probe corroborated (#2495).</i>`,
552
- `<i>Run /auth for full fleet status or /usage for the active account.</i>`,
551
+ `_Threshold: ${THROTTLING_THRESHOLD_PCT}% on either window. Live-probe corroborated (#2495)._`,
552
+ `_Run /auth for full fleet status or /usage for the active account._`,
553
553
  ]
554
554
  .join("\n")
555
555
  .replace(/\n\n\n+/g, "\n\n")
@@ -563,21 +563,16 @@ function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string
563
563
  : "Current quota data unavailable.";
564
564
 
565
565
  return [
566
- `🟢 <b>Quota back in healthy range</b> — <code>${escapeHtml(snap.label)}</code>`,
566
+ `🟢 **Quota back in healthy range** — \`${escapeHtml(snap.label)}\``,
567
567
  ``,
568
568
  utilLine,
569
569
  ``,
570
- `<i>Below ${THROTTLING_THRESHOLD_PCT}% on both windows.</i>`,
570
+ `_Below ${THROTTLING_THRESHOLD_PCT}% on both windows._`,
571
571
  ].join("\n");
572
572
  }
573
573
 
574
574
  function escapeHtml(s: string): string {
575
- return s
576
- .replace(/&/g, "&amp;")
577
- .replace(/</g, "&lt;")
578
- .replace(/>/g, "&gt;")
579
- .replace(/"/g, "&quot;")
580
- .replace(/'/g, "&#39;");
575
+ return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
581
576
  }
582
577
 
583
578
  // ─── State persistence ────────────────────────────────────────────────────────
@@ -15,8 +15,8 @@
15
15
  * (raw markdown, since reply tools don't always render HTML).
16
16
  *
17
17
  * Smoking-gun evidence: klanker chat 12345, msgs 5025 + 5027,
18
- * 11s apart. msg=5025 had `<b>...</b>` (turn-flush + markdownToHtml).
19
- * msg=5027 had `**...**` (the raw markdown reply tool's payload).
18
+ * 11s apart. The two paths historically rendered identical content two
19
+ * different ways (one HTML-escaped, one raw markdown) and shipped both.
20
20
  * Same content, different formatting, two messages.
21
21
  *
22
22
  * Fix shape: maintain a small in-memory cache of "what we just sent"
@@ -188,9 +188,9 @@ function makeKey(chatId: string, threadId: number | undefined): string {
188
188
 
189
189
  /**
190
190
  * Normalise text for content equality. The bug we're defending
191
- * against produces the SAME content rendered two different ways:
192
- * one path runs `markdownToHtml` (so `**foo**` becomes `<b>foo</b>`),
193
- * the other doesn't. Both must hash identically.
191
+ * against produces the SAME content rendered two different ways
192
+ * (historically one HTML-rendered, the other raw markdown). Both must
193
+ * hash identically — so we strip both HTML tags and markdown markers.
194
194
  *
195
195
  * Steps:
196
196
  * 1. Strip HTML tags (`<b>foo</b>` → `foo`).
@@ -218,7 +218,14 @@ export function openTurnsDb(agentDir: string): SqliteDatabase {
218
218
  const db = new Database(path, { create: true })
219
219
  applySchema(db)
220
220
  try {
221
- chmodSync(path, 0o600)
221
+ // 0o644 on all three SQLite files so the switchroom-web container
222
+ // (different UID, same host bind-mount) can read turn history.
223
+ // WAL mode requires read access to registry.db-shm and registry.db-wal
224
+ // in addition to the main file — all three must be world-readable.
225
+ chmodSync(path, 0o644)
226
+ for (const suffix of ['-shm', '-wal']) {
227
+ try { chmodSync(path + suffix, 0o644) } catch { /* doesn't exist yet */ }
228
+ }
222
229
  } catch {
223
230
  /* ignore — chmod not supported on some FUSE mounts */
224
231
  }
@@ -496,11 +503,46 @@ export function findRecentTurnsForChat(
496
503
  *
497
504
  * `limit` defaults to 20, max 200.
498
505
  */
506
+ /**
507
+ * Return distinct thread_ids (null = general topic) for a given chat_id.
508
+ * Used by the Hermes adapter to enumerate forum topics as separate sessions.
509
+ */
510
+ export function listDistinctThreadIds(
511
+ db: SqliteDatabase,
512
+ chatId: string,
513
+ ): (string | null)[] {
514
+ const rows = db.prepare(`
515
+ SELECT DISTINCT thread_id FROM turns
516
+ WHERE chat_id = ?
517
+ ORDER BY thread_id ASC
518
+ `).all(chatId) as { thread_id: string | null }[]
519
+ return rows.map((r) => r.thread_id)
520
+ }
521
+
499
522
  export function listTurnsForAgent(
500
523
  db: SqliteDatabase,
501
- opts: { limit?: number } = {},
524
+ opts: { limit?: number; chatId?: string; threadId?: string | null } = {},
502
525
  ): Turn[] {
503
526
  const limit = Math.min(Math.max(1, opts.limit ?? 20), 200)
527
+ if (opts.chatId && 'threadId' in opts) {
528
+ // threadId may be a string ID or null (general topic)
529
+ const rows = db.prepare(`
530
+ SELECT * FROM turns
531
+ WHERE chat_id = ? AND thread_id IS ?
532
+ ORDER BY started_at DESC
533
+ LIMIT ?
534
+ `).all(opts.chatId, opts.threadId ?? null, limit) as RawTurnRow[]
535
+ return rows.map(mapRow)
536
+ }
537
+ if (opts.chatId) {
538
+ const rows = db.prepare(`
539
+ SELECT * FROM turns
540
+ WHERE chat_id = ?
541
+ ORDER BY started_at DESC
542
+ LIMIT ?
543
+ `).all(opts.chatId, limit) as RawTurnRow[]
544
+ return rows.map(mapRow)
545
+ }
504
546
  const rows = db.prepare(`
505
547
  SELECT * FROM turns
506
548
  ORDER BY started_at DESC
@@ -253,13 +253,16 @@ export async function retryWithThreadFallback<T>(
253
253
 
254
254
  /**
255
255
  * True when Telegram rejected a message because it couldn't parse the
256
- * HTML/entities we sent — our prevention (markdownToHtml +
257
- * sanitizeForTelegram + splitHtmlChunks) let something malformed
258
- * through anyway. These 400s are deliberately NOT swallowed or retried
259
- * by `retryApiCall` (only not-modified / not-found / thread-not-found
260
- * are) — they surface to the caller, which recovers by resending the
261
- * chunk as plain text (parse_mode unset). Same "caller-level fallback"
262
- * shape as the THREAD_NOT_FOUND contract above.
256
+ * markdown entities we sent on the rich-message path (#2669). There is no
257
+ * new rich-specific error class in grammy 1.44 — a malformed-markdown
258
+ * failure still throws `GrammyError` with the standard
259
+ * `{ ok:false, error_code:400, description }` shape, the same family as the
260
+ * legacy "can't parse entities" 400. These 400s are deliberately NOT
261
+ * swallowed or retried by `retryApiCall` (only not-modified / not-found /
262
+ * thread-not-found are) — they surface to the caller, which recovers by
263
+ * resending the chunk as plain text (no rich-message wrapper, so the
264
+ * parser never runs). Same "caller-level fallback" shape as the
265
+ * THREAD_NOT_FOUND contract above.
263
266
  */
264
267
  export function isHtmlParseRejectError(err: unknown): boolean {
265
268
  if (!(err instanceof GrammyError) || err.error_code !== 400) return false
@@ -267,9 +270,14 @@ export function isHtmlParseRejectError(err: unknown): boolean {
267
270
  return (
268
271
  d.includes("can't parse entities") ||
269
272
  d.includes('can’t parse entities') ||
273
+ d.includes("can't parse") ||
274
+ d.includes('can’t parse') ||
275
+ d.includes('parse markdown') ||
276
+ d.includes('parse rich') ||
270
277
  d.includes('unsupported start tag') ||
271
278
  d.includes('unclosed start tag') ||
272
279
  d.includes("can't find end of the entity") ||
280
+ d.includes('can’t find end of the entity') ||
273
281
  // covers both "expected end tag" and "unexpected end tag"
274
282
  d.includes('expected end tag')
275
283
  )
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Rich-message send/edit helpers (Bot API 10.1, #2669).
3
+ *
4
+ * Every outbound message in the plugin goes through `sendRichMessage` /
5
+ * `editMessageText({ markdown })` with raw GFM markdown. These two tiny
6
+ * helpers build the canonical call shape so the ~dozens of card surfaces
7
+ * and the gateway core all share ONE rendering path:
8
+ *
9
+ * - `richMessage(text)` → `{ markdown: text }`, the `InputRichMessage`
10
+ * accepted by both `sendRichMessage` and `editMessageText`.
11
+ * - `isParseEntitiesError(err)` → true when Telegram rejected the body
12
+ * because it couldn't parse the markdown entities. There is NO new
13
+ * rich-specific error class in grammy 1.44 — a malformed-markdown
14
+ * failure still throws `GrammyError` with the standard
15
+ * `{ ok:false, error_code:400, description }` shape, the same family
16
+ * as the legacy "can't parse entities" 400. Callers recover by
17
+ * resending the SAME message id as plain text (no rich, no parse_mode).
18
+ */
19
+
20
+ import { GrammyError } from 'grammy'
21
+
22
+ /** The `InputRichMessage` shape grammy 1.44 accepts on send AND edit. */
23
+ export interface InputRichMessageMarkdown {
24
+ markdown: string
25
+ }
26
+
27
+ /** Wrap raw GFM markdown into the rich-message input object. */
28
+ export function richMessage(markdown: string): InputRichMessageMarkdown {
29
+ return { markdown }
30
+ }
31
+
32
+ /**
33
+ * True when Telegram rejected a message because it couldn't parse the
34
+ * markdown entities we sent. These 400s are deliberately NOT swallowed or
35
+ * retried by the retry policy — they surface to the caller, which recovers
36
+ * by resending the body as plain text (a literal string, no rich-message
37
+ * wrapper, so the parser never runs). Same "caller-level fallback" contract
38
+ * the old HTML path used.
39
+ */
40
+ export function isParseEntitiesError(err: unknown): boolean {
41
+ if (!(err instanceof GrammyError) || err.error_code !== 400) return false
42
+ const d = (err.description || '').toLowerCase()
43
+ return (
44
+ d.includes("can't parse entities") ||
45
+ d.includes('can’t parse entities') ||
46
+ d.includes("can't parse") ||
47
+ d.includes('can’t parse') ||
48
+ d.includes('parse markdown') ||
49
+ d.includes('parse rich') ||
50
+ d.includes("can't find end of the entity") ||
51
+ d.includes('can’t find end of the entity') ||
52
+ d.includes('unsupported start tag') ||
53
+ d.includes('unclosed start tag') ||
54
+ // covers both "expected end tag" and "unexpected end tag"
55
+ d.includes('expected end tag')
56
+ )
57
+ }
@@ -80,7 +80,7 @@ describe("renderVaultCliError", () => {
80
80
  // appeared in output (so the operator knew which key triggered
81
81
  // the card). New copy keeps the key in <code>…</code> form via
82
82
  // htmlEscape — assert it.
83
- expect(out.html).toContain("<code>my_key</code>");
83
+ expect(out.html).toContain("`my_key`");
84
84
  });
85
85
 
86
86
  it("renders sandbox_context for verb=set WITHOUT a key (defensive fallback)", () => {
@@ -92,7 +92,7 @@ describe("renderVaultCliError", () => {
92
92
  { verb: "set" },
93
93
  );
94
94
  expect(out.html).toMatch(/vault_request_save/);
95
- expect(out.html).not.toContain("<code></code>");
95
+ expect(out.html).not.toContain("``");
96
96
  });
97
97
 
98
98
  it("renders sandbox_context for verb=get with /vault get", () => {
@@ -119,7 +119,7 @@ describe("renderVaultCliError", () => {
119
119
  );
120
120
  expect(out.suppressRaw).toBe(true);
121
121
  expect(out.html).toContain("operator approval required");
122
- expect(out.html).toContain("<code>telegram_bot_token</code>");
122
+ expect(out.html).toContain("`telegram_bot_token`");
123
123
  expect(out.html).toMatch(/vault_request_save/);
124
124
  expect(out.html).not.toMatch(/on the way/i);
125
125
  });
@@ -175,12 +175,12 @@ describe("renderVaultCliError", () => {
175
175
  expect(out.html).toBe("");
176
176
  });
177
177
 
178
- it("escapes HTML special characters in the key", () => {
178
+ it("keeps the key literal inside the code span (< > stay verbatim, #2669)", () => {
179
179
  const out = renderVaultCliError(
180
180
  { kind: "needs_approval", original: "x", key: "key<with>html" },
181
181
  { verb: "save" },
182
182
  );
183
- expect(out.html).not.toContain("<with>");
184
- expect(out.html).toContain("key&lt;with&gt;html");
183
+ // < > are literal in rich markdown and ride verbatim in the `code span`.
184
+ expect(out.html).toContain("`key<with>html`");
185
185
  });
186
186
  });
@@ -127,10 +127,17 @@ export interface VaultErrorRendering {
127
127
  }
128
128
 
129
129
  function htmlEscape(s: string): string {
130
- return s
131
- .replace(/&/g, "&amp;")
132
- .replace(/</g, "&lt;")
133
- .replace(/>/g, "&gt;");
130
+ return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
131
+ }
132
+
133
+ /**
134
+ * Make a string safe to interpolate INSIDE a `code span` without escaping —
135
+ * content there is literal, so markdown-escaping a key would leak visible
136
+ * backslashes (e.g. `my\_key`). Only a backtick can prematurely close the
137
+ * span, so split it with a zero-width space (#2669).
138
+ */
139
+ function codeSpanSafe(s: string): string {
140
+ return s.replace(/`/g, "`​");
134
141
  }
135
142
 
136
143
  /**
@@ -173,13 +180,13 @@ export function renderVaultCliError(
173
180
  return {
174
181
  suppressRaw: true,
175
182
  html:
176
- `⚠️ <b>New vault key — operator approval required.</b>\n` +
183
+ `⚠️ **New vault key — operator approval required.**\n` +
177
184
  (key
178
- ? `The agent tried to save <code>${htmlEscape(key)}</code>, but `
185
+ ? `The agent tried to save \`${codeSpanSafe(key)}\`, but `
179
186
  : `The agent tried to save a new key, but `) +
180
187
  `agents can only rotate existing keys via the broker; introducing ` +
181
188
  `a new key needs an operator action.\n\n` +
182
- `<b>Ask the agent</b> to call its <code>vault_request_save</code> ` +
189
+ `**Ask the agent** to call its \`vault_request_save\` ` +
183
190
  `tool — it'll render an approval card in this chat with ` +
184
191
  `[✅ Save once] [🚫 Discard] [✏️ Rename] buttons. One tap saves the ` +
185
192
  `secret to the vault; no re-paste needed.`,
@@ -194,12 +201,12 @@ export function renderVaultCliError(
194
201
  return {
195
202
  suppressRaw: true,
196
203
  html:
197
- `⚠️ <b>Vault broker isn't reachable.</b>\n` +
204
+ `⚠️ **Vault broker isn't reachable.**\n` +
198
205
  `From inside the agent sandbox there's no fallback path. ` +
199
206
  `Telegram-native broker recovery is tracked as a follow-up — ` +
200
207
  `for now, on the host:\n` +
201
- `<pre>switchroom vault broker status</pre>\n` +
202
- `<i>Or, if the broker is wedged: <code>docker compose -p switchroom restart vault-broker</code>.</i>`,
208
+ "```\nswitchroom vault broker status\n```\n" +
209
+ `_Or, if the broker is wedged: \`docker compose -p switchroom restart vault-broker\`._`,
203
210
  };
204
211
  case "broker_denied":
205
212
  // Telegram-native grant flow shipped in #969 P2b + #1012:
@@ -210,14 +217,14 @@ export function renderVaultCliError(
210
217
  return {
211
218
  suppressRaw: true,
212
219
  html:
213
- `⚠️ <b>Vault broker refused the request.</b>\n` +
220
+ `⚠️ **Vault broker refused the request.**\n` +
214
221
  (key
215
- ? `The agent isn't authorized to access <code>${htmlEscape(key)}</code>. `
222
+ ? `The agent isn't authorized to access \`${codeSpanSafe(key)}\`. `
216
223
  : `The agent isn't authorized to access this key. `) +
217
224
  `Grant access in two taps:\n` +
218
- `• <code>/vault audit &lt;agent&gt;</code> in this chat → tap ` +
225
+ `• \`/vault audit <agent>\` in this chat → tap ` +
219
226
  `[🔓 Allow${key ? ` ${htmlEscape(key)}` : ""}] on the recent denial, OR\n` +
220
- `• ask the agent to call <code>vault_request_access</code> — ` +
227
+ `• ask the agent to call \`vault_request_access\` — ` +
221
228
  `an approval card lands here with [✅ Approve] / [🚫 Deny].`,
222
229
  };
223
230
  case "other":
@@ -234,36 +241,36 @@ function renderSandboxContextSuggestion(
234
241
  verb: "set" | "get" | "list" | "init" | "remove" | "save",
235
242
  key: string | undefined,
236
243
  ): string {
237
- const head = `⚠️ <b>Direct vault file IO isn't available from inside the agent sandbox.</b>\n`;
244
+ const head = `⚠️ **Direct vault file IO isn't available from inside the agent sandbox.**\n`;
238
245
  switch (verb) {
239
246
  case "set":
240
247
  case "save":
241
248
  return (
242
249
  head +
243
250
  (key
244
- ? `<b>Ask the agent</b> to call its <code>vault_request_save</code> ` +
245
- `tool for <code>${htmlEscape(key)}</code> — an approval card ` +
251
+ ? `**Ask the agent** to call its \`vault_request_save\` ` +
252
+ `tool for \`${codeSpanSafe(key)}\` — an approval card ` +
246
253
  `lands here with [✅ Save once] / [🚫 Discard] / [✏️ Rename] buttons.`
247
- : `<b>Ask the agent</b> to call its <code>vault_request_save</code> ` +
254
+ : `**Ask the agent** to call its \`vault_request_save\` ` +
248
255
  `tool — an approval card lands here with [✅ Save once] / [🚫 Discard] / [✏️ Rename] buttons.`)
249
256
  );
250
257
  case "get":
251
258
  return (
252
259
  head +
253
- `From this chat: <code>/vault get${key ? ` ${htmlEscape(key)}` : " &lt;key&gt;"}</code>`
260
+ `From this chat: \`/vault get${key ? ` ${codeSpanSafe(key)}` : " <key>"}\``
254
261
  );
255
262
  case "list":
256
- return head + `From this chat: <code>/vault list</code>`;
263
+ return head + `From this chat: \`/vault list\``;
257
264
  case "remove":
258
265
  return (
259
266
  head +
260
267
  `Use the operator host CLI for now — Telegram-native delete is tracked as a follow-up. ` +
261
- `<i>(Removing a vault key is a rare, irreversible operation; an in-chat confirmation card is on the punch list.)</i>`
268
+ `_(Removing a vault key is a rare, irreversible operation; an in-chat confirmation card is on the punch list.)_`
262
269
  );
263
270
  case "init":
264
271
  return (
265
272
  head +
266
- `Vault bootstrap is a one-time host-shell step: <code>switchroom vault init</code>.`
273
+ `Vault bootstrap is a one-time host-shell step: \`switchroom vault init\`.`
267
274
  );
268
275
  }
269
276
  }
@@ -154,14 +154,25 @@ export function createRobustApiCall() {
154
154
  })
155
155
  }
156
156
 
157
- // ─── HTML escape helpers ─────────────────────────────────────────────────
157
+ // ─── Markdown escape helpers (#2669) ──────────────────────────────────────
158
158
 
159
+ /**
160
+ * Escape GFM-markdown specials in a dynamic value interpolated into prose.
161
+ * Kept under the legacy `escapeHtmlForTg` name so callers don't churn.
162
+ */
159
163
  export function escapeHtmlForTg(text: string): string {
160
- return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
164
+ return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
161
165
  }
162
166
 
167
+ /**
168
+ * Wrap CLI / command output in a fenced code block. Inside a fence the
169
+ * content is literal (no escaping), so we pass `text` through verbatim —
170
+ * except a fence-closing ``` sequence in the content, which we defuse so it
171
+ * can't terminate the block early.
172
+ */
163
173
  export function preBlock(text: string): string {
164
- return '<pre>' + escapeHtmlForTg(text) + '</pre>'
174
+ const safe = text.replace(/```/g, '`​``')
175
+ return '```\n' + safe + '\n```'
165
176
  }
166
177
 
167
178
  export function stripAnsi(text: string): string {
@@ -278,11 +289,17 @@ export function makeSwitchroomReply(
278
289
  ): Promise<void> {
279
290
  const chatId = String(ctx.chat!.id)
280
291
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
281
- await ctx.reply(text, {
292
+ const opts = {
282
293
  ...(threadId != null ? { message_thread_id: threadId } : {}),
283
- ...(options.html ? { parse_mode: 'HTML' as const, link_preview_options: { is_disabled: true } } : {}),
284
294
  ...(options.reply_markup ? { reply_markup: options.reply_markup } : {}),
285
- })
295
+ }
296
+ // #2669: `options.html:true` now means "render `text` as GFM markdown via
297
+ // the rich-message path" (legacy field name kept). Plain otherwise.
298
+ if (options.html) {
299
+ await ctx.replyWithRichMessage({ markdown: text }, opts)
300
+ } else {
301
+ await ctx.reply(text, opts)
302
+ }
286
303
  }
287
304
  }
288
305
 
@@ -377,17 +377,20 @@ export function silenceMsForKey(key: string, now: number): number | null {
377
377
  }
378
378
 
379
379
  /**
380
- * Verbatim framework-fallback text — the user-visible "still working / still
381
- * thinking" message the gateway sends at the 300s threshold when the model
382
- * hasn't broken its own silence. Wording is load-bearing (see
383
- * `reference/rfcs/conversational-pacing.md` § Safety net). Two principles:
380
+ * Framework-fallback text for the 300s silence threshold. Returns `null` for
381
+ * the pure-stall cases (the "still working / still thinking" / "running <Tool>
382
+ * for Nm" notice) — that timer-fired stall ping was a stop-gap from before the
383
+ * live-updating reply/draft carried the progress beats natively, and the
384
+ * operator has retired it (it's the exact cadence-based "still working" update
385
+ * the conversational-pacing RFC's Anti-patterns section bans). The 300s
386
+ * fallback's surviving job — unwedging a turn that produced no output at all —
387
+ * is the gateway's turn-teardown after this call, NOT a user-visible message,
388
+ * so dropping the stall send keeps the safety net's real work intact (see
389
+ * `reference/rfcs/conversational-pacing.md` § Silence-poke fallback).
384
390
  *
385
- * 1. The parenthetical `(no update from agent in N min)` is honest —
386
- * distinguishes from "the agent said something" so users learn to trust
387
- * real agent messages. `N` is derived from `silenceMs`, never hard-coded.
388
- * 2. The verb is `working` by default, `thinking` only when the session
389
- * stream has emitted a `kind: 'thinking'` event in the last 30s. Picked
390
- * by the caller via `fallbackKind`; this helper just formats.
391
+ * The ONE case that still returns a string is `blockedOnApproval`: the turn is
392
+ * parked on an approval card waiting for YOUR tap. That is not a stall — it
393
+ * tells the user the ball is in their court — so it keeps pinging.
391
394
  *
392
395
  * Extracted from the gateway's `onFrameworkFallback` callback so the wording
393
396
  * can be snapshot-tested in isolation. CC-4 in `docs/status-ask-cause-classes.md`.
@@ -397,73 +400,27 @@ export function formatFrameworkFallbackText(
397
400
  silenceMs: number,
398
401
  inFlightTools: ToolSnapshot[] = [],
399
402
  blockedOnApproval = false,
400
- ): string {
403
+ ): string | null {
401
404
  const minutes = Math.max(1, Math.round(silenceMs / 60_000))
402
405
  // The turn isn't stalled — it's parked on an approval card waiting for YOUR
403
406
  // tap (the dominant live "wedge" class is benign approval-latency, not a
404
407
  // hang). Saying "still working…" here actively lies; name the real blocker so
405
408
  // the operator knows the ball is in their court. Takes precedence over the
406
- // in-flight-tool framing (a tool awaiting approval isn't "running").
409
+ // in-flight-tool framing (a tool awaiting approval isn't "running"). This is
410
+ // the only branch that still emits a user-visible message.
407
411
  if (blockedOnApproval) {
408
412
  return `waiting for your approval — tap Approve or Deny on the card above (${minutes} min)`
409
413
  }
410
- const suffix = `(no update from agent in ${minutes} min)`
411
- // #1292 case (a): tools in flight. Name the longest-running one
412
- // (entry[0] — caller pre-sorts by startedAt ascending). Avoid the
413
- // "still working" framing #1292 explicitly calls out as dishonest:
414
- // the agent IS doing work, we can see the tool. Format:
415
- // running Grep "foo" for 4m (no update from agent in 5 min)
416
- // running Grep "foo" + 2 more (4m) (no update from agent in 5 min)
417
- // running Grep (no label) for 4m (no update from agent in 5 min)
418
- //
419
- // Raw MCP tool names (`mcp__server__tool`) are technical identifiers
420
- // and look like a leak when surfaced to a user. When the tool name
421
- // matches that shape AND a human-friendly label is available, drop
422
- // the raw name and lead with the label instead:
423
- // Searching memory for 4m (no update from agent in 5 min)
424
- // Built-in tool names (Grep, Read, Bash) stay as-is — they ARE
425
- // human-readable, and the label is supplementary detail (e.g. the
426
- // search pattern) that reads naturally after the verb.
427
- if (inFlightTools.length > 0) {
428
- const longest = inFlightTools[0]!
429
- const dur = formatDurationShort(longest.durationMs)
430
- const labelTail = longest.label && longest.label.length > 0
431
- ? ` ${truncateLabel(longest.label)}`
432
- : ''
433
- const more = inFlightTools.length > 1
434
- ? ` + ${inFlightTools.length - 1} more`
435
- : ''
436
- const isMcpRawName = /^mcp__/.test(longest.name)
437
- if (isMcpRawName && labelTail !== '') {
438
- // Label-only: "Searching memory for 4m (…)". Drop the raw
439
- // `mcp__server__tool` and the leading "running" because the
440
- // label already reads as a gerund phrase.
441
- return `${truncateLabel(longest.label!)}${more} for ${dur} ${suffix}`
442
- }
443
- return `running ${longest.name}${labelTail}${more} for ${dur} ${suffix}`
444
- }
445
- return fallbackKind === 'thinking'
446
- ? `still thinking… ${suffix}`
447
- : `still working… ${suffix}`
448
- }
449
-
450
- /** Compact m/s rendering for the fallback message. Anything under a
451
- * minute reads as `${s}s`, otherwise `${m}m`. Always rounds toward the
452
- * user-honest direction — "4m" for 4m 30s, "5m" for 4m 45s. */
453
- function formatDurationShort(ms: number): string {
454
- const totalSec = Math.max(0, Math.round(ms / 1000))
455
- if (totalSec < 60) return `${totalSec}s`
456
- const minutes = Math.round(totalSec / 60)
457
- return `${minutes}m`
458
- }
459
-
460
- /** Telegram lines are short on mobile. Clip the label to keep the
461
- * fallback message readable. Truncation point is generous (60 chars)
462
- * because tool labels are pre-truncated by `toolLabel()` already. */
463
- function truncateLabel(label: string): string {
464
- const MAX = 60
465
- if (label.length <= MAX) return label
466
- return label.slice(0, MAX - 1) + '…'
414
+ // Stop-gap retired: the "still working… (no update from agent in N min)" /
415
+ // "running <Tool> for Nm" stall notice (including the #1292 tool-aware
416
+ // enrichment) no longer sends. The live draft + the model's own pacing beats
417
+ // carry progress; a timer-fired stall ping on top of that is the banned
418
+ // cadence-based update. `fallbackKind` and `inFlightTools` are now unused for
419
+ // the stall path but kept on the signature so the gateway's call sites — and
420
+ // the deterministic update-status / unwedge paths around them — are untouched.
421
+ void fallbackKind
422
+ void inFlightTools
423
+ return null
467
424
  }
468
425
 
469
426
  /** Snapshot in-flight tools sorted longest-running first — for the honest