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
@@ -51,7 +51,7 @@ import {
51
51
  AGENT_LIVE_WINDOW_MS,
52
52
  AGENT_LIVE_POLL_INTERVAL_MS,
53
53
  } from './boot-probes.js'
54
- import { escapeHtml } from '../card-format.js'
54
+ import { escapeMarkdown } from '../card-format.js'
55
55
  import {
56
56
  loadCache as loadBootIssueCache,
57
57
  diffProbes as diffBootProbes,
@@ -348,26 +348,28 @@ export interface RenderBootCardOpts {
348
348
  * the eye; everything else stays out of the way.
349
349
  */
350
350
  /**
351
- * Render a probe's `nextStep` hint as Telegram HTML. The hint is
352
- * authored as plain text with backtick-quoted commands (one shell idiom
353
- * across the codebase — search "Run `switchroom"). We translate those
354
- * to <code> spans and escape everything else, so commands stay tap-to-
355
- * copy on mobile without bleeding raw HTML through.
351
+ * Render a probe's `nextStep` hint as GFM markdown. The hint is authored
352
+ * as plain text with backtick-quoted commands (one shell idiom across the
353
+ * codebase — search "Run `switchroom"). The backtick spans already ARE
354
+ * markdown code spans, so commands stay tap-to-copy on mobile; non-code
355
+ * text is markdown-escaped so stray syntax chars render literally.
356
356
  */
357
357
  function renderNextStep(text: string): string {
358
358
  const parts = text.split('`')
359
- // Odd-count backticks means an unterminated <code> span — fall back to
360
- // plain-escaped text rather than rendering the trailing tail inside a
361
- // code block. Author error, not user-input, but defensive is cheap.
362
- if (parts.length % 2 === 0) return escapeHtml(text)
363
- return parts.map((p, i) => (i % 2 === 0 ? escapeHtml(p) : `<code>${escapeHtml(p)}</code>`)).join('')
359
+ // Odd-count backticks means an unterminated code span — fall back to
360
+ // plain-escaped text rather than emitting a half-open span. Author
361
+ // error, not user-input, but defensive is cheap.
362
+ if (parts.length % 2 === 0) return escapeMarkdown(text)
363
+ // Code-span content (odd indices) is literal — backtick content is not
364
+ // re-interpreted, so it must NOT be markdown-escaped.
365
+ return parts.map((p, i) => (i % 2 === 0 ? escapeMarkdown(p) : `\`${p}\``)).join('')
364
366
  }
365
367
 
366
368
  export function renderBootCard(opts: RenderBootCardOpts): string {
367
369
  const { agentName, version, probes, restartReason, restartAgeMs } = opts
368
370
  const agentSlug = opts.agentSlug ?? agentName
369
371
  const ackEmoji = restartReason ? REASON_EMOJI[restartReason] : '✅'
370
- const ack = `${ackEmoji} <b>${escapeHtml(agentName)}</b> back up · ${escapeHtml(version)}`
372
+ const ack = `${ackEmoji} **${escapeMarkdown(agentName)}** back up · ${escapeMarkdown(version)}`
371
373
 
372
374
  const degradedRows: string[] = []
373
375
  const snoozeSet = new Set<ProbeKey>(opts.snoozeRows ?? [])
@@ -380,7 +382,7 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
380
382
  for (const key of opts.resolvedRows) {
381
383
  const lbl = PROBE_LABELS[key]
382
384
  if (!lbl) continue
383
- degradedRows.push(`✅ <b>${escapeHtml(lbl)}</b> resolved`)
385
+ degradedRows.push(`✅ **${escapeMarkdown(lbl)}** resolved`)
384
386
  }
385
387
  }
386
388
 
@@ -392,15 +394,15 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
392
394
  const ageStr = restartAgeMs != null && restartAgeMs > 0
393
395
  ? ` · ${(restartAgeMs / 1000).toFixed(1)}s ago`
394
396
  : ''
395
- degradedRows.push(`⚠️ <b>Restart</b> ${escapeHtml(REASON_LABEL.crash)}${ageStr}`)
397
+ degradedRows.push(`⚠️ **Restart** ${escapeMarkdown(REASON_LABEL.crash)}${ageStr}`)
396
398
  // Principle 1: every failure carries its next step. The crash row
397
399
  // tells the user how to inspect why. Runtime-aware: v0.7+ agents run
398
400
  // in Docker (no systemd/journalctl in-container) — the canonical
399
401
  // detection is SWITCHROOM_RUNTIME (see doctor-docker.ts).
400
402
  const tailCmd = process.env.SWITCHROOM_RUNTIME === 'docker'
401
- ? `docker logs --tail 100 switchroom-${escapeHtml(agentSlug)}`
402
- : `journalctl --user -u switchroom-${escapeHtml(agentSlug)} -n 100`
403
- degradedRows.push(` ↳ Tail logs: <code>${tailCmd}</code>`)
403
+ ? `docker logs --tail 100 switchroom-${agentSlug}`
404
+ : `journalctl --user -u switchroom-${agentSlug} -n 100`
405
+ degradedRows.push(` ↳ Tail logs: \`${tailCmd}\``)
404
406
  }
405
407
 
406
408
  // Probe rows — only those that surfaced as degraded/fail. Healthy
@@ -427,7 +429,7 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
427
429
  // We surface the existing row format unchanged here; the
428
430
  // "Still:" / "New:" distinction is conveyed by which rows the
429
431
  // user does or doesn't see across consecutive boots.
430
- degradedRows.push(`${dot} <b>${PROBE_LABELS[key]}</b> ${escapeHtml(r.detail)}`)
432
+ degradedRows.push(`${dot} **${PROBE_LABELS[key]}** ${escapeMarkdown(r.detail)}`)
431
433
  if (r.nextStep) {
432
434
  degradedRows.push(` ↳ ${renderNextStep(r.nextStep)}`)
433
435
  }
@@ -692,7 +694,6 @@ export async function startBootCard(
692
694
  if (reuseId != null && bot.editMessageTextStrict != null) {
693
695
  try {
694
696
  const outcome = await bot.editMessageTextStrict(chatId, reuseId, ackText, {
695
- parse_mode: 'HTML',
696
697
  link_preview_options: { is_disabled: true },
697
698
  ...(threadId != null ? { message_thread_id: threadId } : {}),
698
699
  })
@@ -708,7 +709,6 @@ export async function startBootCard(
708
709
  if (messageId < 0) {
709
710
  try {
710
711
  const sent = await bot.sendMessage(chatId, ackText, {
711
- parse_mode: 'HTML',
712
712
  link_preview_options: { is_disabled: true },
713
713
  ...(threadId != null ? { message_thread_id: threadId } : {}),
714
714
  ...(ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {}),
@@ -858,7 +858,6 @@ export async function startBootCard(
858
858
  if (currentText !== ackText) {
859
859
  try {
860
860
  await bot.editMessageText(chatId, messageId, currentText, {
861
- parse_mode: 'HTML',
862
861
  link_preview_options: { is_disabled: true },
863
862
  ...(threadId != null ? { message_thread_id: threadId } : {}),
864
863
  })
@@ -915,7 +914,6 @@ export async function startBootCard(
915
914
 
916
915
  try {
917
916
  await bot.editMessageText(chatId, messageId, updatedText, {
918
- parse_mode: 'HTML',
919
917
  link_preview_options: { is_disabled: true },
920
918
  ...(threadId != null ? { message_thread_id: threadId } : {}),
921
919
  })
@@ -41,8 +41,9 @@ export function formatRelativeAgo(iso: string | null): string | null {
41
41
  *
42
42
  * Sanitization: claude --version output is whitespace-collapsed before
43
43
  * embedding — a malicious or rogue `claude` on PATH must not be able to
44
- * smuggle newlines into the ack line. HTML escaping happens at the
45
- * boot-card boundary (see boot-card.ts: escapeHtml(version)).
44
+ * smuggle newlines into the ack line. Markdown escaping happens at the
45
+ * boot-card boundary (see boot-card.ts: escapeMarkdown(version)) so any
46
+ * GFM formatting specials in the version render literally (#2669).
46
47
  */
47
48
  export function composeBootVersionString(inputs: BootVersionInputs): string {
48
49
  const ago = formatRelativeAgo(inputs.commitDate)
@@ -81,39 +81,45 @@ afterEach(() => {
81
81
  _resetPendingConfigApprovalsForTest();
82
82
  });
83
83
 
84
+ // #2669: the diff now ships inside a fenced code block (``` … ```) on the
85
+ // rich-message path. Content inside a fence is LITERAL — `<` / `&` need no
86
+ // escaping and cannot inject formatting. The inline-body cap is the rich
87
+ // 32768-char wire limit (RENDERED_BODY_CAP=32000), not the old 4096.
88
+ const RICH_LIMIT = 32768;
84
89
  describe("buildConfigApprovalCardBody", () => {
85
- it("HTML-escapes the diff body so `<` / `&` can't break out of the <pre> block", () => {
90
+ it("ships the diff verbatim inside a fenced code block (< / & stay literal)", () => {
86
91
  const { body } = buildConfigApprovalCardBody({
87
92
  agentName: "klanker",
88
- reason: "<script>",
93
+ reason: "a_reason",
89
94
  unifiedDiff: "a & b <c>",
90
95
  });
91
- expect(body).toContain("&lt;script&gt;");
92
- expect(body).toContain("a &amp; b &lt;c&gt;");
96
+ // The diff content is literal inside the fence — no HTML entities.
97
+ expect(body).toContain("```\na & b <c>\n```");
93
98
  });
94
99
 
95
- it("rendered body stays under Telegram's 4096-char limit when raw diff is all `&` (worst-case 5x escape inflation)", () => {
96
- // 3000 `&` chars escape to 15000 `&amp;` chars — far past 4096.
97
- // The post-escape cap MUST kick in and truncate the rendered body.
98
- const evilDiff = "&".repeat(3000);
100
+ it("rendered body stays under the rich-message limit when the raw diff is enormous", () => {
101
+ // A diff far larger than the 32000 cap must be truncated with the sentinel.
102
+ const evilDiff = "&".repeat(40000);
99
103
  const { body } = buildConfigApprovalCardBody({
100
104
  agentName: "klanker",
101
105
  reason: "test",
102
106
  unifiedDiff: evilDiff,
103
107
  });
104
- expect(body.length).toBeLessThanOrEqual(4096);
108
+ expect(body.length).toBeLessThanOrEqual(RICH_LIMIT);
105
109
  expect(body).toContain("diff continues, see attached file");
106
110
  });
107
111
 
108
- it("rendered body stays under 4096 when raw diff is all `<` (5x escape)", () => {
109
- const evilDiff = "<".repeat(3000);
110
- const { body } = buildConfigApprovalCardBody({
112
+ it("a small `<`-heavy diff fits and stays literal (no escape inflation, #2669)", () => {
113
+ const diff = "<".repeat(3000);
114
+ const { body, truncated } = buildConfigApprovalCardBody({
111
115
  agentName: "klanker",
112
116
  reason: "test",
113
- unifiedDiff: evilDiff,
117
+ unifiedDiff: diff,
114
118
  });
115
- expect(body.length).toBeLessThanOrEqual(4096);
116
- expect(body).toContain("&lt;");
119
+ // 3000 chars is well under the 32000 cap and no longer 5x-inflated.
120
+ expect(truncated).toBe(false);
121
+ expect(body.length).toBeLessThanOrEqual(RICH_LIMIT);
122
+ expect(body).toContain("<".repeat(3000));
117
123
  });
118
124
 
119
125
  it("clips an unbounded operator-supplied `reason` to ~500 chars with ellipsis", () => {
@@ -123,7 +129,6 @@ describe("buildConfigApprovalCardBody", () => {
123
129
  reason: longReason,
124
130
  unifiedDiff: "small",
125
131
  });
126
- // The escaped reason should appear, but capped.
127
132
  const reasonLine = body
128
133
  .split("\n")
129
134
  .find((l) => l.startsWith("Reason: "))!;
@@ -139,48 +144,43 @@ describe("buildConfigApprovalCardBody", () => {
139
144
  unifiedDiff: "-a\n+b\n",
140
145
  });
141
146
  expect(truncated).toBe(false);
142
- expect(body).toContain("<pre>-a\n+b\n</pre>");
147
+ expect(body).toContain("```\n-a\n+b\n\n```");
143
148
  });
144
149
 
145
150
  it("returns truncated:true and appends the sentinel when the body has to shrink", () => {
146
151
  const { body, truncated } = buildConfigApprovalCardBody({
147
152
  agentName: "klanker",
148
153
  reason: "test",
149
- unifiedDiff: "&".repeat(3000),
154
+ unifiedDiff: "&".repeat(40000),
150
155
  });
151
156
  expect(truncated).toBe(true);
152
157
  expect(body).toContain("diff continues, see attached file");
153
158
  });
154
159
 
155
160
  it("handles a single unbroken line (no `\\n` to snap to) by char-truncation fallback", () => {
156
- // 8000 `x` chars on a single line. After HTML escape (no inflation
157
- // for `x`) the diff body alone is 8000 chars + framing — way past
158
- // the cap. There's no newline to snap to, so the helper must fall
159
- // through to char-truncation rather than returning empty.
160
- const oneLongLine = "x".repeat(8000);
161
+ // A single 40000-char line, way past the 32000 cap, with no newline to
162
+ // snap to → the helper must fall through to char-truncation.
163
+ const oneLongLine = "x".repeat(40000);
161
164
  const { body, truncated } = buildConfigApprovalCardBody({
162
165
  agentName: "klanker",
163
166
  reason: "test",
164
167
  unifiedDiff: oneLongLine,
165
168
  });
166
169
  expect(truncated).toBe(true);
167
- expect(body.length).toBeLessThanOrEqual(4096);
168
- // Should still contain SOME of the line content — the helper
169
- // shouldn't degenerate to "framing + sentinel only" when char-
170
- // truncation is available.
170
+ expect(body.length).toBeLessThanOrEqual(RICH_LIMIT);
171
171
  expect(body).toMatch(/x{100,}/);
172
172
  expect(body).toContain("diff continues, see attached file");
173
173
  });
174
174
 
175
- it("rendered body stays under 4096 even when reason is also adversarial", () => {
176
- const evilDiff = "&".repeat(3000);
175
+ it("rendered body stays under the rich limit even when reason is also adversarial", () => {
176
+ const evilDiff = "&".repeat(40000);
177
177
  const evilReason = "&".repeat(2000);
178
178
  const { body } = buildConfigApprovalCardBody({
179
179
  agentName: "klanker",
180
180
  reason: evilReason,
181
181
  unifiedDiff: evilDiff,
182
182
  });
183
- expect(body.length).toBeLessThanOrEqual(4096);
183
+ expect(body.length).toBeLessThanOrEqual(RICH_LIMIT);
184
184
  });
185
185
  });
186
186
 
@@ -304,8 +304,10 @@ describe("buildLiveNote", () => {
304
304
  expect(buildLiveNote([], false)).toBe("");
305
305
  expect(buildLiveNote(undefined, undefined)).toBe("");
306
306
  });
307
- it("HTML-escapes agent names", () => {
308
- expect(buildLiveNote(["a<b>"], false)).toContain("a&lt;b&gt;");
307
+ it("markdown-escapes agent names (< > stay literal, #2669)", () => {
308
+ // < > are literal in rich markdown; an emphasis special is escaped.
309
+ expect(buildLiveNote(["a<b>"], false)).toContain("a<b>");
310
+ expect(buildLiveNote(["a_b"], false)).toContain("a\\_b");
309
311
  });
310
312
  });
311
313
 
@@ -412,7 +414,7 @@ describe("oversize diff → attachment fallback (#1762)", () => {
412
414
  expect(deps.postCard).toHaveBeenCalledTimes(1);
413
415
  const postArgs = (deps.postCard as ReturnType<typeof vi.fn>).mock
414
416
  .calls[0]![0] as { text: string; replyMarkup: unknown };
415
- expect(postArgs.text.length).toBeLessThanOrEqual(4096);
417
+ expect(postArgs.text.length).toBeLessThanOrEqual(32768);
416
418
  expect(postArgs.text).toMatch(/diff continues, see attached file/);
417
419
  expect(postArgs.replyMarkup).toBeDefined();
418
420
 
@@ -161,13 +161,13 @@ export function truncateDiffForCard(
161
161
  }
162
162
 
163
163
  /**
164
- * Telegram `sendMessage` hard limit. We render with `parse_mode=HTML`,
165
- * so the limit applies to the rendered (escaped) body, NOT the raw
166
- * pre-escape source. Worst-case escape is `&` → `&amp;` (5x).
164
+ * Telegram rich-message hard limit (#2669). We render as GFM markdown and
165
+ * ship the diff inside a fenced code block (content is literal there, so no
166
+ * escape inflation).
167
167
  */
168
- const TELEGRAM_SENDMESSAGE_LIMIT = 4096;
168
+ const TELEGRAM_SENDMESSAGE_LIMIT = 32768;
169
169
  /** Safety margin under the hard limit for invisible framing wobble. */
170
- const RENDERED_BODY_CAP = 3900;
170
+ const RENDERED_BODY_CAP = 32000;
171
171
  /** Operator-supplied `reason` is unbounded at the wire — clip it. */
172
172
  const REASON_MAX_CHARS = 500;
173
173
  const REASON_ELLIPSIS = "…";
@@ -180,8 +180,8 @@ const REASON_ELLIPSIS = "…";
180
180
  */
181
181
  export const DIFF_SENTINEL = "\n[… diff continues, see attached file]";
182
182
 
183
- function escapeHtml(s: string): string {
184
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
183
+ function escapeMd(s: string): string {
184
+ return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
185
185
  }
186
186
 
187
187
  function clipReason(reason: string): string {
@@ -212,10 +212,10 @@ export function buildConfigApprovalCardBody(args: {
212
212
  }): { body: string; truncated: boolean } {
213
213
  const safeReason = clipReason(args.reason);
214
214
  const render = (diff: string): string =>
215
- `🛠 <b>Config edit proposed</b>\n` +
216
- `Agent: <code>${escapeHtml(args.agentName)}</code>\n` +
217
- `Reason: ${escapeHtml(safeReason)}\n\n` +
218
- `<pre>${escapeHtml(diff)}</pre>`;
215
+ `🛠 **Config edit proposed**\n` +
216
+ `Agent: \`${args.agentName}\`\n` +
217
+ `Reason: ${escapeMd(safeReason)}\n\n` +
218
+ "```\n" + diff + "\n```";
219
219
 
220
220
  return truncateRawToFit({
221
221
  raw: args.unifiedDiff,
@@ -287,10 +287,10 @@ export async function handleRequestConfigApproval(
287
287
  return;
288
288
  }
289
289
 
290
- // Pre-flight oversize handling (#1762). Telegram caps sendMessage
291
- // at 4096 chars and we render with parse_mode=HTML, so the limit
292
- // applies to the rendered (escaped) body — worst-case `&` → `&amp;`
293
- // inflates 5x. Fast-path with a cheap raw-input cap, then let
290
+ // Pre-flight oversize handling (#1762 / #2669). The rich-message body
291
+ // caps at 32768 chars; the diff ships inside a fenced code block (literal,
292
+ // no escape inflation) but the framing still counts. Fast-path with a
293
+ // cheap raw-input cap, then let
294
294
  // buildConfigApprovalCardBody enforce the post-escape rendered cap
295
295
  // (which re-truncates the diff if escaping blew past the limit). We
296
296
  // ship the full diff as a `.patch` attachment whenever truncation
@@ -409,10 +409,10 @@ export async function resolvePendingConfigApproval(
409
409
  // Edit the card to an interim/terminal state.
410
410
  const interim =
411
411
  verdict === "approve"
412
- ? "👀 <b>Applying…</b>"
412
+ ? "👀 **Applying…**"
413
413
  : verdict === "deny"
414
- ? "🚫 <b>Denied</b>"
415
- : "⏱ <b>Expired</b>";
414
+ ? "🚫 **Denied**"
415
+ : "⏱ **Expired**";
416
416
  try {
417
417
  // Strip the keyboard: once resolved (approve/deny/timeout) the buttons
418
418
  // must not stay tappable — a stale tap could otherwise re-hit the
@@ -442,14 +442,14 @@ export function buildLiveNote(affectedAgents?: string[], fleetWide?: boolean): s
442
442
  if (fleetWide) {
443
443
  return (
444
444
  `\n\n⚠️ Shared config changed — affects all agents. Not live until they ` +
445
- `restart: run <code>switchroom rollout</code> (or <code>/update apply</code>).`
445
+ `restart: run \`switchroom rollout\` (or \`/update apply\`).`
446
446
  );
447
447
  }
448
448
  const agents = (affectedAgents ?? []).filter((a) => typeof a === "string" && a.length > 0);
449
449
  if (agents.length === 0) return "";
450
- const list = agents.map(escapeHtml).join(", ");
451
- const cmds = agents.map((a) => `/restart ${escapeHtml(a)}`).join(" · ");
452
- return `\n\n🔄 Not live until restart — affects: <b>${list}</b>\n${cmds}`;
450
+ const list = agents.map(escapeMd).join(", ");
451
+ const cmds = agents.map((a) => `/restart ${escapeMd(a)}`).join(" · ");
452
+ return `\n\n🔄 Not live until restart — affects: **${list}**\n${cmds}`;
453
453
  }
454
454
 
455
455
  /** IPC `request_config_finalize` handler — edits the card to the terminal outcome. */
@@ -476,8 +476,8 @@ export async function handleRequestConfigFinalize(
476
476
  msg.outcome === "applied" ? buildLiveNote(msg.affectedAgents, msg.fleetWide) : "";
477
477
  const body =
478
478
  msg.outcome === "applied"
479
- ? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}${liveNote}`
480
- : `⚠️ <b>Reconcile failed; rolled back</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`;
479
+ ? `✅ **Applied**${msg.detail ? `\n${escapeMd(msg.detail)}` : ""}${liveNote}`
480
+ : `⚠️ **Reconcile failed; rolled back**${msg.detail ? `\n${escapeMd(msg.detail)}` : ""}`;
481
481
  try {
482
482
  // Finalize is terminal — strip the keyboard so the buttons are gone.
483
483
  await deps.editCard({
@@ -32,7 +32,7 @@
32
32
  import { createHash } from 'crypto'
33
33
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'
34
34
  import { dirname } from 'path'
35
- import { escapeHtml } from '../card-format.js'
35
+ import { escapeMarkdown } from '../card-format.js'
36
36
 
37
37
  // ─── Types ──────────────────────────────────────────────────────────────────
38
38
 
@@ -189,19 +189,19 @@ export function diffSnapshots(
189
189
  export function renderConfigChangeDim(dim: ConfigChangeDim): string {
190
190
  switch (dim.field) {
191
191
  case 'model': {
192
- const from = escapeHtml(dim.from ?? '(default)')
193
- const to = escapeHtml(dim.to ?? '(default)')
194
- return `⚙️ <b>Config</b> model: ${from} → ${to}`
192
+ const from = escapeMarkdown(dim.from ?? '(default)')
193
+ const to = escapeMarkdown(dim.to ?? '(default)')
194
+ return `⚙️ **Config** model: ${from} → ${to}`
195
195
  }
196
196
  case 'memoryBackend': {
197
- const from = escapeHtml(dim.from ?? '(default)')
198
- const to = escapeHtml(dim.to ?? '(default)')
199
- return `⚙️ <b>Config</b> memory backend: ${from} → ${to}`
197
+ const from = escapeMarkdown(dim.from ?? '(default)')
198
+ const to = escapeMarkdown(dim.to ?? '(default)')
199
+ return `⚙️ **Config** memory backend: ${from} → ${to}`
200
200
  }
201
201
  case 'tools':
202
- return `⚙️ <b>Config</b> tools allowlist changed — run /status for details`
202
+ return `⚙️ **Config** tools allowlist changed — run /status for details`
203
203
  case 'skills':
204
- return `⚙️ <b>Config</b> skills changed — run /status for details`
204
+ return `⚙️ **Config** skills changed — run /status for details`
205
205
  }
206
206
  }
207
207
 
@@ -45,8 +45,8 @@ describe("buildDiffPreviewCard — suggest mode (default)", () => {
45
45
  writeRequestId: "11223344112233441122334411223344",
46
46
  });
47
47
 
48
- // Body: title bold + all preview lines.
49
- expect(card.text).toContain("<b>");
48
+ // Body: title bold (markdown **) + all preview lines.
49
+ expect(card.text).toContain("**");
50
50
  expect(card.text).toContain("klanker");
51
51
  expect(card.text).toContain("Q3 Strategy Notes");
52
52
  expect(card.text).toContain("📍 after heading 'Goals' (level 2)");
@@ -152,20 +152,20 @@ describe("buildDiffPreviewCard — fragility guards", () => {
152
152
  preview,
153
153
  suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
154
154
  });
155
- expect(card.text).not.toContain("<script>");
156
- expect(card.text).toContain("&lt;script&gt;");
155
+ // < > are literal in rich markdown — the doc name is shown verbatim and
156
+ // cannot inject formatting (#2669).
157
+ expect(card.text).toContain("<script>");
157
158
  });
158
159
 
159
- it("HTML-escapes the agent-supplied summary", () => {
160
+ it("passes < > & literally in the agent-supplied summary; escapes emphasis specials (#2669)", () => {
160
161
  const preview = buildDiffPreview(
161
- baseInput({ agentSummary: "Hi <b>bold</b> & <i>tags</i>" }),
162
+ baseInput({ agentSummary: "Hi <tag> & a_b" }),
162
163
  );
163
164
  const card = buildDiffPreviewCard({
164
165
  preview,
165
166
  suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
166
167
  });
167
- expect(card.text).not.toMatch(/💬.*<b>/);
168
- expect(card.text).toContain("&lt;b&gt;");
168
+ expect(card.text).toContain("<tag> & a\\_b");
169
169
  });
170
170
  });
171
171
 
@@ -88,7 +88,7 @@ export function buildDiffPreviewCard(
88
88
  // The 📍 + line-count rows are surfaced verbatim — they're
89
89
  // wrapper-attested and the agent has no input into their content.
90
90
  const bodyLines: string[] = [];
91
- bodyLines.push(`<b>${escapeHtml(preview.title)}</b>`);
91
+ bodyLines.push(`**${escapeHtml(preview.title)}**`);
92
92
  for (const line of preview.lines) {
93
93
  bodyLines.push(escapeHtml(line.text));
94
94
  }
@@ -163,8 +163,5 @@ export function buildDiffPreviewCard(
163
163
  }
164
164
 
165
165
  function escapeHtml(s: string): string {
166
- return s
167
- .replace(/&/g, "&amp;")
168
- .replace(/</g, "&lt;")
169
- .replace(/>/g, "&gt;");
166
+ return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
170
167
  }
@@ -45,8 +45,6 @@ export interface DisconnectFlushDeps<Ctrl extends { finalize: (reason?: 'done' |
45
45
 
46
46
  /** Open draft-stream handles keyed by chat:thread:replyId. */
47
47
  activeDraftStreams: Map<string, Stream>
48
- /** Mirror map: same keys → parse mode. */
49
- activeDraftParseModes: Map<string, 'HTML' | 'MarkdownV2' | undefined>
50
48
 
51
49
  /** Persist-side reaction registry (per-agent on-disk state). */
52
50
  clearActiveReactions: () => void
@@ -85,7 +83,6 @@ export function flushOnAgentDisconnect<
85
83
  activeTurnStartedAt,
86
84
  claudeBusyKeys,
87
85
  activeDraftStreams,
88
- activeDraftParseModes,
89
86
  clearActiveReactions,
90
87
  disposeProgressDriver,
91
88
  onDanglingTurnsSwept,
@@ -177,7 +174,6 @@ export function flushOnAgentDisconnect<
177
174
  for (const [key, stream] of activeDraftStreams.entries()) {
178
175
  if (!stream.isFinal()) void stream.finalize().catch(() => {})
179
176
  activeDraftStreams.delete(key)
180
- activeDraftParseModes.delete(key)
181
177
  }
182
178
 
183
179
  return true
@@ -95,17 +95,17 @@ export interface EffortCommandReply {
95
95
  }
96
96
 
97
97
  const PERSIST_NOTE =
98
- '<i>Session-only — reverts to the configured default on restart. To change the default, set <code>thinking_effort:</code> in switchroom.yaml and restart.</i>'
98
+ '_Session-only — reverts to the configured default on restart. To change the default, set \`thinking_effort:\` in switchroom.yaml and restart._'
99
99
 
100
- const LEVELS_INLINE = EFFORT_LEVELS.map(l => `<code>${l}</code>`).join(' · ')
100
+ const LEVELS_INLINE = EFFORT_LEVELS.map(l => `\`${l}\``).join(' · ')
101
101
 
102
102
  function helpText(deps: EffortCommandDeps, reason?: string): EffortCommandReply {
103
103
  const lines: string[] = []
104
104
  if (reason) lines.push(`⚠️ ${deps.escapeHtml(reason)}`)
105
105
  lines.push(
106
- '<b>/effort</b> — show or switch the reasoning effort (faster→smarter)',
107
- '<code>/effort</code> — show the configured effort + a tap menu',
108
- `<code>/effort &lt;level&gt;</code> — switch the live session (${LEVELS_INLINE})`,
106
+ '**/effort** — show or switch the reasoning effort (faster→smarter)',
107
+ '\`/effort\` — show the configured effort + a tap menu',
108
+ `\`/effort <level>\` — switch the live session (${LEVELS_INLINE})`,
109
109
  PERSIST_NOTE,
110
110
  )
111
111
  return { text: lines.join('\n'), html: true }
@@ -122,9 +122,9 @@ export async function handleEffortCommand(
122
122
  const shown = configured && configured.length > 0 ? configured : 'low'
123
123
  return {
124
124
  text: [
125
- `<b>Effort — ${deps.escapeHtml(deps.getAgentName())}</b>`,
126
- `Configured default: <code>${deps.escapeHtml(shown)}</code>`,
127
- `Switch the live session: ${EFFORT_LEVELS.map(l => `<code>/effort ${l}</code>`).join(' · ')}`,
125
+ `**Effort — ${deps.escapeHtml(deps.getAgentName())}**`,
126
+ `Configured default: \`${deps.escapeHtml(shown)}\``,
127
+ `Switch the live session: ${EFFORT_LEVELS.map(l => `\`/effort ${l}\``).join(' · ')}`,
128
128
  PERSIST_NOTE,
129
129
  ].join('\n'),
130
130
  html: true,
@@ -136,7 +136,7 @@ export async function handleEffortCommand(
136
136
  if (!isValidEffortArg(parsed.level)) {
137
137
  return helpText(deps, `not a valid effort level: ${parsed.level}`)
138
138
  }
139
- const verbHtml = `<code>/effort ${deps.escapeHtml(parsed.level)}</code>`
139
+ const verbHtml = `\`/effort ${deps.escapeHtml(parsed.level)}\``
140
140
  let result: EffortApplyResult
141
141
  try {
142
142
  result = await deps.applyEffort(deps.getAgentName(), parsed.level)
@@ -153,17 +153,17 @@ export async function handleEffortCommand(
153
153
  * say so honestly rather than just claiming success.
154
154
  */
155
155
  function applyResultText(level: string, result: EffortApplyResult, deps: EffortCommandDeps): string {
156
- const verbHtml = `<code>/effort ${deps.escapeHtml(level)}</code>`
156
+ const verbHtml = `\`/effort ${deps.escapeHtml(level)}\``
157
157
  if (result.ok) {
158
158
  const lines = [`✅ ${verbHtml} — ${deps.escapeHtml(result.output)}`]
159
159
  if (result.confirmed) {
160
- lines.push('<i>Switched mid-conversation — your next turn re-reads the cached history (slower, one time).</i>')
160
+ lines.push('_Switched mid-conversation — your next turn re-reads the cached history (slower, one time)._')
161
161
  }
162
162
  lines.push(PERSIST_NOTE)
163
163
  return lines.join('\n')
164
164
  }
165
165
  if (result.reason === 'session_missing') {
166
- return '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.'
166
+ return '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove \`experimental.legacy_pty: true\` if set.'
167
167
  }
168
168
  if (result.reason === 'confirm_failed') {
169
169
  const wedged = result.wedged
@@ -172,7 +172,7 @@ function applyResultText(level: string, result: EffortApplyResult, deps: EffortC
172
172
  return `❌ ${verbHtml} — couldn't confirm the switch.${wedged}`
173
173
  }
174
174
  // apply_unverified
175
- return `❌ ${verbHtml} — sent, but couldn't confirm it applied. The agent may be mid-turn; check <code>/inject /status</code>.`
175
+ return `❌ ${verbHtml} — sent, but couldn't confirm it applied. The agent may be mid-turn; check \`/inject /status\`.`
176
176
  }
177
177
 
178
178
  // ---------------------------------------------------------------------------
@@ -220,8 +220,8 @@ export function buildEffortMenu(deps: EffortCommandDeps, highlight?: string): Ef
220
220
  const live = highlight ?? configured
221
221
  return {
222
222
  text: [
223
- `<b>Effort — ${deps.escapeHtml(deps.getAgentName())}</b>`,
224
- `Default: <code>${deps.escapeHtml(configured)}</code> · faster → smarter: ${LEVELS_INLINE}`,
223
+ `**Effort — ${deps.escapeHtml(deps.getAgentName())}**`,
224
+ `Default: \`${deps.escapeHtml(configured)}\` · faster → smarter: ${LEVELS_INLINE}`,
225
225
  'Tap to switch the live session:',
226
226
  PERSIST_NOTE,
227
227
  ].join('\n'),
@@ -260,8 +260,8 @@ export async function handleEffortMenuCallback(
260
260
  const result = await deps.applyEffort(deps.getAgentName(), level)
261
261
  if (result.ok) {
262
262
  banner = result.confirmed
263
- ? `✅ Effort → <code>${deps.escapeHtml(level)}</code> (mid-conversation: next turn re-reads history)`
264
- : `✅ Effort → <code>${deps.escapeHtml(level)}</code> for this session`
263
+ ? `✅ Effort → \`${deps.escapeHtml(level)}\` (mid-conversation: next turn re-reads history)`
264
+ : `✅ Effort → \`${deps.escapeHtml(level)}\` for this session`
265
265
  selected = level
266
266
  } else if (result.reason === 'session_missing') {
267
267
  banner = '❌ tmux session not found — is the agent running under the supervisor?'
@@ -42,12 +42,18 @@ function fakeCtx(userId = 12345): { ctx: Context; spy: FakeCtx } {
42
42
  });
43
43
  return { message_id: 1 };
44
44
  },
45
- editMessageText: async (text: string, opts?: { reply_markup?: { inline_keyboard?: unknown[][] } }) => {
45
+ editMessageText: async (
46
+ text: string | { markdown: string },
47
+ opts?: { reply_markup?: { inline_keyboard?: unknown[][] } },
48
+ ) => {
46
49
  const rows = (opts?.reply_markup?.inline_keyboard ?? []) as Array<
47
50
  Array<{ text: string }>
48
51
  >;
52
+ // #2669: the rich path passes `{ markdown }`; normalize to the body
53
+ // string so the substring assertions keep working.
54
+ const body = typeof text === "object" && text != null ? text.markdown : text;
49
55
  spy.edits.push({
50
- text,
56
+ text: body,
51
57
  keyboardRows: opts?.reply_markup
52
58
  ? rows.map((r) => r.map((b) => b.text))
53
59
  : undefined,