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
@@ -292,13 +292,16 @@ describe('probeQuota — #1163: /v1/messages headers path', () => {
292
292
  expect(result.detail).toContain('18% / 7d')
293
293
  })
294
294
 
295
- it('surfaces auth rejection with login hint on 403', async () => {
295
+ it('surfaces auth rejection with the RFC-H replace-account hint on 403', async () => {
296
296
  const fakeFetch: typeof fetch = async () =>
297
297
  new Response(null, { status: 403 }) as Response
298
298
 
299
299
  const result = await probeQuota(claudeDir, agentDir, fakeFetch)
300
300
  expect(result.status).toBe('degraded')
301
- expect(result.nextStep).toMatch(/switchroom auth login/)
301
+ // Post-RFC-H: per-agent `auth login` is retired. probeQuota emits the
302
+ // broker-aware "replace the account" hint pointing at `auth add ...
303
+ // --replace` instead. See telegram-plugin/gateway/boot-probes.ts.
304
+ expect(result.nextStep).toMatch(/switchroom auth add .*--from-oauth --replace/)
302
305
  })
303
306
 
304
307
  it('writing rate-limited result to cache produces a readable 30 s entry', () => {
@@ -893,42 +896,40 @@ describe('probeAccount — nextStep agent-name interpolation', () => {
893
896
  }
894
897
  })
895
898
 
896
- it('not-signed-in hint interpolates agentName instead of <agent>', async () => {
899
+ it('not-signed-in hint points at RFC H fleet-wide auth verbs', async () => {
897
900
  tmpDir = setupAgentDir({})
898
- const result = await probeAccount(tmpDir, { agentName: 'finn' })
901
+ const result = await probeAccount(tmpDir)
899
902
  expect(result.status).toBe('degraded')
900
903
  expect(result.detail).toBe('not signed in')
901
904
  expect(result.nextStep).toBeDefined()
902
- expect(result.nextStep).toContain('switchroom auth login finn')
903
- expect(result.nextStep).not.toContain('<agent>')
905
+ expect(result.nextStep).toContain('switchroom auth add')
906
+ expect(result.nextStep).toContain('--from-oauth')
907
+ expect(result.nextStep).toContain('switchroom auth use')
908
+ // RFC H: hint must not point at the retired per-agent `auth login` verb.
909
+ expect(result.nextStep).not.toContain('auth login')
904
910
  })
905
911
 
906
- it('expired-token hint interpolates agentName', async () => {
912
+ it('expired-token hint points at broker auto-refresh + manual fallback', async () => {
907
913
  tmpDir = setupAgentDir(
908
914
  { oauthAccount: { emailAddress: 'me@example.com', billingType: 'max' } },
909
915
  { expiresAt: Date.now() - 86_400_000 }, // expired yesterday
910
916
  )
911
- const result = await probeAccount(tmpDir, { agentName: 'klanker' })
917
+ const result = await probeAccount(tmpDir)
912
918
  expect(result.status).toBe('fail')
913
- expect(result.nextStep).toContain('switchroom auth login klanker')
914
- expect(result.nextStep).not.toContain('<agent>')
919
+ expect(result.nextStep).toContain('switchroom auth refresh')
920
+ expect(result.nextStep).toContain('--replace')
921
+ expect(result.nextStep).not.toContain('auth login')
915
922
  })
916
923
 
917
- it('expiring-soon hint interpolates agentName', async () => {
924
+ it('expiring-soon hint points at broker auto-refresh window', async () => {
918
925
  tmpDir = setupAgentDir(
919
926
  { oauthAccount: { emailAddress: 'me@example.com', billingType: 'max' } },
920
927
  { expiresAt: Date.now() + 3 * 86_400_000 }, // 3 days left (< 7)
921
928
  )
922
- const result = await probeAccount(tmpDir, { agentName: 'lawgpt' })
923
- expect(result.status).toBe('degraded')
924
- expect(result.nextStep).toContain('switchroom auth login lawgpt')
925
- expect(result.nextStep).not.toContain('<agent>')
926
- })
927
-
928
- it('falls back to <agent> placeholder when no agentName provided (backwards-compat)', async () => {
929
- tmpDir = setupAgentDir({})
930
929
  const result = await probeAccount(tmpDir)
931
- expect(result.nextStep).toContain('<agent>')
930
+ expect(result.status).toBe('degraded')
931
+ expect(result.nextStep).toContain('switchroom auth refresh')
932
+ expect(result.nextStep).not.toContain('auth login')
932
933
  })
933
934
  })
934
935
 
@@ -1149,14 +1150,18 @@ describe('nextStep — agent systemd states', () => {
1149
1150
  })
1150
1151
 
1151
1152
  describe('nextStep — quota / hindsight / broker / kernel / scheduler', () => {
1152
- it('quota: no OAuth token → degraded with login hint', async () => {
1153
+ it('quota: no OAuth token → degraded with RFC-H add+use hint', async () => {
1153
1154
  const dir = mkdtempSync(join(tmpdir(), 'quota-nextstep-'))
1154
1155
  const oldCachePath = process.env.SWITCHROOM_QUOTA_CACHE_PATH
1155
1156
  process.env.SWITCHROOM_QUOTA_CACHE_PATH = join(dir, 'cache.json')
1156
1157
  try {
1157
1158
  const r = await probeQuota(dir, dir, (async () => new Response('{}')) as unknown as typeof fetch)
1158
1159
  expect(r.status).toBe('degraded')
1159
- expect(r.nextStep).toMatch(/switchroom auth login/)
1160
+ // Post-RFC-H: the no-token nextStep points at `auth add` (register a
1161
+ // fleet account) + `auth use` (set fleet active), not the retired
1162
+ // per-agent `auth login`. See telegram-plugin/gateway/boot-probes.ts.
1163
+ expect(r.nextStep).toMatch(/switchroom auth add .*--from-oauth/)
1164
+ expect(r.nextStep).toMatch(/switchroom auth use/)
1160
1165
  } finally {
1161
1166
  if (oldCachePath) process.env.SWITCHROOM_QUOTA_CACHE_PATH = oldCachePath
1162
1167
  else delete process.env.SWITCHROOM_QUOTA_CACHE_PATH
@@ -0,0 +1,197 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createFleetFallbackGate } from "../fleet-fallback-gate.js";
3
+
4
+ function fakeClock(start = 0) {
5
+ let now = start;
6
+ return {
7
+ nowFn: () => now,
8
+ advance(ms: number) { now += ms; },
9
+ set(ms: number) { now = ms; },
10
+ };
11
+ }
12
+
13
+ describe("createFleetFallbackGate — wouldFire honesty contract", () => {
14
+ test("fresh state: wouldFire is true", () => {
15
+ const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: fakeClock().nowFn });
16
+ expect(gate.wouldFire()).toBe(true);
17
+ });
18
+
19
+ test("in-flight: wouldFire is false until action resolves", async () => {
20
+ const clock = fakeClock();
21
+ const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
22
+
23
+ let resolveAction: (b: boolean) => void = () => {};
24
+ const action = () => new Promise<boolean>((r) => { resolveAction = r; });
25
+
26
+ const firePromise = gate.fire(action);
27
+
28
+ expect(gate.wouldFire()).toBe(false);
29
+ expect(gate.inspect().inFlight).toBe(true);
30
+
31
+ resolveAction(true);
32
+ await firePromise;
33
+
34
+ // After fire stamps lastFiredAtMs, dedup window blocks until clock advances.
35
+ expect(gate.wouldFire()).toBe(false);
36
+ clock.advance(30_000);
37
+ expect(gate.wouldFire()).toBe(true);
38
+ });
39
+
40
+ test("post-fire dedup window blocks wouldFire", async () => {
41
+ const clock = fakeClock();
42
+ const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
43
+
44
+ await gate.fire(async () => true);
45
+ expect(gate.wouldFire()).toBe(false);
46
+
47
+ clock.advance(29_999);
48
+ expect(gate.wouldFire()).toBe(false);
49
+
50
+ clock.advance(1);
51
+ expect(gate.wouldFire()).toBe(true);
52
+ });
53
+
54
+ test("no-op fires (action returns false) DO NOT arm dedup window", async () => {
55
+ const clock = fakeClock();
56
+ const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
57
+
58
+ await gate.fire(async () => false);
59
+ // Window NOT armed — wouldFire should still be true immediately.
60
+ expect(gate.wouldFire()).toBe(true);
61
+ expect(gate.inspect().lastFiredAtMs).toBe(Number.NEGATIVE_INFINITY);
62
+ });
63
+
64
+ test("thrown action: dedup window NOT armed, gate releases in-flight", async () => {
65
+ const clock = fakeClock();
66
+ const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
67
+ const errors: unknown[] = [];
68
+
69
+ await gate.fire(async () => { throw new Error("broker exploded"); }, (e) => errors.push(e));
70
+
71
+ expect(gate.inspect().inFlight).toBe(false);
72
+ expect(gate.inspect().lastFiredAtMs).toBe(Number.NEGATIVE_INFINITY);
73
+ expect(gate.wouldFire()).toBe(true);
74
+ expect((errors[0] as Error).message).toBe("broker exploded");
75
+ });
76
+
77
+ test("no onError: thrown action still releases in-flight without crashing", async () => {
78
+ const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: fakeClock().nowFn });
79
+
80
+ await gate.fire(async () => { throw new Error("silent"); });
81
+
82
+ expect(gate.inspect().inFlight).toBe(false);
83
+ expect(gate.wouldFire()).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe("createFleetFallbackGate — fire semantics", () => {
88
+ test("collapses concurrent callers to one in-flight Promise", async () => {
89
+ const clock = fakeClock();
90
+ const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
91
+ let calls = 0;
92
+ let resolveAction: (b: boolean) => void = () => {};
93
+
94
+ const action = () => {
95
+ calls += 1;
96
+ return new Promise<boolean>((r) => { resolveAction = r; });
97
+ };
98
+
99
+ const p1 = gate.fire(action);
100
+ const p2 = gate.fire(action);
101
+ const p3 = gate.fire(action);
102
+
103
+ // Same in-flight promise returned to all three callers.
104
+ expect(p1).toBe(p2);
105
+ expect(p2).toBe(p3);
106
+ expect(calls).toBe(1);
107
+
108
+ resolveAction(true);
109
+ await Promise.all([p1, p2, p3]);
110
+ expect(calls).toBe(1);
111
+ });
112
+
113
+ test("fire during dedup window resolves immediately without invoking action", async () => {
114
+ const clock = fakeClock();
115
+ const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
116
+ let calls = 0;
117
+
118
+ await gate.fire(async () => { calls += 1; return true; });
119
+ expect(calls).toBe(1);
120
+
121
+ await gate.fire(async () => { calls += 1; return true; });
122
+ expect(calls).toBe(1);
123
+
124
+ clock.advance(30_000);
125
+
126
+ await gate.fire(async () => { calls += 1; return true; });
127
+ expect(calls).toBe(2);
128
+ });
129
+ });
130
+
131
+ describe("createFleetFallbackGate — broker reachability check", () => {
132
+ test("brokerReachable=false makes wouldFire return false even on fresh state", () => {
133
+ const gate = createFleetFallbackGate({
134
+ dedupMs: 30_000,
135
+ nowFn: fakeClock().nowFn,
136
+ brokerReachable: () => false,
137
+ });
138
+ expect(gate.wouldFire()).toBe(false);
139
+ });
140
+
141
+ test("brokerReachable=true gates as if no check provided", () => {
142
+ const gate = createFleetFallbackGate({
143
+ dedupMs: 30_000,
144
+ nowFn: fakeClock().nowFn,
145
+ brokerReachable: () => true,
146
+ });
147
+ expect(gate.wouldFire()).toBe(true);
148
+ });
149
+
150
+ test("brokerReachable=false makes fire() short-circuit without invoking action", async () => {
151
+ let calls = 0;
152
+ const gate = createFleetFallbackGate({
153
+ dedupMs: 30_000,
154
+ nowFn: fakeClock().nowFn,
155
+ brokerReachable: () => false,
156
+ });
157
+
158
+ await gate.fire(async () => { calls += 1; return true; });
159
+ expect(calls).toBe(0);
160
+ expect(gate.inspect().lastFiredAtMs).toBe(Number.NEGATIVE_INFINITY);
161
+ });
162
+
163
+ test("brokerReachable can flip from false to true between calls", async () => {
164
+ let reachable = false;
165
+ let calls = 0;
166
+ const gate = createFleetFallbackGate({
167
+ dedupMs: 30_000,
168
+ nowFn: fakeClock().nowFn,
169
+ brokerReachable: () => reachable,
170
+ });
171
+
172
+ expect(gate.wouldFire()).toBe(false);
173
+ await gate.fire(async () => { calls += 1; return true; });
174
+ expect(calls).toBe(0);
175
+
176
+ reachable = true;
177
+ expect(gate.wouldFire()).toBe(true);
178
+ await gate.fire(async () => { calls += 1; return true; });
179
+ expect(calls).toBe(1);
180
+ });
181
+ });
182
+
183
+ describe("createFleetFallbackGate — reset (test seam)", () => {
184
+ test("reset clears in-flight + lastFiredAtMs", async () => {
185
+ const clock = fakeClock();
186
+ const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
187
+
188
+ await gate.fire(async () => true);
189
+ expect(gate.inspect().lastFiredAtMs).toBeGreaterThan(Number.NEGATIVE_INFINITY);
190
+ expect(gate.wouldFire()).toBe(false);
191
+
192
+ gate.reset();
193
+ expect(gate.inspect().lastFiredAtMs).toBe(Number.NEGATIVE_INFINITY);
194
+ expect(gate.inspect().inFlight).toBe(false);
195
+ expect(gate.wouldFire()).toBe(true);
196
+ });
197
+ });
@@ -154,7 +154,7 @@ describe('formatModelUnavailableCard — actionable card', () => {
154
154
  return resetAt ? { kind, resetAt, raw: 'test' } : { kind, raw: 'test' }
155
155
  }
156
156
 
157
- it('quota_exhausted with reset → snapshot-stable card', () => {
157
+ it('quota_exhausted with reset → snapshot-stable card (manual-action shape)', () => {
158
158
  const card = formatModelUnavailableCard(
159
159
  detection('quota_exhausted', new Date('2026-05-03T13:00:00Z')),
160
160
  'gymbro',
@@ -165,12 +165,30 @@ describe('formatModelUnavailableCard — actionable card', () => {
165
165
  Reason: quota exhausted (resets in 5h)
166
166
 
167
167
  <b>What to try</b>
168
- • <code>/authfallback</code> — switch to the next account slot
168
+ • <code>/auth use &lt;label&gt;</code> — switch the fleet to a healthy account
169
169
  • <code>/auth add</code> — attach another subscription
170
170
  • <code>/usage</code> — show quota breakdown"
171
171
  `)
172
172
  })
173
173
 
174
+ it('autoFallbackInFlight=true → quiet variant (no manual command list)', () => {
175
+ // Regression for the "lying card" bug — when the gateway has
176
+ // already kicked off `fireFleetAutoFallback`, the card MUST NOT
177
+ // list manual commands the user shouldn't run. Otherwise the
178
+ // user manually types /auth use while a fleet swap is mid-flight,
179
+ // racing two writes through the broker.
180
+ const card = formatModelUnavailableCard(
181
+ detection('quota_exhausted', new Date('2026-05-03T13:00:00Z')),
182
+ 'gymbro',
183
+ { now: NOW, autoFallbackInFlight: true },
184
+ )
185
+ expect(card).toContain('Auto-failover in progress')
186
+ expect(card).not.toContain('What to try')
187
+ expect(card).not.toContain('/auth use')
188
+ expect(card).not.toContain('/auth add')
189
+ expect(card).not.toContain('/authfallback')
190
+ })
191
+
174
192
  it('overload without reset omits the parenthetical', () => {
175
193
  const card = formatModelUnavailableCard(detection('overload'), 'clerk', { now: NOW })
176
194
  expect(card).toContain('Reason: model overloaded')
@@ -183,11 +201,14 @@ describe('formatModelUnavailableCard — actionable card', () => {
183
201
  expect(card).not.toContain('(resets')
184
202
  })
185
203
 
186
- it('always includes the three actionable suggestions', () => {
204
+ it('default (no autoFallback) variant includes the actionable suggestions', () => {
187
205
  const card = formatModelUnavailableCard(detection('quota_exhausted'), 'gymbro', { now: NOW })
188
- expect(card).toContain('<code>/authfallback</code>')
206
+ expect(card).toContain('<code>/auth use')
189
207
  expect(card).toContain('<code>/auth add</code>')
190
208
  expect(card).toContain('<code>/usage</code>')
209
+ // Regression — `/authfallback` is no longer a verb (post-RFC-H);
210
+ // pre-fix the card lied by suggesting it.
211
+ expect(card).not.toContain('/authfallback')
191
212
  })
192
213
 
193
214
  it('names the slot in the header when one is supplied', () => {
@@ -283,9 +304,13 @@ describe('integration — gateway suppresses raw stderr in favour of the card',
283
304
  // The actionable card replaces the raw verbatim error.
284
305
  expect(card).toContain('Model unavailable')
285
306
  expect(card).toContain('quota exhausted')
286
- expect(card).toContain('/authfallback')
307
+ // Post-RFC-H: `/authfallback` is no longer a verb. The default
308
+ // (non-auto-fallback) card now points at `/auth use <label>` —
309
+ // the canonical fleet-wide swap.
310
+ expect(card).toContain('/auth use')
287
311
  expect(card).toContain('/auth add')
288
312
  expect(card).toContain('/usage')
313
+ expect(card).not.toContain('/authfallback')
289
314
 
290
315
  // And the raw stderr text never appears in the user-facing card.
291
316
  expect(card).not.toContain('out of extra usage')
@@ -103,4 +103,35 @@ describe('summarizeToolForTitle (#186)', () => {
103
103
  const input = JSON.stringify({ skill: 'mail', name: 'wrong' })
104
104
  expect(summarizeToolForTitle('Skill', input)).toBe('Skill (mail)')
105
105
  })
106
+
107
+ test('MCP curated: agent-config tools render as human verb-phrases (#1215)', () => {
108
+ expect(summarizeToolForTitle('mcp__agent-config__skill_list', undefined)).toBe(
109
+ 'List its own installed skills',
110
+ )
111
+ expect(summarizeToolForTitle('mcp__agent-config__cron_list', undefined)).toBe(
112
+ 'List its own scheduled tasks',
113
+ )
114
+ expect(summarizeToolForTitle('mcp__agent-config__peers_list', undefined)).toBe(
115
+ 'List the other agents on this instance',
116
+ )
117
+ })
118
+
119
+ test('MCP curated: hostd tools render as human verb-phrases (#1215)', () => {
120
+ expect(summarizeToolForTitle('mcp__hostd__agent_logs', undefined)).toBe(
121
+ "Read another agent's container logs",
122
+ )
123
+ expect(summarizeToolForTitle('mcp__hostd__agent_exec', undefined)).toBe(
124
+ 'Run a read-only inspection inside another agent',
125
+ )
126
+ })
127
+
128
+ test('MCP fallback: unknown mcp tool renders as `<server>: <verb with spaces>`', () => {
129
+ expect(summarizeToolForTitle('mcp__some-server__do_thing', undefined)).toBe(
130
+ 'some-server: do thing',
131
+ )
132
+ })
133
+
134
+ test('MCP malformed: bare mcp__ prefix without __<server>__<verb> shape is left alone', () => {
135
+ expect(summarizeToolForTitle('mcp__bad', undefined)).toBe('mcp__bad')
136
+ })
106
137
  })
@@ -380,41 +380,11 @@ describe('fetchAccountQuota — cache + token resolution', () => {
380
380
  }
381
381
  })
382
382
 
383
- it('persists the snapshot under the supplied home, not the real homedir (issue #708 regression)', async () => {
384
- const home = makeAccountHome({
385
- 'work@example.com': { accessToken: 'tok' },
386
- })
387
- const fakeFetch = async () =>
388
- new Response('{}', {
389
- status: 200,
390
- headers: {
391
- 'anthropic-ratelimit-unified-5h-utilization': '0.42',
392
- 'anthropic-ratelimit-unified-7d-utilization': '0.17',
393
- },
394
- })
395
- try {
396
- const r = await fetchAccountQuota('work@example.com', {
397
- home,
398
- fetchImpl: fakeFetch as typeof fetch,
399
- })
400
- expect(r.ok).toBe(true)
401
- const snapPath = join(
402
- home,
403
- '.switchroom',
404
- 'accounts',
405
- 'work@example.com',
406
- 'quota.json',
407
- )
408
- // The bug: writeAccountQuota was called without opts.home, so the
409
- // snapshot landed under the real $HOME instead of the test home.
410
- expect(existsSync(snapPath)).toBe(true)
411
- const snap = JSON.parse(readFileSync(snapPath, 'utf-8'))
412
- expect(snap.fiveHourPct).toBeCloseTo(42, 0)
413
- expect(snap.sevenDayPct).toBeCloseTo(17, 0)
414
- } finally {
415
- rmSync(home, { recursive: true, force: true })
416
- }
417
- })
383
+ // Removed in RFC H: per-account quota.json disk persistence is gone.
384
+ // switchroom-auth-broker holds canonical quota state and exposes it
385
+ // via list-state; the gateway's in-process cache is enough between
386
+ // restarts (and the broker survives gateway restarts, so the state
387
+ // is preserved at the broker side anyway).
418
388
  })
419
389
 
420
390
  describe('getCachedAccountQuota + prefetchAccountQuotaIfStale', () => {