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
@@ -127,10 +127,17 @@ export interface VaultErrorRendering {
127
127
  }
128
128
 
129
129
  function htmlEscape(s: string): string {
130
- return s
131
- .replace(/&/g, "&")
132
- .replace(/</g, "&lt;")
133
- .replace(/>/g, "&gt;");
130
+ return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
131
+ }
132
+
133
+ /**
134
+ * Make a string safe to interpolate INSIDE a `code span` without escaping —
135
+ * content there is literal, so markdown-escaping a key would leak visible
136
+ * backslashes (e.g. `my\_key`). Only a backtick can prematurely close the
137
+ * span, so split it with a zero-width space (#2669).
138
+ */
139
+ function codeSpanSafe(s: string): string {
140
+ return s.replace(/`/g, "`​");
134
141
  }
135
142
 
136
143
  /**
@@ -173,13 +180,13 @@ export function renderVaultCliError(
173
180
  return {
174
181
  suppressRaw: true,
175
182
  html:
176
- `⚠️ <b>New vault key — operator approval required.</b>\n` +
183
+ `⚠️ **New vault key — operator approval required.**\n` +
177
184
  (key
178
- ? `The agent tried to save <code>${htmlEscape(key)}</code>, but `
185
+ ? `The agent tried to save \`${codeSpanSafe(key)}\`, but `
179
186
  : `The agent tried to save a new key, but `) +
180
187
  `agents can only rotate existing keys via the broker; introducing ` +
181
188
  `a new key needs an operator action.\n\n` +
182
- `<b>Ask the agent</b> to call its <code>vault_request_save</code> ` +
189
+ `**Ask the agent** to call its \`vault_request_save\` ` +
183
190
  `tool — it'll render an approval card in this chat with ` +
184
191
  `[✅ Save once] [🚫 Discard] [✏️ Rename] buttons. One tap saves the ` +
185
192
  `secret to the vault; no re-paste needed.`,
@@ -194,12 +201,12 @@ export function renderVaultCliError(
194
201
  return {
195
202
  suppressRaw: true,
196
203
  html:
197
- `⚠️ <b>Vault broker isn't reachable.</b>\n` +
204
+ `⚠️ **Vault broker isn't reachable.**\n` +
198
205
  `From inside the agent sandbox there's no fallback path. ` +
199
206
  `Telegram-native broker recovery is tracked as a follow-up — ` +
200
207
  `for now, on the host:\n` +
201
- `<pre>switchroom vault broker status</pre>\n` +
202
- `<i>Or, if the broker is wedged: <code>docker compose -p switchroom restart vault-broker</code>.</i>`,
208
+ "```\nswitchroom vault broker status\n```\n" +
209
+ `_Or, if the broker is wedged: \`docker compose -p switchroom restart vault-broker\`._`,
203
210
  };
204
211
  case "broker_denied":
205
212
  // Telegram-native grant flow shipped in #969 P2b + #1012:
@@ -210,14 +217,14 @@ export function renderVaultCliError(
210
217
  return {
211
218
  suppressRaw: true,
212
219
  html:
213
- `⚠️ <b>Vault broker refused the request.</b>\n` +
220
+ `⚠️ **Vault broker refused the request.**\n` +
214
221
  (key
215
- ? `The agent isn't authorized to access <code>${htmlEscape(key)}</code>. `
222
+ ? `The agent isn't authorized to access \`${codeSpanSafe(key)}\`. `
216
223
  : `The agent isn't authorized to access this key. `) +
217
224
  `Grant access in two taps:\n` +
218
- `• <code>/vault audit &lt;agent&gt;</code> in this chat → tap ` +
225
+ `• \`/vault audit <agent>\` in this chat → tap ` +
219
226
  `[🔓 Allow${key ? ` ${htmlEscape(key)}` : ""}] on the recent denial, OR\n` +
220
- `• ask the agent to call <code>vault_request_access</code> — ` +
227
+ `• ask the agent to call \`vault_request_access\` — ` +
221
228
  `an approval card lands here with [✅ Approve] / [🚫 Deny].`,
222
229
  };
223
230
  case "other":
@@ -234,36 +241,36 @@ function renderSandboxContextSuggestion(
234
241
  verb: "set" | "get" | "list" | "init" | "remove" | "save",
235
242
  key: string | undefined,
236
243
  ): string {
237
- const head = `⚠️ <b>Direct vault file IO isn't available from inside the agent sandbox.</b>\n`;
244
+ const head = `⚠️ **Direct vault file IO isn't available from inside the agent sandbox.**\n`;
238
245
  switch (verb) {
239
246
  case "set":
240
247
  case "save":
241
248
  return (
242
249
  head +
243
250
  (key
244
- ? `<b>Ask the agent</b> to call its <code>vault_request_save</code> ` +
245
- `tool for <code>${htmlEscape(key)}</code> — an approval card ` +
251
+ ? `**Ask the agent** to call its \`vault_request_save\` ` +
252
+ `tool for \`${codeSpanSafe(key)}\` — an approval card ` +
246
253
  `lands here with [✅ Save once] / [🚫 Discard] / [✏️ Rename] buttons.`
247
- : `<b>Ask the agent</b> to call its <code>vault_request_save</code> ` +
254
+ : `**Ask the agent** to call its \`vault_request_save\` ` +
248
255
  `tool — an approval card lands here with [✅ Save once] / [🚫 Discard] / [✏️ Rename] buttons.`)
249
256
  );
250
257
  case "get":
251
258
  return (
252
259
  head +
253
- `From this chat: <code>/vault get${key ? ` ${htmlEscape(key)}` : " &lt;key&gt;"}</code>`
260
+ `From this chat: \`/vault get${key ? ` ${codeSpanSafe(key)}` : " <key>"}\``
254
261
  );
255
262
  case "list":
256
- return head + `From this chat: <code>/vault list</code>`;
263
+ return head + `From this chat: \`/vault list\``;
257
264
  case "remove":
258
265
  return (
259
266
  head +
260
267
  `Use the operator host CLI for now — Telegram-native delete is tracked as a follow-up. ` +
261
- `<i>(Removing a vault key is a rare, irreversible operation; an in-chat confirmation card is on the punch list.)</i>`
268
+ `_(Removing a vault key is a rare, irreversible operation; an in-chat confirmation card is on the punch list.)_`
262
269
  );
263
270
  case "init":
264
271
  return (
265
272
  head +
266
- `Vault bootstrap is a one-time host-shell step: <code>switchroom vault init</code>.`
273
+ `Vault bootstrap is a one-time host-shell step: \`switchroom vault init\`.`
267
274
  );
268
275
  }
269
276
  }
@@ -29,6 +29,7 @@ import { createHash } from 'crypto'
29
29
  import { AsyncLocalStorage } from 'async_hooks'
30
30
  import { clearStaleTelegramPollingState } from '../startup-reset.js'
31
31
  import { createRetryApiCall } from '../retry-api-call.js'
32
+ import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
32
33
 
33
34
  // ─── tg-post tag plumbing ─────────────────────────────────────────────────
34
35
 
@@ -154,21 +155,36 @@ export function createRobustApiCall() {
154
155
  })
155
156
  }
156
157
 
157
- // ─── HTML escape helpers ─────────────────────────────────────────────────
158
+ // ─── Markdown escape helpers (#2669) ──────────────────────────────────────
158
159
 
160
+ /**
161
+ * Escape GFM-markdown specials in a dynamic value interpolated into prose.
162
+ * Kept under the legacy `escapeHtmlForTg` name so callers don't churn.
163
+ */
159
164
  export function escapeHtmlForTg(text: string): string {
160
- return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
165
+ return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
161
166
  }
162
167
 
168
+ /**
169
+ * Wrap CLI / command output in a fenced code block. Inside a fence the
170
+ * content is literal (no escaping), so we pass `text` through verbatim —
171
+ * except a fence-closing ``` sequence in the content, which we defuse so it
172
+ * can't terminate the block early.
173
+ */
163
174
  export function preBlock(text: string): string {
164
- return '<pre>' + escapeHtmlForTg(text) + '</pre>'
175
+ const safe = text.replace(/```/g, '`​``')
176
+ return '```\n' + safe + '\n```'
165
177
  }
166
178
 
167
179
  export function stripAnsi(text: string): string {
168
180
  return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
169
181
  }
170
182
 
171
- export function formatSwitchroomOutput(output: string, maxLen = 4000): string {
183
+ // Default truncation budget for CLI output bound for Telegram. Post-#2669 the
184
+ // rich-message wire cap is RICH_MESSAGE_MAX_CHARS (32768), not the legacy 4096
185
+ // plain-text limit; the preBlock fence framing (~8 chars) easily fits the
186
+ // remaining headroom.
187
+ export function formatSwitchroomOutput(output: string, maxLen = RICH_MESSAGE_MAX_CHARS): string {
172
188
  const trimmed = output.trim()
173
189
  if (trimmed.length <= maxLen) return trimmed
174
190
  return trimmed.slice(0, maxLen - 20) + '\n... (truncated)'
@@ -278,11 +294,17 @@ export function makeSwitchroomReply(
278
294
  ): Promise<void> {
279
295
  const chatId = String(ctx.chat!.id)
280
296
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
281
- await ctx.reply(text, {
297
+ const opts = {
282
298
  ...(threadId != null ? { message_thread_id: threadId } : {}),
283
- ...(options.html ? { parse_mode: 'HTML' as const, link_preview_options: { is_disabled: true } } : {}),
284
299
  ...(options.reply_markup ? { reply_markup: options.reply_markup } : {}),
285
- })
300
+ }
301
+ // #2669: `options.html:true` now means "render `text` as GFM markdown via
302
+ // the rich-message path" (legacy field name kept). Plain otherwise.
303
+ if (options.html) {
304
+ await ctx.replyWithRichMessage({ markdown: text }, opts)
305
+ } else {
306
+ await ctx.reply(text, opts)
307
+ }
286
308
  }
287
309
  }
288
310
 
@@ -377,17 +377,20 @@ export function silenceMsForKey(key: string, now: number): number | null {
377
377
  }
378
378
 
379
379
  /**
380
- * Verbatim framework-fallback text — the user-visible "still working / still
381
- * thinking" message the gateway sends at the 300s threshold when the model
382
- * hasn't broken its own silence. Wording is load-bearing (see
383
- * `reference/rfcs/conversational-pacing.md` § Safety net). Two principles:
380
+ * Framework-fallback text for the 300s silence threshold. Returns `null` for
381
+ * the pure-stall cases (the "still working / still thinking" / "running <Tool>
382
+ * for Nm" notice) — that timer-fired stall ping was a stop-gap from before the
383
+ * live-updating reply/draft carried the progress beats natively, and the
384
+ * operator has retired it (it's the exact cadence-based "still working" update
385
+ * the conversational-pacing RFC's Anti-patterns section bans). The 300s
386
+ * fallback's surviving job — unwedging a turn that produced no output at all —
387
+ * is the gateway's turn-teardown after this call, NOT a user-visible message,
388
+ * so dropping the stall send keeps the safety net's real work intact (see
389
+ * `reference/rfcs/conversational-pacing.md` § Silence-poke fallback).
384
390
  *
385
- * 1. The parenthetical `(no update from agent in N min)` is honest —
386
- * distinguishes from "the agent said something" so users learn to trust
387
- * real agent messages. `N` is derived from `silenceMs`, never hard-coded.
388
- * 2. The verb is `working` by default, `thinking` only when the session
389
- * stream has emitted a `kind: 'thinking'` event in the last 30s. Picked
390
- * by the caller via `fallbackKind`; this helper just formats.
391
+ * The ONE case that still returns a string is `blockedOnApproval`: the turn is
392
+ * parked on an approval card waiting for YOUR tap. That is not a stall — it
393
+ * tells the user the ball is in their court — so it keeps pinging.
391
394
  *
392
395
  * Extracted from the gateway's `onFrameworkFallback` callback so the wording
393
396
  * can be snapshot-tested in isolation. CC-4 in `docs/status-ask-cause-classes.md`.
@@ -397,73 +400,27 @@ export function formatFrameworkFallbackText(
397
400
  silenceMs: number,
398
401
  inFlightTools: ToolSnapshot[] = [],
399
402
  blockedOnApproval = false,
400
- ): string {
403
+ ): string | null {
401
404
  const minutes = Math.max(1, Math.round(silenceMs / 60_000))
402
405
  // The turn isn't stalled — it's parked on an approval card waiting for YOUR
403
406
  // tap (the dominant live "wedge" class is benign approval-latency, not a
404
407
  // hang). Saying "still working…" here actively lies; name the real blocker so
405
408
  // the operator knows the ball is in their court. Takes precedence over the
406
- // in-flight-tool framing (a tool awaiting approval isn't "running").
409
+ // in-flight-tool framing (a tool awaiting approval isn't "running"). This is
410
+ // the only branch that still emits a user-visible message.
407
411
  if (blockedOnApproval) {
408
412
  return `waiting for your approval — tap Approve or Deny on the card above (${minutes} min)`
409
413
  }
410
- const suffix = `(no update from agent in ${minutes} min)`
411
- // #1292 case (a): tools in flight. Name the longest-running one
412
- // (entry[0] — caller pre-sorts by startedAt ascending). Avoid the
413
- // "still working" framing #1292 explicitly calls out as dishonest:
414
- // the agent IS doing work, we can see the tool. Format:
415
- // running Grep "foo" for 4m (no update from agent in 5 min)
416
- // running Grep "foo" + 2 more (4m) (no update from agent in 5 min)
417
- // running Grep (no label) for 4m (no update from agent in 5 min)
418
- //
419
- // Raw MCP tool names (`mcp__server__tool`) are technical identifiers
420
- // and look like a leak when surfaced to a user. When the tool name
421
- // matches that shape AND a human-friendly label is available, drop
422
- // the raw name and lead with the label instead:
423
- // Searching memory for 4m (no update from agent in 5 min)
424
- // Built-in tool names (Grep, Read, Bash) stay as-is — they ARE
425
- // human-readable, and the label is supplementary detail (e.g. the
426
- // search pattern) that reads naturally after the verb.
427
- if (inFlightTools.length > 0) {
428
- const longest = inFlightTools[0]!
429
- const dur = formatDurationShort(longest.durationMs)
430
- const labelTail = longest.label && longest.label.length > 0
431
- ? ` ${truncateLabel(longest.label)}`
432
- : ''
433
- const more = inFlightTools.length > 1
434
- ? ` + ${inFlightTools.length - 1} more`
435
- : ''
436
- const isMcpRawName = /^mcp__/.test(longest.name)
437
- if (isMcpRawName && labelTail !== '') {
438
- // Label-only: "Searching memory for 4m (…)". Drop the raw
439
- // `mcp__server__tool` and the leading "running" because the
440
- // label already reads as a gerund phrase.
441
- return `${truncateLabel(longest.label!)}${more} for ${dur} ${suffix}`
442
- }
443
- return `running ${longest.name}${labelTail}${more} for ${dur} ${suffix}`
444
- }
445
- return fallbackKind === 'thinking'
446
- ? `still thinking… ${suffix}`
447
- : `still working… ${suffix}`
448
- }
449
-
450
- /** Compact m/s rendering for the fallback message. Anything under a
451
- * minute reads as `${s}s`, otherwise `${m}m`. Always rounds toward the
452
- * user-honest direction — "4m" for 4m 30s, "5m" for 4m 45s. */
453
- function formatDurationShort(ms: number): string {
454
- const totalSec = Math.max(0, Math.round(ms / 1000))
455
- if (totalSec < 60) return `${totalSec}s`
456
- const minutes = Math.round(totalSec / 60)
457
- return `${minutes}m`
458
- }
459
-
460
- /** Telegram lines are short on mobile. Clip the label to keep the
461
- * fallback message readable. Truncation point is generous (60 chars)
462
- * because tool labels are pre-truncated by `toolLabel()` already. */
463
- function truncateLabel(label: string): string {
464
- const MAX = 60
465
- if (label.length <= MAX) return label
466
- return label.slice(0, MAX - 1) + '…'
414
+ // Stop-gap retired: the "still working… (no update from agent in N min)" /
415
+ // "running <Tool> for Nm" stall notice (including the #1292 tool-aware
416
+ // enrichment) no longer sends. The live draft + the model's own pacing beats
417
+ // carry progress; a timer-fired stall ping on top of that is the banned
418
+ // cadence-based update. `fallbackKind` and `inFlightTools` are now unused for
419
+ // the stall path but kept on the signature so the gateway's call sites — and
420
+ // the deterministic update-status / unwedge paths around them — are untouched.
421
+ void fallbackKind
422
+ void inFlightTools
423
+ return null
467
424
  }
468
425
 
469
426
  /** Snapshot in-flight tools sorted longest-running first — for the honest
@@ -38,8 +38,15 @@
38
38
  * the safety net off; reverts to per-reply fresh send.
39
39
  */
40
40
 
41
- /** Telegram caption / text limit. The accumulator stays under this. */
42
- export const TELEGRAM_MSG_CAP = 4000
41
+ import { RICH_MESSAGE_MAX_CHARS } from './format.js'
42
+
43
+ /**
44
+ * Telegram rich-message text limit. The accumulator stays under this before
45
+ * it rolls to a fresh anchor. Post-#2669 every reply renders as GFM markdown
46
+ * via `sendRichMessage`, so the cap is `RICH_MESSAGE_MAX_CHARS` (32768), not
47
+ * the legacy 4096 plain-text limit.
48
+ */
49
+ export const TELEGRAM_MSG_CAP = RICH_MESSAGE_MAX_CHARS
43
50
 
44
51
  export interface SilentReplyAnchorDecisionInput {
45
52
  /** True when the model passed `disable_notification: true` for
@@ -31,10 +31,15 @@ export interface BannerBotApi {
31
31
  text: string,
32
32
  opts?: Record<string, unknown>,
33
33
  ): Promise<{ message_id: number }>;
34
+ sendRichMessage(
35
+ chat_id: string | number,
36
+ rich_message: { markdown: string },
37
+ opts?: Record<string, unknown>,
38
+ ): Promise<{ message_id: number }>;
34
39
  editMessageText(
35
40
  chat_id: string | number,
36
41
  message_id: number,
37
- text: string,
42
+ text: string | { markdown: string },
38
43
  opts?: Record<string, unknown>,
39
44
  ): Promise<unknown>;
40
45
  pinChatMessage(
@@ -107,9 +112,8 @@ export async function refreshBanner(
107
112
  if (action.kind === 'pin') {
108
113
  let sent: { message_id: number };
109
114
  try {
110
- sent = await args.bot.api.sendMessage(args.ownerChatId, action.text, {
111
- parse_mode: 'HTML',
112
- link_preview_options: { is_disabled: true },
115
+ // sendRichMessage doesn't accept link_preview_options — omit it.
116
+ sent = await args.bot.api.sendRichMessage(args.ownerChatId, { markdown: action.text }, {
113
117
  // OAuth slot banner is a status notice — silence the open ping.
114
118
  // (the pin below is already silent; the edit path doesn't ping.)
115
119
  disable_notification: true,
@@ -135,9 +139,8 @@ export async function refreshBanner(
135
139
  await args.bot.api.editMessageText(
136
140
  args.ownerChatId,
137
141
  action.messageId,
138
- action.text,
142
+ { markdown: action.text },
139
143
  {
140
- parse_mode: 'HTML',
141
144
  link_preview_options: { is_disabled: true },
142
145
  },
143
146
  );
@@ -72,15 +72,12 @@ export function formatBannerHtml(
72
72
  defaultSlot: string,
73
73
  ): string {
74
74
  return [
75
- `📌 <b>${escapeHtml(agentName)}</b> is running on slot <code>${escapeHtml(currentSlot)}</code>`,
76
- `<i>(failover from <code>${escapeHtml(defaultSlot)}</code>)</i>`,
75
+ `📌 **${escapeMarkdown(agentName)}** is running on slot \`${currentSlot}\``,
76
+ `_(failover from \`${defaultSlot}\`)_`,
77
77
  ].join(' ');
78
78
  }
79
79
 
80
- function escapeHtml(text: string): string {
81
- return text
82
- .replace(/&/g, '&amp;')
83
- .replace(/</g, '&lt;')
84
- .replace(/>/g, '&gt;')
85
- .replace(/"/g, '&quot;');
80
+ /** Local GFM-markdown escaper (keeps this module dependency-free). */
81
+ function escapeMarkdown(text: string): string {
82
+ return text.replace(/([\\`*_~=\[\]|])/g, '\\$1');
86
83
  }
@@ -8,6 +8,10 @@
8
8
  * holds the tuning constants that primitive (and its internal helpers)
9
9
  * read, so a forked renderer never re-derives them.
10
10
  *
11
+ * Wire cap: since the Bot API 10.1 rich-message migration (#2669) every card
12
+ * renders as GFM markdown via `sendRichMessage`, whose limit is
13
+ * `RICH_MESSAGE_MAX_CHARS` (32768), not the legacy 4096 plain-text cap.
14
+ *
11
15
  * The former `SWITCHROOM_STATUS_NO_TRUNCATE` feature flag was retired:
12
16
  * rolling-window-with-char-budget is now the only behaviour. The per-line
13
17
  * cap (`STATUS_LINE_MAX`) and rolling window (`STATUS_ROLLING_LINES`) apply
@@ -15,6 +19,8 @@
15
19
  * (`STATUS_CARD_CHAR_BUDGET`) is the wire-limit backstop.
16
20
  */
17
21
 
22
+ import { RICH_MESSAGE_MAX_CHARS } from './format.js'
23
+
18
24
  /**
19
25
  * Number of trailing narrative/step lines shown in the rolling window.
20
26
  * The feed is a fixed-height rolling window: oldest drops off as new arrive.
@@ -30,15 +36,15 @@ export const STATUS_ROLLING_LINES = 5
30
36
  export const STATUS_LINE_MAX = 200
31
37
 
32
38
  /**
33
- * The safe char budget for a rendered Telegram status card. Telegram's hard
34
- * cap is 4096; we use 4000 to leave 96 chars of headroom for HTML framing,
35
- * emoji, and escape expansion — matching the convention in
36
- * pending-work-progress.ts (TELEGRAM_MSG_CAP = 4000).
39
+ * The safe char budget for a rendered Telegram status card. The rich-message
40
+ * wire cap is `RICH_MESSAGE_MAX_CHARS` (32768) post-#2669 — the legacy 4096
41
+ * plain-text cap no longer applies on the rich path. We use the constant
42
+ * directly as the backstop.
37
43
  *
38
44
  * With STATUS_ROLLING_LINES=5 lines each ≤ STATUS_LINE_MAX this backstop
39
45
  * effectively never fires in practice, but is kept as a wire-limit safety net.
40
46
  */
41
- export const STATUS_CARD_CHAR_BUDGET = 4000
47
+ export const STATUS_CARD_CHAR_BUDGET = RICH_MESSAGE_MAX_CHARS
42
48
 
43
49
  /** Indent marker for a nested (foreground sub-agent) step line. */
44
50
  export const NESTED_PREFIX = ' ↳ '
@@ -9,7 +9,6 @@
9
9
  * These helpers are pure so server.ts stays testable without standing up
10
10
  * grammy.
11
11
  */
12
- import { escapeHtml } from './format.js'
13
12
 
14
13
  /**
15
14
  * Detect and strip the `/queue ` or `/q ` prefix.
@@ -181,6 +180,3 @@ export function buildChannelMetaAttributes(opts: ChannelMetaAttributeOptions): s
181
180
  }
182
181
  return parts.length === 0 ? '' : ' ' + parts.join(' ')
183
182
  }
184
-
185
- /** Re-export so callers that want HTML-escape for body content don't need a second import. */
186
- export { escapeHtml }