switchroom 0.8.1 → 0.11.0

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 (137) hide show
  1. package/README.md +54 -61
  2. package/bin/timezone-hook.sh +9 -7
  3. package/dist/agent-scheduler/index.js +285 -45
  4. package/dist/auth-broker/index.js +13932 -0
  5. package/dist/cli/drive-write-pretool.mjs +5418 -0
  6. package/dist/cli/switchroom.js +8890 -5560
  7. package/dist/host-control/main.js +582 -43
  8. package/dist/vault/approvals/kernel-server.js +276 -47
  9. package/dist/vault/broker/server.js +333 -69
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +6 -4
  16. package/profiles/_base/start.sh.hbs +3 -3
  17. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  18. package/profiles/default/CLAUDE.md +10 -0
  19. package/profiles/default/CLAUDE.md.hbs +16 -0
  20. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  21. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  22. package/skills/buildkite-api/SKILL.md +31 -8
  23. package/skills/buildkite-cli/SKILL.md +27 -9
  24. package/skills/buildkite-migration/SKILL.md +22 -9
  25. package/skills/buildkite-pipelines/SKILL.md +26 -9
  26. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  27. package/skills/buildkite-test-engine/SKILL.md +25 -8
  28. package/skills/docx/SKILL.md +1 -1
  29. package/skills/file-bug/SKILL.md +34 -6
  30. package/skills/humanizer/SKILL.md +15 -0
  31. package/skills/humanizer-calibrate/SKILL.md +7 -1
  32. package/skills/mcp-builder/SKILL.md +1 -1
  33. package/skills/pdf/SKILL.md +1 -1
  34. package/skills/pptx/SKILL.md +1 -1
  35. package/skills/skill-creator/SKILL.md +21 -1
  36. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  41. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  42. package/skills/switchroom-cli/SKILL.md +63 -64
  43. package/skills/switchroom-health/SKILL.md +23 -10
  44. package/skills/switchroom-install/SKILL.md +3 -3
  45. package/skills/switchroom-manage/SKILL.md +26 -19
  46. package/skills/switchroom-runtime/SKILL.md +67 -15
  47. package/skills/switchroom-status/SKILL.md +26 -1
  48. package/skills/telegram-test-harness/SKILL.md +3 -0
  49. package/skills/webapp-testing/SKILL.md +31 -1
  50. package/skills/xlsx/SKILL.md +1 -1
  51. package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
  52. package/telegram-plugin/admin-commands/index.ts +9 -5
  53. package/telegram-plugin/auth-snapshot-format.ts +612 -0
  54. package/telegram-plugin/auto-fallback-fleet.ts +215 -0
  55. package/telegram-plugin/auto-fallback.ts +28 -301
  56. package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
  57. package/telegram-plugin/fleet-fallback-gate.ts +105 -0
  58. package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
  59. package/telegram-plugin/gateway/approval-callback.ts +31 -3
  60. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  61. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  62. package/telegram-plugin/gateway/auth-command.ts +905 -0
  63. package/telegram-plugin/gateway/auth-line.ts +123 -0
  64. package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
  65. package/telegram-plugin/gateway/boot-card.ts +23 -37
  66. package/telegram-plugin/gateway/boot-probes.ts +9 -12
  67. package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
  68. package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
  69. package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
  70. package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
  71. package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
  72. package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
  73. package/telegram-plugin/gateway/gateway.ts +1156 -938
  74. package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
  75. package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
  76. package/telegram-plugin/gateway/ipc-server.ts +69 -0
  77. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
  78. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  79. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  80. package/telegram-plugin/model-unavailable.ts +28 -12
  81. package/telegram-plugin/permission-title.ts +56 -0
  82. package/telegram-plugin/quota-check.ts +19 -41
  83. package/telegram-plugin/scripts/build.mjs +0 -1
  84. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  85. package/telegram-plugin/silence-poke.ts +153 -1
  86. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  87. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  88. package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
  89. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  90. package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
  91. package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
  92. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
  93. package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
  94. package/telegram-plugin/tests/boot-probes.test.ts +27 -22
  95. package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
  96. package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
  97. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  98. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  99. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
  100. package/telegram-plugin/tests/silence-poke.test.ts +237 -0
  101. package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
  102. package/telegram-plugin/turn-flush-safety.ts +55 -1
  103. package/telegram-plugin/uat/SETUP.md +35 -1
  104. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  105. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  106. package/telegram-plugin/uat/runners/report.ts +150 -0
  107. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  108. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  109. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  110. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  111. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  112. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  113. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  114. package/telegram-plugin/auth-dashboard.ts +0 -1104
  115. package/telegram-plugin/auth-slot-parser.ts +0 -497
  116. package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
  117. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  118. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  119. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  120. package/telegram-plugin/foreman/foreman.ts +0 -1165
  121. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  122. package/telegram-plugin/foreman/setup-state.ts +0 -239
  123. package/telegram-plugin/foreman/state.ts +0 -203
  124. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  125. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  126. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  127. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  128. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  129. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  130. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
  131. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  132. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  133. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  134. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  135. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  136. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  137. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -0,0 +1,905 @@
1
+ /**
2
+ * `/auth` chat-command parser + handler (RFC H §7.3).
3
+ *
4
+ * The slot-pool-era dashboard at `telegram-plugin/auth-dashboard.ts`
5
+ * — and its callback-driven inline-keyboard surface — is gone. The
6
+ * fleet-wide active-account model offers a verb tree that mirrors
7
+ * `switchroom auth` on the CLI (RFC H Decision 11 — "same shape on
8
+ * the CLI and in Telegram"):
9
+ *
10
+ * /auth — alias of `show`
11
+ * /auth show [<agent>] — fleet snapshot, or one agent's
12
+ * effective account + mirror state
13
+ * /auth list — alias of `show` (no agent)
14
+ * /auth use <label> — admin-only fleet swap
15
+ * /auth rotate — admin-only failover to next non-
16
+ * exhausted entry in fallback_order
17
+ * /auth add <label> — admin-only chat-native OAuth flow
18
+ * /auth cancel — admin-only abort of `/auth add`
19
+ * /auth rm <label> [confirm] — admin-only, two-step destructive
20
+ * /auth refresh [<label>] — admin-only diagnostic force-tick
21
+ * /auth agent override <a> <l|clear>
22
+ * — admin-only per-agent pin
23
+ * /auth help — verb listing
24
+ *
25
+ * Parse is pure (no I/O) so callers can route on the verb without
26
+ * needing a broker. The handler is async and talks to the broker
27
+ * client; on broker failure it returns a user-facing error reply
28
+ * rather than throwing.
29
+ */
30
+
31
+ import type { ListStateData, AccountState } from './auth-line.js'
32
+ import {
33
+ buildSnapshotsFromState,
34
+ renderAuthSnapshotFormat2,
35
+ buildSnapshotKeyboard,
36
+ } from '../auth-snapshot-format.js'
37
+
38
+ // ─── Parser ────────────────────────────────────────────────────────────────
39
+
40
+ export type ParsedAuthCommand =
41
+ | { kind: 'show'; agent?: string }
42
+ | { kind: 'list' }
43
+ | { kind: 'use'; label: string }
44
+ | { kind: 'rotate' }
45
+ | { kind: 'add'; label: string }
46
+ | { kind: 'cancel' }
47
+ | { kind: 'rm-prompt'; label: string }
48
+ | { kind: 'rm-confirmed'; label: string }
49
+ | { kind: 'refresh'; label?: string }
50
+ | { kind: 'override-set'; agent: string; label: string }
51
+ | { kind: 'override-clear'; agent: string }
52
+ | { kind: 'help'; reason?: string }
53
+
54
+ /**
55
+ * TTL for the two-step `/auth rm` confirm window. Operators have
56
+ * 60s between the prompt and the `confirm` follow-up — long enough
57
+ * to read the warning and switch focus from chat to broker docs,
58
+ * short enough that a stale tab from yesterday can't auto-delete an
59
+ * account by accident.
60
+ */
61
+ export const AUTH_RM_CONFIRM_TTL_MS = 60_000
62
+
63
+ /**
64
+ * In-flight `/auth rm` confirm flow keyed by Telegram chat id.
65
+ * Sibling to `pendingAuthAddFlows` in `auth-add-flow.ts` — same
66
+ * shape, smaller surface (no subprocess to lifecycle-manage).
67
+ * The gateway's chat-command handler reads/writes this map; the
68
+ * confirm verb refuses if the entry is missing, expired, or for a
69
+ * different label.
70
+ */
71
+ export interface PendingAuthRmFlow {
72
+ label: string
73
+ expiresAt: number
74
+ }
75
+ export const pendingAuthRmFlows = new Map<string, PendingAuthRmFlow>()
76
+
77
+ /**
78
+ * Account-label regex — must match the broker's `LABEL_RE` in
79
+ * `src/auth/account-store.ts`. Duplicated rather than imported to
80
+ * keep `auth-command.ts` pure (no side-effecting imports) so the
81
+ * parser is cheap to unit test.
82
+ */
83
+ const LABEL_RE = /^[A-Za-z0-9._@+-]+$/
84
+ const LABEL_MAX = 64
85
+
86
+ /** Returns null when label is valid; otherwise a user-facing error string. */
87
+ export function validateAuthAddLabel(label: string): string | null {
88
+ if (!label || label.length === 0) return 'Label cannot be empty.'
89
+ if (label.length > LABEL_MAX) {
90
+ return `Label too long (max ${LABEL_MAX} chars).`
91
+ }
92
+ if (label === '.' || label === '..') return `Label "${label}" is reserved.`
93
+ if (label.includes('/') || label.includes('\\')) {
94
+ return 'Label cannot contain path separators.'
95
+ }
96
+ if (!LABEL_RE.test(label)) {
97
+ return 'Label must match <code>[A-Za-z0-9._@+-]+</code> (letters, digits, dot, underscore, dash, @, +).'
98
+ }
99
+ return null
100
+ }
101
+
102
+ /**
103
+ * Parse a `/auth …` chat command. Returns `null` when the text is
104
+ * not an `/auth` command at all (so the gateway falls through to its
105
+ * other handlers).
106
+ *
107
+ * Whitespace tolerant; case-insensitive on the verb. `/auth` alone
108
+ * resolves to `show` (the read-only default).
109
+ */
110
+ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
111
+ const trimmed = text.trim()
112
+ if (trimmed.length === 0) return null
113
+ // Allow `/auth`, `/auth@botname`, `/auth foo` — the leading token
114
+ // must be `/auth` (optionally with a bot-suffix) or the message
115
+ // isn't ours.
116
+ const m = trimmed.match(/^\/auth(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$/)
117
+ if (!m) return null
118
+ const rest = (m[1] ?? '').trim()
119
+ if (rest.length === 0) return { kind: 'show' }
120
+ const parts = rest.split(/\s+/)
121
+ const verb = (parts[0] ?? '').toLowerCase()
122
+ switch (verb) {
123
+ case 'show': {
124
+ const agent = parts[1]
125
+ if (agent) return { kind: 'show', agent }
126
+ return { kind: 'show' }
127
+ }
128
+ case 'list':
129
+ // List is a strict alias of bare `/auth show` (fleet snapshot).
130
+ // Per RFC H Decision 11 — same shape as the CLI verb.
131
+ return { kind: 'list' }
132
+ case 'rotate':
133
+ return { kind: 'rotate' }
134
+ case 'use': {
135
+ const label = parts[1]
136
+ if (!label) return { kind: 'help', reason: 'Usage: /auth use <label>' }
137
+ return { kind: 'use', label }
138
+ }
139
+ case 'add': {
140
+ const label = parts[1]
141
+ if (!label) return { kind: 'help', reason: 'Usage: /auth add <label>' }
142
+ const err = validateAuthAddLabel(label)
143
+ if (err) return { kind: 'help', reason: err }
144
+ return { kind: 'add', label }
145
+ }
146
+ case 'cancel':
147
+ return { kind: 'cancel' }
148
+ case 'rm': {
149
+ const label = parts[1]
150
+ if (!label) return { kind: 'help', reason: 'Usage: /auth rm <label> [confirm]' }
151
+ const labelErr = validateAuthAddLabel(label)
152
+ if (labelErr) return { kind: 'help', reason: labelErr }
153
+ // Two-step: a literal `confirm` token in slot 2 means "phase 2 —
154
+ // actually delete". Anything else is a "phase 1 — show prompt".
155
+ const tail = (parts[2] ?? '').toLowerCase()
156
+ if (tail === 'confirm') return { kind: 'rm-confirmed', label }
157
+ if (tail.length > 0) {
158
+ return {
159
+ kind: 'help',
160
+ reason: `Unknown <code>rm</code> modifier: <code>${escapeHtml(tail)}</code>. Use <code>/auth rm &lt;label&gt; confirm</code> to confirm.`,
161
+ }
162
+ }
163
+ return { kind: 'rm-prompt', label }
164
+ }
165
+ case 'refresh': {
166
+ const label = parts[1]
167
+ if (label) {
168
+ const err = validateAuthAddLabel(label)
169
+ if (err) return { kind: 'help', reason: err }
170
+ return { kind: 'refresh', label }
171
+ }
172
+ return { kind: 'refresh' }
173
+ }
174
+ case 'agent': {
175
+ // Only `/auth agent override <agent> <label|clear>` is wired. Any
176
+ // other shape is a help-with-reason so the operator sees the
177
+ // expected verb tree.
178
+ const sub = (parts[1] ?? '').toLowerCase()
179
+ if (sub !== 'override') {
180
+ return {
181
+ kind: 'help',
182
+ reason: `Unknown <code>agent</code> subcommand: <code>${escapeHtml(sub || '(none)')}</code>. Try <code>/auth agent override &lt;agent&gt; &lt;label|clear&gt;</code>.`,
183
+ }
184
+ }
185
+ const agent = parts[2]
186
+ const target = parts[3]
187
+ if (!agent || !target) {
188
+ return {
189
+ kind: 'help',
190
+ reason: 'Usage: /auth agent override &lt;agent&gt; &lt;label|clear&gt;',
191
+ }
192
+ }
193
+ if (target.toLowerCase() === 'clear') {
194
+ return { kind: 'override-clear', agent }
195
+ }
196
+ const labelErr = validateAuthAddLabel(target)
197
+ if (labelErr) return { kind: 'help', reason: labelErr }
198
+ return { kind: 'override-set', agent, label: target }
199
+ }
200
+ case 'help':
201
+ return { kind: 'help' }
202
+ default:
203
+ return { kind: 'help', reason: `Unknown verb: <code>${escapeHtml(verb)}</code>` }
204
+ }
205
+ }
206
+
207
+ // ─── Handler ───────────────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Broker client surface this handler depends on. Kept narrow so the
211
+ * gateway can inject the real client (`src/auth/broker/client.ts`)
212
+ * and tests can pass a mock without juggling the full NDJSON shape.
213
+ */
214
+ export interface AuthBrokerClient {
215
+ listState(): Promise<ListStateData>
216
+ setActive(label: string): Promise<{ active: string; fanned: string[] }>
217
+ rmAccount(label: string): Promise<{ label: string }>
218
+ refreshAccount(label: string): Promise<{ account: string; expiresAt?: number }>
219
+ setOverride(
220
+ agent: string,
221
+ account: string | null,
222
+ ): Promise<{ agent: string; account: string | null }>
223
+ }
224
+
225
+ export interface AuthCommandContext {
226
+ /** The agent the gateway is bound to (its socket-path identity). */
227
+ agentName: string
228
+ /**
229
+ * True when this agent is an admin (per `agents.<name>.admin: true`
230
+ * in switchroom.yaml — the same flag PR #1258 introduced for fleet
231
+ * ops, unified into the auth-broker gate by PR #1263). Computed by
232
+ * the gateway from its loaded config and passed through; the
233
+ * handler does not consult any list.
234
+ */
235
+ isAdmin: boolean
236
+ client: AuthBrokerClient
237
+ /**
238
+ * Telegram chat id this command was issued in. Used to key the
239
+ * `/auth rm` two-step confirm window (see `pendingAuthRmFlows`).
240
+ * Optional only so legacy gateway-routed verbs (`add`, `cancel`)
241
+ * that never reach the destructive branches can skip wiring it.
242
+ */
243
+ chatId?: string
244
+ /**
245
+ * Optional Format 2 enricher — when supplied, the `show`/`list`
246
+ * paths probe live quota for every account (in parallel) so the
247
+ * snapshot renders the new health-grouped shape with real-time
248
+ * percentages and reset countdowns. When omitted the legacy
249
+ * ASCII table renders, which keeps tests + broker-only callers
250
+ * working without spinning up the Anthropic API path.
251
+ *
252
+ * Returns a parallel array (same length, same order as
253
+ * `state.accounts`) of QuotaResult — the gateway passes
254
+ * `accounts.map(a => fetchAccountQuota(a.label, {force: true}))`.
255
+ */
256
+ liveQuotas?: (
257
+ accounts: AccountState[],
258
+ ) => Promise<import('../quota-check.js').QuotaResult[]>
259
+ /** Operator timezone forwarded to the Format 2 renderer. */
260
+ tz?: string
261
+ }
262
+
263
+ export interface AuthCommandReply {
264
+ text: string
265
+ /** True when the reply contains HTML markup. */
266
+ html: boolean
267
+ /**
268
+ * Optional inline keyboard (rows of buttons). Format 2 attaches a
269
+ * smart-keyboard here for the fleet snapshot — switch buttons for
270
+ * healthy non-active accounts, plus refresh/usage/+add. Caller
271
+ * translates to grammy's `reply_markup` shape. Empty/missing means
272
+ * no keyboard.
273
+ */
274
+ keyboard?: Array<
275
+ Array<{
276
+ text: string
277
+ callbackData?: string
278
+ insertText?: string
279
+ }>
280
+ >
281
+ }
282
+
283
+ /**
284
+ * Dispatch a parsed `/auth` command. Returns the reply the gateway
285
+ * should send. Never throws — broker errors surface as user-visible
286
+ * text.
287
+ */
288
+ export async function handleAuthCommand(
289
+ parsed: ParsedAuthCommand,
290
+ ctx: AuthCommandContext,
291
+ ): Promise<AuthCommandReply> {
292
+ if (parsed.kind === 'help') {
293
+ const reason = parsed.reason ? `${parsed.reason}\n\n` : ''
294
+ return {
295
+ text:
296
+ `${reason}<b>/auth</b> — verbs (mirror of <code>switchroom auth</code>):\n` +
297
+ ` <code>/auth</code> — show fleet snapshot (alias of <code>show</code>)\n` +
298
+ ` <code>/auth show</code> — show fleet snapshot\n` +
299
+ ` <code>/auth show &lt;agent&gt;</code> — show one agent's effective account + mirror state\n` +
300
+ ` <code>/auth list</code> — alias of <code>/auth show</code>\n` +
301
+ ` <code>/auth use &lt;label&gt;</code> — admin: swap the fleet to &lt;label&gt;\n` +
302
+ ` <code>/auth rotate</code> — admin: cycle to next non-exhausted fallback\n` +
303
+ ` <code>/auth add &lt;label&gt;</code> — admin: OAuth-add a new account from chat\n` +
304
+ ` <code>/auth cancel</code> — abort an <code>/auth add</code> in progress\n` +
305
+ ` <code>/auth rm &lt;label&gt;</code> — admin: remove an account (two-step confirm)\n` +
306
+ ` <code>/auth refresh [&lt;label&gt;]</code> — admin: force a refresh tick\n` +
307
+ ` <code>/auth agent override &lt;agent&gt; &lt;label|clear&gt;</code> — admin: per-agent account override\n` +
308
+ ` <code>/auth help</code> — this list`,
309
+ html: true,
310
+ }
311
+ }
312
+
313
+ // `show` (no agent) and `list` both render the fleet snapshot; share
314
+ // one code path so the two verbs can't diverge.
315
+ if (
316
+ parsed.kind === 'list' ||
317
+ (parsed.kind === 'show' && parsed.agent === undefined)
318
+ ) {
319
+ try {
320
+ const state = await ctx.client.listState()
321
+ let liveQuotas: import('../quota-check.js').QuotaResult[] | undefined
322
+ let liveProbedAtMs: number | undefined
323
+ if (ctx.liveQuotas && state.accounts.length > 0) {
324
+ try {
325
+ liveQuotas = await ctx.liveQuotas(state.accounts)
326
+ liveProbedAtMs = Date.now()
327
+ } catch {
328
+ // Live probe failed — fall back to legacy table silently.
329
+ liveQuotas = undefined
330
+ }
331
+ }
332
+ // Build the smart keyboard only when we have live quota data —
333
+ // without it we can't classify health and the buttons could
334
+ // tempt the user into a blocked account. When omitted, the
335
+ // text still renders (legacy table); just no keyboard.
336
+ let keyboard: AuthCommandReply['keyboard']
337
+ if (liveQuotas && liveQuotas.length === state.accounts.length) {
338
+ const snapshots = buildSnapshotsFromState(state, liveQuotas)
339
+ keyboard = buildSnapshotKeyboard(snapshots)
340
+ }
341
+ return {
342
+ text: renderShowText(state, Date.now(), {
343
+ liveQuotas,
344
+ tz: ctx.tz,
345
+ liveProbedAtMs,
346
+ }),
347
+ html: true,
348
+ keyboard,
349
+ }
350
+ } catch (err) {
351
+ return {
352
+ text: `<b>/auth show failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
353
+ html: true,
354
+ }
355
+ }
356
+ }
357
+
358
+ if (parsed.kind === 'show') {
359
+ // parsed.agent is non-undefined by the branch above.
360
+ const agentName = parsed.agent as string
361
+ try {
362
+ const state = await ctx.client.listState()
363
+ const agent = state.agents.find((a) => a.name === agentName)
364
+ if (!agent) {
365
+ return {
366
+ text:
367
+ `<b>/auth show:</b> no agent named <code>${escapeHtml(agentName)}</code> in broker view.\n` +
368
+ `Run <code>/auth show</code> for the fleet snapshot.`,
369
+ html: true,
370
+ }
371
+ }
372
+ return { text: renderAgentDetail(state, agent), html: true }
373
+ } catch (err) {
374
+ return {
375
+ text: `<b>/auth show failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
376
+ html: true,
377
+ }
378
+ }
379
+ }
380
+
381
+ // Admin-gated verbs from here on.
382
+ if (!isAdmin(ctx)) {
383
+ return {
384
+ text:
385
+ `<b>Not authorized.</b> <code>/auth ${parsed.kind}</code> is admin-only.\n` +
386
+ `Set <code>admin: true</code> on this agent in switchroom.yaml to unlock ` +
387
+ `(the same flag that gates <code>/agents</code>, <code>/restart</code>, ` +
388
+ `<code>/update</code> etc.).`,
389
+ html: true,
390
+ }
391
+ }
392
+
393
+ // `add` and `cancel` are dispatched directly by the gateway (they
394
+ // need to drive the `claude setup-token` scratch-dir lifecycle and
395
+ // the per-chat pending-paste state). They should never reach this
396
+ // handler in production — if they do (defensive), return a clear
397
+ // error rather than silently coercing into a different verb.
398
+ if (parsed.kind === 'add' || parsed.kind === 'cancel') {
399
+ return {
400
+ text:
401
+ `<b>/auth ${parsed.kind} not routed.</b> Internal error — gateway should dispatch this verb directly. Report this.`,
402
+ html: true,
403
+ }
404
+ }
405
+
406
+ if (parsed.kind === 'use') {
407
+ try {
408
+ const result = await ctx.client.setActive(parsed.label)
409
+ return {
410
+ text:
411
+ `<b>Active account →</b> <code>${escapeHtml(result.active)}</code>\n` +
412
+ `Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}.`,
413
+ html: true,
414
+ }
415
+ } catch (err) {
416
+ return {
417
+ text: `<b>/auth use failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
418
+ html: true,
419
+ }
420
+ }
421
+ }
422
+
423
+ if (parsed.kind === 'rotate') {
424
+ try {
425
+ const state = await ctx.client.listState()
426
+ const nextLabel = pickRotateTarget(state)
427
+ if (!nextLabel) {
428
+ return {
429
+ text:
430
+ `<b>/auth rotate</b> — no eligible target.\n` +
431
+ `Either every account in <code>fallback_order</code> is exhausted, ` +
432
+ `or no fallback order is configured.`,
433
+ html: true,
434
+ }
435
+ }
436
+ const result = await ctx.client.setActive(nextLabel)
437
+ return {
438
+ text:
439
+ `<b>Rotated:</b> active → <code>${escapeHtml(result.active)}</code>\n` +
440
+ `Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}.`,
441
+ html: true,
442
+ }
443
+ } catch (err) {
444
+ return {
445
+ text: `<b>/auth rotate failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
446
+ html: true,
447
+ }
448
+ }
449
+ }
450
+
451
+ if (parsed.kind === 'rm-prompt') {
452
+ // Phase 1 — gate, validate against current state, stash pending.
453
+ // Refuse early if the label is unknown or is the fleet active, so
454
+ // the destructive prompt itself can't lie about what's possible.
455
+ let state: ListStateData
456
+ try {
457
+ state = await ctx.client.listState()
458
+ } catch (err) {
459
+ return {
460
+ text: `<b>/auth rm failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
461
+ html: true,
462
+ }
463
+ }
464
+ const exists = state.accounts.some((a) => a.label === parsed.label)
465
+ if (!exists) {
466
+ return {
467
+ text:
468
+ `<b>/auth rm:</b> no account named <code>${escapeHtml(parsed.label)}</code>. ` +
469
+ `Run <code>/auth show</code> for the current list.`,
470
+ html: true,
471
+ }
472
+ }
473
+ if (state.active === parsed.label) {
474
+ return {
475
+ text:
476
+ `<b>/auth rm refused.</b> <code>${escapeHtml(parsed.label)}</code> is the fleet active. ` +
477
+ `Switch with <code>/auth use &lt;other&gt;</code> or <code>/auth rotate</code> first.`,
478
+ html: true,
479
+ }
480
+ }
481
+ // Stash. The gateway is responsible for keying this map by chat
482
+ // id; the handler can't see ctx.chat. We expose the helper as a
483
+ // mutation through the side-channel below so the gateway can wire
484
+ // it after admin-gating. To keep the handler self-contained for
485
+ // tests, fall back to populating the map directly when a chatId
486
+ // is supplied via ctx (set by the gateway wrapper).
487
+ if (ctx.chatId) {
488
+ pendingAuthRmFlows.set(ctx.chatId, {
489
+ label: parsed.label,
490
+ expiresAt: Date.now() + AUTH_RM_CONFIRM_TTL_MS,
491
+ })
492
+ }
493
+ return {
494
+ text:
495
+ `<b>⚠ /auth rm</b> — about to remove <code>${escapeHtml(parsed.label)}</code> from the broker.\n` +
496
+ `The fleet active is unchanged. Any agent override pointing at <code>${escapeHtml(parsed.label)}</code> will stop working.\n\n` +
497
+ `Send <code>/auth rm ${escapeHtml(parsed.label)} confirm</code> within ${Math.round(
498
+ AUTH_RM_CONFIRM_TTL_MS / 1000,
499
+ )}s to proceed.`,
500
+ html: true,
501
+ }
502
+ }
503
+
504
+ if (parsed.kind === 'rm-confirmed') {
505
+ const pending = ctx.chatId ? pendingAuthRmFlows.get(ctx.chatId) : undefined
506
+ const now = Date.now()
507
+ if (!pending || pending.label !== parsed.label || pending.expiresAt <= now) {
508
+ if (ctx.chatId && pending && pending.expiresAt <= now) {
509
+ pendingAuthRmFlows.delete(ctx.chatId)
510
+ }
511
+ return {
512
+ text:
513
+ `<b>/auth rm:</b> no pending confirm for <code>${escapeHtml(parsed.label)}</code> (expired or not started). ` +
514
+ `Send <code>/auth rm ${escapeHtml(parsed.label)}</code> first.`,
515
+ html: true,
516
+ }
517
+ }
518
+ // Clear before the broker call — re-entrance / double-tap should
519
+ // not delete twice.
520
+ if (ctx.chatId) pendingAuthRmFlows.delete(ctx.chatId)
521
+ try {
522
+ const data = await ctx.client.rmAccount(parsed.label)
523
+ return {
524
+ text: `<b>Removed</b> <code>${escapeHtml(data.label)}</code> from the broker.`,
525
+ html: true,
526
+ }
527
+ } catch (err) {
528
+ return {
529
+ text: `<b>/auth rm failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
530
+ html: true,
531
+ }
532
+ }
533
+ }
534
+
535
+ if (parsed.kind === 'refresh') {
536
+ try {
537
+ const state = await ctx.client.listState()
538
+ const targets = parsed.label
539
+ ? state.accounts.filter((a) => a.label === parsed.label).map((a) => a.label)
540
+ : state.accounts.map((a) => a.label)
541
+ if (parsed.label && targets.length === 0) {
542
+ return {
543
+ text:
544
+ `<b>/auth refresh:</b> no account named <code>${escapeHtml(parsed.label)}</code>.`,
545
+ html: true,
546
+ }
547
+ }
548
+ if (targets.length === 0) {
549
+ return { text: `<b>/auth refresh:</b> no accounts to refresh.`, html: true }
550
+ }
551
+ const oldByLabel = new Map(state.accounts.map((a) => [a.label, a.expiresAt]))
552
+ const rows: string[][] = [['ACCOUNT', 'OLD EXPIRY', 'NEW EXPIRY']]
553
+ const failures: string[] = []
554
+ for (const label of targets) {
555
+ try {
556
+ const data = await ctx.client.refreshAccount(label)
557
+ rows.push([
558
+ label,
559
+ formatExpiryAbs(oldByLabel.get(label)),
560
+ formatExpiryAbs(data.expiresAt),
561
+ ])
562
+ } catch (err) {
563
+ failures.push(
564
+ `${label}: ${escapeHtml((err as Error)?.message ?? String(err))}`,
565
+ )
566
+ }
567
+ }
568
+ const head =
569
+ targets.length === 1
570
+ ? `<b>Refreshed</b> <code>${escapeHtml(targets[0]!)}</code>`
571
+ : `<b>Refreshed</b> ${rows.length - 1}/${targets.length} account${targets.length === 1 ? '' : 's'}`
572
+ const table = rows.length > 1
573
+ ? `\n<pre>${alignTable(rows)}</pre>`
574
+ : ''
575
+ const failBlock = failures.length > 0
576
+ ? `\n<b>Failures:</b>\n${failures.map((f) => ` ${f}`).join('\n')}`
577
+ : ''
578
+ return { text: head + table + failBlock, html: true }
579
+ } catch (err) {
580
+ return {
581
+ text: `<b>/auth refresh failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
582
+ html: true,
583
+ }
584
+ }
585
+ }
586
+
587
+ if (parsed.kind === 'override-set') {
588
+ try {
589
+ const data = await ctx.client.setOverride(parsed.agent, parsed.label)
590
+ return {
591
+ text:
592
+ `<b>Override set.</b> <code>${escapeHtml(data.agent)}</code> is now pinned to ` +
593
+ `<code>${escapeHtml(data.account ?? parsed.label)}</code>.`,
594
+ html: true,
595
+ }
596
+ } catch (err) {
597
+ return {
598
+ text: `<b>/auth agent override failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
599
+ html: true,
600
+ }
601
+ }
602
+ }
603
+
604
+ if (parsed.kind === 'override-clear') {
605
+ try {
606
+ const data = await ctx.client.setOverride(parsed.agent, null)
607
+ return {
608
+ text:
609
+ `<b>Override cleared</b> on <code>${escapeHtml(data.agent)}</code> ` +
610
+ `— back to fleet active.`,
611
+ html: true,
612
+ }
613
+ } catch (err) {
614
+ return {
615
+ text: `<b>/auth agent override failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
616
+ html: true,
617
+ }
618
+ }
619
+ }
620
+
621
+ // Exhaustiveness — any future ParsedAuthCommand variant lands here.
622
+ const _exhaustive: never = parsed
623
+ void _exhaustive
624
+ return {
625
+ text: `<b>/auth:</b> unhandled verb. Report this.`,
626
+ html: true,
627
+ }
628
+ }
629
+
630
+ // ─── Helpers ───────────────────────────────────────────────────────────────
631
+
632
+ /**
633
+ * Admin gate. Exposed so the gateway-routed verbs (`/auth add`,
634
+ * `/auth cancel`) reuse the same ACL check as the handler-routed
635
+ * verbs (`/auth use`, `/auth rotate`).
636
+ *
637
+ * Post-RFC-H + PR #1263 unification, admin is a per-agent boolean
638
+ * (`agents.<name>.admin === true`) computed by the gateway from
639
+ * its loaded config. The gate is the boolean lookup itself.
640
+ */
641
+ export function isAuthAdmin(args: { isAdmin: boolean }): boolean {
642
+ return args.isAdmin === true
643
+ }
644
+
645
+ function isAdmin(ctx: AuthCommandContext): boolean {
646
+ return ctx.isAdmin === true
647
+ }
648
+
649
+ /**
650
+ * Choose the next account `auth rotate` should set active. Walks
651
+ * `fallback_order` starting *after* the currently-active label,
652
+ * wrapping; returns the first label whose account is not exhausted.
653
+ * Returns null when nothing is eligible.
654
+ */
655
+ export function pickRotateTarget(state: ListStateData, now: number = Date.now()): string | null {
656
+ const order = state.fallback_order
657
+ if (order.length === 0) return null
658
+ const byLabel = new Map<string, AccountState>(state.accounts.map((a) => [a.label, a]))
659
+ const start = Math.max(0, order.indexOf(state.active))
660
+ for (let step = 1; step <= order.length; step++) {
661
+ const candidate = order[(start + step) % order.length]
662
+ if (!candidate || candidate === state.active) continue
663
+ const acc = byLabel.get(candidate)
664
+ if (!acc) continue
665
+ if (acc.exhausted && (acc.exhausted_until == null || acc.exhausted_until > now)) continue
666
+ return candidate
667
+ }
668
+ return null
669
+ }
670
+
671
+ /**
672
+ * Render the two-table `auth show` format from RFC §4.6, adapted for
673
+ * Telegram (HTML, monospace blocks). Three sections, each suppressed
674
+ * when empty.
675
+ */
676
+ export interface RenderShowOpts {
677
+ /** Optional live quota probes, parallel to state.accounts. When
678
+ * present, the Accounts section uses Format 2 (health-grouped,
679
+ * causal-runway). When absent (legacy callers, broker-only render),
680
+ * falls back to the original ASCII table. */
681
+ liveQuotas?: import('../quota-check.js').QuotaResult[]
682
+ /** Operator timezone for absolute reset times in Format 2. */
683
+ tz?: string
684
+ /** Wall-clock ms when the live probes returned, used for "refreshed
685
+ * Ns ago" footer. Omit to suppress that footer line. */
686
+ liveProbedAtMs?: number
687
+ }
688
+
689
+ /**
690
+ * Render the fleet snapshot. Two shapes coexist transparently:
691
+ *
692
+ * 1. Format 2 (preferred) — when `opts.liveQuotas` is supplied:
693
+ * health-grouped per-account view (🟢 HEALTHY / 🟡 THROTTLING /
694
+ * 🔴 BLOCKED), live percent + reset times, recommendation
695
+ * footer. See `auth-snapshot-format.ts → renderAuthSnapshotFormat2`.
696
+ *
697
+ * 2. Legacy ASCII table — when no live data is available
698
+ * (broker-only path, tests, or the live probe failed). Same
699
+ * visual shape RFC §4.6 originally specified; preserved so the
700
+ * broker can still answer `/auth show` with no Anthropic-API
701
+ * round-trip.
702
+ *
703
+ * The Agents and Consumers tables render identically under both
704
+ * shapes — those tables don't depend on quota state.
705
+ */
706
+ export function renderShowText(
707
+ state: ListStateData,
708
+ now: number = Date.now(),
709
+ opts: RenderShowOpts = {},
710
+ ): string {
711
+ const lines: string[] = []
712
+
713
+ if (state.accounts.length > 0 && opts.liveQuotas && opts.liveQuotas.length === state.accounts.length) {
714
+ // Format 2 path. Build snapshots, render the new shape inline at
715
+ // the top of the message — replaces the legacy "Auth — fleet
716
+ // snapshot" header + Accounts table.
717
+ const snapshots = buildSnapshotsFromState(state, opts.liveQuotas)
718
+ lines.push(
719
+ renderAuthSnapshotFormat2(snapshots, {
720
+ tz: opts.tz,
721
+ now: new Date(now),
722
+ liveProbedAtMs: opts.liveProbedAtMs,
723
+ }),
724
+ )
725
+ } else {
726
+ lines.push('<b>Auth — fleet snapshot</b>')
727
+ if (state.accounts.length > 0) {
728
+ lines.push('')
729
+ lines.push('<b>Accounts</b>')
730
+ lines.push('<pre>')
731
+ lines.push(formatAccountsTable(state, now))
732
+ lines.push('</pre>')
733
+ }
734
+ }
735
+
736
+ // Agents table
737
+ if (state.agents.length > 0) {
738
+ lines.push('<b>Agents</b>')
739
+ lines.push('<pre>')
740
+ lines.push(formatAgentsTable(state))
741
+ lines.push('</pre>')
742
+ }
743
+
744
+ // Consumers table — only when there are any (typical case: hindsight).
745
+ if (state.consumers.length > 0) {
746
+ lines.push('<b>Consumers</b>')
747
+ lines.push('<pre>')
748
+ lines.push(formatConsumersTable(state, now))
749
+ lines.push('</pre>')
750
+ }
751
+
752
+ // Discovery hint — operators on a quota-walled fleet need to know
753
+ // `/auth add` exists so they can add a fresh account without an
754
+ // LLM in the loop. Keep it short; the help text has the full menu.
755
+ lines.push(
756
+ '<i>Add a new Anthropic account: <code>/auth add &lt;label&gt;</code> (admin)</i>',
757
+ )
758
+
759
+ return lines.join('\n')
760
+ }
761
+
762
+ function formatAccountsTable(state: ListStateData, now: number): string {
763
+ const rows: string[][] = [['ACCOUNT', 'STATUS', 'EXPIRES', 'QUOTA-RESET']]
764
+ for (const acc of state.accounts) {
765
+ const isActive = acc.label === state.active
766
+ const marker = isActive
767
+ ? '●' // ●
768
+ : acc.exhausted
769
+ ? '!'
770
+ : '✓' // ✓
771
+ const status = isActive ? 'active' : acc.exhausted ? 'exhausted' : 'available'
772
+ const expires = acc.expiresAt != null ? formatRelativeMs(acc.expiresAt - now) : '—'
773
+ const quotaReset =
774
+ acc.exhausted && acc.exhausted_until != null && acc.exhausted_until > now
775
+ ? formatRelativeMs(acc.exhausted_until - now)
776
+ : '—'
777
+ rows.push([`${marker} ${escapeHtml(acc.label)}`, status, expires, quotaReset])
778
+ }
779
+ return alignTable(rows)
780
+ }
781
+
782
+ function formatAgentsTable(state: ListStateData): string {
783
+ const rows: string[][] = [['AGENT', 'ACTIVE', 'SOURCE']]
784
+ for (const a of state.agents) {
785
+ const source = a.override
786
+ ? 'override'
787
+ : a.account === state.active
788
+ ? 'fleet-active'
789
+ : 'pinned'
790
+ rows.push([escapeHtml(a.name), escapeHtml(a.account), source])
791
+ }
792
+ return alignTable(rows)
793
+ }
794
+
795
+ /**
796
+ * Per-agent detail block — what `switchroom auth show <agent>` prints
797
+ * on the CLI, adapted to Telegram HTML. Shows effective account,
798
+ * override-vs-fleet-active source, token expiry, last refresh, and
799
+ * exhausted / threshold-violation warnings when relevant.
800
+ */
801
+ export function renderAgentDetail(
802
+ state: ListStateData,
803
+ agent: { name: string; account: string; override: string | null },
804
+ now: number = Date.now(),
805
+ ): string {
806
+ const lines: string[] = []
807
+ lines.push(`<b>${escapeHtml(agent.name)}</b>`)
808
+ const source = agent.override ? 'override' : 'fleet-active'
809
+ lines.push(
810
+ `Active account: <code>${escapeHtml(agent.account)}</code> (${source})`,
811
+ )
812
+ const acct = state.accounts.find((a) => a.label === agent.account)
813
+ if (acct) {
814
+ const expRel = acct.expiresAt != null ? formatRelativeMs(acct.expiresAt - now) : '—'
815
+ lines.push(`Token expires: ${expRel}`)
816
+ if (typeof acct.last_refreshed_at === 'number') {
817
+ lines.push(
818
+ `Last refresh: ${formatRelativeMs(now - acct.last_refreshed_at)} ago`,
819
+ )
820
+ }
821
+ if (acct.exhausted) {
822
+ const resetRel =
823
+ acct.exhausted_until != null && acct.exhausted_until > now
824
+ ? formatRelativeMs(acct.exhausted_until - now)
825
+ : '—'
826
+ lines.push(`<i>Quota: exhausted · resets in ${resetRel}</i>`)
827
+ }
828
+ if (typeof acct.threshold_violations === 'number' && acct.threshold_violations > 0) {
829
+ lines.push(
830
+ `<i>Threshold violations: ${acct.threshold_violations} — claude refreshed under the broker's feet</i>`,
831
+ )
832
+ }
833
+ }
834
+ return lines.join('\n')
835
+ }
836
+
837
+ function formatConsumersTable(state: ListStateData, now: number): string {
838
+ const rows: string[][] = [['CONSUMER', 'ACTIVE', 'STATUS']]
839
+ for (const c of state.consumers) {
840
+ const status =
841
+ c.last_seen_at == null
842
+ ? 'socket bound'
843
+ : `socket bound (last seen ${formatRelativeMs(now - c.last_seen_at)} ago)`
844
+ rows.push([escapeHtml(c.name), escapeHtml(c.account), status])
845
+ }
846
+ return alignTable(rows)
847
+ }
848
+
849
+ // ─── Plain-text helpers ────────────────────────────────────────────────────
850
+
851
+ function escapeHtml(s: string): string {
852
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
853
+ }
854
+
855
+ function formatRelativeMs(ms: number): string {
856
+ if (ms <= 0) return '0s'
857
+ const totalSec = Math.floor(ms / 1000)
858
+ const days = Math.floor(totalSec / 86400)
859
+ const hours = Math.floor((totalSec % 86400) / 3600)
860
+ const mins = Math.floor((totalSec % 3600) / 60)
861
+ const secs = totalSec % 60
862
+ if (days > 0) return `${days}d ${hours}h`
863
+ if (hours > 0) return `${hours}h ${mins}m`
864
+ if (mins > 0) return `${mins}m ${secs}s`
865
+ return `${secs}s`
866
+ }
867
+
868
+ /**
869
+ * Format an absolute expiresAt epoch-ms as a short relative-to-now
870
+ * string. Returns `'—'` when the value is missing or non-finite.
871
+ * Used in the /auth refresh old-vs-new table.
872
+ */
873
+ function formatExpiryAbs(expiresAt?: number, now: number = Date.now()): string {
874
+ if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return '—'
875
+ const delta = expiresAt - now
876
+ if (delta <= 0) return 'expired'
877
+ return formatRelativeMs(delta)
878
+ }
879
+
880
+ /**
881
+ * Right-pad columns so they line up under a fixed-width Telegram
882
+ * `<pre>` block. Last column is left untrimmed so it can run to its
883
+ * natural width.
884
+ */
885
+ function alignTable(rows: string[][]): string {
886
+ if (rows.length === 0) return ''
887
+ const widths: number[] = []
888
+ for (const row of rows) {
889
+ for (let i = 0; i < row.length; i++) {
890
+ const cell = row[i] ?? ''
891
+ widths[i] = Math.max(widths[i] ?? 0, cell.length)
892
+ }
893
+ }
894
+ const out: string[] = []
895
+ for (const row of rows) {
896
+ const parts: string[] = []
897
+ for (let i = 0; i < row.length; i++) {
898
+ const cell = row[i] ?? ''
899
+ if (i === row.length - 1) parts.push(cell)
900
+ else parts.push(cell.padEnd(widths[i] ?? cell.length, ' '))
901
+ }
902
+ out.push(parts.join(' '))
903
+ }
904
+ return out.join('\n')
905
+ }