switchroom 0.16.29 → 0.16.46

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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  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 +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -23,7 +23,7 @@ memory:
23
23
  shared_collection: shared
24
24
 
25
25
  defaults:
26
- model: claude-sonnet-4-6
26
+ model: claude-sonnet-5
27
27
  tools:
28
28
  allow: [all]
29
29
  subagents:
@@ -58,7 +58,7 @@ vault:
58
58
  # Every agent inherits this. Per-agent fields win on conflict.
59
59
  # tools/skills/schedule are unioned; scalars are overridden.
60
60
  defaults:
61
- model: claude-sonnet-4-6
61
+ model: claude-sonnet-5
62
62
  # The switchroom-telegram fork is the default. Uncomment to fall back to
63
63
  # the upstream marketplace plugin: channels.telegram.plugin: official
64
64
  channels:
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.16.29",
3
+ "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
+ "version": "0.16.46",
4
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
6
  "type": "module",
6
7
  "bin": {
@@ -25,7 +26,7 @@
25
26
  "pretest": "npm run build",
26
27
  "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
27
28
  "test:vitest": "vitest run",
28
- "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
29
+ "test:bun": "bun test src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
29
30
  "test:watch": "vitest",
30
31
  "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-web-subscription-honest.mjs",
31
32
  "lint:tsc": "tsc --noEmit",
@@ -43,7 +43,9 @@ tools is to let you do the edit yourself.
43
43
  still shares your memory + tools but drops your accumulated conversation
44
44
  context). A **daily/weekly** cron defaults to a **full turn in your live
45
45
  session** (Tier 2: your model, your whole context). So routine frequent
46
- checks are already cheap without you doing anything.
46
+ checks are already cheap without you doing anything. Cron-fresh sessions
47
+ render rich markdown too, so scheduled/digest/daily-memory output should
48
+ use **bold** section headers + `code` for identifiers, just like a live reply.
47
49
  - *`model: "sonnet"` / `context: "fresh"`* — force the cheap Tier-1 session
48
50
  even for a **daily/weekly** self-contained job (summarise a feed, format a
49
51
  digest) — overrides the cadence default. *`context: "agent"`* does the
@@ -53,7 +55,10 @@ tools is to let you do the edit yourself.
53
55
  ping — text fully determined, no thinking) — ask the **operator** for a
54
56
  **`kind: action`**: it runs **model-free, zero tokens** (no session at all),
55
57
  posting your fixed/templated text or firing a fixed request. The cheapest
56
- tier there is.
58
+ tier there is. The action `text` is rendered as full GFM markdown
59
+ (**bold**, _italic_, `code`, ~~strikethrough~~, links) — so a literal `*`
60
+ or `_` in fixed text becomes formatting unless you set `parse_mode: text`,
61
+ which sends the string verbatim (the escape hatch).
57
62
  - *"Only act when X changes"* — don't poll with a frequent prompt cron (every
58
63
  fire is a wasted turn when nothing changed). Ask the **operator** for a
59
64
  **`kind: poll`** (model-free check, e.g. a webpage/API — only a *change*
@@ -0,0 +1,9 @@
1
+ ## ⛔ SENDING YOUR ANSWER (read first, applies every turn)
2
+
3
+ **Plain text you write is INVISIBLE to the user.** The only thing that reaches Telegram is text passed through the `mcp__switchroom-telegram__reply` tool. Writing the answer in your response is NOT sending it — the user sees nothing until you call the reply tool.
4
+
5
+ - Every turn that answers a user **MUST end with a `reply` tool call** carrying the full answer. No exceptions, no "I'll just write it out."
6
+ - If you did work first (tool calls, lookups), the reply call is still required at the end — don't let the turn end on a tool result or plain prose.
7
+ - If you genuinely have nothing to add, emit exactly `NO_REPLY` and nothing else.
8
+
9
+ If you skip this, the gateway's safety net eventually forces your answer out 2-4 minutes late and burns extra turns. Don't rely on the net. Send it yourself.
@@ -82,7 +82,7 @@ End with a one-line summary: "X of Y agents running."
82
82
 
83
83
  ```
84
84
  assistant — running (2h 14m)
85
- model: claude-sonnet-4-6 collection: general
85
+ model: claude-sonnet-5 collection: general
86
86
 
87
87
  dev — running (45m)
88
88
  model: claude-opus-4-8 collection: coding
@@ -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:
@@ -434,7 +434,8 @@ const TOOL_SCHEMAS = [
434
434
  chat_id: { type: 'string', description: 'Chat to render the approval card in (use the chat_id of the user message that triggered the workflow).' },
435
435
  key: { type: 'string', description: 'Vault key the agent wants access to (matches the key shown in the VAULT-BROKER-DENIED error, e.g. `fatsecret/credentials`).' },
436
436
  scope: { type: 'string', enum: ['read', 'write'], description: 'Access scope: "read" (default) for `vault:<key>` references; "write" if the agent needs to put new values.' },
437
- reason: { type: 'string', description: 'REQUIRED in practice — short human-readable rationale rendered on the card (e.g. "to look up today\'s food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation — they will usually Deny. Always supply a one-line rationale.' },
437
+ reason: { type: 'string', description: 'REQUIRED in practice — short human-readable rationale rendered on the card (e.g. "to look up today\'s food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation — they will usually Deny. Always supply a one-line rationale. `why` is accepted as an alias (matching `vault_request_save`).' },
438
+ why: { type: 'string', description: 'Alias for `reason` (matches the sibling tool `vault_request_save`). If both are supplied, `reason` wins.' },
438
439
  duration: { type: 'string', description: 'Requested grant TTL, like "30d" or "12h". Default 30d, capped at 90d. Beyond 90d the operator should use the host CLI explicitly.' },
439
440
  message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message if not specified.' },
440
441
  },
@@ -27,13 +27,64 @@
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
+ import { normalizeDashes } from './text-voice-scrub.js'
31
32
 
32
33
  /**
33
34
  * Re-export so card surfaces have one import for the markdown escaper they
34
35
  * use when interpolating dynamic values into a hand-built markdown card.
36
+ * `codeSpanSafe` is the code-span (backtick) counterpart: use it for values
37
+ * interpolated INSIDE `` `…` `` (where backslash escaping is wrong).
35
38
  */
36
- export { escapeMarkdown }
39
+ export { escapeMarkdown, codeSpanSafe }
40
+
41
+ /**
42
+ * Join a card's pre-rendered, single-line entries so they STACK in the
43
+ * Bot API 10.1 rich-message renderer (#2669) — the same visual result the
44
+ * main reply path gets after `normalizeParagraphBreaks`.
45
+ *
46
+ * Why this exists: the rich GFM renderer treats a LONE `\n` between two
47
+ * non-blank lines as a SOFT break and collapses them onto one visual line
48
+ * (this is the exact reason `normalizeParagraphBreaks` promotes prose `\n`
49
+ * to a hard break on the reply path). Status / worker / issues / boot
50
+ * cards build a list of styled prose lines (`_✓ step_`, `**→ step**`,
51
+ * header lines, a `─────` rule) and join them with `\n`. Sent verbatim via
52
+ * `richMessage`, those lines collapse into a run-on blob — the long-standing
53
+ * "progress-card bullets collapse into one line" bug.
54
+ *
55
+ * Each card line is a SINGLE styled line with no internal newline (steps are
56
+ * pre-clipped to one line, headers are emitted one-per-array-entry), so we
57
+ * can deterministically promote EVERY inter-line break to a GFM hard break
58
+ * (` \n`, two trailing spaces) without risking a list/table/code corruption:
59
+ * card lines are never GFM block-structure lines (no leading `-`/`*`/`|`/```),
60
+ * they are inline-styled prose. A blank entry (intentional `\n\n` gap, if a
61
+ * caller ever inserts one) is preserved as a real paragraph break.
62
+ *
63
+ * This is the card-surface analogue of the reply path's
64
+ * `normalizeParagraphBreaks`: it guarantees a card authored as stacked
65
+ * bullet/step lines renders identically to a normal reply.
66
+ */
67
+ export function stackCardLines(lines: string[]): string {
68
+ const pieces: string[] = []
69
+ for (let i = 0; i < lines.length; i++) {
70
+ const line = lines[i]
71
+ pieces.push(line)
72
+ if (i === lines.length - 1) break
73
+ const cur = line.trim()
74
+ const next = lines[i + 1].trim()
75
+ // A blank current or next line is a genuine `\n\n` paragraph gap — leave
76
+ // the separator a plain newline so the blank entry reconstructs the gap.
77
+ if (cur === '' || next === '') {
78
+ pieces.push('\n')
79
+ continue
80
+ }
81
+ // Strip any trailing whitespace the line already carried so we emit
82
+ // exactly one ` \n` hard break (never accumulate spaces on a re-run).
83
+ pieces[pieces.length - 1] = line.replace(/[ \t\r]+$/, '')
84
+ pieces.push(' \n')
85
+ }
86
+ return pieces.join('')
87
+ }
37
88
 
38
89
  /**
39
90
  * Render a millisecond duration as `<n>ms` for sub-second values, or
@@ -77,7 +128,12 @@ export function truncate(s: string, n: number): string {
77
128
  * Run this BEFORE `truncate` + `escapeMarkdown`: clean → measure → escape.
78
129
  */
79
130
  export function stripMarkdown(s: string): string {
80
- let out = s;
131
+ // Close the em-dash leak on this model-authored-prose surface using the
132
+ // SAME dash logic as the reply-path voice scrub (no duplicated regex). Run
133
+ // it FIRST, while code spans (`` `…` ``) still have their backticks intact,
134
+ // so `normalizeDashes`'s own code-region masking preserves a dash inside a
135
+ // code span before the backtick-stripping below erases the fences.
136
+ let out = normalizeDashes(s);
81
137
  // Inline + leftover code spans → bare text.
82
138
  out = out.replace(/`+/g, '');
83
139
  // Links / images: [text](url) and ![alt](url) → the label.