switchroom 0.10.0 → 0.11.1

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 (52) hide show
  1. package/README.md +5 -4
  2. package/dist/agent-scheduler/index.js +2 -2
  3. package/dist/auth-broker/index.js +125 -3
  4. package/dist/cli/drive-write-pretool.mjs +5436 -0
  5. package/dist/cli/switchroom.js +231 -29
  6. package/dist/host-control/main.js +2 -2
  7. package/dist/vault/approvals/kernel-server.js +2 -2
  8. package/dist/vault/broker/server.js +2 -2
  9. package/package.json +1 -1
  10. package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
  11. package/telegram-plugin/admin-commands/index.ts +2 -0
  12. package/telegram-plugin/auth-snapshot-format.ts +612 -0
  13. package/telegram-plugin/auto-fallback-fleet.ts +215 -0
  14. package/telegram-plugin/auto-fallback.ts +28 -301
  15. package/telegram-plugin/dist/gateway/gateway.js +4314 -2143
  16. package/telegram-plugin/fleet-fallback-gate.ts +105 -0
  17. package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
  18. package/telegram-plugin/gateway/approval-callback.ts +31 -3
  19. package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
  20. package/telegram-plugin/gateway/auth-command.ts +131 -10
  21. package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
  22. package/telegram-plugin/gateway/boot-card.ts +1 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +6 -9
  24. package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
  25. package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
  26. package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
  27. package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
  28. package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
  29. package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
  30. package/telegram-plugin/gateway/gateway.ts +903 -173
  31. package/telegram-plugin/gateway/hostd-dispatch.ts +137 -2
  32. package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
  33. package/telegram-plugin/gateway/ipc-server.ts +69 -0
  34. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
  35. package/telegram-plugin/model-unavailable.ts +28 -12
  36. package/telegram-plugin/silence-poke.ts +153 -1
  37. package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
  38. package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
  39. package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
  40. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
  41. package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
  42. package/telegram-plugin/tests/boot-probes.test.ts +16 -18
  43. package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
  44. package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
  45. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
  46. package/telegram-plugin/tests/silence-poke.test.ts +237 -0
  47. package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
  48. package/telegram-plugin/turn-flush-safety.ts +55 -1
  49. package/telegram-plugin/uat/SETUP.md +16 -12
  50. package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
  51. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
  52. package/telegram-plugin/tests/hostd-dispatch.test.ts +0 -129
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Re-entry guard + dedup window for fleet-wide auto-fallback.
3
+ *
4
+ * Lifted out of gateway.ts so the dedup state is constructable per-test
5
+ * (gateway.ts module state was hard to reach from vitest — every test
6
+ * shared the same in-flight Promise + last-fired timestamp).
7
+ *
8
+ * Contract (the "honesty contract" from PR #1317 review):
9
+ *
10
+ * `wouldFire()` is the SYNCHRONOUS read the model-unavailable card
11
+ * uses to decide whether to advertise "Auto-failover in progress".
12
+ * It MUST agree with the dispatcher's actual behavior — otherwise
13
+ * the card lies (claims a swap is coming when the dispatcher will
14
+ * dedup-drop or bail).
15
+ *
16
+ * Three reasons `wouldFire()` returns false:
17
+ * 1. A swap is already in flight (collapse concurrent fires).
18
+ * 2. The post-trigger dedup window is still active (the user
19
+ * already saw a swap announcement; another one would oscillate).
20
+ * 3. The broker is unreachable — the dispatcher would just bail
21
+ * with `reason=no-broker-client`, leaving the card to lie.
22
+ * Optional: only checked when `brokerReachable` is supplied.
23
+ *
24
+ * `markFired()` is called ONLY on actual swaps (kind: 'switched').
25
+ * No-ops (no broker, no eligible target, idempotent skip) DO NOT
26
+ * arm the suppression window — otherwise a transient hiccup blocks
27
+ * the next 30s of legitimate fires.
28
+ */
29
+
30
+ export interface FleetFallbackGateOptions {
31
+ /** Suppression window in ms after a successful swap. */
32
+ dedupMs: number;
33
+ /** Time source (overridable in tests). */
34
+ nowFn?: () => number;
35
+ /**
36
+ * Synchronous probe of broker reachability. Optional. Returning false
37
+ * makes `wouldFire()` return false so the card stays honest about a
38
+ * fire that would otherwise bail in the dispatcher.
39
+ *
40
+ * Synchronous on purpose: `wouldFire()` runs on the card-render path
41
+ * and must not block. A connection-cached flag (e.g. a UDS reachability
42
+ * check populated by a background heartbeat) fits this shape.
43
+ */
44
+ brokerReachable?: () => boolean;
45
+ }
46
+
47
+ export interface FleetFallbackGate {
48
+ /** True iff a fresh fire would actually invoke the dispatcher. */
49
+ wouldFire(): boolean;
50
+ /** Run a fire-and-forget action under the gate. Collapses concurrent
51
+ * callers to the same in-flight Promise. The action's resolved value
52
+ * controls whether the dedup window arms (true = arm, false = skip).
53
+ * Caller-thrown errors are swallowed (logged via `onError`). */
54
+ fire(action: () => Promise<boolean>, onError?: (err: unknown) => void): Promise<void>;
55
+ /** Test seam — reset to fresh state. Production code should not call this. */
56
+ reset(): void;
57
+ /** Test/debug — current internal state. */
58
+ inspect(): { inFlight: boolean; lastFiredAtMs: number };
59
+ }
60
+
61
+ export function createFleetFallbackGate(opts: FleetFallbackGateOptions): FleetFallbackGate {
62
+ const nowFn = opts.nowFn ?? (() => Date.now());
63
+ let inFlight: Promise<void> | null = null;
64
+ // -Infinity = never fired. Concrete number = wall-clock ms of the
65
+ // last actual swap. Sentinel matters in tests (fake clocks at t=0
66
+ // would otherwise look like "just fired" and falsely arm dedup).
67
+ let lastFiredAtMs = Number.NEGATIVE_INFINITY;
68
+
69
+ function wouldFire(): boolean {
70
+ if (inFlight) return false;
71
+ if (nowFn() - lastFiredAtMs < opts.dedupMs) return false;
72
+ if (opts.brokerReachable && !opts.brokerReachable()) return false;
73
+ return true;
74
+ }
75
+
76
+ function fire(action: () => Promise<boolean>, onError?: (err: unknown) => void): Promise<void> {
77
+ if (inFlight) return inFlight;
78
+ if (nowFn() - lastFiredAtMs < opts.dedupMs) return Promise.resolve();
79
+ if (opts.brokerReachable && !opts.brokerReachable()) return Promise.resolve();
80
+
81
+ inFlight = (async () => {
82
+ try {
83
+ const didSwap = await action();
84
+ if (didSwap) lastFiredAtMs = nowFn();
85
+ } catch (err) {
86
+ if (onError) onError(err);
87
+ } finally {
88
+ inFlight = null;
89
+ }
90
+ })();
91
+ return inFlight;
92
+ }
93
+
94
+ return {
95
+ wouldFire,
96
+ fire,
97
+ reset() {
98
+ inFlight = null;
99
+ lastFiredAtMs = Number.NEGATIVE_INFINITY;
100
+ },
101
+ inspect() {
102
+ return { inFlight: inFlight !== null, lastFiredAtMs };
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tests for `buildGrantedKeyboard` — the post-tap inline-keyboard
3
+ * surfaced on a granted approval card (RFC E §4.3 — granted-card
4
+ * confirmations gain the [ 📖 Open in Drive ] deep-link button).
5
+ *
6
+ * Scope-driven and pure, so the test runs without mocking grammy's
7
+ * Context or the approval kernel. The full handler in
8
+ * `approval-callback.ts` glues this onto the consumed-scope payload
9
+ * the kernel returns; the routing decision lives entirely in this
10
+ * builder.
11
+ */
12
+
13
+ import { describe, expect, it } from "vitest";
14
+ import { InlineKeyboard } from "grammy";
15
+ import { buildGrantedKeyboard } from "./approval-callback.js";
16
+
17
+ /**
18
+ * Helper — pull the `[{text, url}]` rows out of a grammy InlineKeyboard
19
+ * so we can assert without poking into its internal shape too hard.
20
+ */
21
+ function rows(kb: InlineKeyboard): Array<Array<{ text: string; url?: string }>> {
22
+ return kb.inline_keyboard.map((row) =>
23
+ row.map((btn) => ({
24
+ text: btn.text,
25
+ ...("url" in btn ? { url: btn.url } : {}),
26
+ })),
27
+ );
28
+ }
29
+
30
+ describe("buildGrantedKeyboard — Drive scopes", () => {
31
+ it("emits Open-in-Drive for a single-doc grant", () => {
32
+ const kb = buildGrantedKeyboard("doc:gdrive:D1");
33
+ expect(kb).toBeDefined();
34
+ expect(rows(kb!)).toEqual([
35
+ [
36
+ {
37
+ text: "📖 Open in Drive",
38
+ url: "https://drive.google.com/file/d/D1/view",
39
+ },
40
+ ],
41
+ ]);
42
+ });
43
+
44
+ it("emits Open-in-Drive for a folder grant (canonical folder URL)", () => {
45
+ const kb = buildGrantedKeyboard("doc:gdrive:folder/F1/**");
46
+ expect(kb).toBeDefined();
47
+ expect(rows(kb!)).toEqual([
48
+ [
49
+ {
50
+ text: "📖 Open in Drive",
51
+ url: "https://drive.google.com/drive/folders/F1",
52
+ },
53
+ ],
54
+ ]);
55
+ });
56
+
57
+ it("emits Open-in-Drive for write-namespace grants on a single doc", () => {
58
+ const kb = buildGrantedKeyboard("doc:gdrive:write:D1");
59
+ expect(kb).toBeDefined();
60
+ expect(rows(kb!)).toEqual([
61
+ [
62
+ {
63
+ text: "📖 Open in Drive",
64
+ url: "https://drive.google.com/file/d/D1/view",
65
+ },
66
+ ],
67
+ ]);
68
+ });
69
+
70
+ it("emits Open-in-Drive for suggest-namespace folder grants", () => {
71
+ const kb = buildGrantedKeyboard("doc:gdrive:suggest:folder/F1/**");
72
+ expect(kb).toBeDefined();
73
+ expect(rows(kb!)).toEqual([
74
+ [
75
+ {
76
+ text: "📖 Open in Drive",
77
+ url: "https://drive.google.com/drive/folders/F1",
78
+ },
79
+ ],
80
+ ]);
81
+ });
82
+ });
83
+
84
+ describe("buildGrantedKeyboard — no button cases", () => {
85
+ it("returns undefined for the whole-Drive grant (no specific artifact)", () => {
86
+ expect(buildGrantedKeyboard("doc:gdrive:**")).toBeUndefined();
87
+ expect(buildGrantedKeyboard("doc:gdrive:suggest:**")).toBeUndefined();
88
+ expect(buildGrantedKeyboard("doc:gdrive:write:**")).toBeUndefined();
89
+ });
90
+
91
+ it("returns undefined for non-Drive scopes (secrets, system, vault)", () => {
92
+ expect(buildGrantedKeyboard("secret:OPENAI_API_KEY")).toBeUndefined();
93
+ expect(buildGrantedKeyboard("system:reconnect:gdrive")).toBeUndefined();
94
+ expect(buildGrantedKeyboard("vault:read:gdrive:klanker:refresh_token")).toBeUndefined();
95
+ });
96
+
97
+ it("returns undefined for unparseable Drive scopes (defense in depth)", () => {
98
+ // A folder id containing a slash slips past prefix matching but is
99
+ // rejected by parseDriveScope's id-charset check — the granted-card
100
+ // edit MUST NOT render a URL button derived from such a string.
101
+ expect(buildGrantedKeyboard("doc:gdrive:folder/abc/def/**")).toBeUndefined();
102
+ expect(buildGrantedKeyboard("doc:gdrive:write:abc?evil=1")).toBeUndefined();
103
+ });
104
+ });
@@ -21,13 +21,31 @@
21
21
  * approval_lookup (RFC §10) to discover the outcome and proceed.
22
22
  */
23
23
 
24
- import type { Context } from "grammy";
24
+ import { type Context, InlineKeyboard } from "grammy";
25
25
  import { parseApprovalCallback, ttlMsFromToken } from "./approval-card.js";
26
26
  import {
27
27
  approvalConsume,
28
28
  approvalRecord,
29
29
  } from "../../src/vault/approvals/client.js";
30
30
  import type { ApprovalDecisionMode } from "../../src/vault/approvals/schema.js";
31
+ import { scopeToOpenInDriveButton } from "../../src/drive/deep-links.js";
32
+
33
+ /**
34
+ * Build the post-tap keyboard for a granted decision. Today this is
35
+ * just the `[ 📖 Open in Drive ]` button when the granted scope names
36
+ * a specific Drive doc or folder (RFC E §4.3 — granted-card
37
+ * confirmations gain the deep-link). Returns `undefined` when no
38
+ * post-tap keyboard applies, which the gateway translates into
39
+ * `reply_markup: undefined` to strip the original action buttons.
40
+ *
41
+ * Pure / scope-driven — no kernel I/O — so it stays unit-testable
42
+ * without mocking grammy's Context.
43
+ */
44
+ export function buildGrantedKeyboard(scope: string): InlineKeyboard | undefined {
45
+ const btn = scopeToOpenInDriveButton(scope);
46
+ if (btn === null) return undefined;
47
+ return new InlineKeyboard().url(btn.text, btn.url);
48
+ }
31
49
 
32
50
  export async function handleApprovalCallback(
33
51
  ctx: Context,
@@ -109,7 +127,10 @@ export async function handleApprovalCallback(
109
127
  return;
110
128
  }
111
129
 
112
- // Edit the original card to its post-tap state and drop the keyboard.
130
+ // Edit the original card to its post-tap state. Drop the original
131
+ // action keyboard either way; on a successful grant for a Drive
132
+ // scope, surface `[ 📖 Open in Drive ]` so the user can jump
133
+ // straight from "agent has access" to the doc (RFC E §4.3).
113
134
  const icon = granted ? "✅" : "🚫";
114
135
  const newBody =
115
136
  `${icon} ${displayMode}` +
@@ -117,8 +138,15 @@ export async function handleApprovalCallback(
117
138
  ? ` · /approvals revoke <code>${decision_id}</code>`
118
139
  : "");
119
140
 
141
+ const postTapKeyboard = granted && consumed.scope
142
+ ? buildGrantedKeyboard(consumed.scope)
143
+ : undefined;
144
+
120
145
  try {
121
- await ctx.editMessageText(newBody, { parse_mode: "HTML", reply_markup: undefined });
146
+ await ctx.editMessageText(newBody, {
147
+ parse_mode: "HTML",
148
+ reply_markup: postTapKeyboard,
149
+ });
122
150
  } catch {
123
151
  // Best-effort: card may have been edited or deleted under us.
124
152
  }
@@ -30,6 +30,8 @@ export function createAuthBrokerClient(): {
30
30
  refreshAccount: (label: string) => broker.refreshAccount(label),
31
31
  setOverride: (agent: string, account: string | null) =>
32
32
  broker.setOverride(agent, account),
33
+ probeQuota: (accounts: readonly string[], timeoutMs?: number) =>
34
+ broker.probeQuota(accounts, timeoutMs),
33
35
  }
34
36
  return { client, close: () => broker.close() }
35
37
  }
@@ -29,6 +29,11 @@
29
29
  */
30
30
 
31
31
  import type { ListStateData, AccountState } from './auth-line.js'
32
+ import {
33
+ buildSnapshotsFromState,
34
+ renderAuthSnapshotFormat2,
35
+ buildSnapshotKeyboard,
36
+ } from '../auth-snapshot-format.js'
32
37
 
33
38
  // ─── Parser ────────────────────────────────────────────────────────────────
34
39
 
@@ -215,6 +220,16 @@ export interface AuthBrokerClient {
215
220
  agent: string,
216
221
  account: string | null,
217
222
  ): Promise<{ agent: string; account: string | null }>
223
+ /**
224
+ * Live Anthropic quota probe via the broker (#1336). The broker
225
+ * uses its stored accessTokens to hit `/v1/messages` server-side
226
+ * and returns parsed rate-limit headers. Tokens never reach the
227
+ * caller. Per-label results are returned in input order.
228
+ */
229
+ probeQuota(
230
+ accounts: readonly string[],
231
+ timeoutMs?: number,
232
+ ): Promise<{ results: Array<{ label: string; result: import('../quota-check.js').QuotaResult }> }>
218
233
  }
219
234
 
220
235
  export interface AuthCommandContext {
@@ -236,12 +251,43 @@ export interface AuthCommandContext {
236
251
  * that never reach the destructive branches can skip wiring it.
237
252
  */
238
253
  chatId?: string
254
+ /**
255
+ * Optional Format 2 enricher — when supplied, the `show`/`list`
256
+ * paths probe live quota for every account (in parallel) so the
257
+ * snapshot renders the new health-grouped shape with real-time
258
+ * percentages and reset countdowns. When omitted the legacy
259
+ * ASCII table renders, which keeps tests + broker-only callers
260
+ * working without spinning up the Anthropic API path.
261
+ *
262
+ * Returns a parallel array (same length, same order as
263
+ * `state.accounts`) of QuotaResult — the gateway passes
264
+ * `accounts.map(a => fetchAccountQuota(a.label, {force: true}))`.
265
+ */
266
+ liveQuotas?: (
267
+ accounts: AccountState[],
268
+ ) => Promise<import('../quota-check.js').QuotaResult[]>
269
+ /** Operator timezone forwarded to the Format 2 renderer. */
270
+ tz?: string
239
271
  }
240
272
 
241
273
  export interface AuthCommandReply {
242
274
  text: string
243
275
  /** True when the reply contains HTML markup. */
244
276
  html: boolean
277
+ /**
278
+ * Optional inline keyboard (rows of buttons). Format 2 attaches a
279
+ * smart-keyboard here for the fleet snapshot — switch buttons for
280
+ * healthy non-active accounts, plus refresh/usage/+add. Caller
281
+ * translates to grammy's `reply_markup` shape. Empty/missing means
282
+ * no keyboard.
283
+ */
284
+ keyboard?: Array<
285
+ Array<{
286
+ text: string
287
+ callbackData?: string
288
+ insertText?: string
289
+ }>
290
+ >
245
291
  }
246
292
 
247
293
  /**
@@ -282,7 +328,35 @@ export async function handleAuthCommand(
282
328
  ) {
283
329
  try {
284
330
  const state = await ctx.client.listState()
285
- return { text: renderShowText(state), html: true }
331
+ let liveQuotas: import('../quota-check.js').QuotaResult[] | undefined
332
+ let liveProbedAtMs: number | undefined
333
+ if (ctx.liveQuotas && state.accounts.length > 0) {
334
+ try {
335
+ liveQuotas = await ctx.liveQuotas(state.accounts)
336
+ liveProbedAtMs = Date.now()
337
+ } catch {
338
+ // Live probe failed — fall back to legacy table silently.
339
+ liveQuotas = undefined
340
+ }
341
+ }
342
+ // Build the smart keyboard only when we have live quota data —
343
+ // without it we can't classify health and the buttons could
344
+ // tempt the user into a blocked account. When omitted, the
345
+ // text still renders (legacy table); just no keyboard.
346
+ let keyboard: AuthCommandReply['keyboard']
347
+ if (liveQuotas && liveQuotas.length === state.accounts.length) {
348
+ const snapshots = buildSnapshotsFromState(state, liveQuotas)
349
+ keyboard = buildSnapshotKeyboard(snapshots)
350
+ }
351
+ return {
352
+ text: renderShowText(state, Date.now(), {
353
+ liveQuotas,
354
+ tz: ctx.tz,
355
+ liveProbedAtMs,
356
+ }),
357
+ html: true,
358
+ keyboard,
359
+ }
286
360
  } catch (err) {
287
361
  return {
288
362
  text: `<b>/auth show failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
@@ -609,17 +683,64 @@ export function pickRotateTarget(state: ListStateData, now: number = Date.now())
609
683
  * Telegram (HTML, monospace blocks). Three sections, each suppressed
610
684
  * when empty.
611
685
  */
612
- export function renderShowText(state: ListStateData, now: number = Date.now()): string {
686
+ export interface RenderShowOpts {
687
+ /** Optional live quota probes, parallel to state.accounts. When
688
+ * present, the Accounts section uses Format 2 (health-grouped,
689
+ * causal-runway). When absent (legacy callers, broker-only render),
690
+ * falls back to the original ASCII table. */
691
+ liveQuotas?: import('../quota-check.js').QuotaResult[]
692
+ /** Operator timezone for absolute reset times in Format 2. */
693
+ tz?: string
694
+ /** Wall-clock ms when the live probes returned, used for "refreshed
695
+ * Ns ago" footer. Omit to suppress that footer line. */
696
+ liveProbedAtMs?: number
697
+ }
698
+
699
+ /**
700
+ * Render the fleet snapshot. Two shapes coexist transparently:
701
+ *
702
+ * 1. Format 2 (preferred) — when `opts.liveQuotas` is supplied:
703
+ * health-grouped per-account view (🟢 HEALTHY / 🟡 THROTTLING /
704
+ * 🔴 BLOCKED), live percent + reset times, recommendation
705
+ * footer. See `auth-snapshot-format.ts → renderAuthSnapshotFormat2`.
706
+ *
707
+ * 2. Legacy ASCII table — when no live data is available
708
+ * (broker-only path, tests, or the live probe failed). Same
709
+ * visual shape RFC §4.6 originally specified; preserved so the
710
+ * broker can still answer `/auth show` with no Anthropic-API
711
+ * round-trip.
712
+ *
713
+ * The Agents and Consumers tables render identically under both
714
+ * shapes — those tables don't depend on quota state.
715
+ */
716
+ export function renderShowText(
717
+ state: ListStateData,
718
+ now: number = Date.now(),
719
+ opts: RenderShowOpts = {},
720
+ ): string {
613
721
  const lines: string[] = []
614
- lines.push('<b>Auth — fleet snapshot</b>')
615
722
 
616
- // Accounts table
617
- if (state.accounts.length > 0) {
618
- lines.push('')
619
- lines.push('<b>Accounts</b>')
620
- lines.push('<pre>')
621
- lines.push(formatAccountsTable(state, now))
622
- lines.push('</pre>')
723
+ if (state.accounts.length > 0 && opts.liveQuotas && opts.liveQuotas.length === state.accounts.length) {
724
+ // Format 2 path. Build snapshots, render the new shape inline at
725
+ // the top of the message — replaces the legacy "Auth — fleet
726
+ // snapshot" header + Accounts table.
727
+ const snapshots = buildSnapshotsFromState(state, opts.liveQuotas)
728
+ lines.push(
729
+ renderAuthSnapshotFormat2(snapshots, {
730
+ tz: opts.tz,
731
+ now: new Date(now),
732
+ liveProbedAtMs: opts.liveProbedAtMs,
733
+ }),
734
+ )
735
+ } else {
736
+ lines.push('<b>Auth — fleet snapshot</b>')
737
+ if (state.accounts.length > 0) {
738
+ lines.push('')
739
+ lines.push('<b>Accounts</b>')
740
+ lines.push('<pre>')
741
+ lines.push(formatAccountsTable(state, now))
742
+ lines.push('</pre>')
743
+ }
623
744
  }
624
745
 
625
746
  // Agents table
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Adapt the RFC H broker `auth show --json` / `auth list --json` payload
3
+ * (a `ListStateData`) into the per-agent `AuthSummary` shape the
4
+ * /status panel renders via `formatAuthLine`.
5
+ *
6
+ * Pre-RFC-H, gateway shelled out to `switchroom auth status --json`
7
+ * which already returned per-agent records in `AuthSummary` shape.
8
+ * That verb was retired; this adapter does the per-agent projection
9
+ * over the new fleet-broker payload.
10
+ *
11
+ * Pure & dependency-free so it can be unit-tested without a grammy
12
+ * Context or live broker.
13
+ */
14
+ import type { AuthSummary } from '../welcome-text.js'
15
+
16
+ /** Mirrors `ListStateData` in src/auth/broker/client.ts — duplicated as
17
+ * a structural type so this adapter stays in the telegram-plugin
18
+ * workspace without importing across the src/ boundary. */
19
+ export interface BrokerStateView {
20
+ active: string
21
+ fallback_order: string[]
22
+ accounts: Array<{
23
+ label: string
24
+ expiresAt?: number
25
+ exhausted: boolean
26
+ }>
27
+ agents: Array<{
28
+ name: string
29
+ account: string
30
+ override: string | null
31
+ }>
32
+ }
33
+
34
+ /** Subset of `.claude.json` we need for billingType — duplicated for
35
+ * the same reason as BrokerStateView. */
36
+ export interface ClaudeJsonView {
37
+ oauthAccount?: {
38
+ billingType?: string
39
+ }
40
+ }
41
+
42
+ export function formatExpiresInRelative(expiresAt: number | undefined, now: number = Date.now()): string | null {
43
+ if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return null
44
+ const delta = expiresAt - now
45
+ if (delta <= 0) return 'expired'
46
+ const days = Math.floor(delta / 86_400_000)
47
+ if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}`
48
+ const hours = Math.floor(delta / 3_600_000)
49
+ if (hours >= 1) return `in ${hours} hour${hours === 1 ? '' : 's'}`
50
+ const minutes = Math.max(1, Math.floor(delta / 60_000))
51
+ return `in ${minutes} minute${minutes === 1 ? '' : 's'}`
52
+ }
53
+
54
+ function mapBillingTypeToPlan(billingType: string | undefined): string | null {
55
+ if (!billingType) return null
56
+ const t = billingType.toLowerCase()
57
+ if (t.includes('max')) return 'Max'
58
+ if (t.includes('pro')) return 'Pro'
59
+ return billingType
60
+ }
61
+
62
+ /**
63
+ * Build the per-agent AuthSummary from broker state.
64
+ *
65
+ * - `authenticated` = the agent is bound to an account that the broker
66
+ * knows about. Quota exhaustion is NOT counted as unauthenticated —
67
+ * the agent still has valid credentials, it just can't make calls
68
+ * until the broker rotates (which is a separate signal).
69
+ * - `auth_source` surfaces the bound account label (e.g. the email).
70
+ * Under RFC H all auth flows through the broker, so the source is
71
+ * "which account is currently mirrored to this agent", not the
72
+ * transport.
73
+ * - `subscription_type` is read from the agent's `.claude.json`
74
+ * because the broker doesn't track plan tier.
75
+ * - `expires_in` is computed from the bound account's `expiresAt`.
76
+ */
77
+ export function buildAuthSummaryFromBroker(
78
+ state: BrokerStateView | null | undefined,
79
+ agentName: string,
80
+ claudeJson: ClaudeJsonView | null | undefined,
81
+ now: number = Date.now(),
82
+ ): AuthSummary | null {
83
+ if (!state) return null
84
+ const binding = state.agents.find((a) => a.name === agentName)
85
+ if (!binding) {
86
+ return {
87
+ authenticated: false,
88
+ subscription_type: mapBillingTypeToPlan(claudeJson?.oauthAccount?.billingType),
89
+ expires_in: null,
90
+ auth_source: null,
91
+ }
92
+ }
93
+ const account = state.accounts.find((a) => a.label === binding.account)
94
+ const authenticated = account !== undefined
95
+ return {
96
+ authenticated,
97
+ subscription_type: mapBillingTypeToPlan(claudeJson?.oauthAccount?.billingType),
98
+ expires_in: account ? formatExpiresInRelative(account.expiresAt, now) : null,
99
+ auth_source: binding.account,
100
+ }
101
+ }
@@ -499,7 +499,7 @@ export async function runAllProbes(opts: RunProbesOpts): Promise<ProbeMap> {
499
499
  const slug = opts.agentSlug ?? opts.agentName
500
500
 
501
501
  await Promise.allSettled([
502
- probeAccount(opts.agentDir, { agentName: opts.agentSlug ?? opts.agentName }).then(r => { probes.account = r }),
502
+ probeAccount(opts.agentDir).then(r => { probes.account = r }),
503
503
  probeAgentProcess(slug, { execFileImpl: opts.probeExecFileImpl, tmuxSupervisor: opts.tmuxSupervisor, dockerMode: opts.dockerMode }).then(r => { probes.agent = r }),
504
504
  probeGateway(opts.gatewayInfo).then(r => { probes.gateway = r }),
505
505
  probeQuota(claudeDir, opts.agentDir, opts.fetchImpl).then(r => { probes.quota = r }),
@@ -121,16 +121,10 @@ const TOKEN_EXPIRING_SOON_DAYS = 7
121
121
  */
122
122
  export async function probeAccount(
123
123
  agentDir: string,
124
- opts: { agentName?: string } = {},
125
124
  ): Promise<ProbeResult> {
126
125
  return withTimeout('Account', (async (): Promise<ProbeResult> => {
127
126
  const claudeDir = join(agentDir, '.claude')
128
127
  const claudeJsonPath = join(claudeDir, '.claude.json')
129
- // Fall back to the literal placeholder only when no agentName is plumbed
130
- // through — the renderer's <code> escape will keep that safe in Telegram
131
- // HTML, but real call sites should always pass the name so users can
132
- // tap-to-copy a working command.
133
- const agentRef = opts.agentName ?? '<agent>'
134
128
  let cfg: ClaudeJson = {}
135
129
  try {
136
130
  const raw = readFileSync(claudeJsonPath, 'utf8')
@@ -145,7 +139,10 @@ export async function probeAccount(
145
139
  status: 'degraded',
146
140
  label: 'Account',
147
141
  detail: 'not signed in',
148
- nextStep: `Run \`switchroom auth login ${agentRef}\` to start the OAuth flow`,
142
+ // RFC H: auth is fleet-wide. Recovery is `auth add` + `auth use` —
143
+ // the broker then fans the active label out to every agent. There
144
+ // is no per-agent `auth login` verb anymore.
145
+ nextStep: 'Run `switchroom auth add <label> --from-oauth` then `switchroom auth use <label>` to authenticate the fleet',
149
146
  }
150
147
  }
151
148
 
@@ -176,9 +173,9 @@ export async function probeAccount(
176
173
  }
177
174
 
178
175
  const nextStep = status === 'fail'
179
- ? `OAuth token expired — run \`switchroom auth login ${agentRef}\` to re-authenticate`
176
+ ? 'OAuth token expired — broker should auto-refresh; force with `switchroom auth refresh` or `switchroom auth add <label> --from-oauth --replace` if the refresh token is also bad'
180
177
  : status === 'degraded'
181
- ? `Token expiring soon — run \`switchroom auth login ${agentRef}\` before it lapses`
178
+ ? 'Token expiring soon — broker auto-refreshes < 60min before expiry; force now with `switchroom auth refresh`'
182
179
  : undefined
183
180
  return {
184
181
  status,