switchroom 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +32 -16
  2. package/dist/agent-scheduler/index.js +216 -97
  3. package/dist/auth-broker/index.js +176 -97
  4. package/dist/cli/drive-write-pretool.mjs +26 -11
  5. package/dist/cli/skill-validate-pretool.mjs +7209 -0
  6. package/dist/cli/switchroom.js +45571 -42642
  7. package/dist/cli/ui/index.html +1281 -0
  8. package/dist/host-control/main.js +3628 -309
  9. package/dist/vault/approvals/kernel-server.js +207 -98
  10. package/dist/vault/broker/server.js +249 -119
  11. package/examples/personal-google-workspace-mcp/README.md +8 -3
  12. package/examples/switchroom.yaml +91 -42
  13. package/package.json +4 -3
  14. package/profiles/_base/start.sh.hbs +76 -36
  15. package/profiles/_shared/agent-self-service.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +4 -2
  17. package/skills/file-bug/SKILL.md +6 -4
  18. package/skills/skill-creator/SKILL.md +52 -0
  19. package/skills/switchroom-cli/SKILL.md +20 -4
  20. package/skills/switchroom-install/SKILL.md +3 -3
  21. package/telegram-plugin/auth-snapshot-format.ts +9 -9
  22. package/telegram-plugin/card-format.ts +3 -3
  23. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  24. package/telegram-plugin/dist/gateway/gateway.js +853 -414
  25. package/telegram-plugin/dist/server.js +162 -161
  26. package/telegram-plugin/format.ts +71 -0
  27. package/telegram-plugin/gateway/access-validator.test.ts +8 -8
  28. package/telegram-plugin/gateway/access-validator.ts +1 -1
  29. package/telegram-plugin/gateway/approval-card.test.ts +18 -18
  30. package/telegram-plugin/gateway/approval-card.ts +1 -1
  31. package/telegram-plugin/gateway/auth-command.ts +2 -2
  32. package/telegram-plugin/gateway/boot-card.ts +40 -3
  33. package/telegram-plugin/gateway/boot-probes.ts +114 -30
  34. package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
  35. package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
  36. package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
  37. package/telegram-plugin/gateway/gateway.ts +265 -22
  38. package/telegram-plugin/gateway/update-announce.ts +167 -0
  39. package/telegram-plugin/quota-check.ts +0 -195
  40. package/telegram-plugin/recent-outbound-dedup.ts +1 -1
  41. package/telegram-plugin/registry/turns-schema.ts +1 -1
  42. package/telegram-plugin/retry-api-call.ts +24 -0
  43. package/telegram-plugin/server.ts +8 -5
  44. package/telegram-plugin/tests/auth-add-flow.test.ts +32 -3
  45. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  46. package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
  48. package/telegram-plugin/tests/boot-probes.test.ts +90 -2
  49. package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
  50. package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
  51. package/telegram-plugin/tests/fleet-state.test.ts +3 -2
  52. package/telegram-plugin/tests/quota-check.test.ts +0 -409
  53. package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
  54. package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
  55. package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
  56. package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
  57. package/telegram-plugin/tests/secret-detect.test.ts +8 -8
  58. package/telegram-plugin/tests/telegram-format.test.ts +84 -1
  59. package/telegram-plugin/tests/update-announce.test.ts +154 -0
  60. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
  61. package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -0
  62. package/telegram-plugin/welcome-text.ts +1 -8
  63. package/profiles/default/CLAUDE.md +0 -192
  64. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  65. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  66. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  67. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  68. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  69. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  70. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  71. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  72. package/telegram-plugin/first-paint.ts +0 -225
  73. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  74. package/telegram-plugin/server.js +0 -41795
  75. package/telegram-plugin/tests/html-balanced.ts +0 -63
  76. package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
  77. package/telegram-plugin/tool-error-filter.ts +0 -89
@@ -0,0 +1,1281 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Switchroom Fleet</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #0f1117;
12
+ --surface: #1a1d27;
13
+ --surface-hover: #22263a;
14
+ --border: #2a2e3e;
15
+ --text: #e1e4ed;
16
+ --text-dim: #8b8fa3;
17
+ --green: #34d399;
18
+ --red: #f87171;
19
+ --yellow: #fbbf24;
20
+ --blue: #60a5fa;
21
+ --radius: 10px;
22
+ }
23
+
24
+ body {
25
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ line-height: 1.5;
29
+ min-height: 100vh;
30
+ }
31
+
32
+ header {
33
+ padding: 1.5rem 2rem;
34
+ border-bottom: 1px solid var(--border);
35
+ display: flex;
36
+ justify-content: space-between;
37
+ align-items: center;
38
+ }
39
+
40
+ header h1 {
41
+ font-size: 1.25rem;
42
+ font-weight: 600;
43
+ letter-spacing: -0.02em;
44
+ }
45
+
46
+ header h1 span { color: var(--text-dim); font-weight: 400; }
47
+
48
+ .refresh-info {
49
+ font-size: 0.8rem;
50
+ color: var(--text-dim);
51
+ }
52
+
53
+ main {
54
+ max-width: 1200px;
55
+ margin: 0 auto;
56
+ padding: 1.5rem;
57
+ }
58
+
59
+ .agents-grid {
60
+ display: grid;
61
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
62
+ gap: 1rem;
63
+ }
64
+
65
+ .agent-card {
66
+ background: var(--surface);
67
+ border: 1px solid var(--border);
68
+ border-radius: var(--radius);
69
+ overflow: hidden;
70
+ transition: border-color 0.15s;
71
+ }
72
+
73
+ .agent-card:hover { border-color: #3a3e4e; }
74
+
75
+ .card-header {
76
+ padding: 1rem 1.25rem;
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 0.75rem;
80
+ cursor: pointer;
81
+ user-select: none;
82
+ }
83
+
84
+ .status-dot {
85
+ width: 10px;
86
+ height: 10px;
87
+ border-radius: 50%;
88
+ flex-shrink: 0;
89
+ }
90
+
91
+ .status-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
92
+ .status-dot.inactive { background: var(--red); }
93
+ .status-dot.auth-warning { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
94
+
95
+ .agent-name {
96
+ font-weight: 600;
97
+ font-size: 1rem;
98
+ }
99
+
100
+ .agent-topic {
101
+ font-size: 0.85rem;
102
+ color: var(--text-dim);
103
+ margin-left: auto;
104
+ }
105
+
106
+ .card-meta {
107
+ padding: 0 1.25rem 1rem;
108
+ display: flex;
109
+ flex-wrap: wrap;
110
+ gap: 0.5rem 1.25rem;
111
+ font-size: 0.8rem;
112
+ color: var(--text-dim);
113
+ }
114
+
115
+ .meta-item label {
116
+ color: var(--text-dim);
117
+ opacity: 0.7;
118
+ }
119
+
120
+ .meta-item span { color: var(--text); }
121
+
122
+ .card-actions {
123
+ padding: 0.75rem 1.25rem;
124
+ border-top: 1px solid var(--border);
125
+ display: flex;
126
+ gap: 0.5rem;
127
+ }
128
+
129
+ .btn {
130
+ padding: 0.4rem 0.9rem;
131
+ border: 1px solid var(--border);
132
+ border-radius: 6px;
133
+ background: transparent;
134
+ color: var(--text);
135
+ font-size: 0.78rem;
136
+ cursor: pointer;
137
+ transition: background 0.15s, border-color 0.15s;
138
+ }
139
+
140
+ .btn:hover { background: var(--surface-hover); border-color: #4a4e5e; }
141
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
142
+ .btn.btn-start { color: var(--green); border-color: rgba(52,211,153,0.3); }
143
+ .btn.btn-start:hover { background: rgba(52,211,153,0.1); }
144
+ .btn.btn-stop { color: var(--red); border-color: rgba(248,113,113,0.3); }
145
+ .btn.btn-stop:hover { background: rgba(248,113,113,0.1); }
146
+ .btn.btn-restart { color: var(--blue); border-color: rgba(96,165,250,0.3); }
147
+ .btn.btn-restart:hover { background: rgba(96,165,250,0.1); }
148
+
149
+ .card-logs {
150
+ display: none;
151
+ border-top: 1px solid var(--border);
152
+ }
153
+
154
+ .card-logs.open { display: block; }
155
+
156
+ .logs-header {
157
+ padding: 0.5rem 1.25rem;
158
+ display: flex;
159
+ justify-content: space-between;
160
+ align-items: center;
161
+ font-size: 0.8rem;
162
+ color: var(--text-dim);
163
+ }
164
+
165
+ .logs-header button {
166
+ background: none;
167
+ border: none;
168
+ color: var(--blue);
169
+ cursor: pointer;
170
+ font-size: 0.78rem;
171
+ }
172
+
173
+ .log-output {
174
+ padding: 0.75rem 1.25rem;
175
+ max-height: 300px;
176
+ overflow-y: auto;
177
+ font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
178
+ font-size: 0.72rem;
179
+ line-height: 1.6;
180
+ white-space: pre-wrap;
181
+ word-break: break-all;
182
+ color: var(--text-dim);
183
+ background: rgba(0,0,0,0.2);
184
+ }
185
+
186
+ .error-banner {
187
+ background: rgba(248,113,113,0.1);
188
+ border: 1px solid rgba(248,113,113,0.3);
189
+ color: var(--red);
190
+ padding: 0.75rem 1.25rem;
191
+ border-radius: var(--radius);
192
+ margin-bottom: 1rem;
193
+ font-size: 0.85rem;
194
+ }
195
+
196
+ .loading {
197
+ text-align: center;
198
+ padding: 4rem;
199
+ color: var(--text-dim);
200
+ }
201
+
202
+ nav.tabs {
203
+ display: flex;
204
+ gap: 0.25rem;
205
+ padding: 0 2rem;
206
+ border-bottom: 1px solid var(--border);
207
+ }
208
+
209
+ nav.tabs button {
210
+ background: none;
211
+ border: none;
212
+ color: var(--text-dim);
213
+ padding: 0.75rem 1rem;
214
+ cursor: pointer;
215
+ font-size: 0.9rem;
216
+ border-bottom: 2px solid transparent;
217
+ margin-bottom: -1px;
218
+ }
219
+
220
+ nav.tabs button.active {
221
+ color: var(--text);
222
+ border-bottom-color: var(--blue);
223
+ }
224
+
225
+ nav.tabs button:hover { color: var(--text); }
226
+
227
+ .card-detail {
228
+ display: none;
229
+ border-top: 1px solid var(--border);
230
+ padding: 0.75rem 1.25rem;
231
+ font-size: 0.8rem;
232
+ }
233
+
234
+ .card-detail.open { display: block; }
235
+
236
+ .card-detail h4 {
237
+ font-size: 0.72rem;
238
+ text-transform: uppercase;
239
+ letter-spacing: 0.05em;
240
+ color: var(--text-dim);
241
+ margin-bottom: 0.4rem;
242
+ margin-top: 0.75rem;
243
+ }
244
+ .card-detail h4:first-child { margin-top: 0; }
245
+
246
+ .chip {
247
+ display: inline-block;
248
+ padding: 0.15rem 0.5rem;
249
+ border-radius: 999px;
250
+ background: var(--surface-hover);
251
+ font-size: 0.72rem;
252
+ color: var(--text);
253
+ margin-right: 0.25rem;
254
+ margin-bottom: 0.25rem;
255
+ }
256
+
257
+ .chip.missing { color: var(--yellow); border: 1px solid rgba(251,191,36,0.4); background: rgba(251,191,36,0.05); }
258
+
259
+ .config-pre {
260
+ background: rgba(0,0,0,0.3);
261
+ padding: 0.6rem;
262
+ border-radius: 6px;
263
+ max-height: 240px;
264
+ overflow: auto;
265
+ font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
266
+ font-size: 0.7rem;
267
+ color: var(--text-dim);
268
+ white-space: pre;
269
+ }
270
+
271
+ table.accounts-table {
272
+ width: 100%;
273
+ border-collapse: collapse;
274
+ font-size: 0.85rem;
275
+ }
276
+
277
+ table.accounts-table th, table.accounts-table td {
278
+ padding: 0.6rem 0.75rem;
279
+ text-align: left;
280
+ border-bottom: 1px solid var(--border);
281
+ }
282
+
283
+ table.accounts-table th {
284
+ font-size: 0.7rem;
285
+ text-transform: uppercase;
286
+ letter-spacing: 0.05em;
287
+ color: var(--text-dim);
288
+ font-weight: 500;
289
+ }
290
+
291
+ .health-badge {
292
+ display: inline-block;
293
+ padding: 0.15rem 0.55rem;
294
+ border-radius: 999px;
295
+ font-size: 0.72rem;
296
+ font-weight: 500;
297
+ }
298
+ .health-badge.healthy { color: var(--green); background: rgba(52,211,153,0.12); }
299
+ .health-badge.expired,
300
+ .health-badge.missing-credentials,
301
+ .health-badge.missing-refresh-token { color: var(--red); background: rgba(248,113,113,0.12); }
302
+ .health-badge.quota-exhausted { color: var(--yellow); background: rgba(251,191,36,0.12); }
303
+
304
+ .quota-pct { font-variant-numeric: tabular-nums; }
305
+ .quota-pct.high { color: var(--red); }
306
+ .quota-pct.mid { color: var(--yellow); }
307
+ .quota-pct.stale { color: var(--text-dim); font-style: italic; }
308
+ .agent-list { font-size: 0.8rem; color: var(--text-dim); }
309
+ .agent-list.primary { color: var(--text); }
310
+ .usage-pill {
311
+ display: inline-block;
312
+ padding: 0.15rem 0.55rem;
313
+ border-radius: 999px;
314
+ font-size: 0.75rem;
315
+ font-weight: 500;
316
+ }
317
+ .usage-pill.primary {
318
+ color: var(--green);
319
+ background: rgba(52,211,153,0.12);
320
+ }
321
+ .modal-backdrop {
322
+ position: fixed; inset: 0; background: rgba(0,0,0,0.6);
323
+ display: flex; align-items: center; justify-content: center; z-index: 100;
324
+ }
325
+ .modal {
326
+ background: var(--card); border: 1px solid var(--border); border-radius: 8px;
327
+ padding: 1.25rem; min-width: 320px; max-width: 480px;
328
+ }
329
+ .modal h3 { margin: 0 0 0.75rem 0; }
330
+ .modal label { display: block; font-size: 0.8rem; color: var(--text-dim); margin: 0.5rem 0 0.25rem 0; }
331
+ .modal select, .modal input { width: 100%; padding: 0.4rem; background: rgba(0,0,0,0.3);
332
+ border: 1px solid var(--border); color: var(--text); border-radius: 4px; }
333
+ .modal-actions { margin-top: 1rem; display: flex; gap: 0.5rem; justify-content: flex-end; }
334
+ .toast {
335
+ position: fixed; bottom: 1.5rem; right: 1.5rem; padding: 0.75rem 1rem;
336
+ background: var(--card); border: 1px solid var(--border); border-radius: 6px;
337
+ font-size: 0.85rem; z-index: 200; max-width: 420px;
338
+ }
339
+ .toast.ok { border-color: var(--green); }
340
+ .toast.err { border-color: var(--red); }
341
+
342
+ .accounts-grid {
343
+ display: grid;
344
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
345
+ gap: 1rem;
346
+ }
347
+
348
+ .account-card {
349
+ background: var(--surface);
350
+ border: 1px solid var(--border);
351
+ border-radius: var(--radius);
352
+ padding: 1rem 1.25rem;
353
+ transition: border-color 0.15s;
354
+ }
355
+
356
+ .account-card:hover { border-color: #3a3e4e; }
357
+
358
+ .account-card-header {
359
+ display: flex;
360
+ align-items: center;
361
+ gap: 0.75rem;
362
+ margin-bottom: 0.75rem;
363
+ }
364
+
365
+ .account-label {
366
+ font-weight: 600;
367
+ font-size: 1rem;
368
+ color: var(--text);
369
+ }
370
+
371
+ .account-usage {
372
+ font-size: 0.8rem;
373
+ color: var(--text-dim);
374
+ margin-bottom: 0.75rem;
375
+ }
376
+
377
+ .accounts-grid {
378
+ display: grid;
379
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
380
+ gap: 1rem;
381
+ margin-top: 1rem;
382
+ }
383
+
384
+ @media (max-width: 700px) {
385
+ .accounts-table-wrap { display: none; }
386
+ }
387
+
388
+ @media (min-width: 701px) {
389
+ .accounts-grid { display: none; }
390
+ }
391
+
392
+ @media (max-width: 600px) {
393
+ header { padding: 1rem; }
394
+ main { padding: 1rem; }
395
+ .agents-grid { grid-template-columns: 1fr; }
396
+ .accounts-grid { grid-template-columns: 1fr; }
397
+ .card-meta { gap: 0.3rem 1rem; }
398
+ nav.tabs { padding: 0 1rem; overflow-x: auto; }
399
+ }
400
+ </style>
401
+ </head>
402
+ <body>
403
+ <header>
404
+ <h1>Switchroom <span>Fleet</span></h1>
405
+ <div class="refresh-info">Refreshes every 10s</div>
406
+ </header>
407
+ <nav class="tabs">
408
+ <button id="tab-summary" class="active" onclick="switchTab('summary')">Summary</button>
409
+ <button id="tab-agents" onclick="switchTab('agents')">Agents</button>
410
+ <button id="tab-accounts" onclick="switchTab('accounts')">Accounts</button>
411
+ <button id="tab-system" onclick="switchTab('system')">System</button>
412
+ <button id="tab-google" onclick="switchTab('google')">Google</button>
413
+ <button id="tab-schedule" onclick="switchTab('schedule')">Schedule</button>
414
+ <button id="tab-approvals" onclick="switchTab('approvals')">Approvals</button>
415
+ </nav>
416
+ <main>
417
+ <div id="error"></div>
418
+ <div id="summary" class="loading">Loading summary…</div>
419
+ <div id="agents" style="display:none" class="loading">Loading agents...</div>
420
+ <div id="accounts" style="display:none"></div>
421
+ <div id="system" style="display:none"></div>
422
+ <div id="google" style="display:none"></div>
423
+ <div id="schedule" style="display:none"></div>
424
+ <div id="approvals" style="display:none"></div>
425
+ </main>
426
+
427
+ <script>
428
+ const API = window.location.origin;
429
+ const TOKEN = new URLSearchParams(window.location.search).get('token');
430
+ let agents = [];
431
+ let openLogs = new Set();
432
+ let openDetails = new Set();
433
+ let agentDetails = {};
434
+ let accounts = [];
435
+ let ws = null;
436
+
437
+ function authHeaders() {
438
+ const h = {};
439
+ if (TOKEN) h['Authorization'] = `Bearer ${TOKEN}`;
440
+ return h;
441
+ }
442
+
443
+ async function fetchAgents() {
444
+ try {
445
+ const res = await fetch(`${API}/api/agents`, { headers: authHeaders() });
446
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
447
+ agents = await res.json();
448
+ render();
449
+ clearError();
450
+ } catch (err) {
451
+ showError(`Failed to fetch agents: ${err.message}`);
452
+ }
453
+ }
454
+
455
+ async function fetchAccounts() {
456
+ try {
457
+ const res = await fetch(`${API}/api/accounts`, { headers: authHeaders() });
458
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
459
+ accounts = await res.json();
460
+ renderAccounts();
461
+ clearError();
462
+ } catch (err) {
463
+ showError(`Failed to fetch accounts: ${err.message}`);
464
+ }
465
+ }
466
+
467
+ async function fetchAgentDetail(name) {
468
+ try {
469
+ const [accRes, subRes, cfgRes] = await Promise.all([
470
+ fetch(`${API}/api/agents/${encodeURIComponent(name)}/accounts`, { headers: authHeaders() }),
471
+ fetch(`${API}/api/agents/${encodeURIComponent(name)}/subagents`, { headers: authHeaders() }),
472
+ fetch(`${API}/api/agents/${encodeURIComponent(name)}/config`, { headers: authHeaders() }),
473
+ ]);
474
+ agentDetails[name] = {
475
+ accounts: accRes.ok ? await accRes.json() : null,
476
+ subagents: subRes.ok ? await subRes.json() : null,
477
+ config: cfgRes.ok ? await cfgRes.json() : null,
478
+ };
479
+ render();
480
+ } catch (err) {
481
+ showError(`Failed to load detail for ${name}: ${err.message}`);
482
+ }
483
+ }
484
+
485
+ async function fetchSystemHealth() {
486
+ try {
487
+ const res = await fetch(`${API}/api/system-health`, { headers: authHeaders() });
488
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
489
+ renderSystemHealth(await res.json());
490
+ clearError();
491
+ } catch (err) {
492
+ showError(`Failed to fetch system health: ${err.message}`);
493
+ }
494
+ }
495
+
496
+ async function fetchGoogleAccounts() {
497
+ try {
498
+ const res = await fetch(`${API}/api/google-accounts`, { headers: authHeaders() });
499
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
500
+ renderGoogleAccounts(await res.json());
501
+ clearError();
502
+ } catch (err) {
503
+ showError(`Failed to fetch Google accounts: ${err.message}`);
504
+ }
505
+ }
506
+
507
+ async function fetchSchedule() {
508
+ try {
509
+ const res = await fetch(`${API}/api/schedule`, { headers: authHeaders() });
510
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
511
+ renderSchedule(await res.json());
512
+ clearError();
513
+ } catch (err) {
514
+ showError(`Failed to fetch schedule: ${err.message}`);
515
+ }
516
+ }
517
+
518
+ async function fetchApprovals() {
519
+ try {
520
+ const res = await fetch(`${API}/api/approvals`, { headers: authHeaders() });
521
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
522
+ renderApprovals(await res.json());
523
+ clearError();
524
+ } catch (err) {
525
+ showError(`Failed to fetch approvals: ${err.message}`);
526
+ }
527
+ }
528
+
529
+ function switchTab(tab) {
530
+ const tabs = ['summary', 'agents', 'accounts', 'system', 'google', 'schedule', 'approvals'];
531
+ for (const t of tabs) {
532
+ document.getElementById(`tab-${t}`).classList.toggle('active', tab === t);
533
+ document.getElementById(t).style.display = tab === t ? '' : 'none';
534
+ }
535
+ if (tab === 'summary') fetchSummary();
536
+ if (tab === 'accounts') fetchAccounts();
537
+ if (tab === 'system') fetchSystemHealth();
538
+ if (tab === 'google') fetchGoogleAccounts();
539
+ if (tab === 'schedule') fetchSchedule();
540
+ if (tab === 'approvals') fetchApprovals();
541
+ }
542
+
543
+ // Fleet overview — aggregates the cheap endpoints client-side
544
+ // (one parallel fan-out; no new server work, no extra docker/probe
545
+ // cost beyond what those tabs already do). Each tile degrades
546
+ // independently if its source errors.
547
+ async function fetchSummary() {
548
+ const el = document.getElementById('summary');
549
+ const get = async (p) => {
550
+ try {
551
+ const r = await fetch(`${API}${p}`, { headers: authHeaders() });
552
+ return r.ok ? await r.json() : null;
553
+ } catch { return null; }
554
+ };
555
+ const [ag, sys, appr, sched, accts] = await Promise.all([
556
+ get('/api/agents'), get('/api/system-health'),
557
+ get('/api/approvals'), get('/api/schedule'), get('/api/accounts'),
558
+ ]);
559
+ clearError();
560
+ const tile = (title, body, ok) => `
561
+ <div class="agent-card" style="min-width:220px">
562
+ <div class="card-header" style="cursor:default">
563
+ <span class="status-dot ${ok === null ? '' : ok ? 'active' : 'inactive'}" style="display:inline-block;vertical-align:middle"></span>
564
+ <span class="agent-name">${escapeHtml(title)}</span>
565
+ </div>
566
+ <div style="padding:0 1.25rem 1rem;font-size:0.9rem">${body}</div>
567
+ </div>`;
568
+ const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
569
+
570
+ // Agents up/total
571
+ let agentsTile;
572
+ if (Array.isArray(ag)) {
573
+ const up = ag.filter(x => x.active === 'active').length;
574
+ agentsTile = tile('Agents', `<strong>${up}/${ag.length}</strong> active`, up === ag.length);
575
+ } else agentsTile = tile('Agents', dim('unavailable'), null);
576
+
577
+ // Broker / hindsight / hostd from system-health
578
+ let brokerTile, hsTile, hostdTile;
579
+ if (sys && sys.broker) {
580
+ brokerTile = tile('Auth-broker',
581
+ sys.broker.reachable
582
+ ? `reachable · active <strong>${escapeHtml(sys.broker.active || '—')}</strong><br>${sys.broker.accounts ?? '?'} accts · ${sys.broker.agents ?? '?'} agents`
583
+ : `<span style="color:var(--red)">unreachable</span>`,
584
+ !!sys.broker.reachable);
585
+ } else brokerTile = tile('Auth-broker', dim('unavailable'), null);
586
+ if (sys && sys.hindsight) {
587
+ hsTile = tile('Hindsight',
588
+ sys.hindsight.running
589
+ ? `running<br>${escapeHtml(sys.hindsight.model || '?')} · ${sys.hindsight.mcpStateless == null ? '?' : sys.hindsight.mcpStateless ? 'stateless' : 'stateful'}`
590
+ : `<span style="color:var(--red)">not running</span>`,
591
+ !!sys.hindsight.running);
592
+ } else hsTile = tile('Hindsight', dim('unavailable'), null);
593
+ if (sys && sys.hostd) {
594
+ hostdTile = tile('Hostd',
595
+ sys.hostd.auditLogPresent
596
+ ? `audit present · ${(sys.hostd.recent || []).length} recent`
597
+ : dim('no audit log yet'),
598
+ sys.hostd.auditLogPresent ? true : null);
599
+ } else hostdTile = tile('Hostd', dim('unavailable'), null);
600
+
601
+ // Approvals
602
+ let apprTile;
603
+ if (appr && appr.reachable !== false) {
604
+ const d = appr.decisions || [];
605
+ const active = d.filter(x => !x.revoked_at && !(x.ttl_expires_at && x.ttl_expires_at < Date.now())).length;
606
+ apprTile = tile('Approvals', `<strong>${active}</strong> active · ${d.length} total`, true);
607
+ } else apprTile = tile('Approvals', dim((appr && appr.error) ? 'kernel unreachable' : 'unavailable'), null);
608
+
609
+ // Schedule
610
+ let schedTile;
611
+ if (sched && Array.isArray(sched.entries)) {
612
+ const agents = new Set(sched.entries.map(e => e.agent));
613
+ schedTile = tile('Schedule', `<strong>${sched.entries.length}</strong> cron entr${sched.entries.length === 1 ? 'y' : 'ies'} · ${agents.size} agent(s)`, true);
614
+ } else schedTile = tile('Schedule', dim('unavailable'), null);
615
+
616
+ // Quota headroom — worst cached 5h/7d across accounts (no probe;
617
+ // shows "—" until someone refreshes on the Accounts tab).
618
+ let quotaTile;
619
+ if (Array.isArray(accts) && accts.length) {
620
+ const used = accts.filter(a => a.quotaUsage);
621
+ if (used.length === 0) {
622
+ quotaTile = tile('Quota', dim('not probed yet — see Accounts tab'), null);
623
+ } else {
624
+ const worst5 = Math.max(...used.map(a => a.quotaUsage.fiveHourPct));
625
+ const worst7 = Math.max(...used.map(a => a.quotaUsage.sevenDayPct));
626
+ const anyStale = accts.some(a => a.quotaStale);
627
+ quotaTile = tile('Quota (worst acct)',
628
+ `5h <strong>${Math.round(worst5)}%</strong> · 7d <strong>${Math.round(worst7)}%</strong>${anyStale ? '<br>' + dim('some stale') : ''}`,
629
+ worst5 < 90 && worst7 < 90);
630
+ }
631
+ } else quotaTile = tile('Quota', dim('unavailable'), null);
632
+
633
+ el.classList.remove('loading');
634
+ el.innerHTML = `<div class="agents-grid">${agentsTile}${brokerTile}${hsTile}${hostdTile}${apprTile}${schedTile}${quotaTile}</div>`;
635
+ }
636
+
637
+ function showError(msg) {
638
+ document.getElementById('error').innerHTML =
639
+ `<div class="error-banner">${escapeHtml(msg)}</div>`;
640
+ }
641
+
642
+ function clearError() {
643
+ document.getElementById('error').innerHTML = '';
644
+ }
645
+
646
+ function escapeHtml(s) {
647
+ // Must cover attribute context too: values are interpolated into
648
+ // both text nodes AND title="..." attributes. The old
649
+ // textContent→innerHTML trick escapes &<> but NOT quotes, so an
650
+ // agent-authored string like `" onmouseover="x` could break out
651
+ // of a title= attribute. Escape the full set explicitly.
652
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
653
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
654
+ }[c]));
655
+ }
656
+
657
+ function statusClass(agent) {
658
+ if (agent.active === 'active') {
659
+ if (agent.auth && agent.auth.expiresAt) {
660
+ const remaining = agent.auth.expiresAt - Date.now();
661
+ if (remaining > 0 && remaining < 60 * 60 * 1000) return 'auth-warning';
662
+ }
663
+ return 'active';
664
+ }
665
+ return 'inactive';
666
+ }
667
+
668
+ function formatUptime(timestamp) {
669
+ if (!timestamp) return '--';
670
+ const d = new Date(timestamp);
671
+ if (isNaN(d.getTime())) return timestamp;
672
+ const diff = Date.now() - d.getTime();
673
+ if (diff < 0) return 'just now';
674
+ const mins = Math.floor(diff / 60000);
675
+ if (mins < 60) return `${mins}m`;
676
+ const hrs = Math.floor(mins / 60);
677
+ if (hrs < 24) return `${hrs}h ${mins % 60}m`;
678
+ const days = Math.floor(hrs / 24);
679
+ return `${days}d ${hrs % 24}h`;
680
+ }
681
+
682
+ function render() {
683
+ const container = document.getElementById('agents');
684
+ if (agents.length === 0) {
685
+ container.innerHTML = '<div class="loading">No agents yet. Run <code>switchroom agent create &lt;name&gt; --profile &lt;profile&gt;</code> to add one.</div>';
686
+ return;
687
+ }
688
+
689
+ container.className = 'agents-grid';
690
+ container.innerHTML = agents.map(a => `
691
+ <div class="agent-card" data-agent="${escapeHtml(a.name)}">
692
+ <div class="card-header" onclick="toggleLogs('${escapeHtml(a.name)}')">
693
+ <div class="status-dot ${statusClass(a)}" title="${escapeHtml(a.active)}"></div>
694
+ <div class="agent-name">${escapeHtml(a.name)}</div>
695
+ <div class="agent-topic">${a.topic_emoji ? escapeHtml(a.topic_emoji) + ' ' : ''}${escapeHtml(a.topic_name)}</div>
696
+ </div>
697
+ <div class="card-meta">
698
+ <div class="meta-item"><label>Status </label><span>${escapeHtml(a.active)}</span></div>
699
+ <div class="meta-item"><label>Uptime </label><span>${formatUptime(a.uptime)}</span></div>
700
+ <div class="meta-item"><label>Mem </label><span>${a.memory || '--'}</span></div>
701
+ <div class="meta-item"><label>Profile </label><span>${escapeHtml(a.extends)}</span></div>
702
+ <div class="meta-item"><label>Auth </label><span>${a.auth.authenticated ? '✓' : '✗'}</span></div>
703
+ <div class="meta-item"><label>Account </label><span>${a.primaryAccount ? escapeHtml(a.primaryAccount) : '<span style="color:var(--text-dim)">default</span>'}</span></div>
704
+ <div class="meta-item"><label>Collection </label><span>${escapeHtml(a.memoryCollection)}</span></div>
705
+ </div>
706
+ <div class="card-actions">
707
+ <button class="btn btn-start" onclick="agentAction('${escapeHtml(a.name)}','start')" ${a.active === 'active' ? 'disabled' : ''}>Start</button>
708
+ <button class="btn btn-stop" onclick="agentAction('${escapeHtml(a.name)}','stop')" ${a.active !== 'active' ? 'disabled' : ''}>Stop</button>
709
+ <button class="btn btn-restart" onclick="agentAction('${escapeHtml(a.name)}','restart')" ${a.active !== 'active' ? 'disabled' : ''}>Restart</button>
710
+ <button class="btn" onclick="toggleDetails('${escapeHtml(a.name)}')">${openDetails.has(a.name) ? 'Hide' : 'Details'}</button>
711
+ </div>
712
+ <div class="card-detail ${openDetails.has(a.name) ? 'open' : ''}" id="detail-${escapeHtml(a.name)}">
713
+ ${renderAgentDetail(a.name)}
714
+ </div>
715
+ <div class="card-logs ${openLogs.has(a.name) ? 'open' : ''}" id="logs-${escapeHtml(a.name)}">
716
+ <div class="logs-header">
717
+ <span>Logs</span>
718
+ <button onclick="loadLogs('${escapeHtml(a.name)}')">Refresh</button>
719
+ </div>
720
+ <div class="log-output" id="log-output-${escapeHtml(a.name)}">Click to load logs...</div>
721
+ </div>
722
+ </div>
723
+ `).join('');
724
+ }
725
+
726
+ function renderAgentDetail(name) {
727
+ const d = agentDetails[name];
728
+ if (!d) return '<div style="color:var(--text-dim)">Loading…</div>';
729
+ const accounts = d.accounts;
730
+ const subagents = d.subagents;
731
+ const config = d.config;
732
+
733
+ let accountsHtml;
734
+ if (!accounts || accounts.assigned.length === 0) {
735
+ accountsHtml = '<div style="color:var(--text-dim)">No accounts assigned (falls back to <code>default</code>).</div>';
736
+ } else {
737
+ const byLabel = new Map((accounts.details || []).map(d => [d.label, d]));
738
+ accountsHtml = accounts.assigned.map((label, i) => {
739
+ const info = byLabel.get(label);
740
+ if (!info) return `<span class="chip missing" title="not in ~/.switchroom/accounts/">${escapeHtml(label)} · missing</span>`;
741
+ const tag = i === 0 ? ' · primary' : '';
742
+ return `<span class="chip"><span class="health-badge ${escapeHtml(info.health)}" style="margin-right:0.4rem">${escapeHtml(info.health)}</span>${escapeHtml(label)}${tag}</span>`;
743
+ }).join('');
744
+ }
745
+
746
+ let subHtml;
747
+ if (!subagents || subagents.length === 0) {
748
+ subHtml = '<div style="color:var(--text-dim)">No sub-agents tracked.</div>';
749
+ } else {
750
+ subHtml = subagents.map(s => `<span class="chip">${escapeHtml(s.name || s.id || '?')}${s.status ? ' · ' + escapeHtml(s.status) : ''}</span>`).join('');
751
+ }
752
+
753
+ const configText = config ? JSON.stringify(config, null, 2) : '(unavailable)';
754
+
755
+ return `
756
+ <h4>Accounts</h4>${accountsHtml}
757
+ <h4>Sub-agents</h4>${subHtml}
758
+ <h4>Resolved config</h4><pre class="config-pre">${escapeHtml(configText)}</pre>
759
+ `;
760
+ }
761
+
762
+ function renderAccounts() {
763
+ const container = document.getElementById('accounts');
764
+ if (!accounts || accounts.length === 0) {
765
+ container.innerHTML = '<div class="loading">No accounts. Run <code>switchroom auth account add &lt;label&gt;</code>.</div>';
766
+ return;
767
+ }
768
+ // RFC-H shape: the API returns `usedBy` (agents bound via the
769
+ // fleet-active account or a per-agent override) and a broker
770
+ // `quota` AccountState ({exhausted, exhausted_until,
771
+ // threshold_violations, last_refreshed_at}). The pre-RFC-H
772
+ // primaryFor/fallbackFor lists and quota.fiveHourPct utilization
773
+ // fields no longer exist on the wire.
774
+ // usage % cell: live 5h/7d utilization from the last cached
775
+ // probe (cost-gated — see refreshQuota). null → "—" + a per-
776
+ // account ↻ that force-probes. quotaStale → value shown muted
777
+ // with ↻ to refresh.
778
+ const pctCell = (pct, label, stale) => {
779
+ if (pct == null) return '<span style="color:var(--text-dim)">—</span>';
780
+ let cls = 'quota-pct';
781
+ if (pct >= 90) cls += ' high';
782
+ else if (pct >= 70) cls += ' mid';
783
+ const v = `${Math.round(pct)}%`;
784
+ return stale
785
+ ? `<span class="${cls}" title="stale — click ↻" style="opacity:.55">${v}</span>`
786
+ : `<span class="${cls}">${v}</span>`;
787
+ };
788
+ const enriched = accounts.map(a => {
789
+ const q = a.quota || null;
790
+ const u = a.quotaUsage || null;
791
+ const exhausted = q ? !!q.exhausted : null;
792
+ return {
793
+ a,
794
+ usedBy: a.usedBy || [],
795
+ fiveH: pctCell(u ? u.fiveHourPct : null, '5h', a.quotaStale),
796
+ sevenD: pctCell(u ? u.sevenDayPct : null, '7d', a.quotaStale),
797
+ fiveReset: u && u.fiveHourResetAt ? formatTimestamp(u.fiveHourResetAt) : '<span style="color:var(--text-dim)">—</span>',
798
+ captured: u && u.capturedAt
799
+ ? formatTimestamp(u.capturedAt)
800
+ : '<span style="color:var(--text-dim)">never</span>',
801
+ exhaustedCell: q == null
802
+ ? '<span style="color:var(--text-dim)">broker offline</span>'
803
+ : exhausted
804
+ ? `<span class="quota-pct high">exhausted${q.exhausted_until ? ' → ' + formatTimestamp(q.exhausted_until) : ''}</span>`
805
+ : '<span class="quota-pct">ok</span>',
806
+ violations: q && q.threshold_violations
807
+ ? `<span class="quota-pct mid">${q.threshold_violations}</span>`
808
+ : '<span style="color:var(--text-dim)">0</span>',
809
+ expires: formatTimestamp(a.expiresAt),
810
+ };
811
+ });
812
+ const refreshBtn = (label) =>
813
+ `<button class="btn" title="Probe live quota now (billed Anthropic call)" onclick="refreshQuota('${escapeHtml(label)}', true)">↻</button>`;
814
+ const rows = enriched.map(e => {
815
+ const { a, usedBy, fiveH, sevenD, captured, exhaustedCell, violations, expires } = e;
816
+ return `
817
+ <tr>
818
+ <td>${escapeHtml(a.label)}</td>
819
+ <td><span class="health-badge ${escapeHtml(a.health)}">${escapeHtml(a.health)}</span></td>
820
+ <td>${renderUsedByCell(usedBy)}</td>
821
+ <td>${fiveH}</td>
822
+ <td>${sevenD}</td>
823
+ <td title="quota probed at">${captured}</td>
824
+ <td>${exhaustedCell}</td>
825
+ <td>${violations}</td>
826
+ <td>${expires}</td>
827
+ <td>${refreshBtn(a.label)} <button class="btn" onclick="openPromoteModal('${escapeHtml(a.label)}')">Make fleet-active…</button></td>
828
+ </tr>
829
+ `;
830
+ }).join('');
831
+ const cards = enriched.map(e => {
832
+ const { a, usedBy, fiveH, sevenD, captured, exhaustedCell, violations, expires } = e;
833
+ return `
834
+ <div class="account-card">
835
+ <div class="account-card-header">
836
+ <div class="account-label">${escapeHtml(a.label)}</div>
837
+ <span class="health-badge ${escapeHtml(a.health)}" style="margin-left:auto">${escapeHtml(a.health)}</span>
838
+ </div>
839
+ <div class="account-usage">${renderUsedByCell(usedBy)}</div>
840
+ <div class="card-meta" style="padding:0">
841
+ <div class="meta-item"><label>5h usage </label><span>${fiveH}</span></div>
842
+ <div class="meta-item"><label>7d usage </label><span>${sevenD}</span></div>
843
+ <div class="meta-item"><label>Probed </label><span>${captured}</span></div>
844
+ <div class="meta-item"><label>Quota </label><span>${exhaustedCell}</span></div>
845
+ <div class="meta-item"><label>Threshold viols </label><span>${violations}</span></div>
846
+ <div class="meta-item"><label>Expires </label><span>${expires}</span></div>
847
+ </div>
848
+ <div style="margin-top:0.75rem">
849
+ ${refreshBtn(a.label)}
850
+ <button class="btn" onclick="openPromoteModal('${escapeHtml(a.label)}')">Make fleet-active…</button>
851
+ </div>
852
+ </div>
853
+ `;
854
+ }).join('');
855
+ container.innerHTML = `
856
+ <div style="margin-bottom:0.6rem;display:flex;align-items:center;gap:0.75rem">
857
+ <button class="btn" onclick="refreshQuota(null, true)">↻ Refresh all quota</button>
858
+ <span style="font-size:0.78rem;color:var(--text-dim)">
859
+ 5h/7d usage is cached (≤10 min); ↻ forces a live probe (one billed Anthropic call per account).
860
+ </span>
861
+ </div>
862
+ <div class="accounts-table-wrap">
863
+ <table class="accounts-table">
864
+ <thead><tr>
865
+ <th>Label</th><th>Health</th><th>Used by</th>
866
+ <th>5h&nbsp;%</th><th>7d&nbsp;%</th><th>Probed</th>
867
+ <th>Quota</th><th>Threshold viols</th><th>Expires</th><th></th>
868
+ </tr></thead>
869
+ <tbody>${rows}</tbody>
870
+ </table>
871
+ </div>
872
+ <div class="accounts-grid">${cards}</div>
873
+ `;
874
+ }
875
+
876
+ // Refresh cached quota. label=null → all accounts. force=true →
877
+ // bypass the server's 10-min TTL (the per-account / "all" buttons).
878
+ async function refreshQuota(label, force) {
879
+ try {
880
+ showToast(label ? `Probing ${label}…` : 'Probing all accounts…', true);
881
+ const res = await fetch(`${API}/api/accounts/quota/refresh`, {
882
+ method: 'POST',
883
+ headers: { ...authHeaders(), 'Content-Type': 'application/json' },
884
+ body: JSON.stringify(label ? { labels: [label], force: !!force } : { force: !!force }),
885
+ });
886
+ const data = await res.json();
887
+ if (!res.ok || !data.ok) {
888
+ showToast(`Quota probe failed: ${data.error || `HTTP ${res.status}`}`, false);
889
+ } else {
890
+ showToast('Quota updated', true);
891
+ }
892
+ fetchAccounts(); // re-pull; handleGetAccounts now serves the fresh cache
893
+ } catch (err) {
894
+ showToast(`Quota probe failed: ${err.message}`, false);
895
+ }
896
+ }
897
+
898
+ function renderUsedByCell(usedBy) {
899
+ // RFC-H: a single fleet-active account plus optional per-agent
900
+ // overrides. `usedBy` is the flat list of agents currently bound
901
+ // to this account (either via fleet-active or an override). No
902
+ // more primary/fallback split — binding is singular post-RFC-H.
903
+ if (!usedBy || usedBy.length === 0) {
904
+ return '<span style="color:var(--text-dim)">unused</span>';
905
+ }
906
+ const MAX_INLINE = 4;
907
+ const title = `bound: ${usedBy.join(', ')}`;
908
+ const shown = usedBy.length <= MAX_INLINE
909
+ ? usedBy.map(escapeHtml).join(', ')
910
+ : `${usedBy.slice(0, MAX_INLINE).map(escapeHtml).join(', ')} +${usedBy.length - MAX_INLINE}`;
911
+ return `<span class="usage-pill primary" title="${escapeHtml(title)}">${shown}</span>`;
912
+ }
913
+
914
+ function renderSystemHealth(h) {
915
+ const container = document.getElementById('system');
916
+ const b = h.broker || {};
917
+ const hs = h.hindsight || {};
918
+ const hd = h.hostd || {};
919
+
920
+ const dot = (ok) => `<span class="status-dot ${ok ? 'active' : 'inactive'}" style="display:inline-block;vertical-align:middle"></span>`;
921
+ const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
922
+
923
+ const brokerBody = b.reachable
924
+ ? `<div class="card-meta" style="padding:0">
925
+ <div class="meta-item"><label>Fleet-active </label><span>${escapeHtml(b.active || '—')}</span></div>
926
+ <div class="meta-item"><label>Accounts </label><span>${b.accounts ?? '—'}</span></div>
927
+ <div class="meta-item"><label>Agents </label><span>${b.agents ?? '—'}</span></div>
928
+ <div class="meta-item"><label>Consumers </label><span>${b.consumers ?? '—'}</span></div>
929
+ </div>`
930
+ : `<div style="color:var(--red)">unreachable${b.error ? ' — ' + escapeHtml(b.error) : ''}</div>`;
931
+
932
+ const statelessCell = hs.mcpStateless == null
933
+ ? dim('unknown')
934
+ : hs.mcpStateless ? 'stateless' : '<span style="color:var(--yellow)">stateful</span>';
935
+ const hindsightBody = `
936
+ <div class="card-meta" style="padding:0">
937
+ <div class="meta-item"><label>Container </label><span>${hs.containerStatus ? escapeHtml(hs.containerStatus) : dim('absent')}</span></div>
938
+ <div class="meta-item"><label>Model </label><span>${hs.model ? escapeHtml(hs.model) : dim('unknown')}</span></div>
939
+ <div class="meta-item"><label>Provider </label><span>${hs.provider ? escapeHtml(hs.provider) : dim('unknown')}</span></div>
940
+ <div class="meta-item"><label>MCP </label><span>${statelessCell}</span></div>
941
+ </div>`;
942
+
943
+ let hostdBody;
944
+ if (!hd.auditLogPresent) {
945
+ hostdBody = dim(hd.error ? `audit log error: ${hd.error}` : 'no audit log yet (hostd has handled no privileged verbs)');
946
+ } else if (!hd.recent || hd.recent.length === 0) {
947
+ hostdBody = dim('audit log present, no entries');
948
+ } else {
949
+ const rows = hd.recent.slice().reverse().map(e => {
950
+ const caller = e.caller && e.caller.kind === 'agent' ? escapeHtml(e.caller.name) : 'operator';
951
+ const ok = e.result === 'ok' || e.exit_code === 0;
952
+ return `<tr>
953
+ <td>${dot(ok)} ${escapeHtml(e.op || '?')}</td>
954
+ <td>${caller}</td>
955
+ <td>${escapeHtml(e.result || '?')}${e.exit_code != null ? ` (${e.exit_code})` : ''}</td>
956
+ <td>${escapeHtml(shortTs(e.ts))}</td>
957
+ </tr>`;
958
+ }).join('');
959
+ hostdBody = `<table class="accounts-table" style="margin-top:0.5rem">
960
+ <thead><tr><th>Verb</th><th>Caller</th><th>Result</th><th>When</th></tr></thead>
961
+ <tbody>${rows}</tbody></table>`;
962
+ }
963
+
964
+ container.innerHTML = `
965
+ <div class="agents-grid">
966
+ <div class="agent-card"><div class="card-header" style="cursor:default">
967
+ ${dot(b.reachable)}<span class="agent-name">auth-broker</span></div>
968
+ <div style="padding:0 1.25rem 1rem">${brokerBody}</div></div>
969
+ <div class="agent-card"><div class="card-header" style="cursor:default">
970
+ ${dot(hs.running)}<span class="agent-name">hindsight</span></div>
971
+ <div style="padding:0 1.25rem 1rem">${hindsightBody}</div></div>
972
+ </div>
973
+ <div class="agent-card" style="margin-top:1rem"><div class="card-header" style="cursor:default">
974
+ ${dot(hd.auditLogPresent)}<span class="agent-name">hostd — recent privileged verbs</span></div>
975
+ <div style="padding:0 1.25rem 1rem">${hostdBody}</div></div>`;
976
+ }
977
+
978
+ function shortTs(ts) {
979
+ if (!ts) return '—';
980
+ // 2026-05-15T04:15:13.465Z → 2026-05-15 04:15:13
981
+ return String(ts).replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '');
982
+ }
983
+
984
+ function renderGoogleAccounts(rows) {
985
+ const container = document.getElementById('google');
986
+ if (!rows || rows.length === 0) {
987
+ container.innerHTML = '<div class="loading">No Google accounts. Add one under <code>google_accounts:</code> in switchroom.yaml and run <code>switchroom auth google account add</code>.</div>';
988
+ return;
989
+ }
990
+ const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
991
+ const cards = rows.map(a => {
992
+ const expires = a.expiresAt ? formatTimestamp(a.expiresAt) : dim('—');
993
+ const scope = a.scope ? escapeHtml(a.scope) : dim('broker offline');
994
+ const known = a.brokerKnown
995
+ ? '<span class="usage-pill primary">slot present</span>'
996
+ : '<span style="color:var(--yellow)">config-only (no broker slot)</span>';
997
+ const acl = (a.enabledFor && a.enabledFor.length)
998
+ ? a.enabledFor.map(escapeHtml).join(', ')
999
+ : dim('no agents enabled');
1000
+ return `
1001
+ <div class="account-card">
1002
+ <div class="account-card-header">
1003
+ <div class="account-label">${escapeHtml(a.account)}</div>
1004
+ <span style="margin-left:auto">${known}</span>
1005
+ </div>
1006
+ <div class="account-usage"><label style="color:var(--text-dim);opacity:.7">Enabled for: </label>${acl}</div>
1007
+ <div class="card-meta" style="padding:0">
1008
+ <div class="meta-item"><label>Expires </label><span>${expires}</span></div>
1009
+ <div class="meta-item" title="${a.scope ? escapeHtml(a.scope) : ''}"><label>Scope </label><span>${a.scope ? escapeHtml(a.scope.split(' ').length + ' scope(s)') : dim('—')}</span></div>
1010
+ <div class="meta-item"><label>Client </label><span>${a.clientId ? escapeHtml(a.clientId.slice(0, 16) + '…') : dim('—')}</span></div>
1011
+ </div>
1012
+ </div>`;
1013
+ }).join('');
1014
+ container.innerHTML = `<div class="accounts-grid">${cards}</div>`;
1015
+ }
1016
+
1017
+ function renderSchedule(data) {
1018
+ const container = document.getElementById('schedule');
1019
+ const entries = (data && data.entries) || [];
1020
+ const recent = (data && data.recentByAgent) || {};
1021
+ if (entries.length === 0) {
1022
+ container.innerHTML = '<div class="loading">No <code>schedule:</code> entries in any agent\'s cascade-resolved config.</div>';
1023
+ return;
1024
+ }
1025
+ const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
1026
+ // Group entries by agent.
1027
+ const byAgent = {};
1028
+ for (const e of entries) (byAgent[e.agent] ||= []).push(e);
1029
+ const cards = Object.keys(byAgent).sort().map(agent => {
1030
+ const rows = byAgent[agent].map(e => `
1031
+ <tr>
1032
+ <td><code>${escapeHtml(e.cron)}</code></td>
1033
+ <td title="${escapeHtml(e.prompt)}">${escapeHtml(e.prompt.length > 70 ? e.prompt.slice(0, 70) + '…' : e.prompt)}</td>
1034
+ </tr>`).join('');
1035
+ const fires = (recent[agent] || []).slice().reverse();
1036
+ const fireRows = fires.length === 0
1037
+ ? `<tr><td colspan="3">${dim('no recorded fires yet')}</td></tr>`
1038
+ : fires.map(f => {
1039
+ const ok = f.exitCode === 0;
1040
+ return `<tr>
1041
+ <td><span class="status-dot ${ok ? 'active' : 'inactive'}" style="display:inline-block;vertical-align:middle"></span> ${escapeHtml(shortTs(new Date(f.startedAt).toISOString()))}</td>
1042
+ <td>${escapeHtml(String(f.outputSummary || '').slice(0, 80))}</td>
1043
+ <td>${ok ? 'ok' : 'exit ' + escapeHtml(String(f.exitCode))}</td>
1044
+ </tr>`;
1045
+ }).join('');
1046
+ return `
1047
+ <div class="agent-card" style="margin-bottom:1rem">
1048
+ <div class="card-header" style="cursor:default">
1049
+ <span class="agent-name">${escapeHtml(agent)}</span>
1050
+ <span class="agent-topic">${byAgent[agent].length} schedule entr${byAgent[agent].length === 1 ? 'y' : 'ies'}</span>
1051
+ </div>
1052
+ <div style="padding:0 1.25rem 1rem">
1053
+ <table class="accounts-table"><thead><tr><th>Cron</th><th>Prompt</th></tr></thead><tbody>${rows}</tbody></table>
1054
+ <div style="margin-top:0.6rem;font-size:0.8rem;color:var(--text-dim)">Recent fires (from scheduler.jsonl)</div>
1055
+ <table class="accounts-table"><thead><tr><th>When</th><th>Result</th><th>Exit</th></tr></thead><tbody>${fireRows}</tbody></table>
1056
+ </div>
1057
+ </div>`;
1058
+ }).join('');
1059
+ container.innerHTML = cards;
1060
+ }
1061
+
1062
+ function renderApprovals(data) {
1063
+ const container = document.getElementById('approvals');
1064
+ const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
1065
+ if (!data || data.reachable === false) {
1066
+ const why = (data && data.error) ? escapeHtml(data.error) : 'approval-kernel not reachable from host';
1067
+ container.innerHTML = `<div class="loading">Approval ledger unavailable — ${why}</div>`;
1068
+ return;
1069
+ }
1070
+ const decisions = data.decisions || [];
1071
+ if (decisions.length === 0) {
1072
+ container.innerHTML = '<div class="loading">No approval decisions recorded yet.</div>';
1073
+ return;
1074
+ }
1075
+ const now = Date.now();
1076
+ const statusOf = (d) => {
1077
+ if (d.revoked_at) return ['revoked', 'inactive'];
1078
+ if (d.ttl_expires_at && d.ttl_expires_at < now) return ['expired', 'inactive'];
1079
+ return ['active', 'active'];
1080
+ };
1081
+ const rows = decisions.map(d => {
1082
+ const [label, dot] = statusOf(d);
1083
+ // Drive write scopes are the headline use of the approval flow —
1084
+ // make them visually distinct.
1085
+ const isDrive = /(^|:)(doc|drive|gdrive|sheets)/i.test(d.scope || '');
1086
+ const scopeCell = isDrive
1087
+ ? `<span class="usage-pill primary">${escapeHtml(d.scope)}</span>`
1088
+ : escapeHtml(d.scope || '—');
1089
+ return `
1090
+ <tr>
1091
+ <td><span class="status-dot ${dot}" style="display:inline-block;vertical-align:middle"></span> ${escapeHtml(label)}</td>
1092
+ <td>${escapeHtml(d.agent_unit || '—')}</td>
1093
+ <td>${scopeCell}</td>
1094
+ <td>${escapeHtml(d.action || '—')}</td>
1095
+ <td>${escapeHtml(d.decision || '—')}</td>
1096
+ <td>${d.granted_at ? formatTimestamp(d.granted_at) : dim('—')}</td>
1097
+ <td>${d.ttl_expires_at ? formatTimestamp(d.ttl_expires_at) : dim('—')}</td>
1098
+ <td title="${d.revoke_reason ? escapeHtml(d.revoke_reason) : ''}">${d.revoked_at ? formatTimestamp(d.revoked_at) : dim('—')}</td>
1099
+ </tr>`;
1100
+ }).join('');
1101
+ container.innerHTML = `
1102
+ <div style="margin-bottom:0.6rem;font-size:0.8rem;color:var(--text-dim)">
1103
+ Read-only view via the kernel operator socket — ${decisions.length} decision(s). Drive scopes highlighted.
1104
+ </div>
1105
+ <div class="accounts-table-wrap">
1106
+ <table class="accounts-table">
1107
+ <thead><tr>
1108
+ <th>Status</th><th>Agent</th><th>Scope</th><th>Action</th>
1109
+ <th>Decision</th><th>Granted</th><th>TTL expires</th><th>Revoked</th>
1110
+ </tr></thead>
1111
+ <tbody>${rows}</tbody>
1112
+ </table>
1113
+ </div>`;
1114
+ }
1115
+
1116
+ function openPromoteModal(label) {
1117
+ // RFC-H: there is no per-agent promote. Setting the fleet-active
1118
+ // account is a single fleet-wide knob (POST /api/auth/use); the
1119
+ // broker fans the new credentials out to every agent's per-agent
1120
+ // mirror. Confirm intent, then call.
1121
+ const html = `
1122
+ <div class="modal-backdrop" id="promote-backdrop">
1123
+ <div class="modal">
1124
+ <h3>Make <code>${escapeHtml(label)}</code> the fleet-active account</h3>
1125
+ <div style="margin-top:0.5rem; font-size:0.85rem;">
1126
+ Every agent without a per-agent <code>auth.override</code> will
1127
+ switch to <code>${escapeHtml(label)}</code>. The broker fans
1128
+ the credentials out immediately; agents pick them up on their
1129
+ next turn (no manual restart needed post-RFC-H).
1130
+ </div>
1131
+ <div class="modal-actions">
1132
+ <button class="btn" onclick="closePromoteModal()">Cancel</button>
1133
+ <button class="btn btn-restart" onclick="confirmPromote('${escapeHtml(label)}')">Make fleet-active</button>
1134
+ </div>
1135
+ </div>
1136
+ </div>`;
1137
+ document.body.insertAdjacentHTML('beforeend', html);
1138
+ }
1139
+
1140
+ function closePromoteModal() {
1141
+ const el = document.getElementById('promote-backdrop');
1142
+ if (el) el.remove();
1143
+ }
1144
+
1145
+ async function confirmPromote(label) {
1146
+ closePromoteModal();
1147
+ try {
1148
+ // RFC-H endpoint. Body carries the label (avoids URL-encoding
1149
+ // past the account-label charset). Response: { ok, active, fanned }.
1150
+ const res = await fetch(`${API}/api/auth/use`, {
1151
+ method: 'POST',
1152
+ headers: { ...authHeaders(), 'Content-Type': 'application/json' },
1153
+ body: JSON.stringify({ account: label }),
1154
+ });
1155
+ const data = await res.json();
1156
+ if (!res.ok || !data.ok) {
1157
+ showToast(`Set fleet-active failed: ${data.error || `HTTP ${res.status}`}`, false);
1158
+ return;
1159
+ }
1160
+ const fanned = data.fanned || [];
1161
+ const msg = fanned.length > 0
1162
+ ? `Fleet-active is now ${data.active}. Broker fanned to: ${fanned.join(', ')}.`
1163
+ : `Fleet-active is now ${data.active}.`;
1164
+ showToast(msg, true);
1165
+ fetchAccounts();
1166
+ } catch (err) {
1167
+ showToast(`Set fleet-active failed: ${err.message}`, false);
1168
+ }
1169
+ }
1170
+
1171
+ function showToast(msg, ok) {
1172
+ const el = document.createElement('div');
1173
+ el.className = `toast ${ok ? 'ok' : 'err'}`;
1174
+ el.textContent = msg;
1175
+ document.body.appendChild(el);
1176
+ setTimeout(() => el.remove(), 6000);
1177
+ }
1178
+
1179
+ function formatTimestamp(ms) {
1180
+ if (!ms) return '—';
1181
+ const d = new Date(ms);
1182
+ if (isNaN(d.getTime())) return '—';
1183
+ const diff = ms - Date.now();
1184
+ const abs = Math.abs(diff);
1185
+ const mins = Math.floor(abs / 60000);
1186
+ if (mins < 60) return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
1187
+ const hrs = Math.floor(mins / 60);
1188
+ if (hrs < 24) return diff < 0 ? `${hrs}h ago` : `in ${hrs}h`;
1189
+ const days = Math.floor(hrs / 24);
1190
+ return diff < 0 ? `${days}d ago` : `in ${days}d`;
1191
+ }
1192
+
1193
+ function toggleDetails(name) {
1194
+ if (openDetails.has(name)) {
1195
+ openDetails.delete(name);
1196
+ render();
1197
+ } else {
1198
+ openDetails.add(name);
1199
+ render();
1200
+ if (!agentDetails[name]) fetchAgentDetail(name);
1201
+ }
1202
+ }
1203
+
1204
+ async function toggleLogs(name) {
1205
+ if (openLogs.has(name)) {
1206
+ openLogs.delete(name);
1207
+ } else {
1208
+ openLogs.add(name);
1209
+ await loadLogs(name);
1210
+ }
1211
+ render();
1212
+ }
1213
+
1214
+ async function loadLogs(name) {
1215
+ const el = document.getElementById(`log-output-${name}`);
1216
+ if (!el) return;
1217
+ el.textContent = 'Loading...';
1218
+ try {
1219
+ const res = await fetch(`${API}/api/agents/${encodeURIComponent(name)}/logs?lines=50`, { headers: authHeaders() });
1220
+ const data = await res.json();
1221
+ el.textContent = data.ok ? (data.logs || '(no output)') : `Error: ${data.error}`;
1222
+ el.scrollTop = el.scrollHeight;
1223
+ } catch (err) {
1224
+ el.textContent = `Failed: ${err.message}`;
1225
+ }
1226
+ }
1227
+
1228
+ async function agentAction(name, action) {
1229
+ try {
1230
+ const res = await fetch(`${API}/api/agents/${encodeURIComponent(name)}/${action}`, {
1231
+ method: 'POST',
1232
+ headers: authHeaders()
1233
+ });
1234
+ const data = await res.json();
1235
+ if (!data.ok) showError(`${action} ${name}: ${data.error}`);
1236
+ // Refresh after brief delay to let systemd settle
1237
+ setTimeout(fetchAgents, 1000);
1238
+ } catch (err) {
1239
+ showError(`${action} ${name}: ${err.message}`);
1240
+ }
1241
+ }
1242
+
1243
+ function connectWebSocket() {
1244
+ let wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
1245
+ if (TOKEN) wsUrl += `?token=${encodeURIComponent(TOKEN)}`;
1246
+ ws = new WebSocket(wsUrl);
1247
+
1248
+ ws.onmessage = (event) => {
1249
+ try {
1250
+ const msg = JSON.parse(event.data);
1251
+ if (msg.type === 'log' && msg.agent) {
1252
+ const el = document.getElementById(`log-output-${msg.agent}`);
1253
+ if (el && openLogs.has(msg.agent)) {
1254
+ el.textContent += msg.data;
1255
+ el.scrollTop = el.scrollHeight;
1256
+ }
1257
+ }
1258
+ } catch {}
1259
+ };
1260
+
1261
+ ws.onclose = () => {
1262
+ setTimeout(connectWebSocket, 3000);
1263
+ };
1264
+
1265
+ ws.onerror = () => {
1266
+ ws.close();
1267
+ };
1268
+ }
1269
+
1270
+ // Init. Summary is the default visible tab; fetchAgents still runs
1271
+ // (populates the agents tab + keeps the 10s fleet poll warm + the
1272
+ // log WS depends on it). Summary is fetched on init + on tab-switch
1273
+ // only — deliberately NOT on the 10s interval (it fans out to
1274
+ // system-health etc.; on-demand refresh is enough).
1275
+ fetchSummary();
1276
+ fetchAgents();
1277
+ connectWebSocket();
1278
+ setInterval(fetchAgents, 10000);
1279
+ </script>
1280
+ </body>
1281
+ </html>