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
@@ -15,13 +15,14 @@
15
15
  *
16
16
  * No HTML escaping at the boundary — callers pass already-trusted
17
17
  * label strings (broker-vetted account labels). If that ever changes
18
- * the per-line `escapeHtml` helper below is the place to gate.
18
+ * the per-line `escapeMarkdown` helper (imported from card-format) is the place to gate.
19
19
  */
20
20
 
21
21
  import type { QuotaResult, QuotaUtilization } from './quota-check.js';
22
22
  import { isProbeThin, refillNormalizedUtils } from '../src/auth/quota.js';
23
23
  import type { AccountState, LastQuotaSnapshot, ListStateData } from '../src/auth/broker/client.js';
24
24
  import { maskEmail } from './demo-mask.js';
25
+ import { escapeMarkdown, codeSpanSafe } from './card-format.js';
25
26
 
26
27
  // ── shared types ─────────────────────────────────────────────────────
27
28
 
@@ -190,9 +191,68 @@ export function formatAbsolute(
190
191
  });
191
192
  }
192
193
 
193
- /** Round-trim percentage to 1 dp (more precision is noise on a UX). */
194
+ /**
195
+ * Render a future absolute time for the table's Status column, showing the
196
+ * DATE only when it's not "today" in the user's timezone — all in that tz so
197
+ * DST is handled by the tz database (never a hard-coded offset).
198
+ *
199
+ * - same calendar day as `now` (user tz) → time only: `11:00 PM`
200
+ * - a different day, but within the next 6 days → weekday + time: `Wed 1:00 AM`
201
+ * - further out → weekday + day + month + time: `Wed 1 Jul 1:00 AM`
202
+ *
203
+ * `tz` is an IANA tz-database name (e.g. `Australia/Melbourne`); the caller
204
+ * sources it from `SWITCHROOM_TIMEZONE` / `TZ`. Returns "—" for null.
205
+ */
206
+ export function formatStatusTime(
207
+ target: Date | null,
208
+ now: Date = new Date(),
209
+ tz: string = 'UTC',
210
+ ): string {
211
+ if (!target) return '—';
212
+ // Compare calendar dates in the user's tz using en-CA (YYYY-MM-DD) so the
213
+ // "is it today?" test is timezone-correct, not UTC-relative.
214
+ const dayFmt = new Intl.DateTimeFormat('en-CA', {
215
+ timeZone: tz,
216
+ year: 'numeric',
217
+ month: '2-digit',
218
+ day: '2-digit',
219
+ });
220
+ const timeFmt = new Intl.DateTimeFormat('en-US', {
221
+ timeZone: tz,
222
+ hour: 'numeric',
223
+ minute: '2-digit',
224
+ hour12: true,
225
+ });
226
+ const timeStr = timeFmt.format(target);
227
+ if (dayFmt.format(target) === dayFmt.format(now)) {
228
+ return timeStr;
229
+ }
230
+ // Different day — always include the weekday. Add the day-of-month + month
231
+ // once the target falls outside the current week (≥ 7 days away), where a
232
+ // bare weekday would be ambiguous.
233
+ const withinWeek = target.getTime() - now.getTime() < 7 * 24 * 60 * 60 * 1000;
234
+ const dateFmt = new Intl.DateTimeFormat('en-US', {
235
+ timeZone: tz,
236
+ weekday: 'short',
237
+ ...(withinWeek ? {} : { day: 'numeric', month: 'short' }),
238
+ });
239
+ return `${dateFmt.format(target)} ${timeStr}`;
240
+ }
241
+
242
+ /**
243
+ * Render a utilization percentage for display, CLAMPED to [0, 100].
244
+ *
245
+ * Anthropic's unified rate-limit headers return a decimal utilization that can
246
+ * exceed 1.0 (e.g. 1.01 → 101%) when an account is over its window cap. A
247
+ * displayed value over 100% erodes trust ("how can I be at 101%?"), so the
248
+ * DISPLAYED number is clamped to 100 here at the format boundary. This does NOT
249
+ * mutate the source quota data — `classifyHealth` / `blockedReason` still read
250
+ * the true (possibly >100) value, so the blocked/exhausted state still surfaces
251
+ * via the Status column and the health emoji. Only the rendered number is
252
+ * clamped.
253
+ */
194
254
  export function fmtPct(pct: number): string {
195
- return `${Math.round(pct)}%`;
255
+ return `${Math.min(100, Math.round(pct))}%`;
196
256
  }
197
257
 
198
258
  // ── /auth snapshot — Format 2 ────────────────────────────────────────
@@ -232,20 +292,7 @@ function displayLabel(label: string, opts: SnapshotRenderOpts): string {
232
292
  return opts.demo ? maskEmail(label) : label;
233
293
  }
234
294
 
235
- /**
236
- * Header line shape: emoji + group title + count.
237
- *
238
- * 🟢 HEALTHY (4)
239
- * 🟡 ACTIVE — REFRESHING SOON (1)
240
- * 🔴 BLOCKED (1)
241
- * ⚪ UNKNOWN (1)
242
- */
243
- function groupHeader(health: AccountHealth, count: number): string {
244
- const emoji = HEALTH_EMOJI[health];
245
- const title = HEALTH_TITLE[health];
246
- return `${emoji} **${title}** (${count})`;
247
- }
248
-
295
+ /** Health → State-column emoji for the snapshot table. */
249
296
  const HEALTH_EMOJI: Record<AccountHealth, string> = {
250
297
  healthy: '🟢',
251
298
  throttling: '🟡',
@@ -253,13 +300,6 @@ const HEALTH_EMOJI: Record<AccountHealth, string> = {
253
300
  unknown: '⚪',
254
301
  };
255
302
 
256
- const HEALTH_TITLE: Record<AccountHealth, string> = {
257
- healthy: 'HEALTHY',
258
- throttling: 'THROTTLING',
259
- blocked: 'BLOCKED',
260
- unknown: 'UNKNOWN',
261
- };
262
-
263
303
  /**
264
304
  * One-line per-account summary inside its health group.
265
305
  *
@@ -284,10 +324,10 @@ function renderAccountRow(
284
324
 
285
325
  if (!snap.quota) {
286
326
  lines.push(
287
- `${marker}\`${escapeHtml(label)}\` _quota probe failed_`,
327
+ `${marker}\`${codeSpanSafe(label)}\` _quota probe failed_`,
288
328
  );
289
329
  if (snap.quotaError) {
290
- lines.push(` _${escapeHtml(snap.quotaError)}_`);
330
+ lines.push(` _${escapeMarkdown(snap.quotaError)}_`);
291
331
  }
292
332
  return lines;
293
333
  }
@@ -297,7 +337,7 @@ function renderAccountRow(
297
337
  // it as a data-quality gap, never a confident "0% / 0%".
298
338
  if (isProbeThin(q)) {
299
339
  lines.push(
300
- `${marker}\`${escapeHtml(label)}\` _quota unknown (thin probe)_`,
340
+ `${marker}\`${codeSpanSafe(label)}\` _quota unknown (thin probe)_`,
301
341
  );
302
342
  return lines;
303
343
  }
@@ -307,7 +347,7 @@ function renderAccountRow(
307
347
  const fiveStr = fmtPct(norm.fiveHourUtilizationPct);
308
348
  const sevenStr = fmtPct(norm.sevenDayUtilizationPct);
309
349
  lines.push(
310
- `${marker}\`${escapeHtml(label)}\` ${fiveStr} / ${sevenStr}`,
350
+ `${marker}\`${codeSpanSafe(label)}\` ${fiveStr} / ${sevenStr}`,
311
351
  );
312
352
 
313
353
  const health = classifyHealth(snap, now);
@@ -318,7 +358,9 @@ function renderAccountRow(
318
358
  const reset = win === '5h' ? q.fiveHourResetAt : q.sevenDayResetAt;
319
359
  const winLabel = win === '5h' ? '5-hour' : '7-day';
320
360
  lines.push(
321
- ` _quota exhausted — back ${formatAbsolute(reset, tz)} (in ${formatRelative(reset, now)}, ${winLabel} cap)_`,
361
+ reset
362
+ ? ` _quota exhausted — back ${formatAbsolute(reset, tz)} (\`in ${formatRelative(reset, now)}\`, ${winLabel} cap)_`
363
+ : ` _quota exhausted — ${winLabel} cap, reset time unknown_`,
322
364
  );
323
365
  return lines;
324
366
  }
@@ -332,10 +374,10 @@ function renderAccountRow(
332
374
  const sevenResetIn = q.sevenDayResetAt ? q.sevenDayResetAt.getTime() - now.getTime() : Infinity;
333
375
  const fiveFirst = fiveResetIn <= sevenResetIn;
334
376
  const fiveSeg = q.fiveHourResetAt
335
- ? `5h refills ${formatAbsolute(q.fiveHourResetAt, tz)} (in ${formatRelative(q.fiveHourResetAt, now)})`
377
+ ? `5h refills ${formatAbsolute(q.fiveHourResetAt, tz)} (\`in ${formatRelative(q.fiveHourResetAt, now)}\`)`
336
378
  : '5h refills —';
337
379
  const sevenSeg = q.sevenDayResetAt
338
- ? `7d resets ${formatAbsolute(q.sevenDayResetAt, tz)} (in ${formatRelative(q.sevenDayResetAt, now)})`
380
+ ? `7d resets ${formatAbsolute(q.sevenDayResetAt, tz)} (\`in ${formatRelative(q.sevenDayResetAt, now)}\`)`
339
381
  : '7d resets —';
340
382
  lines.push(` _${fiveFirst ? fiveSeg : sevenSeg}_`);
341
383
  lines.push(` _${fiveFirst ? sevenSeg : fiveSeg}_`);
@@ -343,7 +385,7 @@ function renderAccountRow(
343
385
  // surface it as a sub-line on a healthy/throttling row — NOT a blocked badge.
344
386
  if (q.overageDisabledReason != null && OVERAGE_EXHAUSTED_REASONS.has(q.overageDisabledReason)) {
345
387
  lines.push(
346
- ` _overage off (${escapeHtml(q.overageDisabledReason)}) — serving from quota_`,
388
+ ` _overage off (${escapeMarkdown(q.overageDisabledReason)}) — serving from quota_`,
347
389
  );
348
390
  }
349
391
  return lines;
@@ -374,42 +416,110 @@ function formatAgeStamp(atMs: number, now: Date = new Date()): string {
374
416
  return ageSec < 60 ? `${ageSec}s ago` : `${Math.round(ageSec / 60)}m ago`;
375
417
  }
376
418
 
419
+ /**
420
+ * Health-group rank for the table's secondary sort (after active-first):
421
+ * problems before good news so the user scans the walled accounts at the top.
422
+ * blocked → throttling → unknown → healthy.
423
+ */
424
+ const TABLE_HEALTH_RANK: Record<AccountHealth, number> = {
425
+ blocked: 0,
426
+ throttling: 1,
427
+ unknown: 2,
428
+ healthy: 3,
429
+ };
430
+
431
+ /** Escape a cell value for a GFM table cell: pipes break the column grid, and
432
+ * newlines break the row. Emails don't normally carry either, but be safe. */
433
+ function tableCell(s: string): string {
434
+ return s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
435
+ }
436
+
437
+ /** The two percent cells (5h / 7d), refill-normalized + clamped, or a
438
+ * data-quality marker when the probe is missing/thin. */
439
+ function pctCells(
440
+ snap: AccountSnapshot,
441
+ now: Date,
442
+ ): { five: string; seven: string } {
443
+ if (!snap.quota) return { five: '—', seven: '—' };
444
+ if (isProbeThin(snap.quota)) return { five: '?', seven: '?' };
445
+ const norm = refillNormalizedUtils(snap.quota, now);
446
+ return { five: fmtPct(norm.fiveHourUtilizationPct), seven: fmtPct(norm.sevenDayUtilizationPct) };
447
+ }
448
+
449
+ /**
450
+ * The Status cell: for a blocked account, when the binding window comes back
451
+ * (`back <when>`); for a healthy/throttling account, when the soonest window
452
+ * refills (`refills <when>`). `<when>` is the tz-aware absolute time (date shown
453
+ * only when not today) plus the concise relative hint in parens.
454
+ */
455
+ function statusCell(snap: AccountSnapshot, now: Date, tz: string): string {
456
+ if (!snap.quota) {
457
+ return snap.quotaError ? `probe failed (${snap.quotaError})` : 'probe failed';
458
+ }
459
+ if (isProbeThin(snap.quota)) return 'quota unknown';
460
+ const q = snap.quota;
461
+ const health = classifyHealth(snap, now);
462
+ if (health === 'blocked') {
463
+ const win = bindingWindow(q);
464
+ const reset = win === '5h' ? q.fiveHourResetAt : q.sevenDayResetAt;
465
+ if (!reset) return 'back — (reset unknown)';
466
+ return `back ${formatStatusTime(reset, now, tz)} (in ${formatRelative(reset, now)})`;
467
+ }
468
+ // healthy / throttling — show the soonest refill across both windows.
469
+ const fiveIn = q.fiveHourResetAt ? q.fiveHourResetAt.getTime() - now.getTime() : Infinity;
470
+ const sevenIn = q.sevenDayResetAt ? q.sevenDayResetAt.getTime() - now.getTime() : Infinity;
471
+ const soonest = fiveIn <= sevenIn ? q.fiveHourResetAt : q.sevenDayResetAt;
472
+ if (!soonest) return 'refills —';
473
+ return `refills ${formatStatusTime(soonest, now, tz)} (in ${formatRelative(soonest, now)})`;
474
+ }
475
+
377
476
  export function renderAuthSnapshotFormat2(
378
477
  snapshots: AccountSnapshot[],
379
478
  opts: SnapshotRenderOpts = {},
380
479
  ): string {
381
480
  const now = opts.now ?? new Date();
481
+ const tz = opts.tz ?? 'UTC';
382
482
  const lines: string[] = [];
383
483
  lines.push('🔋 **Auth — fleet status**');
384
484
 
385
- // Group by health. Render BLOCKED first (it's the urgent action),
386
- // then THROTTLING (potential next problem), then HEALTHY (good
387
- // news), then UNKNOWN (data quality issue). The active account
388
- // floats to the top of its group regardless.
389
- const order: AccountHealth[] = ['blocked', 'throttling', 'healthy', 'unknown'];
390
- const grouped = new Map<AccountHealth, AccountSnapshot[]>();
391
- for (const s of snapshots) {
392
- const h = classifyHealth(s, now);
393
- if (!grouped.has(h)) grouped.set(h, []);
394
- grouped.get(h)!.push(s);
395
- }
396
- // Within each group, active first.
397
- for (const arr of grouped.values()) {
398
- arr.sort((a, b) => Number(b.isActive) - Number(a.isActive));
399
- }
485
+ // Single sorted table: the ACTIVE account always sorts to the top row,
486
+ // regardless of its health group. Remaining rows order by health so
487
+ // problems are visible blocked throttling unknown → healthy — then
488
+ // stable/alpha on label for a deterministic order.
489
+ const ordered = [...snapshots].sort((a, b) => {
490
+ if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
491
+ const r = TABLE_HEALTH_RANK[classifyHealth(a, now)] - TABLE_HEALTH_RANK[classifyHealth(b, now)];
492
+ if (r !== 0) return r;
493
+ return a.label.localeCompare(b.label);
494
+ });
400
495
 
401
- for (const h of order) {
402
- const arr = grouped.get(h);
403
- if (!arr || arr.length === 0) continue;
496
+ if (ordered.length > 0) {
404
497
  lines.push('');
405
- lines.push(groupHeader(h, arr.length));
406
- for (const s of arr) {
407
- for (const ln of renderAccountRow(s, opts)) lines.push(ln);
498
+ lines.push('| State | Account | 5h | 7d | Status |');
499
+ lines.push('| --- | --- | --- | --- | --- |');
500
+ for (const s of ordered) {
501
+ const emoji = HEALTH_EMOJI[classifyHealth(s, now)];
502
+ // Account cell: FULL email, never truncated; active gets a (active) suffix.
503
+ // The black-dot active marker is intentionally removed — the suffix reads.
504
+ const label = displayLabel(s.label, opts);
505
+ // #2701 — the account label is an identifier (email / slug) that can
506
+ // contain markdown-active chars (`_ * ~` etc.). `tableCell` only guards
507
+ // the table grid (`\ | \n`), NOT inline formatting, so a raw label like
508
+ // `ken_max` was parsed as emphasis and corrupted the row. Render it in a
509
+ // code span (backtick content is literal) exactly like renderAccountRow
510
+ // does, defusing embedded backticks via codeSpanSafe. The wrapping
511
+ // backticks still need pipe/newline guarding for the grid, hence the
512
+ // tableCell wrap around the finished span.
513
+ const accountCell = `\`${codeSpanSafe(s.isActive ? `${label} (active)` : label)}\``;
514
+ const { five, seven } = pctCells(s, now);
515
+ const status = statusCell(s, now, tz);
516
+ lines.push(
517
+ `| ${emoji} | ${tableCell(accountCell)} | ${five} | ${seven} | ${tableCell(status)} |`,
518
+ );
408
519
  }
409
520
  }
410
521
 
411
522
  lines.push('');
412
- lines.push('────────────────────────────');
413
523
  lines.push(`_${recommendation(snapshots, now, opts.demo ?? false)}_`);
414
524
  // #2495 Change 2 — a failed probe-on-open renders an explicit "cached Nm
415
525
  // ago" warning, never a false live stamp. The degraded variant takes
@@ -615,10 +725,10 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
615
725
  // same per-account row + earliest-recovery helpers the /auth table uses so
616
726
  // the formatting stays consistent with the rest of the auth surface.
617
727
  lines.push(
618
- `🔴 **All accounts blocked · ${headerLimit} on ${escapeHtml(input.oldLabel)}**`,
728
+ `🔴 **All accounts blocked · ${headerLimit} on ${escapeMarkdown(input.oldLabel)}**`,
619
729
  );
620
730
  lines.push('');
621
- lines.push(`Triggered by: agent **${escapeHtml(input.triggerAgent)}**`);
731
+ lines.push(`Triggered by: agent **${escapeMarkdown(input.triggerAgent)}**`);
622
732
 
623
733
  const fleet = input.fleetSnapshots ?? [];
624
734
  if (fleet.length > 0) {
@@ -639,7 +749,7 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
639
749
  if (earliest) {
640
750
  lines.push('');
641
751
  lines.push(
642
- `Earliest recovery: \`${escapeHtml(earliest.label)}\` ` +
752
+ `Earliest recovery: \`${codeSpanSafe(earliest.label)}\` ` +
643
753
  `${formatAbsolute(earliest.at, tz)} (in ${formatRelative(earliest.at, now)})`,
644
754
  );
645
755
  }
@@ -648,7 +758,7 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
648
758
  const recovery = recoveryAtFor(input.oldQuota);
649
759
  if (recovery) {
650
760
  lines.push(
651
- `${escapeHtml(input.oldLabel)} recovers ${formatAbsolute(recovery, tz)} ` +
761
+ `${escapeMarkdown(input.oldLabel)} recovers ${formatAbsolute(recovery, tz)} ` +
652
762
  `(in ${formatRelative(recovery, now)})`,
653
763
  );
654
764
  }
@@ -663,20 +773,20 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
663
773
 
664
774
  // Successful swap.
665
775
  lines.push(
666
- `✓ **Switched fleet · ${headerLimit} on ${escapeHtml(input.oldLabel)}**`,
776
+ `✓ **Switched fleet · ${headerLimit} on ${escapeMarkdown(input.oldLabel)}**`,
667
777
  );
668
778
  lines.push('');
669
779
  lines.push(
670
- `\`${escapeHtml(input.oldLabel)}\` → \`${escapeHtml(input.newLabel)}\``,
780
+ `\`${codeSpanSafe(input.oldLabel)}\` → \`${codeSpanSafe(input.newLabel)}\``,
671
781
  );
672
- lines.push(`Triggered by: agent **${escapeHtml(input.triggerAgent)}**`);
782
+ lines.push(`Triggered by: agent **${escapeMarkdown(input.triggerAgent)}**`);
673
783
  lines.push('');
674
784
 
675
785
  if (input.oldQuota) {
676
786
  const recovery = recoveryAtFor(input.oldQuota);
677
787
  if (recovery) {
678
788
  lines.push(
679
- `\`${escapeHtml(input.oldLabel)}\` recovers ` +
789
+ `\`${codeSpanSafe(input.oldLabel)}\` recovers ` +
680
790
  `${formatAbsolute(recovery, tz)} (in ${formatRelative(recovery, now)})`,
681
791
  );
682
792
  }
@@ -690,7 +800,7 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
690
800
  input.newQuota.sevenDayUtilizationPct < THROTTLING_THRESHOLD_PCT;
691
801
  const headroomStr = hasHeadroom ? '_(plenty of headroom)_' : '_(near limit — watch this)_';
692
802
  lines.push(
693
- `\`${escapeHtml(input.newLabel)}\` now: ${fiveStr} of 5h · ${sevenStr} of 7d ${headroomStr}`,
803
+ `\`${codeSpanSafe(input.newLabel)}\` now: ${fiveStr} of 5h · ${sevenStr} of 7d ${headroomStr}`,
694
804
  );
695
805
  } else {
696
806
  lines.push(
@@ -807,10 +917,6 @@ function switchPriority(s: AccountSnapshot, now: Date = new Date()): number {
807
917
 
808
918
  // ── shared HTML escape ───────────────────────────────────────────────
809
919
 
810
- function escapeHtml(s: string): string {
811
- return s.replace(/([\\`*_~=\[\]|])/g, '\\$1');
812
- }
813
-
814
920
  // ── snapshot assembly helper ─────────────────────────────────────────
815
921
 
816
922
  /**
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import type { QuotaResult, QuotaUtilization } from './quota-check.js';
38
+ import { escapeMarkdown } from './card-format.js';
38
39
  import type { ListStateData } from '../src/auth/broker/client.js';
39
40
  import {
40
41
  renderFallbackAnnouncement,
@@ -54,16 +55,12 @@ import {
54
55
  */
55
56
  export function renderFallbackFailureNotice(triggerAgent: string, reason: string): string {
56
57
  return (
57
- `⚠️ **Auto-failover could not run** (trigger: **${escFailureHtml(triggerAgent)}**)\n` +
58
- `${escFailureHtml(reason)}\n\n` +
58
+ `⚠️ **Auto-failover could not run** (trigger: **${escapeMarkdown(triggerAgent)}**)\n` +
59
+ `${escapeMarkdown(reason)}\n\n` +
59
60
  `_Switch manually with \`/auth use <label>\`, or \`/auth\` for fleet status._`
60
61
  );
61
62
  }
62
63
 
63
- function escFailureHtml(s: string): string {
64
- return s.replace(/([\\`*_~=\[\]|])/g, '\\$1');
65
- }
66
-
67
64
  /**
68
65
  * Cooldown for the failure notice. The fleetFallbackGate's dedup window
69
66
  * deliberately arms ONLY on a successful swap (fleet-fallback-gate.ts:
@@ -27,13 +27,63 @@
27
27
  * which is itself state-free.
28
28
  */
29
29
 
30
- import { escapeMarkdown } from './format.js'
30
+ import { escapeMarkdown, codeSpanSafe } from './format.js'
31
31
 
32
32
  /**
33
33
  * Re-export so card surfaces have one import for the markdown escaper they
34
34
  * use when interpolating dynamic values into a hand-built markdown card.
35
+ * `codeSpanSafe` is the code-span (backtick) counterpart: use it for values
36
+ * interpolated INSIDE `` `…` `` (where backslash escaping is wrong).
35
37
  */
36
- export { escapeMarkdown }
38
+ export { escapeMarkdown, codeSpanSafe }
39
+
40
+ /**
41
+ * Join a card's pre-rendered, single-line entries so they STACK in the
42
+ * Bot API 10.1 rich-message renderer (#2669) — the same visual result the
43
+ * main reply path gets after `normalizeParagraphBreaks`.
44
+ *
45
+ * Why this exists: the rich GFM renderer treats a LONE `\n` between two
46
+ * non-blank lines as a SOFT break and collapses them onto one visual line
47
+ * (this is the exact reason `normalizeParagraphBreaks` promotes prose `\n`
48
+ * to a hard break on the reply path). Status / worker / issues / boot
49
+ * cards build a list of styled prose lines (`_✓ step_`, `**→ step**`,
50
+ * header lines, a `─────` rule) and join them with `\n`. Sent verbatim via
51
+ * `richMessage`, those lines collapse into a run-on blob — the long-standing
52
+ * "progress-card bullets collapse into one line" bug.
53
+ *
54
+ * Each card line is a SINGLE styled line with no internal newline (steps are
55
+ * pre-clipped to one line, headers are emitted one-per-array-entry), so we
56
+ * can deterministically promote EVERY inter-line break to a GFM hard break
57
+ * (` \n`, two trailing spaces) without risking a list/table/code corruption:
58
+ * card lines are never GFM block-structure lines (no leading `-`/`*`/`|`/```),
59
+ * they are inline-styled prose. A blank entry (intentional `\n\n` gap, if a
60
+ * caller ever inserts one) is preserved as a real paragraph break.
61
+ *
62
+ * This is the card-surface analogue of the reply path's
63
+ * `normalizeParagraphBreaks`: it guarantees a card authored as stacked
64
+ * bullet/step lines renders identically to a normal reply.
65
+ */
66
+ export function stackCardLines(lines: string[]): string {
67
+ const pieces: string[] = []
68
+ for (let i = 0; i < lines.length; i++) {
69
+ const line = lines[i]
70
+ pieces.push(line)
71
+ if (i === lines.length - 1) break
72
+ const cur = line.trim()
73
+ const next = lines[i + 1].trim()
74
+ // A blank current or next line is a genuine `\n\n` paragraph gap — leave
75
+ // the separator a plain newline so the blank entry reconstructs the gap.
76
+ if (cur === '' || next === '') {
77
+ pieces.push('\n')
78
+ continue
79
+ }
80
+ // Strip any trailing whitespace the line already carried so we emit
81
+ // exactly one ` \n` hard break (never accumulate spaces on a re-run).
82
+ pieces[pieces.length - 1] = line.replace(/[ \t\r]+$/, '')
83
+ pieces.push(' \n')
84
+ }
85
+ return pieces.join('')
86
+ }
37
87
 
38
88
  /**
39
89
  * Render a millisecond duration as `<n>ms` for sub-second values, or
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
25
25
  import { join } from "path";
26
+ import { escapeMarkdown } from "./card-format.js";
26
27
 
27
28
  const STATE_FILE = "credits-watch.json";
28
29
 
@@ -157,7 +158,7 @@ export function evaluateCreditState(args: {
157
158
  if (!currentIsFatal && prevIsFatal) {
158
159
  return {
159
160
  kind: "notify",
160
- message: `✅ **${escapeHtml(agentName)}**: credits restored — agent should resume normal operation.`,
161
+ message: `✅ **${escapeMarkdown(agentName)}**: credits restored — agent should resume normal operation.`,
161
162
  newState: { lastNotifiedReason: null, lastNotifiedAt: now },
162
163
  transition: "exited",
163
164
  };
@@ -193,12 +194,12 @@ export function evaluateCreditState(args: {
193
194
  function buildEntryMessage(agentName: string, reason: string): string {
194
195
  const desc = humanizeReason(reason);
195
196
  return [
196
- `⚠️ **${escapeHtml(agentName)}**: ${desc}`,
197
+ `⚠️ **${escapeMarkdown(agentName)}**: ${desc}`,
197
198
  ``,
198
199
  `Cron tasks and inbound replies will fail until this is resolved. Check`,
199
200
  `your subscription or pre-paid usage at [console.anthropic.com](https://console.anthropic.com).`,
200
201
  ``,
201
- `_Source: Claude CLI cache (cachedExtraUsageDisabledReason=${escapeHtml(reason)})_`,
202
+ `_Source: Claude CLI cache (cachedExtraUsageDisabledReason=${escapeMarkdown(reason)})_`,
202
203
  ].join("\n");
203
204
  }
204
205
 
@@ -217,10 +218,6 @@ function humanizeReason(reason: string): string {
217
218
  }
218
219
  }
219
220
 
220
- function escapeHtml(s: string): string {
221
- return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
222
- }
223
-
224
221
  // ─── State persistence ───────────────────────────────────────────────────────
225
222
 
226
223
  export function loadCreditState(stateDir: string): CreditState {