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
@@ -51,6 +51,7 @@ import {
51
51
  fmtPct,
52
52
  } from "./auth-snapshot-format.js";
53
53
  import type { QuotaUtilization } from "./quota-check.js";
54
+ import { escapeMarkdown, codeSpanSafe } from "./card-format.js";
54
55
 
55
56
  const STATE_FILE = "quota-watch.json";
56
57
 
@@ -511,7 +512,7 @@ function buildFleetRecoveredMessage(
511
512
  accounts: Array<{ label: string; exhausted: boolean }>,
512
513
  ): string {
513
514
  const healthy = accounts.filter((a) => !a.exhausted).map((a) => a.label);
514
- const which = healthy.length > 0 ? ` (\`${escapeHtml(healthy[0]!)}\`)` : "";
515
+ const which = healthy.length > 0 ? ` (\`${codeSpanSafe(healthy[0]!)}\`)` : "";
515
516
  return [
516
517
  `🟢 **Fleet recovered** — at least one account is healthy again${which}.`,
517
518
  ``,
@@ -521,7 +522,7 @@ function buildFleetRecoveredMessage(
521
522
 
522
523
  // ─── Message builders ─────────────────────────────────────────────────────────
523
524
 
524
- function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): string {
525
+ export function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): string {
525
526
  const q = snap.quota!; // classifyHealth returned throttling, so quota is non-null
526
527
  const fiveStr = fmtPct(q.fiveHourUtilizationPct);
527
528
  const sevenStr = fmtPct(q.sevenDayUtilizationPct);
@@ -535,14 +536,14 @@ function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): strin
535
536
 
536
537
  const activeNote = snap.isActive
537
538
  ? ""
538
- : `\nThis is a non-active account. Consider \`/auth use ${escapeHtml(snap.label)}\` to switch, or keep it as a fallback reserve.`;
539
+ : `\nThis is a non-active account. Consider \`/auth use ${codeSpanSafe(snap.label)}\` to switch, or keep it as a fallback reserve.`;
539
540
 
540
541
  const altNote = snap.isActive
541
542
  ? `\nConsider \`/auth use <other-account>\` if you have a healthier account, or wait for the ${winLabel} window to refill${resetStr}.`
542
543
  : "";
543
544
 
544
545
  return [
545
- `🟡 **Quota approaching limit** — \`${escapeHtml(snap.label)}\``,
546
+ `🟡 **Quota approaching limit** — \`${codeSpanSafe(snap.label)}\``,
546
547
  ``,
547
548
  `${fiveStr} of 5h · ${sevenStr} of 7d`,
548
549
  `Binding window: ${winLabel}${resetStr}`,
@@ -556,14 +557,14 @@ function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): strin
556
557
  .trim();
557
558
  }
558
559
 
559
- function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string {
560
+ export function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string {
560
561
  const q = snap.quota;
561
562
  const utilLine = q
562
563
  ? `Current: ${fmtPct(q.fiveHourUtilizationPct)} of 5h · ${fmtPct(q.sevenDayUtilizationPct)} of 7d`
563
564
  : "Current quota data unavailable.";
564
565
 
565
566
  return [
566
- `🟢 **Quota back in healthy range** — \`${escapeHtml(snap.label)}\``,
567
+ `🟢 **Quota back in healthy range** — \`${codeSpanSafe(snap.label)}\``,
567
568
  ``,
568
569
  utilLine,
569
570
  ``,
@@ -571,10 +572,6 @@ function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string
571
572
  ].join("\n");
572
573
  }
573
574
 
574
- function escapeHtml(s: string): string {
575
- return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
576
- }
577
-
578
575
  // ─── State persistence ────────────────────────────────────────────────────────
579
576
 
580
577
  export function loadQuotaWatchState(stateDir: string): QuotaWatchState {
@@ -30,7 +30,9 @@
30
30
  * - Robust error handling: 429 retry, thread-not-found fallback, network retry
31
31
  * - SQLite history buffer (history.ts) for cross-restart recovery
32
32
  * - Vault-grant inline-keyboard wizard
33
- * - Pinned progress card with sub-agent visibility
33
+ * - Live in-conversation status feed with sub-agent visibility (the pinned
34
+ * progress card was retired in #1122; the status message is now a regular
35
+ * in-chat message, silently pinned while in-flight — status-pin.ts)
34
36
  *
35
37
  * State (access.json / pairing / allowlists) lives at
36
38
  * ~/.claude/channels/telegram/ — managed by the /telegram:access skill.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Status-pin driver — executes a PinAction against a Telegram Bot API.
3
+ *
4
+ * The pure decision lives in `status-pin.ts` (decidePinAction). This
5
+ * module is the side-effecting half: takes the previously-claimed state
6
+ * and the desired state, computes ONE action, executes it, and returns
7
+ * the next state. The state itself stays in the caller (the gateway holds
8
+ * a `Map<pinKey, PinState>` and re-passes the entry on every call).
9
+ *
10
+ * Copied from `slot-banner-driver.ts`. The load-bearing contract (see
11
+ * slot-banner-driver.ts:100-110):
12
+ *
13
+ * - Pins are SILENT (`disable_notification: true`) — a status pin must
14
+ * never buzz the device.
15
+ * - We pin an EXISTING message (the already-rendered per-turn status /
16
+ * `🛠 Worker` message). We NEVER send a new message to pin. This is
17
+ * the boundary that keeps us inside the one sanctioned exception on
18
+ * `chat-is-the-single-source-of-truth`.
19
+ * - On UNPIN we DROP THE CLAIM (clear state) EVEN IF `unpinChatMessage`
20
+ * throws. This is the whole point: pin state must never get stuck.
21
+ * The message may have been unpinned out-of-band (operator, or a
22
+ * crash) — re-claiming it would be more confusing than surfacing it
23
+ * again later, and a stuck claim would leave a permanent pin.
24
+ * - API failures are reported via `onError` but never throw; the caller
25
+ * decides logging cadence.
26
+ *
27
+ * See `reference/invariants.md` § `chat-is-the-single-source-of-truth`
28
+ * (the sanctioned silent-pin exception) and the `know-what-my-agent-is-doing`
29
+ * job spec.
30
+ */
31
+
32
+ import type { PinState, DesiredPin } from './status-pin.js'
33
+ import { decidePinAction } from './status-pin.js'
34
+
35
+ /** Minimal subset of grammy's `bot.api` the pin driver depends on.
36
+ * Lets tests swap in a fake without dragging in the full Bot type. */
37
+ export interface PinBotApi {
38
+ pinChatMessage(
39
+ chat_id: string | number,
40
+ message_id: number,
41
+ opts?: Record<string, unknown>,
42
+ ): Promise<unknown>
43
+ unpinChatMessage(
44
+ chat_id: string | number,
45
+ message_id: number,
46
+ ): Promise<unknown>
47
+ }
48
+
49
+ export interface ReconcilePinArgs {
50
+ api: PinBotApi
51
+ chatId: string
52
+ /** State the caller is holding from the last reconcile. `null` on first. */
53
+ prevState: PinState | null
54
+ /** What the caller wants pinned for this key right now. */
55
+ desired: DesiredPin
56
+ /** Optional API-failure observer. Default: silent. */
57
+ onError?: (phase: 'pin' | 'unpin', err: unknown) => void
58
+ }
59
+
60
+ /**
61
+ * Execute the next pin-state transition for one key. Returns the new
62
+ * `PinState` (or `null` when unpinned / nothing pinned). Always resolves;
63
+ * never throws — API errors route through `onError`.
64
+ *
65
+ * - `pin` : pins the existing message SILENTLY; on failure the claim is
66
+ * NOT taken (returns prevState) so the next reconcile retries
67
+ * rather than tracking a message it never pinned.
68
+ * - `unpin`: unpins best-effort and returns `null` — the claim is dropped
69
+ * EVEN IF the unpin throws (never leave state stuck pinned).
70
+ * - `noop` : returns prevState unchanged.
71
+ */
72
+ export async function reconcilePin(
73
+ args: ReconcilePinArgs,
74
+ ): Promise<PinState | null> {
75
+ const action = decidePinAction(args.prevState, args.desired)
76
+
77
+ if (action.kind === 'noop') return args.prevState
78
+
79
+ if (action.kind === 'unpin') {
80
+ try {
81
+ await args.api.unpinChatMessage(args.chatId, action.messageId)
82
+ } catch (err) {
83
+ args.onError?.('unpin', err)
84
+ }
85
+ // Drop the claim regardless of the unpin outcome. A stuck claim would
86
+ // leave a permanent pin on a crash / out-of-band unpin — the exact
87
+ // failure this driver exists to prevent (see slot-banner-driver.ts).
88
+ return null
89
+ }
90
+
91
+ // action.kind === 'pin' — pin an EXISTING message, silently.
92
+ try {
93
+ await args.api.pinChatMessage(args.chatId, action.messageId, {
94
+ disable_notification: true,
95
+ })
96
+ } catch (err) {
97
+ args.onError?.('pin', err)
98
+ // Don't claim a message we failed to pin — the next reconcile retries.
99
+ return args.prevState
100
+ }
101
+ return { messageId: action.messageId }
102
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Status-pin — pure decision logic.
3
+ *
4
+ * Keeps an already-rendered status message pinned while its work is
5
+ * in-flight, and unpins it the moment the work completes. This is the
6
+ * ONE sanctioned pin under the `chat-is-the-single-source-of-truth`
7
+ * invariant (see `reference/invariants.md` §
8
+ * `chat-is-the-single-source-of-truth` and the
9
+ * `know-what-my-agent-is-doing` job spec): it re-surfaces a message the
10
+ * conversation ALREADY owns — the per-turn activity/status message and
11
+ * the `🛠 Worker` background-worker message — and never renders a new
12
+ * parallel surface.
13
+ *
14
+ * Why a pin at all: fast answers, more background workers, and much
15
+ * longer turns mean the conversation moves on quickly in the feed and
16
+ * the user loses sight of in-flight work — work now routinely outlives
17
+ * the visible window. Pinning the message the feed already holds keeps
18
+ * it in view; it carries no new content.
19
+ *
20
+ * This module is dependency-free so it's provable in isolation; the
21
+ * gateway (via `status-pin-driver.ts`) translates a `PinAction` into
22
+ * actual Telegram API calls. The design mirrors `slot-banner.ts` +
23
+ * `slot-banner-driver.ts` — one pure decision, one side-effecting
24
+ * reconcile, the claim dropped on unpin even when the API throws.
25
+ */
26
+
27
+ /** The message the framework is currently claiming as pinned for a key. */
28
+ export type PinState = {
29
+ /** Telegram message_id we pinned. */
30
+ messageId: number
31
+ }
32
+
33
+ /** What the caller wants pinned for a key right now. */
34
+ export type DesiredPin =
35
+ /** Work is in-flight; this already-rendered message should be pinned. */
36
+ | { pinned: true; messageId: number }
37
+ /** Work is done (or never opened a message); nothing should be pinned. */
38
+ | { pinned: false }
39
+
40
+ export type PinAction =
41
+ | { kind: 'noop'; reason: string }
42
+ /** Pin an EXISTING message. Caller pins, then records the message_id
43
+ * back into PinState. No new message is sent — this pins a message the
44
+ * chat already rendered. */
45
+ | { kind: 'pin'; messageId: number }
46
+ /** Unpin + forget. Caller unpins (best-effort) and clears state EVEN IF
47
+ * the unpin call throws. */
48
+ | { kind: 'unpin'; messageId: number }
49
+
50
+ /**
51
+ * Decide the single pin action for a key given the previously-claimed
52
+ * state and what the caller now wants.
53
+ *
54
+ * Exactly one action per (prev, desired) — the whole point is that pin
55
+ * state can never get stuck: an in-flight → done transition always yields
56
+ * an `unpin`, and the driver drops the claim on unpin regardless of API
57
+ * outcome.
58
+ */
59
+ export function decidePinAction(
60
+ prev: PinState | null,
61
+ desired: DesiredPin,
62
+ ): PinAction {
63
+ if (!desired.pinned) {
64
+ if (prev) return { kind: 'unpin', messageId: prev.messageId }
65
+ return { kind: 'noop', reason: 'nothing pinned, nothing wanted' }
66
+ }
67
+ // desired.pinned === true
68
+ if (prev == null) return { kind: 'pin', messageId: desired.messageId }
69
+ if (prev.messageId === desired.messageId) {
70
+ return { kind: 'noop', reason: 'already pinned this message' }
71
+ }
72
+ // The message we want pinned changed (the feed re-posted after a stale
73
+ // edit dropped its id). Unpin the old claim; a subsequent desired-pin
74
+ // re-pins the new one on the next reconcile.
75
+ return { kind: 'unpin', messageId: prev.messageId }
76
+ }
@@ -158,6 +158,14 @@ export interface StreamReplyDeps {
158
158
  * the raw (repaired) text is sent unchanged.
159
159
  */
160
160
  normalizeParagraphBreaks?: (text: string) => string
161
+ /**
162
+ * Insert a visible blank-line spacer into each prose `\n\n` gap so the rich
163
+ * GFM renderer shows a real empty line between paragraphs (the rich engine
164
+ * otherwise renders `\n\n` tight — the post-#2669 paragraph-spacing
165
+ * regression). Applied only on the rich path (never on `format:'text'`).
166
+ * Optional for backward compat; omitted → no spacers added.
167
+ */
168
+ addParagraphSpacers?: (text: string) => string
161
169
  /** Validates the chat id against the access list. Throws on deny. */
162
170
  assertAllowedChat: (chatId: string) => void
163
171
  /** Resolves the effective thread id (explicit, last-inbound, or undefined). */
@@ -337,7 +345,12 @@ export async function handleStreamReply(
337
345
  // markdown→HTML / MarkdownV2 rendering happens here anymore — the raw
338
346
  // text IS the wire payload.
339
347
  const literalText = format === 'text'
340
- let effectiveText: string = rawText
348
+ // Paragraph-spacing fix (rich-message regression after #2669): inject a
349
+ // visible blank-line spacer into prose `\n\n` gaps on the rich path so
350
+ // multi-paragraph answers don't render jammed together. The literal
351
+ // (`format:'text'`) path must stay byte-exact, so it is left untouched.
352
+ let effectiveText: string =
353
+ !literalText && deps.addParagraphSpacers ? deps.addParagraphSpacers(rawText) : rawText
341
354
 
342
355
  // Inline status-accent header (issue #320 fallback). Prepended so it
343
356
  // leads the body. Since stream_reply callers pass the full text snapshot
@@ -13,9 +13,12 @@
13
13
  * does not yet exist (Phase 2 Pre hook hasn't fired), the update is a no-op
14
14
  * and the event is logged — no INSERT here, identity belongs to Phase 2.
15
15
  *
16
- * Sub-agent state is surfaced to the user via the progress card's
17
- * [Sub-agents · N running] block (progress-card.ts), not a separate pinned
18
- * card. See issue #142.
16
+ * Sub-agent state is surfaced to the user in the conversation itself — a
17
+ * background worker via its in-chat `🛠 Worker` message (worker-activity-feed.ts),
18
+ * a foreground sub-agent nested in the parent turn's activity message. The
19
+ * pinned progress card was retired in #1122; the `🛠 Worker` message is now
20
+ * silently pinned while the worker runs (status-pin.ts), not a bespoke card.
21
+ * See issue #142.
19
22
  *
20
23
  * Architecture notes:
21
24
  * - Option B from the spec: filesystem-driven, no IPC contract.
@@ -93,8 +93,12 @@ describe('renderShowText — Format 2 vs legacy', () => {
93
93
  });
94
94
  expect(out).toContain('🔋 **Auth — fleet status**');
95
95
  expect(out).toContain('Recommendation:');
96
- expect(out).toContain('🔴 **BLOCKED**');
97
- expect(out).toContain('🟢 **HEALTHY**');
96
+ // GFM table card (#2700): State emoji per row, not group headers.
97
+ expect(out).toContain('| State | Account | 5h | 7d | Status |');
98
+ expect(out).toContain('| 🔴 |'); // the blocked account's State cell
99
+ expect(out).toContain('| 🟢 |'); // a healthy account's State cell
100
+ expect(out).not.toContain('**BLOCKED**');
101
+ expect(out).not.toContain('**HEALTHY**');
98
102
  // Legacy ASCII column headers should be absent
99
103
  expect(out).not.toContain('ACCOUNT STATUS');
100
104
  });
@@ -140,6 +140,24 @@ describe('parseAuthCommand — new verbs', () => {
140
140
  expect((p as { reason?: string }).reason).toMatch(/confirm/i)
141
141
  })
142
142
 
143
+ // Boundary-escaping (#2695 escaper de-dup): the help/reason strings now
144
+ // render dynamic user input via `codeSpanSafe` — the reason interpolates the
145
+ // value INSIDE a `code span`, where backslash escaping is wrong (#2695). A
146
+ // metacharacter-laden verb must come back LITERAL (no stray backslashes).
147
+ it('renders a metacharacter-laden unknown verb literally in the code span', () => {
148
+ const p = parseAuthCommand('/auth a_b*c')
149
+ expect(p?.kind).toBe('help')
150
+ expect((p as { reason?: string }).reason).toContain('`a_b*c`')
151
+ expect((p as { reason?: string }).reason).not.toContain('a\\_b\\*c')
152
+ })
153
+
154
+ it('renders a metacharacter-laden rm modifier literally in the code span', () => {
155
+ const p = parseAuthCommand('/auth rm spare x_y*z')
156
+ expect(p?.kind).toBe('help')
157
+ expect((p as { reason?: string }).reason).toContain('`x_y*z`')
158
+ expect((p as { reason?: string }).reason).not.toContain('x\\_y\\*z')
159
+ })
160
+
143
161
  it('rejects /auth rm with no label', () => {
144
162
  const p = parseAuthCommand('/auth rm')
145
163
  expect(p?.kind).toBe('help')