switchroom 0.16.28 → 0.16.38

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 (93) hide show
  1. package/dist/agent-scheduler/index.js +13 -2
  2. package/dist/auth-broker/index.js +13 -2
  3. package/dist/cli/notion-write-pretool.mjs +13 -2
  4. package/dist/cli/switchroom.js +60648 -59800
  5. package/dist/host-control/main.js +730 -18
  6. package/dist/vault/approvals/kernel-server.js +51 -37
  7. package/dist/vault/broker/server.js +72 -58
  8. package/package.json +2 -1
  9. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  10. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  11. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  12. package/telegram-plugin/card-format.ts +52 -2
  13. package/telegram-plugin/credits-watch.ts +4 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
  15. package/telegram-plugin/dist/server.js +0 -1
  16. package/telegram-plugin/format.ts +264 -7
  17. package/telegram-plugin/gateway/approval-card.ts +7 -14
  18. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  19. package/telegram-plugin/gateway/auth-command.ts +35 -38
  20. package/telegram-plugin/gateway/boot-card.ts +7 -2
  21. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  22. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  23. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  24. package/telegram-plugin/gateway/gateway.ts +1293 -97
  25. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  27. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  28. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  29. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  31. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  32. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  33. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  34. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  35. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  36. package/telegram-plugin/history.ts +17 -7
  37. package/telegram-plugin/idle-footer.ts +2 -2
  38. package/telegram-plugin/issues-card.ts +12 -7
  39. package/telegram-plugin/model-unavailable.ts +3 -6
  40. package/telegram-plugin/operator-events.ts +4 -6
  41. package/telegram-plugin/quota-check.ts +2 -2
  42. package/telegram-plugin/quota-watch.ts +7 -10
  43. package/telegram-plugin/server.ts +3 -1
  44. package/telegram-plugin/status-pin-driver.ts +102 -0
  45. package/telegram-plugin/status-pin.ts +76 -0
  46. package/telegram-plugin/stream-reply-handler.ts +14 -1
  47. package/telegram-plugin/subagent-watcher.ts +6 -3
  48. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  49. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  51. package/telegram-plugin/tests/card-format.test.ts +51 -0
  52. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  53. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  54. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  55. package/telegram-plugin/tests/history.test.ts +38 -2
  56. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  57. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  58. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  59. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  60. package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
  61. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  62. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  63. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  64. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  65. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  66. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  68. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  69. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  70. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  71. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  72. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  73. package/telegram-plugin/tests/telegram-format.test.ts +88 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
  75. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  76. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  77. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  78. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  79. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  80. package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
  81. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  82. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  83. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  84. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  85. package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
  86. package/telegram-plugin/tool-activity-summary.ts +11 -8
  87. package/telegram-plugin/uat/driver.ts +8 -1
  88. package/telegram-plugin/voice-normalize-text.ts +340 -0
  89. package/telegram-plugin/voice-ondemand.ts +138 -0
  90. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  91. package/telegram-plugin/voice-synthesize.ts +128 -0
  92. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  93. package/telegram-plugin/worker-activity-feed.ts +13 -4
@@ -16994,7 +16994,6 @@ var init_plugin_logger = __esm(() => {
16994
16994
  DEFAULT_LOG_PATH2 = join2(homedir2(), ".switchroom", "logs", "telegram-plugin.log");
16995
16995
  ROTATE_AT_BYTES2 = 50 * 1024 * 1024;
16996
16996
  });
16997
-
16998
16997
  // operator-events.ts
16999
16998
  function classifyClaudeError(raw) {
17000
16999
  try {
@@ -53,6 +53,24 @@ export function escapeMarkdown(text: string): string {
53
53
  return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
54
54
  }
55
55
 
56
+ /**
57
+ * Make a string safe to interpolate INSIDE a `code span`.
58
+ *
59
+ * Inside a GFM code span the content is LITERAL — backslash escaping does
60
+ * NOT apply, so `escapeMarkdown` is exactly wrong there: it emits visible
61
+ * backslashes (e.g. `openai\_key` for an identifier containing `_`). The
62
+ * only character that can prematurely CLOSE the span is a backtick, so the
63
+ * sole transform needed is to defuse embedded backticks. We insert a
64
+ * zero-width space after each backtick so the raw ``` ` ``` can no longer
65
+ * terminate the surrounding span while remaining visually identical.
66
+ *
67
+ * This is the canonical home for the helper (#2695 regression fix); other
68
+ * modules re-export from here so there's one implementation.
69
+ */
70
+ export function codeSpanSafe(s: string): string {
71
+ return s.replace(/`/g, '`​')
72
+ }
73
+
56
74
  /**
57
75
  * Repair LLM-side JSON escape bungles.
58
76
  *
@@ -190,11 +208,63 @@ function maskCodeRegions(text: string, nonce: string): MaskedCode {
190
208
  * Code fences and inline code are masked out before any of this runs, so their
191
209
  * interior `\n`s are never touched.
192
210
  */
211
+ /**
212
+ * Split a collapsed inline bullet list onto separate lines.
213
+ *
214
+ * Agents sometimes emit an entire bullet list on ONE line using interior
215
+ * `•`/`·` separators, e.g.:
216
+ *
217
+ * • Master Bath 1, clean • <b>Master Bath 2</b>, 33% loss • Cabinet, clean
218
+ *
219
+ * which renders as a single run-on line instead of stacked bullets (the GFM
220
+ * rich path treats `•`/`·` as ordinary text, not list syntax, so nothing
221
+ * stacks). This deterministically inserts a newline before each interior
222
+ * unicode-bullet separator so each bullet lands on its own line.
223
+ *
224
+ * Conservative by construction:
225
+ * - Only a line that STARTS (after optional leading whitespace) with a bullet
226
+ * marker (`•`, `·`, `-`, or `*` followed by a space) is eligible — prose
227
+ * with a mid-sentence `•` is left untouched.
228
+ * - Only the unicode bullets `•`/`·` are split on (an interior whitespace +
229
+ * `•`/`·` + whitespace). `-`/`*` are NEVER used as interior split points —
230
+ * too many false positives (hyphens, ranges, "a * b" multiplication).
231
+ * They are only accepted as the LEADING marker.
232
+ * - The bullet glyphs are left as-is; we only insert `\n` before each split
233
+ * bullet and normalize the inter-bullet whitespace to a single space.
234
+ * - Idempotent: once split, each bullet begins its own line, so the
235
+ * interior-separator pattern (whitespace + bullet + whitespace) no longer
236
+ * matches anywhere on those lines.
237
+ *
238
+ * Runs on already-code-masked text, so a `•` inside a code span/fence is safe.
239
+ */
240
+ export function splitCollapsedInlineBullets(text: string): string {
241
+ if (!/[•·]/.test(text)) return text
242
+ // Leading marker: optional indent, then • · - or * followed by a space.
243
+ // Interior separator to split on: whitespace + • or · + whitespace.
244
+ const interiorSep = /[ \t]+([•·])[ \t]+/g
245
+ return text
246
+ .split('\n')
247
+ .map((line) => {
248
+ if (!/^[ \t]*[•·*-] /.test(line)) return line
249
+ if (!/[ \t][•·][ \t]/.test(line)) return line
250
+ return line.replace(interiorSep, '\n$1 ')
251
+ })
252
+ .join('\n')
253
+ }
254
+
193
255
  export function normalizeParagraphBreaks(text: string): string {
194
- if (!text.includes('\n')) return text
256
+ // A text with no newline still needs the inline-bullet split (a collapsed
257
+ // bullet list is a SINGLE line). Only bail early when there is neither a
258
+ // newline nor a unicode bullet to potentially split.
259
+ if (!text.includes('\n') && !/[•·]/.test(text)) return text
195
260
 
196
261
  const nonce = Math.random().toString(36).slice(2)
197
- const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
262
+ const { masked: maskedRaw, restore, placeholder } = maskCodeRegions(text, nonce)
263
+
264
+ // Step 0: split a collapsed inline bullet list onto separate lines. Done on
265
+ // the code-masked text so a `•` inside a fenced block or inline code span is
266
+ // never touched. See splitCollapsedInlineBullets for the exact rule.
267
+ const masked = splitCollapsedInlineBullets(maskedRaw)
198
268
 
199
269
  // Step 1: collapse 3+ newlines to exactly two. This also normalizes runs that
200
270
  // contain interleaved spaces only between the newlines is NOT done here —
@@ -236,8 +306,10 @@ export function normalizeParagraphBreaks(text: string): string {
236
306
  // Step 3: guarantee a blank line (`\n\n`) at BLOCK BOUNDARIES. The
237
307
  // prose-promotion above keeps lists/tables tight by leaving their single
238
308
  // `\n` separators alone — but GFM's rich renderer needs a blank line to
239
- // START a new block, so a block that is glued to the previous line by a
240
- // single `\n` fails to render (a table prints as literal pipe text, prose
309
+ // START a new block. A properly block-separated table DOES render as a
310
+ // real table (Bot API 10.1 rich messages); the failure mode this pass
311
+ // fixes is the *glued* case — a block joined to the previous line by a
312
+ // single `\n` (an un-separated table degrades to literal pipe text, prose
241
313
  // after a list is absorbed as a lazy list continuation). This pass inserts
242
314
  // the missing blank line at those transitions only, on the same masked text,
243
315
  // never touching code interiors, never collapsing/expanding existing `\n\n`,
@@ -247,6 +319,156 @@ export function normalizeParagraphBreaks(text: string): string {
247
319
  return restore(out)
248
320
  }
249
321
 
322
+ // ---------------------------------------------------------------------------
323
+ // Paragraph spacers — restore a VISIBLE blank line between prose paragraphs
324
+ // ---------------------------------------------------------------------------
325
+
326
+ /**
327
+ * The non-collapsible spacer paragraph injected between two prose paragraphs.
328
+ *
329
+ * Telegram's Bot API 10.1 rich-message renderer (the GFM/CommonMark engine
330
+ * behind `sendRichMessage` / `editMessageText({ markdown })`) renders a `\n\n`
331
+ * paragraph break TIGHT — the two paragraphs sit on adjacent lines with no
332
+ * visible empty line between them. The legacy markdown→HTML path (removed in
333
+ * #2669) sent `\n\n` literally with `parse_mode:"HTML"`, where two newlines
334
+ * render as a real blank line. That regression is the operator-confirmed
335
+ * "paragraphs jammed together" symptom.
336
+ *
337
+ * CommonMark discards blank lines made of ASCII whitespace, but a line whose
338
+ * only content is a NON-breaking space (U+00A0) is a genuine, non-empty
339
+ * paragraph — it renders as a visible empty line. So `A\n\n \n\nB`
340
+ * renders as three paragraphs: A, a blank-looking line, then B — the visible
341
+ * gap the HTML path used to produce.
342
+ */
343
+ export const PARAGRAPH_SPACER = ' '
344
+
345
+ /**
346
+ * Insert a visible blank-line spacer into each genuine `\n\n` paragraph gap so
347
+ * the rich GFM renderer shows a real empty line between paragraphs (matching
348
+ * the pre-#2669 HTML behaviour). See PARAGRAPH_SPACER for why a U+00A0 line is
349
+ * the reliable trick.
350
+ *
351
+ * Conservative by construction — a spacer is inserted into a `\n\n` gap ONLY
352
+ * when BOTH the block ENDING above the gap and the block STARTING below it are
353
+ * ordinary prose. A gap adjacent to a structural block (list item, table row,
354
+ * fenced code, blockquote, heading, indented code) is left exactly as it was:
355
+ * those blocks already carry their own vertical rhythm, and a spacer paragraph
356
+ * wedged against a tight list / table would either re-introduce the cramped
357
+ * look from the other side or break the block's contiguity.
358
+ *
359
+ * Runs on code-masked text (so a blank line inside a fenced block is never
360
+ * touched) and is idempotent — a gap that already contains a U+00A0 spacer
361
+ * paragraph is recognised and never doubled.
362
+ *
363
+ * Intended to run in the outbound send path AFTER normalizeParagraphBreaks,
364
+ * which has already collapsed 3+ newline runs to `\n\n`, promoted lone prose
365
+ * breaks, and guaranteed block-boundary blank lines. normalizeParagraphBreaks
366
+ * itself deliberately does NOT do this so its (well-tested) `\n\n`-preserving
367
+ * contract is unchanged.
368
+ */
369
+ export function addParagraphSpacers(text: string): string {
370
+ if (!text.includes('\n\n')) return text
371
+
372
+ const nonce = Math.random().toString(36).slice(2)
373
+ const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
374
+
375
+ if (!masked.includes('\n\n')) return restore(masked)
376
+
377
+ // The line we inject for a spacer paragraph (its only content is U+00A0).
378
+ const spacerLine = PARAGRAPH_SPACER
379
+
380
+ // CRITICAL: `String.prototype.trim()` strips U+00A0, so a spacer line would
381
+ // read as "blank" and the pass would lose idempotency (re-spacing an
382
+ // already-spaced gap). Detect blank-ness with an ASCII-whitespace-only test
383
+ // so the U+00A0 spacer line is correctly seen as NON-blank.
384
+ const isBlankLine = (line: string): boolean => /^[ \t\r\f\v]*$/.test(line)
385
+ // Trim ASCII-only (preserve U+00A0) so the spacer line is recognisable.
386
+ const asciiTrim = (line: string): string => line.replace(/^[ \t\r\f\v]+|[ \t\r\f\v]+$/g, '')
387
+
388
+ // A line is "prose" for spacing purposes when it is non-blank, not a
389
+ // structural block marker, not the spacer line itself. Only a gap with prose
390
+ // on BOTH sides earns a visible spacer.
391
+ const isProseLine = (line: string): boolean => {
392
+ if (isBlankLine(line)) return false
393
+ if (asciiTrim(line) === spacerLine) return false
394
+ if (isMarkerLine(line, placeholder)) return false
395
+ // A standalone masked fenced block line is structural.
396
+ if (isFenceOpenLine(line, placeholder)) return false
397
+ return true
398
+ }
399
+
400
+ // Split into blank-line-delimited segments, then re-join inserting a spacer
401
+ // paragraph between two adjacent NON-blank segments whose facing lines are
402
+ // both prose and which are not already separated by a spacer.
403
+ // A `\n\n` paragraph gap is a SINGLE blank entry between two content lines
404
+ // (`"A\n\nB".split('\n')` → `["A", "", "B"]`). normalizeParagraphBreaks has
405
+ // already collapsed 3+ newline runs to exactly `\n\n`, so we only ever see a
406
+ // one-blank gap here; a multi-blank run is handled defensively the same way
407
+ // (the FIRST blank of the run carries the spacer decision).
408
+ const lines = masked.split('\n')
409
+ const out: string[] = []
410
+ for (let i = 0; i < lines.length; i++) {
411
+ const line = lines[i]
412
+ const isBlank = isBlankLine(line)
413
+ // The spacer decision is made at the FIRST blank of a gap, i.e. when the
414
+ // previously emitted line is non-blank prose. Inject the spacer BEFORE the
415
+ // blank so the result is `above \n\n   \n\n below`.
416
+ if (isBlank) {
417
+ const prevEmitted = out.length > 0 ? out[out.length - 1] : null
418
+ const prevIsBlank = prevEmitted != null && isBlankLine(prevEmitted)
419
+ if (!prevIsBlank) {
420
+ const above = lastNonBlank(out, isBlankLine)
421
+ const below = nextNonBlank(lines, i + 1, isBlankLine)
422
+ const alreadySpaced =
423
+ (above != null && asciiTrim(above) === spacerLine) ||
424
+ (below != null && asciiTrim(below) === spacerLine)
425
+ if (
426
+ !alreadySpaced &&
427
+ above != null &&
428
+ below != null &&
429
+ isProseLine(above) &&
430
+ isProseLine(below)
431
+ ) {
432
+ // Emit: blank, spacer paragraph, blank — a U+00A0 paragraph wedged
433
+ // between two real blank lines so CommonMark renders it as a visible
434
+ // empty line between the two prose paragraphs.
435
+ out.push('')
436
+ out.push(spacerLine)
437
+ out.push('')
438
+ continue
439
+ }
440
+ }
441
+ }
442
+ out.push(line)
443
+ }
444
+
445
+ return restore(out.join('\n'))
446
+ }
447
+
448
+ /**
449
+ * Last non-blank entry already emitted into `arr`, or null. `isBlank` is the
450
+ * caller's blank test (ASCII-only, so a U+00A0 spacer line counts as
451
+ * non-blank — `String.trim()` would wrongly strip it).
452
+ */
453
+ function lastNonBlank(arr: string[], isBlank: (s: string) => boolean): string | null {
454
+ for (let i = arr.length - 1; i >= 0; i--) {
455
+ if (!isBlank(arr[i])) return arr[i]
456
+ }
457
+ return null
458
+ }
459
+
460
+ /** First non-blank line at or after index `from` in `lines`, or null. */
461
+ function nextNonBlank(
462
+ lines: string[],
463
+ from: number,
464
+ isBlank: (s: string) => boolean,
465
+ ): string | null {
466
+ for (let i = from; i < lines.length; i++) {
467
+ if (!isBlank(lines[i])) return lines[i]
468
+ }
469
+ return null
470
+ }
471
+
250
472
  // ---------------------------------------------------------------------------
251
473
  // Block-boundary blank-line guarantee (Step 3 of normalizeParagraphBreaks)
252
474
  // ---------------------------------------------------------------------------
@@ -495,11 +717,46 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
495
717
  break
496
718
  }
497
719
 
498
- chunks.push(rest.slice(0, cut))
499
- rest = rest.slice(cut).replace(/^\n+/, '')
720
+ chunks.push(stripBoundarySpacers(rest.slice(0, cut), 'trailing'))
721
+ rest = stripBoundarySpacers(rest.slice(cut), 'leading')
500
722
  }
501
723
 
502
- return chunks
724
+ return chunks.map((c) => stripBoundarySpacers(c, 'trailing'))
725
+ }
726
+
727
+ /**
728
+ * Strip stray paragraph-spacer / blank lines off a chunk boundary so a cut that
729
+ * lands inside an injected spacer gap (`\n\n${PARAGRAPH_SPACER}\n\n`, see
730
+ * addParagraphSpacers) never leaves a continuation chunk that OPENS with a bare
731
+ * U+00A0 spacer line, nor a prior chunk that ENDS with one.
732
+ *
733
+ * A "boundary blank run" is any sequence of newlines and spacer-only lines (a
734
+ * line whose only content is the U+00A0 spacer, optionally surrounded by ASCII
735
+ * spaces/tabs) in ANY interleaving — `\n \n`, ` \n\n`, `\n\n \n`, etc. The
736
+ * legacy behaviour (strip leading ASCII `\n+` only) is a strict subset, so a
737
+ * boundary with NO spacer is unaffected. Idempotent: a chunk already trimmed
738
+ * has nothing left to strip.
739
+ *
740
+ * - `'leading'` → strip the run from the START (the continuation chunk).
741
+ * - `'trailing'` → strip the run from the END (the just-emitted prior chunk).
742
+ */
743
+ function stripBoundarySpacers(chunk: string, side: 'leading' | 'trailing'): string {
744
+ // One blank-or-spacer line: optional ASCII ws, optional one U+00A0, optional
745
+ // ASCII ws — i.e. a line that renders empty. A run of these (joined by \n,
746
+ // with leading/trailing \n) is what we peel off the boundary.
747
+ const sp = PARAGRAPH_SPACER
748
+ if (side === 'leading') {
749
+ // Leading: one-or-more newlines, optionally with spacer-only lines mixed in.
750
+ return chunk.replace(
751
+ new RegExp(`^(?:[ \\t]*${sp}?[ \\t]*\\n+)+`),
752
+ '',
753
+ )
754
+ }
755
+ // Trailing: a newline run, optionally with spacer-only lines, at the very end.
756
+ return chunk.replace(
757
+ new RegExp(`(?:\\n+[ \\t]*${sp}?[ \\t]*)+$`),
758
+ '',
759
+ )
503
760
  }
504
761
 
505
762
  /**
@@ -14,8 +14,13 @@
14
14
  * param = (for ttl) 1h | 24h | 7d
15
15
  */
16
16
 
17
+ import { escapeMarkdown, codeSpanSafe } from '../format.js';
17
18
  import { InlineKeyboard } from "grammy";
18
19
 
20
+ // Re-export so existing importers (e.g. vault-request-access-card.ts) keep
21
+ // resolving `codeSpanSafe` from here; the canonical impl lives in format.ts.
22
+ export { codeSpanSafe };
23
+
19
24
  export interface ApprovalCardOptions {
20
25
  request_id: string; // 8-hex from kernel.requestApproval
21
26
  agent: string; // shown in the title
@@ -37,7 +42,7 @@ export interface BuiltApprovalCard {
37
42
  */
38
43
  export function buildApprovalCard(opts: ApprovalCardOptions): BuiltApprovalCard {
39
44
  const lines: string[] = [];
40
- lines.push(`🔐 **${escapeHtml(opts.agent)}** wants approval`);
45
+ lines.push(`🔐 **${escapeMarkdown(opts.agent)}** wants approval`);
41
46
  // The scope rides in a `code span` — content there is LITERAL, so it must
42
47
  // NOT be markdown-escaped (escaping would leak visible backslashes, e.g.
43
48
  // `secret:OPENAI\_API\_KEY`). Only an embedded backtick could break out of
@@ -45,7 +50,7 @@ export function buildApprovalCard(opts: ApprovalCardOptions): BuiltApprovalCard
45
50
  lines.push(`\`${codeSpanSafe(opts.scope_humanized)}\``);
46
51
  if (opts.why && opts.why.trim().length > 0) {
47
52
  lines.push("");
48
- lines.push(escapeHtml(opts.why.trim()));
53
+ lines.push(escapeMarkdown(opts.why.trim()));
49
54
  }
50
55
  const text = lines.join("\n");
51
56
 
@@ -123,15 +128,3 @@ export function ttlMsFromToken(token: string): number | null {
123
128
  return null;
124
129
  }
125
130
 
126
- function escapeHtml(s: string): string {
127
- return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
128
- }
129
-
130
- /**
131
- * Make a string safe to interpolate INSIDE a `code span` without escaping
132
- * (content there is literal). The only character that can prematurely close
133
- * the span is a backtick, so split it with a zero-width space.
134
- */
135
- function codeSpanSafe(s: string): string {
136
- return s.replace(/`/g, "`​");
137
- }
@@ -13,6 +13,7 @@
13
13
  * add on top of the same client. Tracked in the migration TODO inline.
14
14
  */
15
15
 
16
+ import { escapeMarkdown, codeSpanSafe } from '../format.js';
16
17
  import type { Bot, Context } from "grammy";
17
18
  import { richMessage } from "../rich-send.js";
18
19
  import {
@@ -65,7 +66,7 @@ export function registerApprovalsCommands(
65
66
  const byAgent = new Map<string, number>();
66
67
  for (const d of decisions) byAgent.set(d.agent_unit, (byAgent.get(d.agent_unit) ?? 0) + 1);
67
68
  const summary = Array.from(byAgent.entries())
68
- .map(([a, n]) => `• **${escapeHtml(a)}**: ${n}`)
69
+ .map(([a, n]) => `• **${escapeMarkdown(a)}**: ${n}`)
69
70
  .join("\n");
70
71
  const detail = decisions
71
72
  .slice(0, 20)
@@ -76,10 +77,10 @@ export function registerApprovalsCommands(
76
77
  : `until ${new Date(d.ttl_expires_at).toISOString().slice(0, 16).replace("T", " ")}`;
77
78
  return (
78
79
  `\`${d.id.slice(0, 8)}\` ` +
79
- `${escapeHtml(d.agent_unit)} → ` +
80
- `\`${d.scope}\` ` +
81
- `(${escapeHtml(d.action)}, ${ttl}) ` +
82
- `· /approvals revoke ${escapeHtml(d.id)}`
80
+ `${escapeMarkdown(d.agent_unit)} → ` +
81
+ `\`${codeSpanSafe(d.scope)}\` ` +
82
+ `(${escapeMarkdown(d.action)}, ${ttl}) ` +
83
+ `· /approvals revoke ${escapeMarkdown(d.id)}`
83
84
  );
84
85
  })
85
86
  .join("\n");
@@ -114,7 +115,3 @@ export function registerApprovalsCommands(
114
115
  ));
115
116
  });
116
117
  }
117
-
118
- function escapeHtml(s: string): string {
119
- return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
120
- }
@@ -28,6 +28,7 @@
28
28
  * rather than throwing.
29
29
  */
30
30
 
31
+ import { escapeMarkdown, codeSpanSafe } from '../format.js'
31
32
  import type { ListStateData, AccountState } from './auth-line.js'
32
33
  import {
33
34
  buildSnapshotsFromState,
@@ -158,7 +159,7 @@ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
158
159
  if (tail.length > 0) {
159
160
  return {
160
161
  kind: 'help',
161
- reason: `Unknown \`rm\` modifier: \`${escapeHtml(tail)}\`. Use \`/auth rm <label> confirm\` to confirm.`,
162
+ reason: `Unknown \`rm\` modifier: \`${codeSpanSafe(tail)}\`. Use \`/auth rm <label> confirm\` to confirm.`,
162
163
  }
163
164
  }
164
165
  return { kind: 'rm-prompt', label }
@@ -180,7 +181,7 @@ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
180
181
  if (sub !== 'override') {
181
182
  return {
182
183
  kind: 'help',
183
- reason: `Unknown \`agent\` subcommand: \`${escapeHtml(sub || '(none)')}\`. Try \`/auth agent override <agent> <label|clear>\`.`,
184
+ reason: `Unknown \`agent\` subcommand: \`${codeSpanSafe(sub || '(none)')}\`. Try \`/auth agent override <agent> <label|clear>\`.`,
184
185
  }
185
186
  }
186
187
  const agent = parts[2]
@@ -201,7 +202,7 @@ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
201
202
  case 'help':
202
203
  return { kind: 'help' }
203
204
  default:
204
- return { kind: 'help', reason: `Unknown verb: \`${escapeHtml(verb)}\`` }
205
+ return { kind: 'help', reason: `Unknown verb: \`${codeSpanSafe(verb)}\`` }
205
206
  }
206
207
  }
207
208
 
@@ -417,7 +418,7 @@ export async function handleAuthCommand(
417
418
  }
418
419
  } catch (err) {
419
420
  return {
420
- text: `**/auth show failed:** ${escapeHtml((err as Error)?.message ?? String(err))}`,
421
+ text: `**/auth show failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
421
422
  html: true,
422
423
  }
423
424
  }
@@ -432,7 +433,7 @@ export async function handleAuthCommand(
432
433
  if (!agent) {
433
434
  return {
434
435
  text:
435
- `**/auth show:** no agent named \`${escapeHtml(agentName)}\` in broker view.\n` +
436
+ `**/auth show:** no agent named \`${codeSpanSafe(agentName)}\` in broker view.\n` +
436
437
  `Run \`/auth show\` for the fleet snapshot.`,
437
438
  html: true,
438
439
  }
@@ -440,7 +441,7 @@ export async function handleAuthCommand(
440
441
  return { text: renderAgentDetail(state, agent), html: true }
441
442
  } catch (err) {
442
443
  return {
443
- text: `**/auth show failed:** ${escapeHtml((err as Error)?.message ?? String(err))}`,
444
+ text: `**/auth show failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
444
445
  html: true,
445
446
  }
446
447
  }
@@ -476,13 +477,13 @@ export async function handleAuthCommand(
476
477
  const result = await ctx.client.setActive(parsed.label)
477
478
  return {
478
479
  text:
479
- `**Active account →** \`${escapeHtml(result.active)}\`\n` +
480
+ `**Active account →** \`${codeSpanSafe(result.active)}\`\n` +
480
481
  `Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}.`,
481
482
  html: true,
482
483
  }
483
484
  } catch (err) {
484
485
  return {
485
- text: `**/auth use failed:** ${escapeHtml((err as Error)?.message ?? String(err))}`,
486
+ text: `**/auth use failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
486
487
  html: true,
487
488
  }
488
489
  }
@@ -504,13 +505,13 @@ export async function handleAuthCommand(
504
505
  const result = await ctx.client.setActive(nextLabel)
505
506
  return {
506
507
  text:
507
- `**Rotated:** active → \`${escapeHtml(result.active)}\`\n` +
508
+ `**Rotated:** active → \`${codeSpanSafe(result.active)}\`\n` +
508
509
  `Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}.`,
509
510
  html: true,
510
511
  }
511
512
  } catch (err) {
512
513
  return {
513
- text: `**/auth rotate failed:** ${escapeHtml((err as Error)?.message ?? String(err))}`,
514
+ text: `**/auth rotate failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
514
515
  html: true,
515
516
  }
516
517
  }
@@ -525,7 +526,7 @@ export async function handleAuthCommand(
525
526
  state = await ctx.client.listState()
526
527
  } catch (err) {
527
528
  return {
528
- text: `**/auth rm failed:** ${escapeHtml((err as Error)?.message ?? String(err))}`,
529
+ text: `**/auth rm failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
529
530
  html: true,
530
531
  }
531
532
  }
@@ -533,7 +534,7 @@ export async function handleAuthCommand(
533
534
  if (!exists) {
534
535
  return {
535
536
  text:
536
- `**/auth rm:** no account named \`${escapeHtml(parsed.label)}\`. ` +
537
+ `**/auth rm:** no account named \`${codeSpanSafe(parsed.label)}\`. ` +
537
538
  `Run \`/auth show\` for the current list.`,
538
539
  html: true,
539
540
  }
@@ -541,7 +542,7 @@ export async function handleAuthCommand(
541
542
  if (state.active === parsed.label) {
542
543
  return {
543
544
  text:
544
- `**/auth rm refused.** \`${escapeHtml(parsed.label)}\` is the fleet active. ` +
545
+ `**/auth rm refused.** \`${codeSpanSafe(parsed.label)}\` is the fleet active. ` +
545
546
  `Switch with \`/auth use <other>\` or \`/auth rotate\` first.`,
546
547
  html: true,
547
548
  }
@@ -560,9 +561,9 @@ export async function handleAuthCommand(
560
561
  }
561
562
  return {
562
563
  text:
563
- `**⚠ /auth rm** — about to remove \`${escapeHtml(parsed.label)}\` from the broker.\n` +
564
- `The fleet active is unchanged. Any agent override pointing at \`${escapeHtml(parsed.label)}\` will stop working.\n\n` +
565
- `Send \`/auth rm ${escapeHtml(parsed.label)} confirm\` within ${Math.round(
564
+ `**⚠ /auth rm** — about to remove \`${codeSpanSafe(parsed.label)}\` from the broker.\n` +
565
+ `The fleet active is unchanged. Any agent override pointing at \`${codeSpanSafe(parsed.label)}\` will stop working.\n\n` +
566
+ `Send \`/auth rm ${codeSpanSafe(parsed.label)} confirm\` within ${Math.round(
566
567
  AUTH_RM_CONFIRM_TTL_MS / 1000,
567
568
  )}s to proceed.`,
568
569
  html: true,
@@ -578,8 +579,8 @@ export async function handleAuthCommand(
578
579
  }
579
580
  return {
580
581
  text:
581
- `**/auth rm:** no pending confirm for \`${escapeHtml(parsed.label)}\` (expired or not started). ` +
582
- `Send \`/auth rm ${escapeHtml(parsed.label)}\` first.`,
582
+ `**/auth rm:** no pending confirm for \`${codeSpanSafe(parsed.label)}\` (expired or not started). ` +
583
+ `Send \`/auth rm ${codeSpanSafe(parsed.label)}\` first.`,
583
584
  html: true,
584
585
  }
585
586
  }
@@ -589,12 +590,12 @@ export async function handleAuthCommand(
589
590
  try {
590
591
  const data = await ctx.client.rmAccount(parsed.label)
591
592
  return {
592
- text: `**Removed** \`${escapeHtml(data.label)}\` from the broker.`,
593
+ text: `**Removed** \`${codeSpanSafe(data.label)}\` from the broker.`,
593
594
  html: true,
594
595
  }
595
596
  } catch (err) {
596
597
  return {
597
- text: `**/auth rm failed:** ${escapeHtml((err as Error)?.message ?? String(err))}`,
598
+ text: `**/auth rm failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
598
599
  html: true,
599
600
  }
600
601
  }
@@ -609,7 +610,7 @@ export async function handleAuthCommand(
609
610
  if (parsed.label && targets.length === 0) {
610
611
  return {
611
612
  text:
612
- `**/auth refresh:** no account named \`${escapeHtml(parsed.label)}\`.`,
613
+ `**/auth refresh:** no account named \`${codeSpanSafe(parsed.label)}\`.`,
613
614
  html: true,
614
615
  }
615
616
  }
@@ -629,13 +630,13 @@ export async function handleAuthCommand(
629
630
  ])
630
631
  } catch (err) {
631
632
  failures.push(
632
- `${label}: ${escapeHtml((err as Error)?.message ?? String(err))}`,
633
+ `${label}: ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
633
634
  )
634
635
  }
635
636
  }
636
637
  const head =
637
638
  targets.length === 1
638
- ? `**Refreshed** \`${escapeHtml(targets[0]!)}\``
639
+ ? `**Refreshed** \`${codeSpanSafe(targets[0]!)}\``
639
640
  : `**Refreshed** ${rows.length - 1}/${targets.length} account${targets.length === 1 ? '' : 's'}`
640
641
  const table = rows.length > 1
641
642
  ? "\n```\n" + alignTable(rows) + "\n```"
@@ -646,7 +647,7 @@ export async function handleAuthCommand(
646
647
  return { text: head + table + failBlock, html: true }
647
648
  } catch (err) {
648
649
  return {
649
- text: `**/auth refresh failed:** ${escapeHtml((err as Error)?.message ?? String(err))}`,
650
+ text: `**/auth refresh failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
650
651
  html: true,
651
652
  }
652
653
  }
@@ -657,13 +658,13 @@ export async function handleAuthCommand(
657
658
  const data = await ctx.client.setOverride(parsed.agent, parsed.label)
658
659
  return {
659
660
  text:
660
- `**Override set.** \`${escapeHtml(data.agent)}\` is now pinned to ` +
661
- `\`${escapeHtml(data.account ?? parsed.label)}\`.`,
661
+ `**Override set.** \`${codeSpanSafe(data.agent)}\` is now pinned to ` +
662
+ `\`${codeSpanSafe(data.account ?? parsed.label)}\`.`,
662
663
  html: true,
663
664
  }
664
665
  } catch (err) {
665
666
  return {
666
- text: `**/auth agent override failed:** ${escapeHtml((err as Error)?.message ?? String(err))}`,
667
+ text: `**/auth agent override failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
667
668
  html: true,
668
669
  }
669
670
  }
@@ -674,13 +675,13 @@ export async function handleAuthCommand(
674
675
  const data = await ctx.client.setOverride(parsed.agent, null)
675
676
  return {
676
677
  text:
677
- `**Override cleared** on \`${escapeHtml(data.agent)}\` ` +
678
+ `**Override cleared** on \`${codeSpanSafe(data.agent)}\` ` +
678
679
  `— back to fleet active.`,
679
680
  html: true,
680
681
  }
681
682
  } catch (err) {
682
683
  return {
683
- text: `**/auth agent override failed:** ${escapeHtml((err as Error)?.message ?? String(err))}`,
684
+ text: `**/auth agent override failed:** ${escapeMarkdown((err as Error)?.message ?? String(err))}`,
684
685
  html: true,
685
686
  }
686
687
  }
@@ -852,7 +853,7 @@ function formatAccountsTable(state: ListStateData, now: number, demo = false): s
852
853
  ? formatRelativeMs(acc.exhausted_until - now)
853
854
  : '—'
854
855
  rows.push([
855
- `${marker} ${escapeHtml(demo ? maskEmail(acc.label) : acc.label)}`,
856
+ `${marker} ${escapeMarkdown(demo ? maskEmail(acc.label) : acc.label)}`,
856
857
  status,
857
858
  expires,
858
859
  formatQuotaUtilCell(acc, now),
@@ -892,7 +893,7 @@ function formatAgentsTable(state: ListStateData, demo = false): string {
892
893
  : a.account === state.active
893
894
  ? 'fleet-active'
894
895
  : 'pinned'
895
- rows.push([escapeHtml(a.name), escapeHtml(demo ? maskEmail(a.account) : a.account), source])
896
+ rows.push([escapeMarkdown(a.name), escapeMarkdown(demo ? maskEmail(a.account) : a.account), source])
896
897
  }
897
898
  return alignTable(rows)
898
899
  }
@@ -909,10 +910,10 @@ export function renderAgentDetail(
909
910
  now: number = Date.now(),
910
911
  ): string {
911
912
  const lines: string[] = []
912
- lines.push(`**${escapeHtml(agent.name)}**`)
913
+ lines.push(`**${escapeMarkdown(agent.name)}**`)
913
914
  const source = agent.override ? 'override' : 'fleet-active'
914
915
  lines.push(
915
- `Active account: \`${escapeHtml(agent.account)}\` (${source})`,
916
+ `Active account: \`${codeSpanSafe(agent.account)}\` (${source})`,
916
917
  )
917
918
  const acct = state.accounts.find((a) => a.label === agent.account)
918
919
  if (acct) {
@@ -948,17 +949,13 @@ function formatConsumersTable(state: ListStateData, now: number, demo = false):
948
949
  c.last_seen_at == null
949
950
  ? 'socket bound'
950
951
  : `socket bound (last seen ${formatRelativeMs(now - c.last_seen_at)} ago)`
951
- rows.push([escapeHtml(c.name), escapeHtml(demo ? maskEmail(c.account) : c.account), status])
952
+ rows.push([escapeMarkdown(c.name), escapeMarkdown(demo ? maskEmail(c.account) : c.account), status])
952
953
  }
953
954
  return alignTable(rows)
954
955
  }
955
956
 
956
957
  // ─── Plain-text helpers ────────────────────────────────────────────────────
957
958
 
958
- function escapeHtml(s: string): string {
959
- return s.replace(/([\\`*_~=\[\]|])/g, '\\$1')
960
- }
961
-
962
959
  function formatRelativeMs(ms: number): string {
963
960
  if (ms <= 0) return '0s'
964
961
  const totalSec = Math.floor(ms / 1000)