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
@@ -0,0 +1,167 @@
1
+ /**
2
+ * One-tap skill-improvement proposal card (#2670, "one-tap
3
+ * self-improvement").
4
+ *
5
+ * The weekly skill-synthesis cron drafts a proposed personal skill and
6
+ * persists it (full draft bundle) in the self-improve proposal store
7
+ * (`src/self-improve/skill-proposals.ts`). This module renders that
8
+ * proposal as a Telegram Approve / Dismiss card and builds the synthetic
9
+ * inbound the gateway injects when the operator taps Approve — instructing
10
+ * the live agent to APPLY the stored draft through the existing
11
+ * `skill_init_personal` / `skill_edit_personal` write pipeline (so the
12
+ * merged `scanBundleForSecrets` gate runs; the agent never self-applies —
13
+ * the operator tap is the authorization, satisfying `no-self-escalation`).
14
+ *
15
+ * Pure builders, kept out of gateway.ts so the card text + callback shape
16
+ * + inbound shape are pinned by tests independent of the bot plumbing.
17
+ *
18
+ * callback_data shape (must fit Telegram's 64-byte limit):
19
+ * skprop:approve:<id>
20
+ * skprop:deny:<id>
21
+ * where <id> is the proposal's UUID from the store.
22
+ */
23
+
24
+ import type { InboundMessage } from './ipc-protocol.js'
25
+
26
+ export const SKILL_PROPOSAL_CALLBACK_PREFIX = 'skprop:'
27
+
28
+ /** Narrow shape of a stored proposal the card/inbound builders need.
29
+ * Mirrors SkillProposal in src/self-improve/skill-proposals.ts without
30
+ * importing across the tsconfig boundary. */
31
+ export interface SkillProposalView {
32
+ id: string
33
+ skill_slug: string
34
+ is_new: boolean
35
+ lesson: string
36
+ evidence: string
37
+ /** SKILL.md body, for the optional "show full draft" / step preview. */
38
+ skill_md?: string
39
+ }
40
+
41
+ function escapeHtml(s: string): string {
42
+ return s
43
+ .replace(/&/g, '&amp;')
44
+ .replace(/</g, '&lt;')
45
+ .replace(/>/g, '&gt;')
46
+ }
47
+
48
+ /** A short bullet preview of the drafted procedure (first few non-empty,
49
+ * non-frontmatter lines of SKILL.md), capped for the card. */
50
+ export function previewSteps(skillMd: string | undefined, maxLines = 5): string[] {
51
+ if (!skillMd) return []
52
+ // Drop YAML frontmatter block if present.
53
+ let body = skillMd
54
+ if (body.startsWith('---')) {
55
+ const end = body.indexOf('\n---', 3)
56
+ if (end >= 0) body = body.slice(body.indexOf('\n', end + 1) + 1)
57
+ }
58
+ const out: string[] = []
59
+ for (const raw of body.split('\n')) {
60
+ const line = raw.trim()
61
+ if (!line || line.startsWith('#')) continue
62
+ out.push(line.replace(/^[-*\d.)\s]+/, '').slice(0, 120))
63
+ if (out.length >= maxLines) break
64
+ }
65
+ return out
66
+ }
67
+
68
+ /** Render the proposal card text (HTML parse mode). */
69
+ export function renderSkillProposalCard(p: SkillProposalView): string {
70
+ const verb = p.is_new ? 'New personal skill' : 'Update to personal skill'
71
+ const lines: string[] = []
72
+ lines.push(`🧩 <b>Skill improvement proposed</b>`)
73
+ lines.push('')
74
+ lines.push(`<b>${escapeHtml(verb)}:</b> <code>${escapeHtml(p.skill_slug)}</code>`)
75
+ lines.push(`<b>Lesson:</b> ${escapeHtml(p.lesson)}`)
76
+ if (p.evidence) lines.push(`<i>${escapeHtml(p.evidence)}</i>`)
77
+ const steps = previewSteps(p.skill_md)
78
+ if (steps.length > 0) {
79
+ lines.push('')
80
+ lines.push('<b>Draft procedure:</b>')
81
+ for (const s of steps) lines.push(`• ${escapeHtml(s)}`)
82
+ }
83
+ lines.push('')
84
+ lines.push(
85
+ '<i>Tap Add to apply it (runs through the secret-scan write pipeline). ' +
86
+ 'Tap Dismiss and it won’t be proposed again.</i>',
87
+ )
88
+ return lines.join('\n')
89
+ }
90
+
91
+ /** Inline keyboard for the proposal card. */
92
+ export function skillProposalKeyboard(id: string): {
93
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
94
+ } {
95
+ return {
96
+ inline_keyboard: [
97
+ [
98
+ { text: '✅ Add skill', callback_data: `${SKILL_PROPOSAL_CALLBACK_PREFIX}approve:${id}` },
99
+ { text: '🚫 Dismiss', callback_data: `${SKILL_PROPOSAL_CALLBACK_PREFIX}deny:${id}` },
100
+ ],
101
+ ],
102
+ }
103
+ }
104
+
105
+ /** Parse a `skprop:` callback into { action, id }, or null if not ours. */
106
+ export function parseSkillProposalCallback(
107
+ data: string,
108
+ ): { action: 'approve' | 'deny'; id: string } | null {
109
+ if (!data.startsWith(SKILL_PROPOSAL_CALLBACK_PREFIX)) return null
110
+ const rest = data.slice(SKILL_PROPOSAL_CALLBACK_PREFIX.length)
111
+ const idx = rest.indexOf(':')
112
+ if (idx < 0) return null
113
+ const action = rest.slice(0, idx)
114
+ const id = rest.slice(idx + 1)
115
+ if ((action !== 'approve' && action !== 'deny') || id.length === 0) return null
116
+ return { action, id }
117
+ }
118
+
119
+ export interface SkillProposalInboundContext {
120
+ agent: string
121
+ chat_id: string
122
+ threadId?: number
123
+ }
124
+
125
+ /**
126
+ * Build the synthetic inbound injected when the operator taps Approve.
127
+ * It instructs the live agent to apply the stored draft via the personal-
128
+ * skill write pipeline — the agent never self-applies; this tap is the
129
+ * authorization. `meta.source = "skill_proposal_apply"` so the bridge
130
+ * renders it as `<channel source="skill_proposal_apply">`.
131
+ */
132
+ export function buildSkillProposalApplyInbound(opts: {
133
+ ctx: SkillProposalInboundContext
134
+ proposalId: string
135
+ skillSlug: string
136
+ isNew: boolean
137
+ operatorId: string
138
+ nowMs?: number
139
+ }): InboundMessage {
140
+ const ts = opts.nowMs ?? Date.now()
141
+ const tool = opts.isNew ? 'skill_init_personal' : 'skill_edit_personal'
142
+ return {
143
+ type: 'inbound',
144
+ chatId: opts.ctx.chat_id,
145
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
146
+ messageId: ts,
147
+ user: 'skill-synthesis',
148
+ userId: 0,
149
+ ts,
150
+ text:
151
+ `✅ Operator approved your proposed skill \`${opts.skillSlug}\` ` +
152
+ `(proposal ${opts.proposalId}). Apply it now: read the stored draft ` +
153
+ `from the self-improve proposal store and call \`${tool}\` with that ` +
154
+ `exact draft bundle. Do NOT re-draft or add anything from this ` +
155
+ `conversation — write the stored bundle verbatim so the secret-scan ` +
156
+ `write pipeline validates it. Confirm to the operator when it lands.`,
157
+ meta: {
158
+ source: 'skill_proposal_apply',
159
+ agent: opts.ctx.agent,
160
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
161
+ proposal_id: opts.proposalId,
162
+ skill_slug: opts.skillSlug,
163
+ is_new: String(opts.isNew),
164
+ operator_id: opts.operatorId,
165
+ },
166
+ }
167
+ }
@@ -200,7 +200,10 @@ export interface FinalizeCallbackContext {
200
200
  answerCallbackQuery: (
201
201
  opts?: { text?: string; show_alert?: boolean },
202
202
  ) => Promise<unknown>
203
- editMessageText: (text: string, opts?: Record<string, unknown>) => Promise<unknown>
203
+ editMessageText: (
204
+ text: string | { markdown: string },
205
+ opts?: Record<string, unknown>,
206
+ ) => Promise<unknown>
204
207
  }
205
208
 
206
209
  export interface FinalizeCallbackOptions {
@@ -225,11 +228,12 @@ export interface FinalizeCallbackOptions {
225
228
  */
226
229
  newText: string
227
230
  /**
228
- * Parse mode for `newText`. Match the original message's parse mode
229
- * mixing modes mid-edit silently breaks formatting. Optional; omitted
230
- * means plain text.
231
+ * When true, `newText` is edited as a LITERAL plain string (no markdown
232
+ * parsing) match the original message's send shape. Default (false) →
233
+ * the rich-markdown path, so `newText` is rendered as GFM markdown
234
+ * (#2669, successor to the old `parseMode` option).
231
235
  */
232
- parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2'
236
+ literalText?: boolean
233
237
  /**
234
238
  * Side effect invoked AFTER `editMessageText` resolves. Use for
235
239
  * synthesizing the `<channel source="...">` inbound that wakes the
@@ -274,14 +278,16 @@ export async function finalizeCallback(
274
278
  })
275
279
  // Invariant 2 — strip keyboard + append status line, atomic edit.
276
280
  try {
277
- await ctx.editMessageText(opts.newText, {
278
- reply_markup: { inline_keyboard: [] },
279
- ...(opts.parseMode ? { parse_mode: opts.parseMode } : {}),
280
- // Default link_preview_options off — most finalized cards don't
281
- // benefit from preview cards, and a stale preview survives the
282
- // edit otherwise.
283
- link_preview_options: { is_disabled: true },
284
- })
281
+ await ctx.editMessageText(
282
+ opts.literalText ? opts.newText : { markdown: opts.newText },
283
+ {
284
+ reply_markup: { inline_keyboard: [] },
285
+ // Default link_preview_options off most finalized cards don't
286
+ // benefit from preview cards, and a stale preview survives the
287
+ // edit otherwise.
288
+ link_preview_options: { is_disabled: true },
289
+ },
290
+ )
285
291
  } catch (err) {
286
292
  // MESSAGE_NOT_MODIFIED (text didn't change) and MESSAGE_TO_EDIT_NOT_FOUND
287
293
  // (operator already deleted the card) are both benign. Other failures
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { readFileSync, writeFileSync } from "node:fs";
20
- import { escapeHtml } from "./card-format.js";
20
+ import { escapeMarkdown } from "./card-format.js";
21
21
  import type { IssueEvent, IssueSeverity } from "../src/issues/index.js";
22
22
 
23
23
  export interface BotApiForIssuesCard {
@@ -92,7 +92,7 @@ export function renderIssuesCard(opts: RenderIssuesCardOpts): string | null {
92
92
  const maxSeverity = sorted[0].severity;
93
93
  const headerEmoji = SEVERITY_EMOJI[maxSeverity];
94
94
  const count = sorted.length;
95
- const header = `${headerEmoji} <b>${escapeHtml(opts.agentName)}</b> · ${count} ${count === 1 ? "issue" : "issues"}`;
95
+ const header = `${headerEmoji} **${escapeMarkdown(opts.agentName)}** · ${count} ${count === 1 ? "issue" : "issues"}`;
96
96
 
97
97
  const maxRows = opts.maxRows ?? DEFAULT_MAX_ROWS;
98
98
  const visible = sorted.slice(0, maxRows);
@@ -101,22 +101,22 @@ export function renderIssuesCard(opts: RenderIssuesCardOpts): string | null {
101
101
  const now = opts.now ?? Date.now();
102
102
  const rows = visible.map((e) => {
103
103
  const emoji = SEVERITY_EMOJI[e.severity];
104
- const occ = e.occurrences > 1 ? ` <i>(×${e.occurrences})</i>` : "";
104
+ const occ = e.occurrences > 1 ? ` _(×${e.occurrences})_` : "";
105
105
  const ago = relTime(now - e.last_seen);
106
- const head = `${emoji} <code>${escapeHtml(e.fingerprint)}</code> ${escapeHtml(e.summary)}${occ} — <i>${ago}</i>`;
106
+ const head = `${emoji} \`${e.fingerprint}\` ${escapeMarkdown(e.summary)}${occ} — _${ago}_`;
107
107
  // Render the `detail` line below the summary when present and short
108
108
  // enough to be a remediation hint (not a multi-line stderr tail).
109
109
  // Convention from the cron prompt template: agents put "Fix: <cmd>"
110
110
  // or "→ <cmd>" in detail. Long stderr details are omitted from the
111
111
  // card to keep the layout tight; users can run /issues to see them.
112
112
  const remediation = formatRemediation(e.detail);
113
- return remediation == null ? head : `${head}\n → <i>${escapeHtml(remediation)}</i>`;
113
+ return remediation == null ? head : `${head}\n → _${escapeMarkdown(remediation)}_`;
114
114
  });
115
115
 
116
116
  const lines = [header, "", ...rows];
117
117
  if (overflow > 0) {
118
118
  lines.push("");
119
- lines.push(`<i>+${overflow} more not shown — run <code>/issues</code></i>`);
119
+ lines.push(`_+${overflow} more not shown — run \`/issues\`_`);
120
120
  }
121
121
  return lines.join("\n");
122
122
  }
@@ -326,7 +326,6 @@ export function createIssuesCardHandle(
326
326
  if (body === lastBody && messageId != null) return;
327
327
 
328
328
  const sendOpts: Record<string, unknown> = {
329
- parse_mode: "HTML",
330
329
  disable_web_page_preview: true,
331
330
  // Status card, not the user's answer — silence the open ping.
332
331
  // (editMessageText ignores disable_notification, so the shared
@@ -366,10 +366,10 @@ export function formatModelUnavailableCard(
366
366
  opts: FormatCardOptions = {},
367
367
  ): string {
368
368
  const now = opts.now ?? new Date()
369
- const slotPart = opts.slot ? ` (slot <b>${escHtml(opts.slot)}</b>)` : ''
369
+ const slotPart = opts.slot ? ` (slot **${escHtml(opts.slot)}**)` : ''
370
370
  const reason = formatReason(detection, now)
371
371
  const lines = [
372
- `⚠️ <b>Model unavailable</b> on agent <b>${escHtml(agent)}</b>${slotPart}`,
372
+ `⚠️ **Model unavailable** on agent **${escHtml(agent)}**${slotPart}`,
373
373
  `Reason: ${reason}`,
374
374
  '',
375
375
  ]
@@ -378,7 +378,7 @@ export function formatModelUnavailableCard(
378
378
  // swap; a follow-up announcement (causal-shape) will land within
379
379
  // ~1s. Mention it explicitly so the user knows not to react.
380
380
  lines.push(
381
- '<i>Auto-failover in progress — see the announcement below.</i>',
381
+ '_Auto-failover in progress — see the announcement below._',
382
382
  )
383
383
  } else {
384
384
  // Default — kinds where auto-fallback can't help (network)
@@ -386,10 +386,10 @@ export function formatModelUnavailableCard(
386
386
  // a verb (post-RFC-H); `/auth use <label>` is the canonical
387
387
  // fleet-wide swap.
388
388
  lines.push(
389
- '<b>What to try</b>',
390
- '• <code>/auth use &lt;label&gt;</code> — switch the fleet to a healthy account',
391
- '• <code>/auth add</code> — attach another subscription',
392
- '• <code>/usage</code> — show quota breakdown',
389
+ '**What to try**',
390
+ '• \`/auth use <label>\` — switch the fleet to a healthy account',
391
+ '• \`/auth add\` — attach another subscription',
392
+ '• \`/usage\` — show quota breakdown',
393
393
  )
394
394
  }
395
395
  return lines.join('\n')
@@ -471,9 +471,5 @@ export function resolveModelUnavailableFromOperatorEvent(
471
471
  // ─── HTML escape (mirrors operator-events.ts) ────────────────────────────────
472
472
 
473
473
  function escHtml(text: string): string {
474
- return text
475
- .replace(/&/g, '&amp;')
476
- .replace(/</g, '&lt;')
477
- .replace(/>/g, '&gt;')
478
- .replace(/"/g, '&quot;')
474
+ return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
479
475
  }
@@ -78,7 +78,7 @@ export function formatLastEventLine(
78
78
  }
79
79
 
80
80
  const kindIcon = EVENT_KIND_ICON[ev.kind] ?? '⚪'
81
- return ` <i>last: ${kindIcon} ${ev.kind} (${age})</i>`
81
+ return ` _last: ${kindIcon} ${ev.kind} (${age})_`
82
82
  }
83
83
 
84
84
  const EVENT_KIND_ICON: Record<string, string> = {
@@ -217,9 +217,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
217
217
  case 'credentials-expired':
218
218
  return {
219
219
  text: [
220
- `🔑 <b>Claude login expired</b> for <b>${agent}</b>.`,
221
- detail ? `<i>${detail}</i>` : '',
222
- `Tap <b>Reauth now</b> to refresh credentials.`,
220
+ `🔑 **Claude login expired** for **${agent}**.`,
221
+ detail ? `_${detail}_` : '',
222
+ `Tap **Reauth now** to refresh credentials.`,
223
223
  ]
224
224
  .filter(Boolean)
225
225
  .join('\n'),
@@ -236,9 +236,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
236
236
  case 'credentials-invalid':
237
237
  return {
238
238
  text: [
239
- `🔑 <b>Invalid Claude credentials</b> for <b>${agent}</b>.`,
240
- detail ? `<i>${detail}</i>` : '',
241
- `Run <code>/auth reauth ${agent}</code> or tap below.`,
239
+ `🔑 **Invalid Claude credentials** for **${agent}**.`,
240
+ detail ? `_${detail}_` : '',
241
+ `Run \`/auth reauth ${agent}\` or tap below.`,
242
242
  ]
243
243
  .filter(Boolean)
244
244
  .join('\n'),
@@ -255,9 +255,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
255
255
  case 'credit-exhausted':
256
256
  return {
257
257
  text: [
258
- `💳 <b>Credit balance too low</b> for <b>${agent}</b>.`,
259
- detail ? `<i>${detail}</i>` : '',
260
- `Use <code>/auth use &lt;label&gt;</code> to switch account slot or <code>/auth add</code> to add one.`,
258
+ `💳 **Credit balance too low** for **${agent}**.`,
259
+ detail ? `_${detail}_` : '',
260
+ `Use \`/auth use <label>\` to switch account slot or \`/auth add\` to add one.`,
261
261
  ]
262
262
  .filter(Boolean)
263
263
  .join('\n'),
@@ -274,9 +274,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
274
274
  // are the historical source; this is now the single owner.
275
275
  return {
276
276
  text: [
277
- `⚠️ <b>Quota exhausted</b> for <b>${agent}</b>.`,
278
- detail ? `<i>${detail}</i>` : '',
279
- `All account slots are at the usage limit. Switchroom will auto-fallback when another slot is available. Use <code>/auth use &lt;label&gt;</code> to switch manually.`,
277
+ `⚠️ **Quota exhausted** for **${agent}**.`,
278
+ detail ? `_${detail}_` : '',
279
+ `All account slots are at the usage limit. Switchroom will auto-fallback when another slot is available. Use \`/auth use <label>\` to switch manually.`,
280
280
  ]
281
281
  .filter(Boolean)
282
282
  .join('\n'),
@@ -290,8 +290,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
290
290
  case 'rate-limited':
291
291
  return {
292
292
  text: [
293
- `🚦 <b>Rate limited</b> for <b>${agent}</b>.`,
294
- detail ? `<i>${detail}</i>` : '',
293
+ `🚦 **Rate limited** for **${agent}**.`,
294
+ detail ? `_${detail}_` : '',
295
295
  `Claude is temporarily rate-limiting requests. Will retry automatically.`,
296
296
  ]
297
297
  .filter(Boolean)
@@ -306,8 +306,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
306
306
  case 'agent-crashed':
307
307
  return {
308
308
  text: [
309
- `💥 <b>Agent crashed</b>: <b>${agent}</b>.`,
310
- detail ? `<i>${detail}</i>` : '',
309
+ `💥 **Agent crashed**: **${agent}**.`,
310
+ detail ? `_${detail}_` : '',
311
311
  ]
312
312
  .filter(Boolean)
313
313
  .join('\n'),
@@ -324,8 +324,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
324
324
  case 'agent-restarted-unexpectedly':
325
325
  return {
326
326
  text: [
327
- `🔄 <b>Agent restarted unexpectedly</b>: <b>${agent}</b>.`,
328
- detail ? `<i>${detail}</i>` : '',
327
+ `🔄 **Agent restarted unexpectedly**: **${agent}**.`,
328
+ detail ? `_${detail}_` : '',
329
329
  `This may indicate a crash-loop. Check logs if it happens again.`,
330
330
  ]
331
331
  .filter(Boolean)
@@ -343,8 +343,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
343
343
  case 'unknown-4xx':
344
344
  return {
345
345
  text: [
346
- `⚠️ <b>API error (4xx)</b> for <b>${agent}</b>.`,
347
- detail ? `<code>${detail}</code>` : '',
346
+ `⚠️ **API error (4xx)** for **${agent}**.`,
347
+ detail ? `\`${detail}\`` : '',
348
348
  ]
349
349
  .filter(Boolean)
350
350
  .join('\n'),
@@ -361,8 +361,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
361
361
  case 'unknown-5xx':
362
362
  return {
363
363
  text: [
364
- `🔥 <b>Server error (5xx)</b> for <b>${agent}</b>.`,
365
- detail ? `<code>${detail}</code>` : '',
364
+ `🔥 **Server error (5xx)** for **${agent}**.`,
365
+ detail ? `\`${detail}\`` : '',
366
366
  `Anthropic may be experiencing issues. Will retry automatically.`,
367
367
  ]
368
368
  .filter(Boolean)
@@ -411,12 +411,8 @@ export function resetAllCooldowns(): void {
411
411
  cooldownMap.clear()
412
412
  }
413
413
 
414
- // ─── HTML escape ─────────────────────────────────────────────────────────────
414
+ // ─── Markdown escape (#2669) ──────────────────────────────────────────────────
415
415
 
416
416
  function escHtml(text: string): string {
417
- return text
418
- .replace(/&/g, '&amp;')
419
- .replace(/</g, '&lt;')
420
- .replace(/>/g, '&gt;')
421
- .replace(/"/g, '&quot;')
417
+ return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
422
418
  }
@@ -32,7 +32,7 @@
32
32
  "@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
33
33
  "@secretlint/types": "^12.2.0",
34
34
  "@xterm/headless": "^6.0.0",
35
- "grammy": "^1.21.0",
35
+ "grammy": "^1.44",
36
36
  "posthog-node": "^5.29.2"
37
37
  },
38
38
  "engines": {
@@ -49,14 +49,13 @@
49
49
  * interval is short (5s) but edits are spaced at EDIT_INTERVAL_MS so
50
50
  * the Telegram bot.api editMessageText rate stays well under limits.
51
51
  *
52
- * Edits preserve the anchor's original `parse_mode` (issue #1698). The
53
- * anchor was sent through the reply tool, which defaults to HTML; an
54
- * earlier version of this module dropped parse_mode on edit, which made
55
- * the next "still working (Nm)" tick re-render `<b>` / `<code>` tags as
56
- * literal text. The suffix itself is plain text (no `<`/`>`/`&`) so it
57
- * is safe under any parse_mode. On subsequent edits the prior suffix is
58
- * stripped before re-appending so the message never accumulates duplicate
59
- * suffixes.
52
+ * Edits preserve the anchor's original send shape rich-markdown vs the
53
+ * literal `format:'text'` path (#2669, successor to the #1698 parse-mode
54
+ * contract). The anchor was sent through the reply tool, which defaults to
55
+ * the rich-markdown path; the edit re-sends via the SAME path so the
56
+ * anchor body keeps rendering the way it first did. On subsequent edits
57
+ * the prior suffix is stripped before re-appending so the message never
58
+ * accumulates duplicate suffixes.
60
59
  *
61
60
  * Kill switch: `SWITCHROOM_DISABLE_PENDING_PROGRESS=1` disables the
62
61
  * whole subsystem. The conversational-pacing prompt is unaffected.
@@ -65,32 +64,36 @@
65
64
  export const EDIT_INTERVAL_MS = 60_000
66
65
  export const POLL_INTERVAL_MS = 5_000
67
66
  export const MAX_LIFETIME_MS = 30 * 60_000
68
- /** Telegram message length limit is 4096; budget headroom for the
67
+ /** Rich-message wire cap is 32768 (#2669); budget headroom for the
69
68
  * suffix and any escape expansion. If the anchor text plus suffix
70
69
  * would exceed this, we skip the edit (the user still sees the
71
70
  * original) rather than truncate the model's authored prose. */
72
- export const TELEGRAM_MSG_CAP = 4000
71
+ export const TELEGRAM_MSG_CAP = 32768
73
72
 
74
73
  /**
75
74
  * Regex matching the suffix we append. Used to strip a prior suffix
76
75
  * before appending the next one. The (\d+) covers "1m" / "12m" / etc.
77
76
  * The reachability clause is optional so anchors carrying a pre-v0.14.30
78
- * suffix (no clause) are still stripped during a rolling upgrade.
79
- * Kept anchored to end-of-string so it only matches OUR suffix, not
80
- * something the model happened to write.
77
+ * suffix (no clause) are still stripped during a rolling upgrade. Both the
78
+ * legacy em-dash prefix (`— still working`) and the rich-markdown italic
79
+ * form (`_still working _`, #2669) are matched so a rolling upgrade
80
+ * strips either. Kept anchored to end-of-string so it only matches OUR
81
+ * suffix, not something the model happened to write.
81
82
  */
82
83
  const SUFFIX_RE =
83
- /\n\n still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/
84
+ /\n\n(?:— |_)still working \(\d+m\)( · message me anytime, I'll keep you posted)?_?$/
84
85
 
85
86
  export interface PendingProgressEditCtx {
86
87
  chatId: string
87
88
  threadId: number | null
88
89
  messageId: number
89
90
  newText: string
90
- /** Telegram parse_mode the original anchor was sent with (#1698).
91
- * The edit must use the same mode or pre-rendered HTML / MarkdownV2
92
- * tags in `anchorOriginalText` re-render as literal text. */
93
- parseMode: 'HTML' | 'MarkdownV2' | undefined
91
+ /** True when the original anchor was a literal `format:'text'` send
92
+ * (plain `sendMessage`, no rich-message wrapper). The edit must match:
93
+ * a rich anchor re-edits via `editMessageText({ markdown })`, a literal
94
+ * anchor re-edits as a plain string (#2669 single-rich-path migration of
95
+ * the #1698 parse-mode-preservation contract). */
96
+ literalText: boolean
94
97
  }
95
98
 
96
99
  /**
@@ -145,11 +148,11 @@ interface State {
145
148
  /** The captured anchor text — what the model wrote, *minus* any
146
149
  * prior pending-progress suffix. Used as the base for every edit. */
147
150
  anchorOriginalText: string
148
- /** parse_mode the anchor was originally sent with. Edits must
149
- * reuse this or the rendered HTML / MarkdownV2 tags in
150
- * anchorOriginalText render as literal text on the next tick
151
- * (issue #1698). */
152
- anchorParseMode: 'HTML' | 'MarkdownV2' | undefined
151
+ /** True when the anchor was a literal `format:'text'` send. Edits must
152
+ * match the original send shape (rich vs literal) or the suffix re-edit
153
+ * changes how the anchor body renders (the #2669 single-rich-path
154
+ * successor to the #1698 parse-mode-preservation contract). */
155
+ anchorLiteralText: boolean
153
156
  /** Wall-clock ms when the cross-turn ambient state was *activated*
154
157
  * (at turn_end with pending+anchor). null before activation. */
155
158
  activatedAt: number | null
@@ -178,7 +181,7 @@ function ensure(key: string): State {
178
181
  pending: false,
179
182
  anchorMessageId: null,
180
183
  anchorOriginalText: '',
181
- anchorParseMode: undefined,
184
+ anchorLiteralText: false,
182
185
  activatedAt: null,
183
186
  lastEditAt: null,
184
187
  }
@@ -216,7 +219,7 @@ export function startTurn(key: string): void {
216
219
  s.pending = false
217
220
  s.anchorMessageId = null
218
221
  s.anchorOriginalText = ''
219
- s.anchorParseMode = undefined
222
+ s.anchorLiteralText = false
220
223
  }
221
224
 
222
225
  /**
@@ -241,21 +244,18 @@ export function noteOutbound(
241
244
  opts: {
242
245
  messageId: number
243
246
  text: string
244
- /** parse_mode the anchor was sent with. Captured so the
245
- * cross-turn edit tick can reuse it (#1698). Undefined or
246
- * omitted means the original send had no parse_mode (plain
247
- * text). Production callers MUST pass this — every reply path
248
- * knows its own parse_mode. Defaulted to undefined only so test
249
- * fixtures don't have to thread it through where they're
250
- * asserting other behaviour. */
251
- parseMode?: 'HTML' | 'MarkdownV2' | undefined
247
+ /** True when the anchor was a literal `format:'text'` send. Captured
248
+ * so the cross-turn edit tick re-edits with the same shape (#2669
249
+ * single-rich-path successor to the #1698 parse-mode contract).
250
+ * Omitted false (the rich-markdown default). */
251
+ literalText?: boolean
252
252
  },
253
253
  ): void {
254
254
  if (!enabled()) return
255
255
  const s = ensure(key)
256
256
  s.anchorMessageId = opts.messageId
257
257
  s.anchorOriginalText = opts.text.replace(SUFFIX_RE, '')
258
- s.anchorParseMode = opts.parseMode
258
+ s.anchorLiteralText = opts.literalText ?? false
259
259
  }
260
260
 
261
261
  /**
@@ -399,7 +399,7 @@ function tick(now: number): void {
399
399
  // user-visible counter reads honestly (we only edit at intervals
400
400
  // ≥ EDIT_INTERVAL_MS = 60s).
401
401
  const minutes = Math.max(1, Math.round(elapsed / 60_000))
402
- const suffix = `\n\n— still working (${minutes}m) · message me anytime, I'll keep you posted`
402
+ const suffix = `\n\n_still working (${minutes}m) · message me anytime, I'll keep you posted_`
403
403
  const newText = s.anchorOriginalText + suffix
404
404
 
405
405
  if (newText.length > TELEGRAM_MSG_CAP) {
@@ -417,7 +417,7 @@ function tick(now: number): void {
417
417
  threadId,
418
418
  messageId: s.anchorMessageId,
419
419
  newText,
420
- parseMode: s.anchorParseMode,
420
+ literalText: s.anchorLiteralText,
421
421
  }
422
422
  // Fire-and-forget so a slow edit doesn't block the tick loop.
423
423
  // Errors are logged but never bubble (a 429 / "message not modified"