switchroom 0.16.23 → 0.16.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +82 -82
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  7. package/dist/cli/skill-validate-pretool.mjs +2936 -119
  8. package/dist/cli/switchroom.js +804 -465
  9. package/dist/host-control/main.js +169 -163
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +4 -4
  13. package/telegram-plugin/answer-stream.ts +20 -49
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/bridge/bridge.ts +1 -1
  17. package/telegram-plugin/card-format.ts +28 -25
  18. package/telegram-plugin/credits-watch.ts +5 -10
  19. package/telegram-plugin/dist/bridge/bridge.js +113 -113
  20. package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
  21. package/telegram-plugin/dist/server.js +161 -161
  22. package/telegram-plugin/draft-stream.ts +4 -4
  23. package/telegram-plugin/format.ts +427 -680
  24. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  25. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  26. package/telegram-plugin/gateway/approval-card.ts +16 -6
  27. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  28. package/telegram-plugin/gateway/auth-command.ts +74 -74
  29. package/telegram-plugin/gateway/auth-line.ts +5 -15
  30. package/telegram-plugin/gateway/boot-card.ts +20 -22
  31. package/telegram-plugin/gateway/boot-version.ts +3 -2
  32. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  33. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  34. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  35. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  36. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  37. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  38. package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
  39. package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
  40. package/telegram-plugin/gateway/effort-command.ts +17 -17
  41. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  42. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  43. package/telegram-plugin/gateway/gateway.ts +881 -633
  44. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  45. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  46. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  47. package/telegram-plugin/gateway/ipc-server.ts +39 -6
  48. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  49. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  50. package/telegram-plugin/gateway/model-command.ts +25 -25
  51. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  52. package/telegram-plugin/gateway/permission-timeout.ts +76 -0
  53. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  54. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  55. package/telegram-plugin/issues-card.ts +6 -7
  56. package/telegram-plugin/model-unavailable.ts +8 -12
  57. package/telegram-plugin/operator-events-history.ts +1 -1
  58. package/telegram-plugin/operator-events.ts +24 -28
  59. package/telegram-plugin/package.json +1 -1
  60. package/telegram-plugin/pending-work-progress.ts +36 -36
  61. package/telegram-plugin/permission-title.ts +39 -20
  62. package/telegram-plugin/pty-partial-handler.ts +5 -13
  63. package/telegram-plugin/quota-check.ts +5 -5
  64. package/telegram-plugin/quota-watch.ts +13 -18
  65. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  66. package/telegram-plugin/registry/turns-schema.ts +43 -3
  67. package/telegram-plugin/retry-api-call.ts +42 -7
  68. package/telegram-plugin/rich-send.ts +86 -0
  69. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  70. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  71. package/telegram-plugin/shared/bot-runtime.ts +29 -7
  72. package/telegram-plugin/silence-poke.ts +26 -69
  73. package/telegram-plugin/silent-reply-anchor.ts +9 -2
  74. package/telegram-plugin/slot-banner-driver.ts +9 -6
  75. package/telegram-plugin/slot-banner.ts +5 -8
  76. package/telegram-plugin/status-no-truncate.ts +11 -5
  77. package/telegram-plugin/steering.ts +0 -4
  78. package/telegram-plugin/stream-controller.ts +59 -62
  79. package/telegram-plugin/stream-reply-handler.ts +49 -98
  80. package/telegram-plugin/subagent-watcher.ts +2 -2
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  82. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  83. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  84. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  87. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  88. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  89. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  90. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  91. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  92. package/telegram-plugin/tests/card-format.test.ts +6 -4
  93. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  94. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  95. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  96. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  97. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  98. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  99. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
  100. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  101. package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
  102. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  103. package/telegram-plugin/tests/model-command.test.ts +2 -2
  104. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  105. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  106. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  107. package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
  108. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  109. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
  110. package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +88 -41
  112. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  113. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  114. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  115. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  116. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  117. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  118. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  119. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  120. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  121. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  122. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  123. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  124. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  125. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  126. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  127. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  128. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  129. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  130. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  131. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  132. package/telegram-plugin/text-voice-scrub.ts +8 -11
  133. package/telegram-plugin/tool-activity-summary.ts +29 -29
  134. package/telegram-plugin/welcome-text.ts +82 -83
  135. package/telegram-plugin/worker-activity-feed.ts +2 -3
  136. package/telegram-plugin/html-sanitize.ts +0 -244
  137. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  138. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -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
@@ -12,6 +12,7 @@ import {
12
12
  type DriveApprovalHandlerDeps,
13
13
  handleRequestDriveApproval,
14
14
  } from "./drive-write-approval.js";
15
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
15
16
 
16
17
  // ────────────────────────────────────────────────────────────────────────
17
18
  // Fixtures
@@ -284,17 +285,16 @@ describe("handleRequestDriveApproval — TTL clamping", () => {
284
285
  // ────────────────────────────────────────────────────────────────────────
285
286
 
286
287
  describe("handleRequestDriveApproval — oversize card body fit (#1767)", () => {
287
- it("truncates the rendered text under 4096 chars before posting", async () => {
288
+ it("truncates the rendered text under the rich-message cap before posting", async () => {
288
289
  const spy = makeSpy();
289
- // buildCard returns a body well past Telegram's 4096-char limit
290
- // (simulates a docTitle / anchor / summary whose HTML-escape
291
- // inflated past the limit). Handler must shrink it before postCard.
290
+ // buildCard returns a body well past the rich-message wire cap
291
+ // (RICH_MESSAGE_MAX_CHARS, 32768 post-#2669 — simulates a docTitle /
292
+ // anchor / summary whose escape inflated past the limit). Handler must
293
+ // shrink it before postCard.
292
294
  const giantText =
293
295
  "<b>Title</b>\n" +
294
- Array.from({ length: 200 }, (_, i) => `line-${i}-${"x".repeat(40)}`).join(
295
- "\n",
296
- );
297
- expect(giantText.length).toBeGreaterThan(4096);
296
+ "x".repeat(RICH_MESSAGE_MAX_CHARS + 4000);
297
+ expect(giantText.length).toBeGreaterThan(RICH_MESSAGE_MAX_CHARS);
298
298
  await handleRequestDriveApproval(
299
299
  clientFor(spy),
300
300
  msgFor(),
@@ -304,7 +304,7 @@ describe("handleRequestDriveApproval — oversize card body fit (#1767)", () =>
304
304
  }),
305
305
  );
306
306
  expect(spy.posted).toHaveLength(1);
307
- expect(spy.posted[0]!.text.length).toBeLessThanOrEqual(4096);
307
+ expect(spy.posted[0]!.text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
308
308
  expect(spy.posted[0]!.text).toContain("preview truncated");
309
309
  // Card was successfully posted → ok:true response went back.
310
310
  expect(spy.sent[0]?.ok).toBe(true);
@@ -334,7 +334,7 @@ describe("handleRequestDriveApproval — oversize card body fit (#1767)", () =>
334
334
  it("post failure after truncation surfaces a structured reason", async () => {
335
335
  const spy = makeSpy();
336
336
  const giantText =
337
- "<b>Title</b>\n" + "x".repeat(8000);
337
+ "<b>Title</b>\n" + "x".repeat(RICH_MESSAGE_MAX_CHARS + 4000);
338
338
  await handleRequestDriveApproval(
339
339
  clientFor(spy),
340
340
  msgFor(),
@@ -31,6 +31,7 @@ import type {
31
31
  RequestDriveApprovalMessage,
32
32
  } from "./ipc-protocol.js";
33
33
  import { truncateRawToFit } from "./oversize-card-body.js";
34
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
34
35
 
35
36
  // ────────────────────────────────────────────────────────────────────────
36
37
  // Injected deps — caller (gateway.ts) wires these from the existing
@@ -102,15 +103,20 @@ const MAX_TTL_MS = 30 * 60 * 1000; // 30 minutes
102
103
  const MIN_TTL_MS = 30 * 1000; // 30 seconds
103
104
 
104
105
  /**
105
- * Telegram `sendMessage` hard limit. `buildDiffPreviewCard` HTML-
106
- * escapes the docTitle + every body line with no upstream length
107
- * cap, so an adversarial or just-unusually-long docTitle / anchor
108
- * displayName / agent-supplied summary can render past 4096 once
109
- * `&` / `<` / `>` inflate up to 5x. We rebuild a truncated body
110
- * keyed off the wrapper-attested fields when that happens. (#1767)
106
+ * Telegram rich-message hard limit. `buildDiffPreviewCard` escapes the
107
+ * docTitle + every body line with no upstream length cap, so an adversarial
108
+ * or just-unusually-long docTitle / anchor displayName / agent-supplied
109
+ * summary can render past the wire cap once escaping inflates it. We rebuild a
110
+ * truncated body keyed off the wrapper-attested fields when that happens.
111
+ * (#1767)
112
+ *
113
+ * Post-#2669 every card renders as GFM markdown via `sendRichMessage`, so the
114
+ * hard cap is `RICH_MESSAGE_MAX_CHARS` (32768), not the legacy 4096 plain-text
115
+ * limit. RENDERED_BODY_CAP keeps the proportional ~5% headroom under the hard
116
+ * cap that the original 3900-under-4096 budget carried.
111
117
  */
112
- const TELEGRAM_SENDMESSAGE_LIMIT = 4096;
113
- const RENDERED_BODY_CAP = 3900;
118
+ const TELEGRAM_SENDMESSAGE_LIMIT = RICH_MESSAGE_MAX_CHARS;
119
+ const RENDERED_BODY_CAP = RICH_MESSAGE_MAX_CHARS - 200;
114
120
  const OVERSIZE_SENTINEL = "\n[… preview truncated; open in Drive for full context]";
115
121
 
116
122
  /**