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,612 @@
1
+ /**
2
+ * Format 2 — health-grouped /auth snapshot + causal auto-fallback
3
+ * announcement. Pure functions; the gateway handles the live-API probe
4
+ * (via `fetchAccountQuota({force: true})`) and the broker `listState`,
5
+ * then hands shaped data to these formatters.
6
+ *
7
+ * JTBD this module serves:
8
+ * "Which accounts are maxed, what % I've used of limits, and when
9
+ * does it come back?"
10
+ *
11
+ * The previous "quota exhausted" wording conflated the 5-hour and
12
+ * 7-day windows — but those have completely different recovery times
13
+ * (hours vs days), and that's the most-asked question after a switch.
14
+ * Every text surface here names the limit type explicitly.
15
+ *
16
+ * No HTML escaping at the boundary — callers pass already-trusted
17
+ * label strings (broker-vetted account labels). If that ever changes
18
+ * the per-line `escapeHtml` helper below is the place to gate.
19
+ */
20
+
21
+ import type { QuotaResult, QuotaUtilization } from './quota-check.js';
22
+ import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
23
+
24
+ // ── shared types ─────────────────────────────────────────────────────
25
+
26
+ /** Tri-state health verdict per account, derived from live quota. */
27
+ export type AccountHealth = 'healthy' | 'throttling' | 'blocked' | 'unknown';
28
+
29
+ /**
30
+ * Combined per-account view used by every formatter in this module.
31
+ * Bundles the broker's persisted state with the most recent live
32
+ * quota probe (or `null` on probe failure / no creds).
33
+ */
34
+ export interface AccountSnapshot {
35
+ label: string;
36
+ /** True when this is the fleet's `auth.active`. */
37
+ isActive: boolean;
38
+ /** Live quota probe result; null when the probe failed (e.g. revoked
39
+ * creds, network error). Renderers degrade gracefully. */
40
+ quota: QuotaUtilization | null;
41
+ /** Reason the quota probe failed, when `quota` is null. */
42
+ quotaError?: string;
43
+ /** Mirrors the broker's `expiresAt` so the table can show token-life
44
+ * for accounts whose creds are about to expire. */
45
+ expiresAtMs?: number;
46
+ }
47
+
48
+ // ── health classification ────────────────────────────────────────────
49
+
50
+ /**
51
+ * Threshold above which an account is "throttling" (close enough to a
52
+ * limit that we want the user to know). 80% on either window flips
53
+ * the badge — gives a 20%-buffer warning before the wall.
54
+ */
55
+ export const THROTTLING_THRESHOLD_PCT = 80;
56
+
57
+ /**
58
+ * Decide the health verdict for one account. The two "binding" facts:
59
+ * - 5h or 7d utilization >= 100% (or `representativeClaim` non-null
60
+ * plus utilization >= 99.5%) → blocked
61
+ * - either window above 80%, or representativeClaim set with > 50% →
62
+ * throttling
63
+ * - everything else → healthy
64
+ * - probe failure → unknown
65
+ */
66
+ export function classifyHealth(snap: AccountSnapshot): AccountHealth {
67
+ if (!snap.quota) return 'unknown';
68
+ const q = snap.quota;
69
+ const max = Math.max(q.fiveHourUtilizationPct, q.sevenDayUtilizationPct);
70
+ if (max >= 99.5) return 'blocked';
71
+ if (max >= THROTTLING_THRESHOLD_PCT) return 'throttling';
72
+ return 'healthy';
73
+ }
74
+
75
+ /**
76
+ * Which window is the user-visible "binding" one — the one that ran
77
+ * out, or is closer to running out. Returned as a label for headers
78
+ * ("hit 5-hour limit", "hit 7-day limit"). Falls back to whichever
79
+ * window is currently higher.
80
+ */
81
+ export type BindingWindow = '5h' | '7d';
82
+
83
+ export function bindingWindow(q: QuotaUtilization): BindingWindow {
84
+ if (q.representativeClaim === 'seven_day') return '7d';
85
+ if (q.representativeClaim === 'five_hour') return '5h';
86
+ return q.sevenDayUtilizationPct >= q.fiveHourUtilizationPct ? '7d' : '5h';
87
+ }
88
+
89
+ // ── time/format helpers ──────────────────────────────────────────────
90
+
91
+ /**
92
+ * Render a future Date as a friendly relative countdown ("4h 56m",
93
+ * "in 2d 9h", "in 6m"). Returns "—" for null/past targets so callers
94
+ * can use it inline without null guards.
95
+ */
96
+ export function formatRelative(target: Date | null, now: Date = new Date()): string {
97
+ if (!target) return '—';
98
+ const deltaMs = target.getTime() - now.getTime();
99
+ if (deltaMs <= 0) return 'now';
100
+ const totalMin = Math.round(deltaMs / 60_000);
101
+ if (totalMin < 60) return `${totalMin}m`;
102
+ const h = Math.floor(totalMin / 60);
103
+ const m = totalMin % 60;
104
+ if (h < 24) return m > 0 ? `${h}h ${m}m` : `${h}h`;
105
+ const d = Math.floor(h / 24);
106
+ const rh = h % 24;
107
+ return rh > 0 ? `${d}d ${rh}h` : `${d}d`;
108
+ }
109
+
110
+ /**
111
+ * Render a Date as a friendly absolute time in the operator's
112
+ * timezone ("Fri 3:50 PM", "Sun 8:00 PM", "Tue 5:00 AM"). The
113
+ * weekday is included because resets often span a day boundary and
114
+ * "5:00 AM" alone is ambiguous.
115
+ *
116
+ * `tz` is forwarded to `toLocaleString`. Defaults to UTC; callers
117
+ * should pass `process.env.TZ` or the agent's configured timezone.
118
+ */
119
+ export function formatAbsolute(
120
+ target: Date | null,
121
+ tz: string = 'UTC',
122
+ ): string {
123
+ if (!target) return '—';
124
+ return target.toLocaleString('en-US', {
125
+ timeZone: tz,
126
+ weekday: 'short',
127
+ hour: 'numeric',
128
+ minute: '2-digit',
129
+ hour12: true,
130
+ });
131
+ }
132
+
133
+ /** Round-trim percentage to 1 dp (more precision is noise on a UX). */
134
+ export function fmtPct(pct: number): string {
135
+ return `${Math.round(pct)}%`;
136
+ }
137
+
138
+ // ── /auth snapshot — Format 2 ────────────────────────────────────────
139
+
140
+ export interface SnapshotRenderOpts {
141
+ /** Operator-local timezone for absolute reset times. Forwarded to
142
+ * formatAbsolute. */
143
+ tz?: string;
144
+ now?: Date;
145
+ /** Refresh stamp shown in the footer; usually `Date.now()` of the
146
+ * most recent live probe. Omit to suppress. */
147
+ liveProbedAtMs?: number;
148
+ }
149
+
150
+ /**
151
+ * Header line shape: emoji + group title + count.
152
+ *
153
+ * 🟢 HEALTHY (4)
154
+ * 🟡 ACTIVE — REFRESHING SOON (1)
155
+ * 🔴 BLOCKED (1)
156
+ * ⚪ UNKNOWN (1)
157
+ */
158
+ function groupHeader(health: AccountHealth, count: number): string {
159
+ const emoji = HEALTH_EMOJI[health];
160
+ const title = HEALTH_TITLE[health];
161
+ return `${emoji} <b>${title}</b> (${count})`;
162
+ }
163
+
164
+ const HEALTH_EMOJI: Record<AccountHealth, string> = {
165
+ healthy: '🟢',
166
+ throttling: '🟡',
167
+ blocked: '🔴',
168
+ unknown: '⚪',
169
+ };
170
+
171
+ const HEALTH_TITLE: Record<AccountHealth, string> = {
172
+ healthy: 'HEALTHY',
173
+ throttling: 'THROTTLING',
174
+ blocked: 'BLOCKED',
175
+ unknown: 'UNKNOWN',
176
+ };
177
+
178
+ /**
179
+ * One-line per-account summary inside its health group.
180
+ *
181
+ * pixsoul@gmail.com ● 8% / 20%
182
+ * 5h refills 11:00 AM (in 6m) · 7d resets Sun 11:00 AM
183
+ *
184
+ * Two lines actually: the label/percent line and a sub-line with the
185
+ * reset details. The blocked variant replaces the sub-line with the
186
+ * recovery countdown.
187
+ */
188
+ function renderAccountRow(
189
+ snap: AccountSnapshot,
190
+ opts: SnapshotRenderOpts,
191
+ ): string[] {
192
+ const now = opts.now ?? new Date();
193
+ const tz = opts.tz ?? 'UTC';
194
+ const lines: string[] = [];
195
+ const marker = snap.isActive ? '● ' : '';
196
+
197
+ if (!snap.quota) {
198
+ lines.push(
199
+ `${marker}<code>${escapeHtml(snap.label)}</code> <i>quota probe failed</i>`,
200
+ );
201
+ if (snap.quotaError) {
202
+ lines.push(` <i>${escapeHtml(snap.quotaError)}</i>`);
203
+ }
204
+ return lines;
205
+ }
206
+
207
+ const q = snap.quota;
208
+ const fiveStr = fmtPct(q.fiveHourUtilizationPct);
209
+ const sevenStr = fmtPct(q.sevenDayUtilizationPct);
210
+ lines.push(
211
+ `${marker}<code>${escapeHtml(snap.label)}</code> ${fiveStr} / ${sevenStr}`,
212
+ );
213
+
214
+ const health = classifyHealth(snap);
215
+ if (health === 'blocked') {
216
+ // Surface only the recovery countdown — the binding window's reset
217
+ // is the only thing that matters until then.
218
+ const win = bindingWindow(q);
219
+ const reset = win === '5h' ? q.fiveHourResetAt : q.sevenDayResetAt;
220
+ const winLabel = win === '5h' ? '5-hour' : '7-day';
221
+ lines.push(
222
+ ` <i>back ${formatAbsolute(reset, tz)} (in ${formatRelative(reset, now)}, ${winLabel} cap)</i>`,
223
+ );
224
+ return lines;
225
+ }
226
+
227
+ // Healthy / throttling: show whichever window is closer to refresh
228
+ // first, then the other on the same line. Reverses the screenshot's
229
+ // "5h then 7d" ordering when 7d is the more pressing one — the user
230
+ // wants the imminent number first.
231
+ const fiveResetIn = q.fiveHourResetAt ? q.fiveHourResetAt.getTime() - now.getTime() : Infinity;
232
+ const sevenResetIn = q.sevenDayResetAt ? q.sevenDayResetAt.getTime() - now.getTime() : Infinity;
233
+ const fiveFirst = fiveResetIn <= sevenResetIn;
234
+ const fiveSeg = q.fiveHourResetAt
235
+ ? `5h refills ${formatAbsolute(q.fiveHourResetAt, tz)} (in ${formatRelative(q.fiveHourResetAt, now)})`
236
+ : '5h refills —';
237
+ const sevenSeg = q.sevenDayResetAt
238
+ ? `7d resets ${formatAbsolute(q.sevenDayResetAt, tz)} (in ${formatRelative(q.sevenDayResetAt, now)})`
239
+ : '7d resets —';
240
+ lines.push(` <i>${fiveFirst ? fiveSeg : sevenSeg} · ${fiveFirst ? sevenSeg : fiveSeg}</i>`);
241
+ return lines;
242
+ }
243
+
244
+ /**
245
+ * Build the full Format 2 snapshot. Returns ready-to-send Telegram
246
+ * HTML.
247
+ *
248
+ * Structure:
249
+ * 🔋 Auth — fleet status
250
+ * <empty>
251
+ * <group> ...accounts grouped by health, blocked-first order...
252
+ * <empty>
253
+ * ───────────────────────────
254
+ * Recommendation: <one-line verdict>
255
+ * <i>Live · refreshed Ns ago</i>
256
+ *
257
+ * Caller appends an inline keyboard via the returned hint shape (see
258
+ * `buildSnapshotKeyboard` below) — keep the formatting and the
259
+ * keyboard in lockstep so the buttons always reflect current state.
260
+ */
261
+ export function renderAuthSnapshotFormat2(
262
+ snapshots: AccountSnapshot[],
263
+ opts: SnapshotRenderOpts = {},
264
+ ): string {
265
+ const now = opts.now ?? new Date();
266
+ const lines: string[] = [];
267
+ lines.push('🔋 <b>Auth — fleet status</b>');
268
+
269
+ // Group by health. Render BLOCKED first (it's the urgent action),
270
+ // then THROTTLING (potential next problem), then HEALTHY (good
271
+ // news), then UNKNOWN (data quality issue). The active account
272
+ // floats to the top of its group regardless.
273
+ const order: AccountHealth[] = ['blocked', 'throttling', 'healthy', 'unknown'];
274
+ const grouped = new Map<AccountHealth, AccountSnapshot[]>();
275
+ for (const s of snapshots) {
276
+ const h = classifyHealth(s);
277
+ if (!grouped.has(h)) grouped.set(h, []);
278
+ grouped.get(h)!.push(s);
279
+ }
280
+ // Within each group, active first.
281
+ for (const arr of grouped.values()) {
282
+ arr.sort((a, b) => Number(b.isActive) - Number(a.isActive));
283
+ }
284
+
285
+ for (const h of order) {
286
+ const arr = grouped.get(h);
287
+ if (!arr || arr.length === 0) continue;
288
+ lines.push('');
289
+ lines.push(groupHeader(h, arr.length));
290
+ for (const s of arr) {
291
+ for (const ln of renderAccountRow(s, opts)) lines.push(ln);
292
+ }
293
+ }
294
+
295
+ lines.push('');
296
+ lines.push('────────────────────────────');
297
+ lines.push(`<i>${recommendation(snapshots, now)}</i>`);
298
+ if (opts.liveProbedAtMs != null) {
299
+ const ageSec = Math.max(0, Math.round((Date.now() - opts.liveProbedAtMs) / 1000));
300
+ const ageStr = ageSec < 60 ? `${ageSec}s ago` : `${Math.round(ageSec / 60)}m ago`;
301
+ lines.push(`<i>Live · refreshed ${ageStr}</i>`);
302
+ } else {
303
+ lines.push('<i>Live</i>');
304
+ }
305
+ return lines.join('\n');
306
+ }
307
+
308
+ /**
309
+ * One-sentence verdict for the snapshot footer. Format C's
310
+ * "recommendation engine" in a minimal form — answers "what should I
311
+ * do?" without hiding the table above.
312
+ *
313
+ * Shapes:
314
+ * "Stay on <active> — healthy."
315
+ * "Active <active> is throttling. Best alternative: <healthy>."
316
+ * "Active <active> is BLOCKED. Switch to <healthy> now."
317
+ * "All accounts blocked. Earliest recovery: <label> in <eta>."
318
+ */
319
+ export function recommendation(snapshots: AccountSnapshot[], now: Date = new Date()): string {
320
+ const active = snapshots.find((s) => s.isActive);
321
+ if (!active) return 'No active account set.';
322
+ const activeHealth = classifyHealth(active);
323
+ const others = snapshots.filter((s) => !s.isActive);
324
+ const healthyAlt = others.find((s) => classifyHealth(s) === 'healthy');
325
+
326
+ if (activeHealth === 'healthy') {
327
+ return `Recommendation: stay on ${active.label}.`;
328
+ }
329
+
330
+ if (activeHealth === 'throttling') {
331
+ if (healthyAlt) {
332
+ return `Recommendation: active ${active.label} is throttling. Switch to ${healthyAlt.label} for headroom.`;
333
+ }
334
+ return `Recommendation: active ${active.label} is throttling; no healthy alternative — wait for refill.`;
335
+ }
336
+
337
+ if (activeHealth === 'blocked') {
338
+ if (healthyAlt) {
339
+ return `Recommendation: active ${active.label} is BLOCKED — switch to ${healthyAlt.label} now.`;
340
+ }
341
+ // No healthy alternative; surface the earliest recovery time.
342
+ const earliestRecovery = pickEarliestRecovery(snapshots, now);
343
+ if (earliestRecovery) {
344
+ return `All accounts blocked. Earliest recovery: ${earliestRecovery.label} in ${formatRelative(earliestRecovery.at, now)}.`;
345
+ }
346
+ return `All accounts blocked. Run /auth add to attach another subscription.`;
347
+ }
348
+
349
+ // unknown
350
+ return `Active ${active.label}: quota probe failed; broker last_seen unknown.`;
351
+ }
352
+
353
+ function pickEarliestRecovery(
354
+ snapshots: AccountSnapshot[],
355
+ now: Date,
356
+ ): { label: string; at: Date } | null {
357
+ let best: { label: string; at: Date } | null = null;
358
+ for (const s of snapshots) {
359
+ if (!s.quota) continue;
360
+ const win = bindingWindow(s.quota);
361
+ const at = win === '5h' ? s.quota.fiveHourResetAt : s.quota.sevenDayResetAt;
362
+ if (!at || at.getTime() <= now.getTime()) continue;
363
+ if (!best || at.getTime() < best.at.getTime()) {
364
+ best = { label: s.label, at };
365
+ }
366
+ }
367
+ return best;
368
+ }
369
+
370
+ // ── auto-fallback announcement (causal) ──────────────────────────────
371
+
372
+ export interface FallbackAnnouncementInput {
373
+ /** Account that just hit a limit. */
374
+ oldLabel: string;
375
+ /** Quota snapshot for the old account *at the moment of failure*.
376
+ * Used to name the limit type and recovery time. */
377
+ oldQuota: QuotaUtilization | null;
378
+ /** Account we just switched to. Null when no fallback was possible. */
379
+ newLabel: string | null;
380
+ /** Quota snapshot for the new account, for headroom messaging. */
381
+ newQuota: QuotaUtilization | null;
382
+ /** Agent that triggered the fallback (for context — fleet swap
383
+ * affects all agents but the user wants to know which one tripped). */
384
+ triggerAgent: string;
385
+ tz?: string;
386
+ now?: Date;
387
+ }
388
+
389
+ /**
390
+ * Render the causal-shape fallback announcement.
391
+ *
392
+ * ✓ Switched fleet · 5-hour limit on ken
393
+ *
394
+ * ken.thompson@outlook → pixsoul@gmail.com
395
+ * Triggered by: agent carrie
396
+ *
397
+ * ken recovers Fri 3:50 PM (in 4h 56m)
398
+ * pixsoul now: 8% of 5h · 20% of 7d (plenty of headroom)
399
+ *
400
+ * Falls back to a different shape when no eligible target was found
401
+ * (`newLabel === null`) — see "all-blocked" branch.
402
+ */
403
+ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): string {
404
+ const now = input.now ?? new Date();
405
+ const tz = input.tz ?? 'UTC';
406
+ const lines: string[] = [];
407
+
408
+ const limitWord = input.oldQuota ? limitWordFor(input.oldQuota) : 'quota';
409
+ const headerLimit = limitWord === 'quota' ? 'quota cap' : `${limitWord} limit`;
410
+
411
+ if (!input.newLabel) {
412
+ // All-blocked path — no swap occurred. Tell user what's broken
413
+ // and when the earliest reset is.
414
+ lines.push(
415
+ `🔴 <b>All accounts blocked · ${headerLimit} on ${escapeHtml(input.oldLabel)}</b>`,
416
+ );
417
+ lines.push('');
418
+ lines.push(`Triggered by: agent <b>${escapeHtml(input.triggerAgent)}</b>`);
419
+ if (input.oldQuota) {
420
+ const recovery = recoveryAtFor(input.oldQuota);
421
+ if (recovery) {
422
+ lines.push(
423
+ `${escapeHtml(input.oldLabel)} recovers ${formatAbsolute(recovery, tz)} ` +
424
+ `(in ${formatRelative(recovery, now)})`,
425
+ );
426
+ }
427
+ }
428
+ lines.push('');
429
+ lines.push(
430
+ `Run <code>/auth add &lt;label&gt;</code> to attach another subscription, ` +
431
+ `or <code>/auth refresh</code> to re-probe.`,
432
+ );
433
+ return lines.join('\n');
434
+ }
435
+
436
+ // Successful swap.
437
+ lines.push(
438
+ `✓ <b>Switched fleet · ${headerLimit} on ${escapeHtml(input.oldLabel)}</b>`,
439
+ );
440
+ lines.push('');
441
+ lines.push(
442
+ `<code>${escapeHtml(input.oldLabel)}</code> → <code>${escapeHtml(input.newLabel)}</code>`,
443
+ );
444
+ lines.push(`Triggered by: agent <b>${escapeHtml(input.triggerAgent)}</b>`);
445
+ lines.push('');
446
+
447
+ if (input.oldQuota) {
448
+ const recovery = recoveryAtFor(input.oldQuota);
449
+ if (recovery) {
450
+ lines.push(
451
+ `<code>${escapeHtml(input.oldLabel)}</code> recovers ` +
452
+ `${formatAbsolute(recovery, tz)} (in ${formatRelative(recovery, now)})`,
453
+ );
454
+ }
455
+ }
456
+
457
+ if (input.newQuota) {
458
+ const fiveStr = fmtPct(input.newQuota.fiveHourUtilizationPct);
459
+ const sevenStr = fmtPct(input.newQuota.sevenDayUtilizationPct);
460
+ const hasHeadroom =
461
+ input.newQuota.fiveHourUtilizationPct < THROTTLING_THRESHOLD_PCT &&
462
+ input.newQuota.sevenDayUtilizationPct < THROTTLING_THRESHOLD_PCT;
463
+ const headroomStr = hasHeadroom ? '<i>(plenty of headroom)</i>' : '<i>(near limit — watch this)</i>';
464
+ lines.push(
465
+ `<code>${escapeHtml(input.newLabel)}</code> now: ${fiveStr} of 5h · ${sevenStr} of 7d ${headroomStr}`,
466
+ );
467
+ } else {
468
+ lines.push(
469
+ `<i>(quota probe for new account is pending — will reflect on next /auth)</i>`,
470
+ );
471
+ }
472
+
473
+ return lines.join('\n');
474
+ }
475
+
476
+ /** Pick which window to name in the headline. */
477
+ function limitWordFor(q: QuotaUtilization): '5-hour' | '7-day' | 'quota' {
478
+ // If a representative-claim is present and the named window is
479
+ // actually maxed, name it. Otherwise pick by which window is
480
+ // higher.
481
+ if (q.representativeClaim === 'seven_day' && q.sevenDayUtilizationPct >= 99) return '7-day';
482
+ if (q.representativeClaim === 'five_hour' && q.fiveHourUtilizationPct >= 99) return '5-hour';
483
+ if (q.sevenDayUtilizationPct >= 99) return '7-day';
484
+ if (q.fiveHourUtilizationPct >= 99) return '5-hour';
485
+ // Throttling case (called pre-emptively): prefer the higher one.
486
+ return q.sevenDayUtilizationPct >= q.fiveHourUtilizationPct ? '7-day' : '5-hour';
487
+ }
488
+
489
+ function recoveryAtFor(q: QuotaUtilization): Date | null {
490
+ const word = limitWordFor(q);
491
+ if (word === '7-day') return q.sevenDayResetAt;
492
+ if (word === '5-hour') return q.fiveHourResetAt;
493
+ // Both windows healthy (called pre-emptively under explicit trigger):
494
+ // earliest reset wins.
495
+ if (!q.fiveHourResetAt) return q.sevenDayResetAt;
496
+ if (!q.sevenDayResetAt) return q.fiveHourResetAt;
497
+ return q.fiveHourResetAt.getTime() < q.sevenDayResetAt.getTime()
498
+ ? q.fiveHourResetAt
499
+ : q.sevenDayResetAt;
500
+ }
501
+
502
+ // ── inline keyboard hints ────────────────────────────────────────────
503
+
504
+ export interface KeyboardButton {
505
+ text: string;
506
+ /** Either a callback_data string (tap-to-action) or a switch_inline
507
+ * hint. We model both as a discriminated union so the gateway can
508
+ * trivially translate to grammy's keyboard builder. */
509
+ callbackData?: string;
510
+ /** Convenience for buttons that paste a slash-command into the input. */
511
+ insertText?: string;
512
+ }
513
+
514
+ export type KeyboardRow = KeyboardButton[];
515
+
516
+ export interface SnapshotKeyboardOpts {
517
+ /** Limit how many "Switch → X" buttons we render. Beyond this, the
518
+ * user can drill in via /usage. Default 3. */
519
+ maxSwitchButtons?: number;
520
+ }
521
+
522
+ /**
523
+ * Build the inline keyboard for the /auth snapshot.
524
+ *
525
+ * Smart-hide rules (per JTBD — never tempt the user to switch into a
526
+ * blocked account):
527
+ * - Switch buttons render only for HEALTHY non-active accounts.
528
+ * - If active is healthy, switch buttons are still shown but
529
+ * deprioritized (the recommendation footer says "stay").
530
+ * - "Refresh" always present (forces fresh quota probes).
531
+ * - Bottom row: /usage, + Add (admin shows full menu).
532
+ */
533
+ export function buildSnapshotKeyboard(
534
+ snapshots: AccountSnapshot[],
535
+ opts: SnapshotKeyboardOpts = {},
536
+ ): KeyboardRow[] {
537
+ const max = opts.maxSwitchButtons ?? 3;
538
+ const rows: KeyboardRow[] = [];
539
+
540
+ // Switch buttons — healthy non-active first, then throttling
541
+ // non-active. Skip blocked entirely.
542
+ const switchTargets = snapshots
543
+ .filter((s) => !s.isActive)
544
+ .sort((a, b) => switchPriority(a) - switchPriority(b))
545
+ .filter((s) => classifyHealth(s) !== 'blocked' && classifyHealth(s) !== 'unknown')
546
+ .slice(0, max);
547
+
548
+ for (const t of switchTargets) {
549
+ rows.push([
550
+ {
551
+ text: `Switch fleet → ${t.label}`,
552
+ callbackData: `auth:use:${t.label}`,
553
+ },
554
+ ]);
555
+ }
556
+
557
+ rows.push([
558
+ { text: '↻ Refresh', callbackData: 'auth:refresh' },
559
+ { text: '/usage', insertText: '/usage' },
560
+ { text: '+ Add', insertText: '/auth add ' },
561
+ ]);
562
+
563
+ return rows;
564
+ }
565
+
566
+ /** Lower number = higher priority for "switch to me" button. */
567
+ function switchPriority(s: AccountSnapshot): number {
568
+ const h = classifyHealth(s);
569
+ if (h === 'healthy') return 0;
570
+ if (h === 'throttling') return 1;
571
+ if (h === 'unknown') return 2;
572
+ return 3; // blocked
573
+ }
574
+
575
+ // ── shared HTML escape ───────────────────────────────────────────────
576
+
577
+ function escapeHtml(s: string): string {
578
+ return s
579
+ .replace(/&/g, '&amp;')
580
+ .replace(/</g, '&lt;')
581
+ .replace(/>/g, '&gt;');
582
+ }
583
+
584
+ // ── snapshot assembly helper ─────────────────────────────────────────
585
+
586
+ /**
587
+ * Given the broker's `listState` data + a parallel array of live quota
588
+ * results (same length, same order), return the AccountSnapshot[] the
589
+ * formatters need.
590
+ *
591
+ * The gateway calls this after running `Promise.all(accounts.map(a =>
592
+ * fetchAccountQuota(a.label, {force: true})))` — both arrays are
593
+ * caller-provided, this is just a zip + classify.
594
+ */
595
+ export function buildSnapshotsFromState(
596
+ state: ListStateData,
597
+ quotas: QuotaResult[],
598
+ ): AccountSnapshot[] {
599
+ const out: AccountSnapshot[] = [];
600
+ for (let i = 0; i < state.accounts.length; i++) {
601
+ const acc: AccountState = state.accounts[i]!;
602
+ const q = quotas[i];
603
+ out.push({
604
+ label: acc.label,
605
+ isActive: acc.label === state.active,
606
+ quota: q && q.ok ? q.data : null,
607
+ quotaError: q && !q.ok ? q.reason : undefined,
608
+ expiresAtMs: acc.expiresAt,
609
+ });
610
+ }
611
+ return out;
612
+ }