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,123 @@
1
+ /**
2
+ * Boot-card auth row formatter (RFC H §7.3).
3
+ *
4
+ * The old auth-dashboard exported `formatAccountQuotaLine` + an
5
+ * `AccountSummary` shape that the boot card consumed for its
6
+ * "Accounts (N)" section. Both source-of-truth and shape moved to
7
+ * the auth-broker's `list-state` response. This module reformats that
8
+ * response into the same one-line-per-account block the boot card
9
+ * used to render — visual output unchanged, data source is now the
10
+ * broker.
11
+ *
12
+ * Inputs: a `list-state` data shape (see
13
+ * `src/auth/broker/protocol.ts` → `ListStateDataSchema`) plus the
14
+ * caller agent's name.
15
+ *
16
+ * Output: an array of HTML-safe lines. Empty array when there's
17
+ * nothing to show — preserves the boot-card's silent-when-healthy
18
+ * default.
19
+ */
20
+
21
+ import type { ListStateData, AccountState } from '../../src/auth/broker/client.js'
22
+
23
+ export type { ListStateData, AccountState }
24
+
25
+ // Local HTML-escape (mirrors the helper formerly co-located in
26
+ // auth-dashboard.ts so we keep the same escaping discipline without
27
+ // pulling in a heavier util).
28
+ function escapeHtml(s: string): string {
29
+ return s
30
+ .replace(/&/g, '&')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ }
34
+
35
+ /** Format a duration in ms as a short relative string ("1h 22m", "12s"). */
36
+ function formatRelativeMs(ms: number): string {
37
+ if (ms <= 0) return '0s'
38
+ const totalSec = Math.floor(ms / 1000)
39
+ const days = Math.floor(totalSec / 86400)
40
+ const hours = Math.floor((totalSec % 86400) / 3600)
41
+ const mins = Math.floor((totalSec % 3600) / 60)
42
+ const secs = totalSec % 60
43
+ if (days > 0) return `${days}d ${hours}h`
44
+ if (hours > 0) return `${hours}h ${mins}m`
45
+ if (mins > 0) return `${mins}m ${secs}s`
46
+ return `${secs}s`
47
+ }
48
+
49
+ /**
50
+ * Render the per-account quota inline for one account row. Returns
51
+ * null when there's nothing quota-shaped to say (account is healthy
52
+ * and we have no reset countdown to surface).
53
+ */
54
+ export function formatAuthQuotaLine(acc: AccountState, now: number = Date.now()): string | null {
55
+ if (acc.exhausted) {
56
+ const until = acc.exhausted_until
57
+ if (until != null && until > now) {
58
+ return `<i>exhausted · resets in ${formatRelativeMs(until - now)}</i>`
59
+ }
60
+ return `<i>exhausted</i>`
61
+ }
62
+ return null
63
+ }
64
+
65
+ /**
66
+ * Boot-card auth-row block.
67
+ *
68
+ * Strategy:
69
+ * 1. Determine *which* account is active for `agentName` (per-agent
70
+ * override wins over fleet-active).
71
+ * 2. Emit one row for that account marked with `▶` plus a
72
+ * best-effort quota suffix.
73
+ * 3. Emit one row per other account in `fallback_order` marked with
74
+ * `↳` so the operator sees the rollover plan at a glance.
75
+ *
76
+ * Returns an empty array when `state` is empty (no accounts) — the
77
+ * boot card's silent-when-healthy contract.
78
+ */
79
+ export function renderAuthLine(
80
+ state: ListStateData,
81
+ agentName: string,
82
+ now: number = Date.now(),
83
+ ): string[] {
84
+ if (!state || state.accounts.length === 0) return []
85
+
86
+ const agentEntry = state.agents.find((a) => a.name === agentName)
87
+ const activeLabel = agentEntry?.override ?? agentEntry?.account ?? state.active
88
+
89
+ // Stable display order: active first, then `fallback_order` minus
90
+ // the active label, then any remaining accounts (defensive — should
91
+ // be empty in steady state) in account-list order.
92
+ const seen = new Set<string>()
93
+ const order: string[] = []
94
+ if (activeLabel) {
95
+ order.push(activeLabel)
96
+ seen.add(activeLabel)
97
+ }
98
+ for (const label of state.fallback_order) {
99
+ if (!seen.has(label)) {
100
+ order.push(label)
101
+ seen.add(label)
102
+ }
103
+ }
104
+ for (const acc of state.accounts) {
105
+ if (!seen.has(acc.label)) {
106
+ order.push(acc.label)
107
+ seen.add(acc.label)
108
+ }
109
+ }
110
+
111
+ const byLabel = new Map(state.accounts.map((a) => [a.label, a]))
112
+ const rows: string[] = []
113
+ rows.push(`<b>Accounts (${state.accounts.length})</b>`)
114
+ for (const label of order) {
115
+ const acc = byLabel.get(label)
116
+ if (!acc) continue
117
+ const marker = label === activeLabel ? '▶' : '↳'
118
+ const labelHtml = `<code>${escapeHtml(acc.label)}</code>`
119
+ const quotaLine = formatAuthQuotaLine(acc, now)
120
+ rows.push(quotaLine ? `${marker} ${labelHtml} ${quotaLine}` : `${marker} ${labelHtml}`)
121
+ }
122
+ return rows
123
+ }
@@ -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
+ }
@@ -33,8 +33,8 @@
33
33
  */
34
34
 
35
35
  import type { ProbeResult, GatewayRuntimeInfo } from './boot-probes.js'
36
- import type { AccountSummary } from '../auth-dashboard.js'
37
- import { formatAccountQuotaLine } from '../auth-dashboard.js'
36
+ import type { ListStateData } from './auth-line.js'
37
+ import { renderAuthLine } from './auth-line.js'
38
38
  import {
39
39
  probeAccount,
40
40
  probeAgentProcess,
@@ -274,9 +274,12 @@ export interface RenderBootCardOpts {
274
274
  * silent-when-healthy contract for callers that don't pass account
275
275
  * data (tests, harnesses, gateways without the auth model).
276
276
  *
277
- * Closes #708.
277
+ * Post-RFC H (auth-broker rewire): this carries the broker's
278
+ * `list-state` shape — `renderAuthLine` consumes it to emit the
279
+ * same one-line-per-account rows as before. Callers that pass
280
+ * `null`/`undefined` get no section. Closes #708.
278
281
  */
279
- accounts?: ReadonlyArray<AccountSummary>
282
+ accounts?: ListStateData | null
280
283
  /** Probe keys for which the prior boot saw degraded/fail and this boot
281
284
  * sees ok. Rendered as a small ✅ line above the degraded section so
282
285
  * the user gets positive-feedback that a known issue is gone. */
@@ -380,11 +383,13 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
380
383
  }
381
384
  }
382
385
 
383
- // Per-account quota section (issue #708) — one line per enabled
384
- // account showing 5h % / 7d % / nearest reset, with the active
385
- // account marked. Renders alongside the ack line so users see
386
- // headroom without running /auth or /usage.
387
- const accountRows = renderAccountRows(opts.accounts, opts.now ?? new Date())
386
+ // Per-account auth section (issue #708, RFC H rewire) — one line
387
+ // per known account with the active account marked. Renders
388
+ // alongside the ack line so users see headroom without running
389
+ // /auth or /usage. Source of truth: auth-broker list-state.
390
+ const accountRows = opts.accounts
391
+ ? renderAuthLine(opts.accounts, agentName, (opts.now ?? new Date()).getTime())
392
+ : []
388
393
 
389
394
  const sections: string[] = [ack]
390
395
  if (degradedRows.length > 0) sections.push('', ...degradedRows)
@@ -394,31 +399,12 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
394
399
  }
395
400
 
396
401
  /**
397
- * Render the per-account quota rows. Returns an empty array when no
398
- * accounts are passed keeping the boot card's silent-when-healthy
399
- * default for callers that don't supply account data.
400
- *
401
- * Reuses the dashboard's `formatAccountQuotaLine` so the two surfaces
402
- * speak with one voice.
402
+ * Re-export the broker-fed auth-row renderer under its historical
403
+ * name so direct callers (tests, harnesses) keep working without
404
+ * importing two modules. New code should import `renderAuthLine`
405
+ * from `./auth-line.js` directly.
403
406
  */
404
- export function renderAccountRows(
405
- accounts: ReadonlyArray<AccountSummary> | undefined,
406
- now: Date,
407
- ): string[] {
408
- if (!accounts || accounts.length === 0) return []
409
- const rows: string[] = []
410
- rows.push(`<b>Accounts (${accounts.length})</b>`)
411
- const nowMs = now.getTime()
412
- for (const a of accounts) {
413
- const marker = a.activeForThisAgent ? '▶' : '↳'
414
- const labelHtml = `<code>${escapeHtml(a.label)}</code>`
415
- // formatAccountQuotaLine returns HTML (with <i> tags) so we don't
416
- // re-escape — pass it through verbatim.
417
- const quotaLine = formatAccountQuotaLine(a, nowMs)
418
- rows.push(quotaLine ? `${marker} ${labelHtml} ${quotaLine}` : `${marker} ${labelHtml}`)
419
- }
420
- return rows
421
- }
407
+ export { renderAuthLine as renderAccountRows } from './auth-line.js'
422
408
 
423
409
  // ─── Probe orchestration ─────────────────────────────────────────────────────
424
410
 
@@ -480,9 +466,9 @@ export interface RunProbesOpts {
480
466
  * during the post-settle re-render so the first paint stays fast.
481
467
  */
482
468
  loadAccounts?: () =>
483
- | ReadonlyArray<AccountSummary>
469
+ | ListStateData
484
470
  | null
485
- | Promise<ReadonlyArray<AccountSummary> | null>
471
+ | Promise<ListStateData | null>
486
472
  /** When true, resolve the agent PID via cgroup walk instead of MainPID
487
473
  * (which is the tmux server pid under tmux supervisor). */
488
474
  tmuxSupervisor?: boolean
@@ -513,7 +499,7 @@ export async function runAllProbes(opts: RunProbesOpts): Promise<ProbeMap> {
513
499
  const slug = opts.agentSlug ?? opts.agentName
514
500
 
515
501
  await Promise.allSettled([
516
- probeAccount(opts.agentDir, { agentName: opts.agentSlug ?? opts.agentName }).then(r => { probes.account = r }),
502
+ probeAccount(opts.agentDir).then(r => { probes.account = r }),
517
503
  probeAgentProcess(slug, { execFileImpl: opts.probeExecFileImpl, tmuxSupervisor: opts.tmuxSupervisor, dockerMode: opts.dockerMode }).then(r => { probes.agent = r }),
518
504
  probeGateway(opts.gatewayInfo).then(r => { probes.gateway = r }),
519
505
  probeQuota(claudeDir, opts.agentDir, opts.fetchImpl).then(r => { probes.quota = r }),
@@ -604,7 +590,7 @@ export async function startBootCard(
604
590
  // Per-account rows (issue #708). Loaded best-effort
605
591
  // alongside probes; failures are swallowed so the card still
606
592
  // renders correctly with no accounts section.
607
- let accountRows: ReadonlyArray<AccountSummary> | null = null
593
+ let accountRows: ListStateData | null = null
608
594
  if (opts.loadAccounts) {
609
595
  try {
610
596
  accountRows = await opts.loadAccounts()
@@ -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,
@@ -861,7 +858,7 @@ export async function probeQuota(
861
858
  status: 'degraded',
862
859
  label: 'Quota',
863
860
  detail: 'no OAuth token',
864
- nextStep: 'No OAuth token on disk — run `switchroom auth login <agent>` to authenticate',
861
+ nextStep: 'No OAuth token on disk — register a fleet account: `switchroom auth add <label> --from-oauth` then `switchroom auth use <label>` (RFC H)',
865
862
  }
866
863
  }
867
864
 
@@ -880,8 +877,8 @@ export async function probeQuota(
880
877
  label: 'Quota',
881
878
  detail: probe.reason,
882
879
  nextStep: isAuth
883
- ? 'Auth rejected by Anthropic — re-authenticate with `switchroom auth login <agent>`'
884
- : 'Anthropic quota probe failed — re-check after a minute; if persistent, `switchroom auth login <agent>`',
880
+ ? 'Auth rejected by Anthropic — broker auto-refreshes; if persistent, replace the account: `switchroom auth add <label> --from-oauth --replace`'
881
+ : 'Anthropic quota probe failed — re-check after a minute; broker auto-rotates per `auth.fallback_order`',
885
882
  }
886
883
  }
887
884
 
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Tests for the Telegram diff-preview card renderer — RFC E §4.2.
3
+ */
4
+
5
+ import { describe, expect, it } from "vitest";
6
+ import { InlineKeyboard } from "grammy";
7
+
8
+ import { buildDiffPreview } from "../../src/drive/diff-preview.js";
9
+ import type { DiffPreviewInput } from "../../src/drive/diff-preview.js";
10
+ import { buildDiffPreviewCard } from "./diff-preview-card.js";
11
+
12
+ /** Pull row-major button shape out of grammy's InlineKeyboard. */
13
+ function rows(kb: InlineKeyboard): Array<Array<{ text: string; callback_data?: string; url?: string }>> {
14
+ return kb.inline_keyboard.map((row) =>
15
+ row.map((b) => ({
16
+ text: b.text,
17
+ ...("callback_data" in b ? { callback_data: b.callback_data } : {}),
18
+ ...("url" in b ? { url: b.url } : {}),
19
+ })),
20
+ );
21
+ }
22
+
23
+ function baseInput(overrides: Partial<DiffPreviewInput> = {}): DiffPreviewInput {
24
+ return {
25
+ agentName: "klanker",
26
+ docTitle: "Q3 Strategy Notes",
27
+ fileId: "DOC1",
28
+ mimeType: "application/vnd.google-apps.document",
29
+ resolvedAnchor: {
30
+ op: { kind: "insert_after", paragraphIndex: 4 },
31
+ displayName: "after heading 'Goals' (level 2)",
32
+ },
33
+ metrics: { linesAdded: 47, linesRemoved: 0 },
34
+ mode: "suggest",
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describe("buildDiffPreviewCard — suggest mode (default)", () => {
40
+ it("emits the wrapper-attested body + all four buttons in the RFC layout", () => {
41
+ const preview = buildDiffPreview(baseInput({ agentSummary: "Added Hiring section" }));
42
+ const card = buildDiffPreviewCard({
43
+ preview,
44
+ suggestRequestId: "aabbccdd",
45
+ writeRequestId: "11223344",
46
+ });
47
+
48
+ // Body: title bold + all preview lines.
49
+ expect(card.text).toContain("<b>");
50
+ expect(card.text).toContain("klanker");
51
+ expect(card.text).toContain("Q3 Strategy Notes");
52
+ expect(card.text).toContain("📍 after heading 'Goals' (level 2)");
53
+ expect(card.text).toContain("+47");
54
+ expect(card.text).toContain("💬");
55
+ expect(card.text).toContain("Added Hiring section");
56
+
57
+ const r = rows(card.reply_markup);
58
+ // Row 1: [Open in Drive] [Apply as suggestion]
59
+ expect(r[0]?.[0]?.text).toBe("📖 Open in Drive");
60
+ expect(r[0]?.[0]?.url).toBe("https://docs.google.com/document/d/DOC1/edit");
61
+ expect(r[0]?.[1]?.text).toBe("✅ Apply as suggestion");
62
+ expect(r[0]?.[1]?.callback_data).toBe("apv:aabbccdd:once");
63
+ // Row 2: [Apply directly] [Cancel]
64
+ expect(r[1]?.[0]?.text).toBe("⚠ Apply directly");
65
+ expect(r[1]?.[0]?.callback_data).toBe("apv:11223344:once");
66
+ expect(r[1]?.[1]?.text).toBe("🚫 Cancel");
67
+ expect(r[1]?.[1]?.callback_data).toBe("apv:aabbccdd:deny");
68
+ });
69
+
70
+ it("hides 'Apply directly' when writeRequestId is undefined", () => {
71
+ const preview = buildDiffPreview(baseInput());
72
+ const card = buildDiffPreviewCard({
73
+ preview,
74
+ suggestRequestId: "aabbccdd",
75
+ });
76
+ const flat = rows(card.reply_markup).flat();
77
+ expect(flat.find((b) => b.text === "⚠ Apply directly")).toBeUndefined();
78
+ // The other three buttons still present.
79
+ expect(flat.find((b) => b.text === "📖 Open in Drive")).toBeDefined();
80
+ expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeDefined();
81
+ expect(flat.find((b) => b.text === "🚫 Cancel")).toBeDefined();
82
+ });
83
+ });
84
+
85
+ describe("buildDiffPreviewCard — write mode (opt-in via expand)", () => {
86
+ it("only emits Apply-directly + Open-in-Drive + Cancel (no suggest button)", () => {
87
+ const preview = buildDiffPreview(baseInput({ mode: "write" }));
88
+ const card = buildDiffPreviewCard({
89
+ preview,
90
+ // In write-mode the suggest id is still needed for the Cancel
91
+ // callback's deny channel — semantically Cancel is "don't grant
92
+ // either scope" but reusing the suggest id keeps the existing
93
+ // approval-callback handler stateless.
94
+ suggestRequestId: "aabbccdd",
95
+ writeRequestId: "11223344",
96
+ });
97
+
98
+ const r = rows(card.reply_markup);
99
+ const flat = r.flat();
100
+ expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeUndefined();
101
+ const directly = flat.find((b) => b.text === "⚠ Apply directly");
102
+ expect(directly).toBeDefined();
103
+ expect(directly?.callback_data).toBe("apv:11223344:once");
104
+ // Title icon swaps to ⚠.
105
+ expect(card.text).toContain("⚠");
106
+ });
107
+ });
108
+
109
+ describe("buildDiffPreviewCard — input validation", () => {
110
+ it("throws on a malformed suggestRequestId", () => {
111
+ const preview = buildDiffPreview(baseInput());
112
+ expect(() =>
113
+ buildDiffPreviewCard({ preview, suggestRequestId: "not-hex" }),
114
+ ).toThrow(/8 hex chars/);
115
+ });
116
+
117
+ it("throws on a malformed writeRequestId", () => {
118
+ const preview = buildDiffPreview(baseInput());
119
+ expect(() =>
120
+ buildDiffPreviewCard({
121
+ preview,
122
+ suggestRequestId: "aabbccdd",
123
+ writeRequestId: "ABCDEF01", // wrong case
124
+ }),
125
+ ).toThrow(/8 hex chars/);
126
+ });
127
+ });
128
+
129
+ describe("buildDiffPreviewCard — fragility guards", () => {
130
+ it("drops the Open-in-Drive button when fileId is the 'pending-create' sentinel", () => {
131
+ // create_doc prep emits "pending-create" as a placeholder fileId
132
+ // (the doc doesn't exist yet). The renderer must NOT emit a Drive
133
+ // URL pointing at a nonexistent doc.
134
+ const preview = buildDiffPreview(
135
+ baseInput({ fileId: "pending-create" }),
136
+ );
137
+ const card = buildDiffPreviewCard({
138
+ preview,
139
+ suggestRequestId: "aabbccdd",
140
+ });
141
+ const flat = rows(card.reply_markup).flat();
142
+ expect(flat.find((b) => b.text === "📖 Open in Drive")).toBeUndefined();
143
+ // Apply buttons still present — the doc creation flow is still actionable.
144
+ expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeDefined();
145
+ });
146
+
147
+ it("HTML-escapes title + lines (no markup injection from doc names)", () => {
148
+ const preview = buildDiffPreview(
149
+ baseInput({ docTitle: "<script>alert(1)</script>" }),
150
+ );
151
+ const card = buildDiffPreviewCard({
152
+ preview,
153
+ suggestRequestId: "aabbccdd",
154
+ });
155
+ expect(card.text).not.toContain("<script>");
156
+ expect(card.text).toContain("&lt;script&gt;");
157
+ });
158
+
159
+ it("HTML-escapes the agent-supplied summary", () => {
160
+ const preview = buildDiffPreview(
161
+ baseInput({ agentSummary: "Hi <b>bold</b> & <i>tags</i>" }),
162
+ );
163
+ const card = buildDiffPreviewCard({
164
+ preview,
165
+ suggestRequestId: "aabbccdd",
166
+ });
167
+ expect(card.text).not.toMatch(/💬.*<b>/);
168
+ expect(card.text).toContain("&lt;b&gt;");
169
+ });
170
+ });
171
+
172
+ describe("buildDiffPreviewCard — audit fidelity", () => {
173
+ it("preview audit row matches what the user sees on the card", () => {
174
+ const input = baseInput({ agentSummary: "Added the Hiring section" });
175
+ const preview = buildDiffPreview(input);
176
+ const card = buildDiffPreviewCard({
177
+ preview,
178
+ suggestRequestId: "aabbccdd",
179
+ writeRequestId: "11223344",
180
+ });
181
+ // The audit row captures both wrapper truth + agent framing,
182
+ // exactly as surfaced on the card.
183
+ expect(preview.audit.wrapperAttested.anchorDisplayName).toBe(
184
+ "after heading 'Goals' (level 2)",
185
+ );
186
+ expect(preview.audit.wrapperAttested.linesAdded).toBe(47);
187
+ expect(preview.audit.agentSupplied.summary).toBe("Added the Hiring section");
188
+ // Card body contains both.
189
+ expect(card.text).toContain("after heading 'Goals' (level 2)");
190
+ expect(card.text).toContain("Added the Hiring section");
191
+ });
192
+ });