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
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { basename } from "node:path";
21
+ import { escapeMarkdown } from "./card-format.js";
21
22
  import { prettyMcpServer, type ScopeOption } from "./permission-rule.js";
22
23
  import { redact } from "./secret-detect/redact.js";
23
24
 
@@ -62,6 +63,9 @@ const MCP_TOOL_DESCRIPTIONS: Record<string, string> = {
62
63
  "mcp__hostd__agent_exec": "Run a read-only inspection inside another agent",
63
64
  "mcp__hostd__update_check": "Check what a fleet-wide update would do",
64
65
  "mcp__hostd__update_apply": "Apply a fleet-wide update (pull + recreate)",
66
+ "mcp__hostd__rollout": "Roll the fleet to a pinned version",
67
+ "mcp__hostd__config_propose_edit": "Propose an edit to switchroom.yaml",
68
+ "mcp__hostd__get_status": "Read the last fleet-update status",
65
69
  // hindsight — memory
66
70
  "mcp__hindsight__recall": "Recall relevant memories",
67
71
  "mcp__hindsight__retain": "Retain a memory",
@@ -96,10 +100,10 @@ const HOSTD_AGENT_TARGET_VERBS = new Set([
96
100
  /**
97
101
  * Build the multi-line card body for an approval prompt.
98
102
  *
99
- * 🔐 <b>Gymbro</b> wants to edit: supplement-log.md
100
- * why: <i>logging today's lifts</i>
103
+ * 🔐 **Gymbro** wants to edit: supplement-log.md
104
+ * why: _logging today's lifts_
101
105
  *
102
- * Output is HTML-escaped for `parse_mode: 'HTML'`. The agent name is
106
+ * Output is GFM markdown for the rich-message path (#2669). The agent name is
103
107
  * capitalized for the sentence; dropped (with "wants to") when null —
104
108
  * the bridge client can be anonymous during early-boot edge cases.
105
109
  *
@@ -127,10 +131,10 @@ export function formatPermissionCardBody(opts: {
127
131
 
128
132
  if (opts.agentName && opts.agentName.length > 0) {
129
133
  lines.push(
130
- `🔐 <b>${escapeTgHtml(capFirst(opts.agentName))}</b> wants to ${escapeTgHtml(action)}`,
134
+ `🔐 **${escapeTgHtml(capFirst(opts.agentName))}** wants to ${escapeActionMarkdown(action)}`,
131
135
  );
132
136
  } else {
133
- lines.push(`🔐 ${escapeTgHtml(capFirst(action))}`);
137
+ lines.push(`🔐 ${escapeActionMarkdown(capFirst(action))}`);
134
138
  }
135
139
 
136
140
  // why: the caller-supplied rationale (`reason`/`why` arg), never the
@@ -143,8 +147,8 @@ export function formatPermissionCardBody(opts: {
143
147
  : rawWhy;
144
148
  lines.push(
145
149
  truncatedWhy.length > 0
146
- ? `why: <i>${escapeTgHtml(truncatedWhy)}</i>`
147
- : `why: <i>not provided</i>`,
150
+ ? `why: _${escapeTgHtml(truncatedWhy)}_`
151
+ : `why: _not provided_`,
148
152
  );
149
153
 
150
154
  // Third line (REST-wrapper MCP writes only): a redaction-safe summary of
@@ -152,7 +156,7 @@ export function formatPermissionCardBody(opts: {
152
156
  // endpoint — e.g. "↳ to: lisa@…, subject: Priority access…".
153
157
  const argSummary = mcpArgSummary(opts.toolName, opts.inputPreview);
154
158
  if (argSummary) {
155
- lines.push(`↳ <i>${escapeTgHtml(argSummary)}</i>`);
159
+ lines.push(`↳ _${escapeTgHtml(argSummary)}_`);
156
160
  }
157
161
 
158
162
  return lines.join("\n");
@@ -424,10 +428,10 @@ export function describeGrant(
424
428
  * the operator scrolls past — so this is the legible signal that the tap
425
429
  * landed and names the work being (re)started.
426
430
  *
427
- * Mirrors `formatPermissionCardBody`'s style ("🔐 <b>Gymbro</b> wants to
428
- * edit: log.md" → "▶️ <b>Gymbro</b> — got it, continuing: edit: log.md").
431
+ * Mirrors `formatPermissionCardBody`'s style ("🔐 **Gymbro** wants to
432
+ * edit: log.md" → "▶️ **Gymbro** — got it, continuing: edit: log.md").
429
433
  * `action` is a phrase from {@link naturalAction} (already operator-facing,
430
- * no tool ids). Output is HTML-escaped for `parse_mode: 'HTML'`.
434
+ * no tool ids). Output is GFM markdown for the rich-message path (#2669).
431
435
  *
432
436
  * `timeoutMinutes` marks the TTL auto-deny variant (no operator tapped —
433
437
  * the request aged out) so the wording reflects "no answer" rather than a
@@ -441,25 +445,25 @@ export function formatPermissionResumeMessage(opts: {
441
445
  }): string {
442
446
  const who =
443
447
  opts.agentName && opts.agentName.length > 0
444
- ? `<b>${escapeTgHtml(capFirst(opts.agentName))}</b>`
445
- : `<b>Agent</b>`;
448
+ ? `**${escapeTgHtml(capFirst(opts.agentName))}**`
449
+ : `**Agent**`;
446
450
  const act = (opts.action ?? "").trim();
447
451
  const hasAction = act.length > 0;
448
452
 
449
453
  if (opts.behavior === "allow") {
450
454
  return hasAction
451
- ? `▶️ ${who} — got it, continuing: <i>${escapeTgHtml(act)}</i>`
455
+ ? `▶️ ${who} — got it, continuing: _${escapeActionMarkdown(act)}_`
452
456
  : `▶️ ${who} — got it, back to work.`;
453
457
  }
454
458
 
455
459
  // deny
456
460
  if (opts.timeoutMinutes != null) {
457
461
  return hasAction
458
- ? `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it (<i>${escapeTgHtml(act)}</i>).`
462
+ ? `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it (_${escapeActionMarkdown(act)}_).`
459
463
  : `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it.`;
460
464
  }
461
465
  return hasAction
462
- ? `🚫 ${who} — noted, I won't ${escapeTgHtml(lowerFirst(act))}. Continuing without it.`
466
+ ? `🚫 ${who} — noted, I won't ${escapeActionMarkdown(lowerFirst(act))}. Continuing without it.`
463
467
  : `🚫 ${who} — noted, continuing without it.`;
464
468
  }
465
469
 
@@ -520,12 +524,27 @@ function capFirst(text: string): string {
520
524
  return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : text;
521
525
  }
522
526
 
523
- /** Minimal HTML escape for Telegram `parse_mode=HTML`. */
527
+ /** Markdown escape for the rich-message path (#2669). */
524
528
  function escapeTgHtml(text: string): string {
529
+ return escapeMarkdown(text);
530
+ }
531
+
532
+ /**
533
+ * Escape a phrase that may itself contain DELIBERATE `` `code spans` ``
534
+ * (e.g. the hostd fleet-verb phrases name the target agent in a code span:
535
+ * "restart agent `carrie` in the fleet"). The structural backticks must NOT
536
+ * be escaped — that would render literal `` \`carrie\` `` to the operator
537
+ * (#2669 regression). Everything OUTSIDE a backtick span is still escaped so
538
+ * a hostile Bash command / filename can't inject emphasis. Content INSIDE a
539
+ * span is left verbatim — code spans render literally, so there is no
540
+ * injection risk there.
541
+ */
542
+ function escapeActionMarkdown(text: string): string {
543
+ // Split on balanced single-backtick spans, keeping the delimiters.
525
544
  return text
526
- .replace(/&/g, "&amp;")
527
- .replace(/</g, "&lt;")
528
- .replace(/>/g, "&gt;");
545
+ .split(/(`[^`]*`)/g)
546
+ .map((seg) => (seg.startsWith("`") && seg.endsWith("`") && seg.length >= 2 ? seg : escapeMarkdown(seg)))
547
+ .join("");
529
548
  }
530
549
 
531
550
  function parseInput(raw: string | undefined): Record<string, unknown> | null {
@@ -48,13 +48,6 @@ export interface PtyHandlerState {
48
48
  pendingPtyPartial: { text: string } | null
49
49
  /** Active streams, keyed by `chat_id:thread_id`. */
50
50
  activeDraftStreams: Map<string, DraftStreamHandle>
51
- /**
52
- * Parallel to activeDraftStreams: parseMode baked into each controller.
53
- * Lets `handleStreamReply` detect and rotate streams whose parseMode no
54
- * longer matches the caller's resolved format (bug 1). Optional for
55
- * backwards compatibility.
56
- */
57
- activeDraftParseModes?: Map<string, 'HTML' | 'MarkdownV2' | undefined>
58
51
  /**
59
52
  * Chats whose PTY preview is claimed by an in-flight reply handler.
60
53
  * Partials for these chats are dropped to avoid duplicates.
@@ -67,8 +60,6 @@ export interface PtyHandlerState {
67
60
  export interface PtyHandlerDeps {
68
61
  bot: { api: StreamBotApi }
69
62
  retry?: RetryPolicy
70
- /** Markdown → HTML renderer applied to the text before stream.update. */
71
- renderText: (text: string) => string
72
63
  /** Optional structured event hook, called once per invocation. */
73
64
  logEvent?: (ev: {
74
65
  kind: 'pty_partial_received'
@@ -154,7 +145,10 @@ export function handlePtyPartialPure(
154
145
  bot: deps.bot,
155
146
  chatId,
156
147
  threadId,
157
- parseMode: 'HTML',
148
+ // PTY-tail previews are raw terminal/TUI output, not authored
149
+ // markdown — send them literally so control glyphs and box-drawing
150
+ // characters aren't misinterpreted as markdown syntax (#2669).
151
+ literalText: true,
158
152
  disableLinkPreview: true,
159
153
  throttleMs: 600,
160
154
  retry: deps.retry,
@@ -177,11 +171,9 @@ export function handlePtyPartialPure(
177
171
  : {}),
178
172
  })
179
173
  state.activeDraftStreams.set(sKey, stream)
180
- state.activeDraftParseModes?.set(sKey, 'HTML')
181
174
  }
182
175
 
183
- const rendered = deps.renderText(text)
184
- void stream.update(rendered).catch(() => { /* swallow — logged elsewhere */ })
176
+ void stream.update(text).catch(() => { /* swallow — logged elsewhere */ })
185
177
 
186
178
  return created ? 'update-new' : 'update-existing'
187
179
  }
@@ -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,9 +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
- // 0o644 so the switchroom-web container (different UID, same host bind-mount)
222
- // can read turn history for the Hermes Desktop history panel.
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.
223
225
  chmodSync(path, 0o644)
226
+ for (const suffix of ['-shm', '-wal']) {
227
+ try { chmodSync(path + suffix, 0o644) } catch { /* doesn't exist yet */ }
228
+ }
224
229
  } catch {
225
230
  /* ignore — chmod not supported on some FUSE mounts */
226
231
  }
@@ -498,11 +503,46 @@ export function findRecentTurnsForChat(
498
503
  *
499
504
  * `limit` defaults to 20, max 200.
500
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
+
501
522
  export function listTurnsForAgent(
502
523
  db: SqliteDatabase,
503
- opts: { limit?: number } = {},
524
+ opts: { limit?: number; chatId?: string; threadId?: string | null } = {},
504
525
  ): Turn[] {
505
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
+ }
506
546
  const rows = db.prepare(`
507
547
  SELECT * FROM turns
508
548
  ORDER BY started_at DESC
@@ -253,24 +253,59 @@ 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
269
+ // A too-long rejection is a LENGTH error (see isMessageTooLongError) — never
270
+ // route it through the plain-text parse-reject fallback, which would resend
271
+ // the same oversized body and fail again. The caller re-splits instead.
272
+ if (isMessageTooLongError(err)) return false
266
273
  const d = (err.description || '').toLowerCase()
267
274
  return (
268
275
  d.includes("can't parse entities") ||
269
276
  d.includes('can’t parse entities') ||
277
+ d.includes("can't parse") ||
278
+ d.includes('can’t parse') ||
279
+ d.includes('parse markdown') ||
280
+ d.includes('parse rich') ||
270
281
  d.includes('unsupported start tag') ||
271
282
  d.includes('unclosed start tag') ||
272
283
  d.includes("can't find end of the entity") ||
284
+ d.includes('can’t find end of the entity') ||
273
285
  // covers both "expected end tag" and "unexpected end tag"
274
286
  d.includes('expected end tag')
275
287
  )
276
288
  }
289
+
290
+ /**
291
+ * True when Telegram rejected the message because the BODY WAS TOO LONG (over
292
+ * the rich-message wire cap), not because the markdown failed to parse.
293
+ *
294
+ * The rich path surfaces this as `RICH_MESSAGE_TEXT_TOO_LONG` (empirically the
295
+ * description for 32769+ chars); the legacy plain-text path used
296
+ * `MESSAGE_TOO_LONG` / "message is too long". A caller that hits this should
297
+ * re-split the body (`splitMarkdownChunks` at a smaller cap) and resend, NOT
298
+ * treat it as a parse-reject (which resends the same oversized payload as
299
+ * plain text). Mirrors rich-send.ts `isLengthError`.
300
+ */
301
+ export function isMessageTooLongError(err: unknown): boolean {
302
+ if (!(err instanceof GrammyError) || err.error_code !== 400) return false
303
+ const d = (err.description || '').toLowerCase()
304
+ return (
305
+ d.includes('rich_message_text_too_long') ||
306
+ d.includes('message_too_long') ||
307
+ d.includes('text_too_long') ||
308
+ d.includes('message is too long') ||
309
+ d.includes('text is too long')
310
+ )
311
+ }
@@ -0,0 +1,86 @@
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
+ // A too-long rejection is a LENGTH error, not a parse error — never let the
43
+ // length case fall through here (it would be "recovered" by resending the
44
+ // same oversized body as plain text, which fails again). Classify it
45
+ // separately via isLengthError so the caller re-splits instead.
46
+ if (isLengthError(err)) return false
47
+ const d = (err.description || '').toLowerCase()
48
+ return (
49
+ d.includes("can't parse entities") ||
50
+ d.includes('can’t parse entities') ||
51
+ d.includes("can't parse") ||
52
+ d.includes('can’t parse') ||
53
+ d.includes('parse markdown') ||
54
+ d.includes('parse rich') ||
55
+ d.includes("can't find end of the entity") ||
56
+ d.includes('can’t find end of the entity') ||
57
+ d.includes('unsupported start tag') ||
58
+ d.includes('unclosed start tag') ||
59
+ // covers both "expected end tag" and "unexpected end tag"
60
+ d.includes('expected end tag')
61
+ )
62
+ }
63
+
64
+ /**
65
+ * True when Telegram rejected the message because the BODY WAS TOO LONG (over
66
+ * the rich-message wire cap), as opposed to a markdown-parse failure.
67
+ *
68
+ * The rich path surfaces this distinctly: `RICH_MESSAGE_TEXT_TOO_LONG`
69
+ * (empirically the description for a body of 32769+ chars). The legacy
70
+ * plain-text path used `MESSAGE_TOO_LONG` / "message is too long" — both are
71
+ * matched here so a caller that hits either re-splits the body
72
+ * (`splitMarkdownChunks`) and resends, instead of misclassifying it as a parse
73
+ * error (and resending the same oversized payload as plain text) or surfacing
74
+ * the raw 400.
75
+ */
76
+ export function isLengthError(err: unknown): boolean {
77
+ if (!(err instanceof GrammyError) || err.error_code !== 400) return false
78
+ const d = (err.description || '').toLowerCase()
79
+ return (
80
+ d.includes('rich_message_text_too_long') ||
81
+ d.includes('message_too_long') ||
82
+ d.includes('text_too_long') ||
83
+ d.includes('message is too long') ||
84
+ d.includes('text is too long')
85
+ )
86
+ }
@@ -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
  });