switchroom 0.8.1 → 0.10.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 (105) hide show
  1. package/README.md +49 -57
  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/switchroom.js +15931 -12778
  6. package/dist/host-control/main.js +582 -43
  7. package/dist/vault/approvals/kernel-server.js +276 -47
  8. package/dist/vault/broker/server.js +333 -69
  9. package/examples/minimal.yaml +63 -0
  10. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  11. package/examples/personal-google-workspace-mcp/README.md +194 -0
  12. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  13. package/examples/switchroom.yaml +220 -0
  14. package/package.json +6 -4
  15. package/profiles/_base/start.sh.hbs +3 -3
  16. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  17. package/profiles/default/CLAUDE.md +10 -0
  18. package/profiles/default/CLAUDE.md.hbs +16 -0
  19. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  20. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  21. package/skills/buildkite-api/SKILL.md +31 -8
  22. package/skills/buildkite-cli/SKILL.md +27 -9
  23. package/skills/buildkite-migration/SKILL.md +22 -9
  24. package/skills/buildkite-pipelines/SKILL.md +26 -9
  25. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  26. package/skills/buildkite-test-engine/SKILL.md +25 -8
  27. package/skills/docx/SKILL.md +1 -1
  28. package/skills/file-bug/SKILL.md +34 -6
  29. package/skills/humanizer/SKILL.md +15 -0
  30. package/skills/humanizer-calibrate/SKILL.md +7 -1
  31. package/skills/mcp-builder/SKILL.md +1 -1
  32. package/skills/pdf/SKILL.md +1 -1
  33. package/skills/pptx/SKILL.md +1 -1
  34. package/skills/skill-creator/SKILL.md +21 -1
  35. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  36. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  41. package/skills/switchroom-cli/SKILL.md +63 -64
  42. package/skills/switchroom-health/SKILL.md +23 -10
  43. package/skills/switchroom-install/SKILL.md +3 -3
  44. package/skills/switchroom-manage/SKILL.md +26 -19
  45. package/skills/switchroom-runtime/SKILL.md +67 -15
  46. package/skills/switchroom-status/SKILL.md +26 -1
  47. package/skills/telegram-test-harness/SKILL.md +3 -0
  48. package/skills/webapp-testing/SKILL.md +31 -1
  49. package/skills/xlsx/SKILL.md +1 -1
  50. package/telegram-plugin/admin-commands/index.ts +7 -5
  51. package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
  52. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  53. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  54. package/telegram-plugin/gateway/auth-command.ts +794 -0
  55. package/telegram-plugin/gateway/auth-line.ts +123 -0
  56. package/telegram-plugin/gateway/boot-card.ts +22 -36
  57. package/telegram-plugin/gateway/boot-probes.ts +3 -3
  58. package/telegram-plugin/gateway/gateway.ts +313 -798
  59. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  60. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  61. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  62. package/telegram-plugin/permission-title.ts +56 -0
  63. package/telegram-plugin/quota-check.ts +19 -41
  64. package/telegram-plugin/scripts/build.mjs +0 -1
  65. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  66. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  67. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  68. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  69. package/telegram-plugin/tests/boot-probes.test.ts +11 -4
  70. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  71. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  72. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  73. package/telegram-plugin/uat/SETUP.md +31 -1
  74. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  75. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  76. package/telegram-plugin/uat/runners/report.ts +150 -0
  77. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  78. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  79. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  80. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  81. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  82. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  84. package/telegram-plugin/auth-dashboard.ts +0 -1104
  85. package/telegram-plugin/auth-slot-parser.ts +0 -497
  86. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  87. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  88. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  89. package/telegram-plugin/foreman/foreman.ts +0 -1165
  90. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  91. package/telegram-plugin/foreman/setup-state.ts +0 -239
  92. package/telegram-plugin/foreman/state.ts +0 -203
  93. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  94. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  95. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  96. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  97. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  98. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  99. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  100. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  101. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  102. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  103. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  104. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  105. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -1,140 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- buildDashboardText,
4
- buildDashboardKeyboard,
5
- encodeCallbackData,
6
- parseCallbackData,
7
- type DashboardState,
8
- type DashboardSlot,
9
- } from "../auth-dashboard";
10
-
11
- function slot(o: Partial<DashboardSlot> = {}): DashboardSlot {
12
- return { slot: "default", active: false, health: "healthy", quotaExhaustedUntil: null, fiveHourPct: null, sevenDayPct: null, ...o };
13
- }
14
-
15
- /**
16
- * PR C — [♻️ Restart flow] dashboard button.
17
- *
18
- * Pairs with PR B (automatic stale-session detection). When the user
19
- * wants to explicitly kill + restart a pending auth flow without
20
- * waiting for the PKCE challenge to drift, the dashboard surfaces a
21
- * button keyed by the pending slot name. Gateway routes it to
22
- * cancel + re-initiate the matching reauth/add.
23
- */
24
-
25
- describe("restart-flow callback — encode / parse round-trip", () => {
26
- it("encodes as auth:restart-flow:<agent>:<slot>", () => {
27
- expect(encodeCallbackData({ kind: "restart-flow", agent: "clerk", slot: "default" }))
28
- .toBe("auth:restart-flow:clerk:default");
29
- expect(encodeCallbackData({ kind: "restart-flow", agent: "lawgpt", slot: "slot-2" }))
30
- .toBe("auth:restart-flow:lawgpt:slot-2");
31
- });
32
-
33
- it("round-trips cleanly", () => {
34
- for (const action of [
35
- { kind: "restart-flow", agent: "clerk", slot: "default" },
36
- { kind: "restart-flow", agent: "lawgpt", slot: "slot-2" },
37
- { kind: "restart-flow", agent: "klanker", slot: "personal_v2" },
38
- ] as const) {
39
- expect(parseCallbackData(encodeCallbackData(action))).toEqual(action);
40
- }
41
- });
42
-
43
- it("rejects restart-flow without a slot (slot is required)", () => {
44
- // The `use`, `rm`, `confirm-rm`, and `restart-flow` verbs all
45
- // require a slot. Missing slot → noop (doesn't accidentally fire
46
- // a wider-scope reset).
47
- expect(parseCallbackData("auth:restart-flow:clerk")).toEqual({ kind: "noop" });
48
- expect(parseCallbackData("auth:restart-flow:clerk:")).toEqual({ kind: "noop" });
49
- });
50
-
51
- it("rejects unsafe slot names as noop (shell-injection guard)", () => {
52
- expect(parseCallbackData("auth:restart-flow:clerk:../etc")).toEqual({ kind: "noop" });
53
- expect(parseCallbackData("auth:restart-flow:clerk:bad slot")).toEqual({ kind: "noop" });
54
- expect(parseCallbackData("auth:restart-flow:clerk:slot;ls")).toEqual({ kind: "noop" });
55
- });
56
-
57
- it("encoded payload fits Telegram's 64-byte callback_data cap", () => {
58
- // Longest practical: auth:restart-flow:<agent32>:<slot32> = 17 + 32 + 1 + 32 = 82.
59
- // Agent + slot each capped at 16 chars realistic → well under 64.
60
- const realistic = encodeCallbackData({
61
- kind: "restart-flow",
62
- agent: "a".repeat(16),
63
- slot: "b".repeat(16),
64
- });
65
- expect(realistic.length).toBeLessThanOrEqual(64);
66
- });
67
- });
68
-
69
- describe("dashboard renders [Restart flow] button when pending session exists", () => {
70
- const base: DashboardState = {
71
- agent: "lawgpt",
72
- bankId: "lawgpt",
73
- plan: "max",
74
- slots: [slot({ slot: "default", active: true })],
75
- quotaHot: false,
76
- };
77
-
78
- it("omits the button when pendingSessionSlot is absent", () => {
79
- const kb = buildDashboardKeyboard(base);
80
- const flat = kb.inline_keyboard.flat();
81
- expect(flat.some((b) => b.text.includes("Restart"))).toBe(false);
82
- });
83
-
84
- it("renders the button when pendingSessionSlot is set", () => {
85
- const kb = buildDashboardKeyboard({ ...base, pendingSessionSlot: "default" });
86
- const flat = kb.inline_keyboard.flat();
87
- const btn = flat.find((b) => b.text.includes("Restart"));
88
- expect(btn).toBeDefined();
89
- expect(btn!.text).toContain("default");
90
- if (btn && "callback_data" in btn && btn.callback_data) {
91
- expect(btn.callback_data).toBe("auth:restart-flow:lawgpt:default");
92
- }
93
- });
94
-
95
- it("includes slot name in the button label for named-slot pending flows", () => {
96
- const kb = buildDashboardKeyboard({ ...base, pendingSessionSlot: "slot-2" });
97
- const flat = kb.inline_keyboard.flat();
98
- const btn = flat.find((b) => b.text.includes("Restart"));
99
- expect(btn!.text).toContain("slot-2");
100
- if (btn && "callback_data" in btn && btn.callback_data) {
101
- expect(btn.callback_data).toBe("auth:restart-flow:lawgpt:slot-2");
102
- }
103
- });
104
-
105
- it("pendingSessionSlot=null is treated as 'no pending' (no button)", () => {
106
- const kb = buildDashboardKeyboard({ ...base, pendingSessionSlot: null });
107
- const flat = kb.inline_keyboard.flat();
108
- expect(flat.some((b) => b.text.includes("Restart"))).toBe(false);
109
- });
110
- });
111
-
112
- describe("dashboard text includes the pending-flow notice", () => {
113
- const base: DashboardState = {
114
- agent: "lawgpt",
115
- bankId: "lawgpt",
116
- plan: "max",
117
- slots: [slot({ active: true })],
118
- quotaHot: false,
119
- };
120
-
121
- it("no notice when no pending", () => {
122
- const text = buildDashboardText(base);
123
- expect(text).not.toContain("Auth flow pending");
124
- expect(text).not.toContain("⏳");
125
- });
126
-
127
- it("shows a pending-flow notice when pendingSessionSlot is set", () => {
128
- const text = buildDashboardText({ ...base, pendingSessionSlot: "slot-2" });
129
- expect(text).toContain("Auth flow pending");
130
- expect(text).toContain("slot-2");
131
- expect(text).toContain("⏳");
132
- expect(text).toContain("♻️");
133
- });
134
-
135
- it("escapes HTML in pendingSessionSlot to guard against injection", () => {
136
- const text = buildDashboardText({ ...base, pendingSessionSlot: "<script>alert(1)</script>" });
137
- expect(text).not.toContain("<script>");
138
- expect(text).toContain("&lt;script&gt;");
139
- });
140
- });
@@ -1,559 +0,0 @@
1
- /**
2
- * Dashboard v3b — active/fallback marking + promote verb tests.
3
- *
4
- * Three surfaces under test:
5
- * 1. encodeCallbackData / parseCallbackData round-trip for the new
6
- * `account-promote` + `confirm-account-promote` verbs (`apr`/`cpr`).
7
- * 2. formatQuotaBar — the mini-bar renderer used on the active row.
8
- * 3. buildDashboardText / buildDashboardKeyboard — verifies the `▶`
9
- * glyph floats the active account, the "Fallback ↓:" subhead
10
- * appears when there's a distinguished active row, and that the
11
- * v3a unmarked layout is preserved when no account claims active
12
- * (older CLI without primaryForAgents).
13
- *
14
- * Pure module — no gateway/Telegram side effects.
15
- */
16
-
17
- import { describe, expect, it } from "vitest";
18
- import {
19
- encodeCallbackData,
20
- parseCallbackData,
21
- formatQuotaBar,
22
- buildDashboardText,
23
- buildDashboardKeyboard,
24
- buildAccountPromoteConfirmKeyboard,
25
- CALLBACK_BUDGET_BYTES,
26
- type AccountSummary,
27
- type DashboardState,
28
- } from "../auth-dashboard.js";
29
-
30
- const baseState: Omit<DashboardState, "accounts"> = {
31
- agent: "clerk",
32
- bankId: "clerk",
33
- plan: "max",
34
- rateLimitTier: "default_claude_max_20x",
35
- slots: [],
36
- quotaHot: false,
37
- };
38
-
39
- const acc = (
40
- label: string,
41
- overrides: Partial<AccountSummary> = {},
42
- ): AccountSummary => ({
43
- label,
44
- health: "healthy",
45
- enabledHere: true,
46
- ...overrides,
47
- });
48
-
49
- describe("v3b: account-promote callback round-trip", () => {
50
- it("encodes and decodes account-promote (verb apr)", () => {
51
- const encoded = encodeCallbackData({
52
- kind: "account-promote",
53
- agent: "clerk",
54
- label: "pixsoul@gmail.com",
55
- });
56
- expect(encoded).toBe("auth:apr:clerk:pixsoul@gmail.com");
57
- expect(parseCallbackData(encoded)).toEqual({
58
- kind: "account-promote",
59
- agent: "clerk",
60
- label: "pixsoul@gmail.com",
61
- });
62
- });
63
-
64
- it("encodes and decodes confirm-account-promote (verb cpr)", () => {
65
- const encoded = encodeCallbackData({
66
- kind: "confirm-account-promote",
67
- agent: "clerk",
68
- label: "me@kenthompson.com.au",
69
- });
70
- expect(encoded).toBe("auth:cpr:clerk:me@kenthompson.com.au");
71
- expect(parseCallbackData(encoded)).toEqual({
72
- kind: "confirm-account-promote",
73
- agent: "clerk",
74
- label: "me@kenthompson.com.au",
75
- });
76
- });
77
-
78
- it("rejects labels with disallowed characters", () => {
79
- // `/` is rejected by isSafeAccountLabel — would create on-disk
80
- // ambiguity under ~/.switchroom/accounts/.
81
- expect(parseCallbackData("auth:apr:clerk:bad/label")).toEqual({
82
- kind: "noop",
83
- });
84
- // Whitespace, quotes, etc.
85
- expect(parseCallbackData("auth:apr:clerk:bad label")).toEqual({
86
- kind: "noop",
87
- });
88
- });
89
-
90
- it("rejects payloads beyond the 64-byte cap", () => {
91
- const longLabel = "a".repeat(60);
92
- const overlong = `auth:cpr:agent:${longLabel}`;
93
- // sanity — payload exceeds the cap
94
- expect(Buffer.byteLength(overlong, "utf8")).toBeGreaterThan(
95
- CALLBACK_BUDGET_BYTES,
96
- );
97
- expect(parseCallbackData(overlong)).toEqual({ kind: "noop" });
98
- });
99
-
100
- it("rejects empty label segment", () => {
101
- expect(parseCallbackData("auth:apr:clerk:")).toEqual({ kind: "noop" });
102
- });
103
- });
104
-
105
- describe("v3b: formatQuotaBar", () => {
106
- it("renders all-empty for 0%", () => {
107
- expect(formatQuotaBar(0)).toBe("░░░░░░");
108
- });
109
-
110
- it("renders all-full for 100%", () => {
111
- expect(formatQuotaBar(100)).toBe("██████");
112
- });
113
-
114
- it("clamps below full for 99% so the bar reads visibly under the cap", () => {
115
- // Critical UX point: a 99% account is one bad turn from exhaustion.
116
- // The bar must NOT show as full. The cell math floors, so 99/100*6
117
- // = 5.94 → 5 filled cells.
118
- expect(formatQuotaBar(99)).toBe("█████░");
119
- });
120
-
121
- it("scales linearly across the range", () => {
122
- expect(formatQuotaBar(50)).toBe("███░░░");
123
- expect(formatQuotaBar(33)).toBe("█░░░░░"); // 33/100*6=1.98 → 1
124
- expect(formatQuotaBar(17)).toBe("█░░░░░"); // 17/100*6=1.02 → 1
125
- expect(formatQuotaBar(83)).toBe("████░░"); // 83/100*6=4.98 → 4
126
- });
127
-
128
- it("clamps negative or >100 inputs to the legal range", () => {
129
- expect(formatQuotaBar(-5)).toBe("░░░░░░");
130
- expect(formatQuotaBar(150)).toBe("██████");
131
- });
132
-
133
- it("supports a custom cell count", () => {
134
- expect(formatQuotaBar(50, 10)).toBe("█████░░░░░");
135
- expect(formatQuotaBar(0, 0)).toBe("");
136
- });
137
- });
138
-
139
- describe("v3b: buildDashboardText — active-row marking", () => {
140
- it("floats the activeForThisAgent row to the top with a ▶ glyph", () => {
141
- const state: DashboardState = {
142
- ...baseState,
143
- accounts: [
144
- acc("pixsoul@gmail.com", { activeForThisAgent: true }),
145
- acc("me@kenthompson.com.au"),
146
- acc("ken.thompson@outlook.com.au"),
147
- ],
148
- };
149
- const text = buildDashboardText(state);
150
- const pixIdx = text.indexOf("pixsoul@gmail.com");
151
- const meIdx = text.indexOf("me@kenthompson.com.au");
152
- expect(pixIdx).toBeGreaterThan(-1);
153
- expect(meIdx).toBeGreaterThan(-1);
154
- // Active row precedes fallbacks in the rendered text.
155
- expect(pixIdx).toBeLessThan(meIdx);
156
- // ▶ glyph appears on the active row, before the label.
157
- const arrowIdx = text.indexOf("▶");
158
- expect(arrowIdx).toBeGreaterThan(-1);
159
- expect(arrowIdx).toBeLessThan(pixIdx);
160
- });
161
-
162
- it("emits a 'Fallback ↓:' subhead when there's a distinguished active row", () => {
163
- const state: DashboardState = {
164
- ...baseState,
165
- accounts: [
166
- acc("pixsoul@gmail.com", { activeForThisAgent: true }),
167
- acc("me@kenthompson.com.au"),
168
- ],
169
- };
170
- expect(buildDashboardText(state)).toContain("Fallback");
171
- });
172
-
173
- it("falls back to the v3a unmarked layout when no account claims active", () => {
174
- // Older CLI without primaryForAgents → activeForThisAgent is unset
175
- // on every account → no ▶ glyph, no Fallback subhead. The v3a
176
- // bullet-list rendering still works.
177
- const state: DashboardState = {
178
- ...baseState,
179
- accounts: [acc("pixsoul@gmail.com"), acc("me@kenthompson.com.au")],
180
- };
181
- const text = buildDashboardText(state);
182
- expect(text).not.toContain("▶");
183
- expect(text).not.toContain("Fallback");
184
- // Both labels still appear.
185
- expect(text).toContain("pixsoul@gmail.com");
186
- expect(text).toContain("me@kenthompson.com.au");
187
- });
188
-
189
- it("renders inline mini-bars on the active row when both percentages are known", () => {
190
- const state: DashboardState = {
191
- ...baseState,
192
- accounts: [
193
- acc("pixsoul@gmail.com", {
194
- activeForThisAgent: true,
195
- fiveHourPct: 47,
196
- sevenDayPct: 12,
197
- }),
198
- ],
199
- };
200
- const text = buildDashboardText(state);
201
- // Both bars present (the "█"/"░" cells appear in the active-row's
202
- // inline summary). Spot-check the 47% → "██░░░░░" (47/100*6=2.82
203
- // → 2 filled cells) and 12% → "░░░░░░" (12/100*6=0.72 → 0 filled).
204
- expect(text).toContain(formatQuotaBar(47));
205
- expect(text).toContain(formatQuotaBar(12));
206
- expect(text).toContain("47%");
207
- expect(text).toContain("12%");
208
- });
209
-
210
- it("falls back to the legacy quota-line on the active row when only one percentage is known", () => {
211
- const state: DashboardState = {
212
- ...baseState,
213
- accounts: [
214
- acc("pixsoul@gmail.com", {
215
- activeForThisAgent: true,
216
- fiveHourPct: 47,
217
- // sevenDayPct intentionally absent
218
- }),
219
- ],
220
- };
221
- const text = buildDashboardText(state);
222
- // No mini-bar (would require both); the legacy line shows just 5h.
223
- expect(text).toContain("47%");
224
- expect(text).not.toContain("12%");
225
- });
226
-
227
- it("uses the existing 'exhausted · resets in …' line when active is exhausted", () => {
228
- const state: DashboardState = {
229
- ...baseState,
230
- accounts: [
231
- acc("pixsoul@gmail.com", {
232
- activeForThisAgent: true,
233
- quotaExhaustedUntil: Date.now() + 90 * 60_000,
234
- fiveHourPct: 100,
235
- sevenDayPct: 50,
236
- }),
237
- ],
238
- };
239
- const text = buildDashboardText(state);
240
- expect(text).toContain("exhausted");
241
- expect(text).toContain("resets in");
242
- });
243
- });
244
-
245
- describe("v3c: buildDashboardKeyboard — single Switch primary button", () => {
246
- // v3c replaces the v3b per-fallback `⤴ Promote` flood with a single
247
- // `🔀 Switch primary →` entry that opens a picker sub-keyboard.
248
- // Pin the visibility rules + the picker behaviour so a refactor can't
249
- // silently re-surface the v3b button explosion.
250
- const renderRows = (
251
- accounts: AccountSummary[],
252
- ): Array<Array<{ text: string; data: string }>> => {
253
- const kb = buildDashboardKeyboard({ ...baseState, accounts });
254
- const raw = (kb as unknown as { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> })
255
- .inline_keyboard;
256
- return raw.map((row) =>
257
- row.map((b) => ({ text: b.text, data: b.callback_data })),
258
- );
259
- };
260
-
261
- it("emits exactly ONE `🔀 Switch primary →` button when fallbacks exist", () => {
262
- const rows = renderRows([
263
- acc("pixsoul@gmail.com", { activeForThisAgent: true }),
264
- acc("me@kenthompson.com.au"),
265
- acc("ken.thompson@outlook.com.au"),
266
- ]);
267
- const switchButtons = rows
268
- .flat()
269
- .filter((b) => b.text.includes("Switch primary"));
270
- expect(switchButtons.length).toBe(1);
271
- expect(switchButtons[0].data).toBe("auth:spv:clerk");
272
- });
273
-
274
- it("hides the Switch primary button when no fallback exists", () => {
275
- // Only one account, and it's already active → nothing to switch to.
276
- const rows = renderRows([
277
- acc("pixsoul@gmail.com", { activeForThisAgent: true }),
278
- ]);
279
- const switchButtons = rows
280
- .flat()
281
- .filter((b) => b.text.includes("Switch primary"));
282
- expect(switchButtons.length).toBe(0);
283
- });
284
-
285
- it("hides the Switch primary button when no account claims active", () => {
286
- // Older CLI without primaryForAgents → activeForThisAgent unset
287
- // everywhere → can't tell which account to keep, so no picker.
288
- const rows = renderRows([
289
- acc("pixsoul@gmail.com"),
290
- acc("me@kenthompson.com.au"),
291
- ]);
292
- const switchButtons = rows
293
- .flat()
294
- .filter((b) => b.text.includes("Switch primary"));
295
- expect(switchButtons.length).toBe(0);
296
- });
297
-
298
- it("does NOT emit per-fallback ⤴ Promote buttons on the main board", () => {
299
- // The whole point of v3c — kill the button flood.
300
- const rows = renderRows([
301
- acc("pixsoul@gmail.com", { activeForThisAgent: true }),
302
- acc("me@kenthompson.com.au"),
303
- acc("ken.thompson@outlook.com.au"),
304
- ]);
305
- const promoteRows = rows.flat().filter((b) => b.text.includes("⤴ Promote"));
306
- expect(promoteRows.length).toBe(0);
307
- });
308
-
309
- it("does NOT emit per-account drilldown buttons on the main board", () => {
310
- // v3c also drops the per-account `account-view` drilldown buttons
311
- // (av verb) — the text already names every account, the sub-views
312
- // are reachable via Switch primary / Reauth / Add buttons.
313
- const rows = renderRows([
314
- acc("pixsoul@gmail.com", { activeForThisAgent: true }),
315
- acc("me@kenthompson.com.au"),
316
- ]);
317
- const drilldownRows = rows
318
- .flat()
319
- .filter((b) => b.data.startsWith("auth:av:"));
320
- expect(drilldownRows.length).toBe(0);
321
- });
322
- });
323
-
324
- describe("v3c: buildSwitchPrimaryKeyboard — picker", () => {
325
- it("emits one row per candidate, each fires confirm-account-promote", async () => {
326
- const { buildSwitchPrimaryKeyboard } = await import("../auth-dashboard.js");
327
- const kb = buildSwitchPrimaryKeyboard("clerk", [
328
- { label: "me@kenthompson.com.au", health: "healthy" },
329
- { label: "ken.thompson@outlook.com.au", health: "healthy" },
330
- ]);
331
- const raw = (kb as unknown as {
332
- inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
333
- }).inline_keyboard;
334
- // 2 candidate rows + 1 cancel row.
335
- expect(raw.length).toBe(3);
336
- expect(raw[0][0].callback_data).toBe(
337
- "auth:cpr:clerk:me@kenthompson.com.au",
338
- );
339
- expect(raw[1][0].callback_data).toBe(
340
- "auth:cpr:clerk:ken.thompson@outlook.com.au",
341
- );
342
- // Cancel returns to the main board via refresh.
343
- expect(raw[2][0].text).toContain("Cancel");
344
- expect(raw[2][0].callback_data).toBe("auth:refresh:clerk");
345
- });
346
-
347
- it("renders a noop fallback when a candidate's payload exceeds 64 bytes", async () => {
348
- const { buildSwitchPrimaryKeyboard } = await import("../auth-dashboard.js");
349
- const kb = buildSwitchPrimaryKeyboard("a".repeat(50), [
350
- { label: "b".repeat(50), health: "healthy" },
351
- ]);
352
- const raw = (kb as unknown as {
353
- inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
354
- }).inline_keyboard;
355
- const guarded = raw[0][0];
356
- expect(guarded.text).toContain("(use CLI)");
357
- expect(guarded.callback_data).toBe("auth:noop");
358
- });
359
-
360
- it("appends health suffix to each candidate row", async () => {
361
- const { buildSwitchPrimaryKeyboard } = await import("../auth-dashboard.js");
362
- const kb = buildSwitchPrimaryKeyboard("clerk", [
363
- { label: "expired@x.com", health: "expired" },
364
- { label: "good@x.com", health: "healthy" },
365
- ]);
366
- const raw = (kb as unknown as {
367
- inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
368
- }).inline_keyboard;
369
- expect(raw[0][0].text).toContain("⌛");
370
- expect(raw[1][0].text).not.toContain("⌛");
371
- expect(raw[1][0].text).not.toContain("⚠");
372
- });
373
- });
374
-
375
- describe("v3c: switch-primary-view callback round-trip", () => {
376
- it("encodes and decodes (verb spv)", () => {
377
- const encoded = encodeCallbackData({
378
- kind: "switch-primary-view",
379
- agent: "clerk",
380
- });
381
- expect(encoded).toBe("auth:spv:clerk");
382
- expect(parseCallbackData(encoded)).toEqual({
383
- kind: "switch-primary-view",
384
- agent: "clerk",
385
- });
386
- });
387
-
388
- it("rejects unsafe agent names", () => {
389
- expect(parseCallbackData("auth:spv:bad/agent")).toEqual({ kind: "noop" });
390
- });
391
- });
392
-
393
- describe("v3b: Slots + Pool sections hide when active-account signal is present", () => {
394
- // The slot row was rendering `● pixsoul@gmail.com (active) ✓ healthy`
395
- // when the active label was known — a 1:1 duplicate of the
396
- // `▶ pixsoul@gmail.com ✓` active-account row above. Same for the
397
- // `Pool: pixsoul@gmail.com is active` line. So we hide both sections
398
- // entirely under the new account model. Pin the visibility rules so
399
- // a refactor can't silently re-surface the duplication.
400
- const slotRowState = (
401
- activeAccountLabel: string | null,
402
- ): DashboardState => ({
403
- ...baseState,
404
- slots: [
405
- {
406
- slot: "default",
407
- active: true,
408
- health: "active",
409
- },
410
- ],
411
- accounts:
412
- activeAccountLabel != null
413
- ? [
414
- acc(activeAccountLabel, { activeForThisAgent: true }),
415
- acc("ken.thompson@outlook.com.au"),
416
- ]
417
- : [acc("ken.thompson@outlook.com.au")],
418
- });
419
-
420
- it("hides the Slots section entirely when an active-account signal is present", () => {
421
- const text = buildDashboardText(slotRowState("pixsoul@gmail.com"));
422
- // No "Slots (N)" header, no "default" leaking out, no Pool line.
423
- expect(text).not.toContain("Slots (");
424
- expect(text).not.toContain("default");
425
- expect(text).not.toMatch(/Pool:/);
426
- // The ▶ active row is the single source of truth for what's active.
427
- expect(text).toContain("▶");
428
- expect(text).toContain("pixsoul@gmail.com");
429
- });
430
-
431
- it("keeps the legacy Slots + Pool layout when accounts have no active signal", () => {
432
- // Older CLIs don't emit primaryForAgents → no activeForThisAgent
433
- // is set on any account → slots section is the only signal of
434
- // "what's active." Preserve it for graceful degradation.
435
- const text = buildDashboardText(slotRowState(null));
436
- expect(text).toContain("Slots (");
437
- expect(text).toContain("<code>default</code> (active)");
438
- expect(text).toContain("Pool:");
439
- });
440
-
441
- it("keeps the Slots section visible when no accounts exist (fresh-fleet bootstrap)", () => {
442
- // Bootstrap path: no accounts yet, the operator's only handle is
443
- // the slot — they need [➕ Add slot] / [🔄 Reauth] to work.
444
- const text = buildDashboardText({
445
- ...baseState,
446
- slots: [{ slot: "default", active: true, health: "active" }],
447
- accounts: [],
448
- });
449
- expect(text).toContain("Slots (");
450
- expect(text).toContain("default");
451
- });
452
- });
453
-
454
- describe("v3b: buildAccountPromoteConfirmKeyboard", () => {
455
- it("emits a confirm row whose callback dispatches confirm-account-promote", () => {
456
- const kb = buildAccountPromoteConfirmKeyboard("clerk", "pixsoul@gmail.com");
457
- const raw = (kb as unknown as { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> }).inline_keyboard;
458
- const confirm = raw.flat().find((b) => b.text.includes("Confirm promote"));
459
- expect(confirm?.callback_data).toBe("auth:cpr:clerk:pixsoul@gmail.com");
460
- const cancel = raw.flat().find((b) => b.text.includes("Cancel"));
461
- expect(cancel?.callback_data).toBe("auth:refresh:clerk");
462
- });
463
- });
464
-
465
- describe("regression: button count cap on the main board", () => {
466
- // Real-world wedge: a screenshot from /auth showed 8 buttons stacked
467
- // vertically on a three-account fleet (the v3b explosion). v3c
468
- // collapsed everything into a Switch primary picker. Pin the cap so
469
- // a future "let's add one more affordance" PR can't bring it back.
470
- const renderRows = (accounts: AccountSummary[]): number => {
471
- const kb = buildDashboardKeyboard({ ...baseState, accounts });
472
- return (
473
- kb as unknown as {
474
- inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
475
- }
476
- ).inline_keyboard.length;
477
- };
478
-
479
- it("renders <=6 keyboard rows with three accounts (down from 8 in v3b)", () => {
480
- // pixsoul (active) + 2 fallbacks. Expected layout:
481
- // row 1: 🔀 Switch primary →
482
- // row 2: 🔄 Reauth + ➕ Add slot (2 buttons, 1 row)
483
- // row 3: 📊 Full quota
484
- // row 4: 🔁 Refresh
485
- // = 4 rows. Cap at 6 leaves room for a future row without letting
486
- // the v3b explosion return.
487
- expect(
488
- renderRows([
489
- acc("pixsoul@gmail.com", { activeForThisAgent: true }),
490
- acc("me@kenthompson.com.au"),
491
- acc("ken.thompson@outlook.com.au"),
492
- ]),
493
- ).toBeLessThanOrEqual(6);
494
- });
495
-
496
- it("never emits a Promote button targeting the active account", () => {
497
- // The original screenshot bug: ⤴ Promote pixsoul@gmail.com
498
- // appeared even when pixsoul was the active row. Pin that no
499
- // promote callback (apr/cpr verbs) targets the active label.
500
- const kb = buildDashboardKeyboard({
501
- ...baseState,
502
- accounts: [
503
- acc("pixsoul@gmail.com", { activeForThisAgent: true }),
504
- acc("me@kenthompson.com.au"),
505
- ],
506
- });
507
- const allButtons = (
508
- kb as unknown as {
509
- inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
510
- }
511
- ).inline_keyboard.flat();
512
- for (const btn of allButtons) {
513
- const m = btn.callback_data.match(/^auth:(?:apr|cpr):[^:]+:(.+)$/);
514
- if (m) {
515
- expect(m[1], "active label found in promote callback").not.toBe(
516
- "pixsoul@gmail.com",
517
- );
518
- }
519
- }
520
- });
521
- });
522
-
523
- describe("regression: [⚠️ Fall back now] button stays gone (v0.6.11)", () => {
524
- // Removed when the Switch primary picker became the operator-facing
525
- // surface for the same outcome. Two paths to the same action
526
- // confused operators. If quotaHot ever re-surfaces the button, this
527
- // test catches it.
528
- it("absent regardless of quotaHot, slot health, or accounts shape", () => {
529
- const cases: Array<Parameters<typeof buildDashboardKeyboard>[0]> = [
530
- { ...baseState, quotaHot: false },
531
- { ...baseState, quotaHot: true },
532
- {
533
- ...baseState,
534
- quotaHot: true,
535
- slots: [{ slot: "default", active: true, health: "quota-exhausted" }],
536
- },
537
- {
538
- ...baseState,
539
- accounts: [
540
- acc("pixsoul", { activeForThisAgent: true, fiveHourPct: 99 }),
541
- ],
542
- },
543
- ];
544
- for (const state of cases) {
545
- const kb = buildDashboardKeyboard(state);
546
- const labels = (
547
- kb as unknown as {
548
- inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
549
- }
550
- ).inline_keyboard
551
- .flat()
552
- .map((b) => b.text);
553
- expect(
554
- labels.some((t) => /fall.?back/i.test(t)),
555
- `Fall back surfaced under quotaHot=${state.quotaHot}, slots=${state.slots?.length}`,
556
- ).toBe(false);
557
- }
558
- });
559
- });