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
@@ -380,6 +380,77 @@ export function escapeHtml(text: string): string {
380
380
  .replace(/"/g, '"')
381
381
  }
382
382
 
383
+ /**
384
+ * Last-resort renderer: turn (possibly malformed) Telegram HTML into
385
+ * readable plain text. Used by the gateway's send/edit path when
386
+ * Telegram rejects a chunk with a 400 "can't parse entities" /
387
+ * "unsupported start tag" — i.e. our HTML prevention (markdownToHtml +
388
+ * sanitizeForTelegram + splitHtmlChunks) let something through anyway.
389
+ *
390
+ * The caller resends the result with `parse_mode` UNSET, so the output
391
+ * is literal text — we intentionally do NOT re-escape `< > &`. The goal
392
+ * is "the agent's answer lands unformatted" instead of "the answer
393
+ * silently vanishes" (visibility + always-on).
394
+ *
395
+ * Transforms, in order:
396
+ * 1. `<a href="u">label</a>` → `label (u)` (or just `u` when label is
397
+ * empty or equals the href). href/label are themselves stripped +
398
+ * entity-decoded so we never emit nested markup.
399
+ * 2. Block / break boundaries → newline: `<br>`, `</p>`, `</div>`,
400
+ * `</li>`, `</blockquote>`, `</pre>`. (These aren't Telegram-
401
+ * supported tags, but a markdown→HTML slip that emits one is a
402
+ * prime cause of the parse reject we're recovering from.)
403
+ * 3. Strip every remaining tag.
404
+ * 4. Decode the standard HTML entities Telegram uses.
405
+ * 5. Collapse 3+ blank lines to 2; trim trailing per-line whitespace.
406
+ */
407
+ export function telegramHtmlToPlainText(html: string): string {
408
+ const decodeEntities = (s: string): string =>
409
+ s
410
+ .replace(/&amp;/g, '&')
411
+ .replace(/&lt;/g, '<')
412
+ .replace(/&gt;/g, '>')
413
+ .replace(/&quot;/g, '"')
414
+ .replace(/&#0*39;|&#x0*27;|&apos;/gi, "'")
415
+ .replace(/&nbsp;/g, ' ')
416
+ .replace(/&#(\d+);/g, (_m, d: string) => {
417
+ const cp = Number(d)
418
+ return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff
419
+ ? String.fromCodePoint(cp)
420
+ : _m
421
+ })
422
+ .replace(/&#x([0-9a-fA-F]+);/g, (_m, h: string) => {
423
+ const cp = parseInt(h, 16)
424
+ return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff
425
+ ? String.fromCodePoint(cp)
426
+ : _m
427
+ })
428
+
429
+ const stripTags = (s: string): string =>
430
+ decodeEntities(
431
+ s
432
+ .replace(/<\s*br\s*\/?\s*>/gi, '\n')
433
+ .replace(/<\/\s*(?:p|div|li|blockquote|pre|h[1-6])\s*>/gi, '\n')
434
+ .replace(/<[^>]*>/g, ''),
435
+ )
436
+
437
+ // 1. Anchors → "label (href)". Handle double/single/unquoted href.
438
+ const withPlainLinks = html.replace(
439
+ /<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a>/gi,
440
+ (_m, dq: string | undefined, sq: string | undefined, uq: string | undefined, label: string) => {
441
+ const href = decodeEntities((dq ?? sq ?? uq ?? '').trim())
442
+ const text = stripTags(label).trim()
443
+ if (!href) return text
444
+ return !text || text === href ? href : `${text} (${href})`
445
+ },
446
+ )
447
+
448
+ return stripTags(withPlainLinks)
449
+ .replace(/[ \t]+$/gm, '')
450
+ .replace(/\n{3,}/g, '\n\n')
451
+ .trim()
452
+ }
453
+
383
454
  // ---------------------------------------------------------------------------
384
455
  // Output sanitizer — enforces fleet-wide Telegram formatting invariants
385
456
  // ---------------------------------------------------------------------------
@@ -2,8 +2,8 @@
2
2
  * Unit tests for access-validator.ts — validateStringArray.
3
3
  *
4
4
  * This function guards access.json fields at load time. The motivating bug:
5
- * a hand-edit that drops quotes around IDs (`[8248703757]` instead of
6
- * `["8248703757"]`) produces a valid JSON number array. Array.includes() uses
5
+ * a hand-edit that drops quotes around IDs (`[12345]` instead of
6
+ * `["12345"]`) produces a valid JSON number array. Array.includes() uses
7
7
  * strict equality, so number entries never match the string comparison in the
8
8
  * gate — every DM is silently dropped.
9
9
  *
@@ -27,7 +27,7 @@ describe('validateStringArray', () => {
27
27
  // ─── Happy path ────────────────────────────────────────────────────────────
28
28
 
29
29
  it('returns the array unchanged for a valid string array', () => {
30
- expect(validateStringArray('allowFrom', ['8248703757', '9999'])).toEqual(['8248703757', '9999'])
30
+ expect(validateStringArray('allowFrom', ['12345', '9999'])).toEqual(['12345', '9999'])
31
31
  expect(stderrSpy).not.toHaveBeenCalled()
32
32
  })
33
33
 
@@ -49,21 +49,21 @@ describe('validateStringArray', () => {
49
49
  // ─── Bug reproduction: number array ────────────────────────────────────────
50
50
 
51
51
  it('rejects a number array (the hand-edit bug) and returns []', () => {
52
- // This is the exact bug: [8248703757] parses as a number, not a string.
53
- // Array.includes("8248703757") === false for a number entry — silently drops DMs.
54
- const result = validateStringArray('allowFrom', [8248703757])
52
+ // This is the exact bug: [12345] parses as a number, not a string.
53
+ // Array.includes("12345") === false for a number entry — silently drops DMs.
54
+ const result = validateStringArray('allowFrom', [12345])
55
55
  expect(result).toEqual([])
56
56
  expect(stderrSpy).toHaveBeenCalled()
57
57
  const msg = String(stderrSpy.mock.calls[0][0])
58
58
  expect(msg).toContain('allowFrom')
59
59
  expect(msg).toContain('non-string entries')
60
- expect(msg).toContain('8248703757')
60
+ expect(msg).toContain('12345')
61
61
  })
62
62
 
63
63
  // ─── Mixed array ──────────────────────────────────────────────────────────
64
64
 
65
65
  it('rejects a mixed array (some strings, some numbers) and returns []', () => {
66
- const result = validateStringArray('allowFrom', ['8248703757', 9999])
66
+ const result = validateStringArray('allowFrom', ['12345', 9999])
67
67
  expect(result).toEqual([])
68
68
  expect(stderrSpy).toHaveBeenCalled()
69
69
  const msg = String(stderrSpy.mock.calls[0][0])
@@ -2,7 +2,7 @@
2
2
  * Validates fields loaded from access.json, failing loudly on type mismatches.
3
3
  *
4
4
  * JSON has no type system — a hand-edit that drops quotes around IDs
5
- * (e.g. `[8248703757]` instead of `["8248703757"]`) parses without error
5
+ * (e.g. `[12345]` instead of `["12345"]`) parses without error
6
6
  * but produces a number array. The gate compares with strict equality via
7
7
  * Array.includes(), so number entries silently drop every matching DM.
8
8
  *
@@ -12,7 +12,7 @@ import {
12
12
  describe("approval card", () => {
13
13
  it("renders the pristine card with default buttons", () => {
14
14
  const card = buildApprovalCard({
15
- request_id: "a3f1b9c2",
15
+ request_id: "a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2",
16
16
  agent: "klanker",
17
17
  scope_humanized: "secret:OPENAI_API_KEY",
18
18
  why: "needs to call OpenAI",
@@ -24,14 +24,14 @@ describe("approval card", () => {
24
24
  // Validate the inline_keyboard structure carries our four callback shapes
25
25
  const flat = card.reply_markup.inline_keyboard.flat();
26
26
  const datas = flat.map((b) => ("callback_data" in b ? b.callback_data : ""));
27
- expect(datas).toContain("apv:a3f1b9c2:once");
28
- expect(datas).toContain("apv:a3f1b9c2:deny");
29
- expect(datas).toContain("apv:a3f1b9c2:always");
27
+ expect(datas).toContain("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:once");
28
+ expect(datas).toContain("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:deny");
29
+ expect(datas).toContain("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:always");
30
30
  });
31
31
 
32
32
  it("respects offer_always=false and offer_ttl=true", () => {
33
33
  const card = buildApprovalCard({
34
- request_id: "a3f1b9c2",
34
+ request_id: "a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2",
35
35
  agent: "k",
36
36
  scope_humanized: "x",
37
37
  offer_always: false,
@@ -40,13 +40,13 @@ describe("approval card", () => {
40
40
  const datas = card.reply_markup.inline_keyboard
41
41
  .flat()
42
42
  .map((b) => ("callback_data" in b ? b.callback_data : ""));
43
- expect(datas).not.toContain("apv:a3f1b9c2:always");
44
- expect(datas).toContain("apv:a3f1b9c2:ttl:1h");
43
+ expect(datas).not.toContain("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:always");
44
+ expect(datas).toContain("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:ttl:1h");
45
45
  });
46
46
 
47
47
  it("escapes HTML metacharacters in agent and scope", () => {
48
48
  const card = buildApprovalCard({
49
- request_id: "deadbeef",
49
+ request_id: "deadbeefdeadbeefdeadbeefdeadbeef",
50
50
  agent: "<script>",
51
51
  scope_humanized: "a&b",
52
52
  });
@@ -58,21 +58,21 @@ describe("approval card", () => {
58
58
 
59
59
  describe("parseApprovalCallback", () => {
60
60
  it("parses every choice variant", () => {
61
- expect(parseApprovalCallback("apv:a3f1b9c2:once"))
62
- .toEqual({ request_id: "a3f1b9c2", choice: { kind: "once" } });
63
- expect(parseApprovalCallback("apv:a3f1b9c2:always"))
64
- .toEqual({ request_id: "a3f1b9c2", choice: { kind: "always" } });
65
- expect(parseApprovalCallback("apv:a3f1b9c2:deny"))
66
- .toEqual({ request_id: "a3f1b9c2", choice: { kind: "deny" } });
67
- expect(parseApprovalCallback("apv:a3f1b9c2:ttl:1h"))
68
- .toEqual({ request_id: "a3f1b9c2", choice: { kind: "ttl", param: "1h" } });
61
+ expect(parseApprovalCallback("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:once"))
62
+ .toEqual({ request_id: "a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2", choice: { kind: "once" } });
63
+ expect(parseApprovalCallback("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:always"))
64
+ .toEqual({ request_id: "a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2", choice: { kind: "always" } });
65
+ expect(parseApprovalCallback("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:deny"))
66
+ .toEqual({ request_id: "a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2", choice: { kind: "deny" } });
67
+ expect(parseApprovalCallback("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:ttl:1h"))
68
+ .toEqual({ request_id: "a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2", choice: { kind: "ttl", param: "1h" } });
69
69
  });
70
70
 
71
71
  it("rejects malformed prefixes and bad ids", () => {
72
72
  expect(parseApprovalCallback("perm:more:abc")).toBeNull();
73
73
  expect(parseApprovalCallback("apv:NOTHEX12:once")).toBeNull();
74
- expect(parseApprovalCallback("apv:a3f1b9c2:bogus")).toBeNull();
75
- expect(parseApprovalCallback("apv:a3f1b9c2:ttl")).toBeNull();
74
+ expect(parseApprovalCallback("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:bogus")).toBeNull();
75
+ expect(parseApprovalCallback("apv:a3f1b9c2a3f1b9c2a3f1b9c2a3f1b9c2:ttl")).toBeNull();
76
76
  });
77
77
  });
78
78
 
@@ -89,7 +89,7 @@ export function parseApprovalCallback(data: string): ParsedApprovalCallback | nu
89
89
  if (parts.length < 3) return null;
90
90
  const request_id = parts[1];
91
91
  const choiceStr = parts[2];
92
- if (!/^[0-9a-f]{8}$/.test(request_id ?? "")) return null;
92
+ if (!/^[0-9a-f]{32}$/.test(request_id ?? "")) return null;
93
93
  switch (choiceStr) {
94
94
  case "once":
95
95
  return { request_id: request_id as string, choice: { kind: "once" } };
@@ -260,8 +260,8 @@ export interface AuthCommandContext {
260
260
  * working without spinning up the Anthropic API path.
261
261
  *
262
262
  * Returns a parallel array (same length, same order as
263
- * `state.accounts`) of QuotaResult — the gateway passes
264
- * `accounts.map(a => fetchAccountQuota(a.label, {force: true}))`.
263
+ * `state.accounts`) of QuotaResult — sourced from the broker
264
+ * `probe-quota` op (#1336).
265
265
  */
266
266
  liveQuotas?: (
267
267
  accounts: AccountState[],
@@ -33,6 +33,7 @@
33
33
  */
34
34
 
35
35
  import type { ProbeResult, GatewayRuntimeInfo } from './boot-probes.js'
36
+ import type { QuotaResult } from '../quota-check.js'
36
37
  import type { ListStateData } from './auth-line.js'
37
38
  import { renderAuthLine } from './auth-line.js'
38
39
  import {
@@ -291,6 +292,12 @@ export interface RenderBootCardOpts {
291
292
  snoozeRows?: ReadonlyArray<ProbeKey>
292
293
  /** Clock injection point for tests; defaults to `new Date()`. */
293
294
  now?: Date
295
+ /** PR C update-flow: outcome line for the most recent terminal
296
+ * update_apply audit row (rendered by `update-announce.ts`). When
297
+ * present, appended as a stand-alone section below the probe rows so
298
+ * the operator sees what happened with the update that triggered
299
+ * this boot without trawling the audit log. */
300
+ updateOutcomeLine?: string
294
301
  }
295
302
 
296
303
  /**
@@ -348,8 +355,13 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
348
355
  : ''
349
356
  degradedRows.push(`⚠️ <b>Restart</b> ${escapeHtml(REASON_LABEL.crash)}${ageStr}`)
350
357
  // Principle 1: every failure carries its next step. The crash row
351
- // tells the user how to inspect why.
352
- degradedRows.push(` ↳ Tail logs: <code>journalctl --user -u switchroom-${escapeHtml(agentSlug)} -n 100</code>`)
358
+ // tells the user how to inspect why. Runtime-aware: v0.7+ agents run
359
+ // in Docker (no systemd/journalctl in-container) the canonical
360
+ // detection is SWITCHROOM_RUNTIME (see doctor-docker.ts).
361
+ const tailCmd = process.env.SWITCHROOM_RUNTIME === 'docker'
362
+ ? `docker logs --tail 100 switchroom-${escapeHtml(agentSlug)}`
363
+ : `journalctl --user -u switchroom-${escapeHtml(agentSlug)} -n 100`
364
+ degradedRows.push(` ↳ Tail logs: <code>${tailCmd}</code>`)
353
365
  }
354
366
 
355
367
  // Probe rows — only those that surfaced as degraded/fail. Healthy
@@ -394,6 +406,12 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
394
406
  const sections: string[] = [ack]
395
407
  if (degradedRows.length > 0) sections.push('', ...degradedRows)
396
408
  if (accountRows.length > 0) sections.push('', ...accountRows)
409
+ if (opts.updateOutcomeLine) {
410
+ // PR C: each line of the update-outcome blob is its own row in the
411
+ // sections array so the join('\n') below renders the multi-line
412
+ // failure-with-recovery hint cleanly.
413
+ sections.push('', ...opts.updateOutcomeLine.split('\n'))
414
+ }
397
415
  if (sections.length === 1) return ack
398
416
  return sections.join('\n')
399
417
  }
@@ -469,6 +487,15 @@ export interface RunProbesOpts {
469
487
  | ListStateData
470
488
  | null
471
489
  | Promise<ListStateData | null>
490
+ /**
491
+ * Canonical quota source (#1336): probes this agent's effective
492
+ * account via the broker (server-side /v1/messages). Returns null
493
+ * when the broker is unreachable / no active account → `probeQuota`
494
+ * falls back to a direct probe. Wired from gateway.ts
495
+ * (`probeQuotaForBootCard`); omitted in non-docker / unit tests where
496
+ * the direct fallback preserves prior behaviour.
497
+ */
498
+ probeQuotaViaBroker?: (timeoutMs?: number) => Promise<QuotaResult | null>
472
499
  /** When true, resolve the agent PID via cgroup walk instead of MainPID
473
500
  * (which is the tmux server pid under tmux supervisor). */
474
501
  tmuxSupervisor?: boolean
@@ -486,6 +513,13 @@ export interface RunProbesOpts {
486
513
  * and externally-managed surface text instead of execing systemctl
487
514
  * (which doesn't exist in the agent image). See `runtime-mode.ts`. */
488
515
  dockerMode?: boolean
516
+ /** PR C: pass-through to the renderer. When the gateway boot path
517
+ * detects a recent terminal `update_apply` audit row, it computes the
518
+ * outcome line via `update-announce.ts` and passes it here so the
519
+ * card surfaces "✅ update completed (channel:dev, sha:…)" or the
520
+ * failure body with a recovery hint. Append-only — never replaces
521
+ * the existing ack/probe sections. */
522
+ updateOutcomeLine?: string
489
523
  }
490
524
 
491
525
  /** Run all six probes concurrently with their own per-probe timeouts.
@@ -502,7 +536,7 @@ export async function runAllProbes(opts: RunProbesOpts): Promise<ProbeMap> {
502
536
  probeAccount(opts.agentDir).then(r => { probes.account = r }),
503
537
  probeAgentProcess(slug, { execFileImpl: opts.probeExecFileImpl, tmuxSupervisor: opts.tmuxSupervisor, dockerMode: opts.dockerMode }).then(r => { probes.agent = r }),
504
538
  probeGateway(opts.gatewayInfo).then(r => { probes.gateway = r }),
505
- probeQuota(claudeDir, opts.agentDir, opts.fetchImpl).then(r => { probes.quota = r }),
539
+ probeQuota(claudeDir, opts.agentDir, opts.fetchImpl, { brokerProbe: opts.probeQuotaViaBroker }).then(r => { probes.quota = r }),
506
540
  probeHindsight(opts.bankName, opts.fetchImpl).then(r => { probes.hindsight = r }),
507
541
  probeScheduler(slug, { dockerMode: opts.dockerMode }).then(r => { probes.scheduler = r }),
508
542
  probeBroker(undefined, { dockerMode: opts.dockerMode }).then(r => { probes.broker = r }),
@@ -540,6 +574,7 @@ export async function startBootCard(
540
574
  version: opts.version,
541
575
  restartReason: opts.restartReason,
542
576
  restartAgeMs: opts.restartAgeMs,
577
+ ...(opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}),
543
578
  })
544
579
 
545
580
  // Silence the notification for operator-initiated redeploys. A
@@ -646,6 +681,7 @@ export async function startBootCard(
646
681
  ...(accountRows ? { accounts: accountRows } : {}),
647
682
  ...(resolvedRows.length > 0 ? { resolvedRows } : {}),
648
683
  ...(snoozeRows.length > 0 ? { snoozeRows } : {}),
684
+ ...(opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}),
649
685
  })
650
686
 
651
687
  if (currentText !== ackText) {
@@ -697,6 +733,7 @@ export async function startBootCard(
697
733
  // cache write from the live-watch loop.
698
734
  ...(resolvedRows.length > 0 ? { resolvedRows } : {}),
699
735
  ...(snoozeRows.length > 0 ? { snoozeRows } : {}),
736
+ ...(opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}),
700
737
  })
701
738
 
702
739
  if (updatedText === currentText) continue
@@ -17,7 +17,7 @@ import { execFile as execFileCb } from 'child_process'
17
17
  import { promisify } from 'util'
18
18
 
19
19
  import { readQuotaCache, writeQuotaCache } from './quota-cache.js'
20
- import { fetchQuota, formatQuotaLine } from '../quota-check.js'
20
+ import { fetchQuota, formatQuotaLine, type QuotaResult } from '../quota-check.js'
21
21
 
22
22
  const execFile = promisify(execFileCb)
23
23
 
@@ -47,6 +47,25 @@ export interface ProbeResult {
47
47
 
48
48
  const PROBE_TIMEOUT_MS = 2000
49
49
 
50
+ /**
51
+ * Quota is the one probe that legitimately needs a network round-trip.
52
+ * The boot card is NOT blocked on probes — it's posted immediately and
53
+ * probe rows edit in at the 6s settle window (see boot-card.ts
54
+ * SETTLE_WINDOW_MS) — so the old 1.8s direct-fetch budget was a
55
+ * self-inflicted abort, not a real constraint. Post-#1336 the broker
56
+ * owns the canonical probe; the gateway just awaits a local UDS
57
+ * round-trip while the broker does the Anthropic call server-side.
58
+ *
59
+ * BROKER — passed to client.probeQuota(); the broker's server-side
60
+ * /v1/messages timeout.
61
+ * DIRECT — fallback direct fetch when the broker is unreachable
62
+ * (non-docker, boot-time socket race). Still bounded.
63
+ * OUTER — withTimeout() guard; must exceed BROKER + UDS RTT.
64
+ */
65
+ const QUOTA_BROKER_TIMEOUT_MS = 7000
66
+ const QUOTA_DIRECT_FALLBACK_TIMEOUT_MS = 5000
67
+ const QUOTA_PROBE_OUTER_TIMEOUT_MS = 9000
68
+
50
69
  /**
51
70
  * Race a probe against a hard timeout. Returns a fail ProbeResult if the
52
71
  * probe doesn't settle within timeoutMs.
@@ -831,6 +850,14 @@ export async function probeQuota(
831
850
  claudeConfigDir: string,
832
851
  _agentDir: string,
833
852
  fetchImpl: typeof fetch = fetch,
853
+ opts: {
854
+ /** Canonical path (#1336): the broker probes Anthropic server-side
855
+ * for this agent's effective account. Returns null when the broker
856
+ * is unreachable / no active account → caller falls back to a
857
+ * direct probe. Injected from gateway.ts (probeQuotaForBootCard);
858
+ * omitted in non-docker / unit tests. */
859
+ brokerProbe?: (timeoutMs?: number) => Promise<QuotaResult | null>
860
+ } = {},
834
861
  ): Promise<ProbeResult> {
835
862
  return withTimeout('Quota', (async (): Promise<ProbeResult> => {
836
863
  const cached = readQuotaCache()
@@ -838,35 +865,50 @@ export async function probeQuota(
838
865
  return cached
839
866
  }
840
867
 
841
- // The fallback per-agent token path is `accounts/default/.oauth-token`;
842
- // fetchQuota's own resolver only checks the top-level `.oauth-token`,
843
- // so prefer that, and if it's missing surface the same degraded row
844
- // we did before (no live probe that's a setup issue, not a runtime
845
- // one).
846
- let claudeDirForProbe: string | null = null
847
- for (const candidate of [
848
- claudeConfigDir,
849
- join(claudeConfigDir, 'accounts', 'default'),
850
- ]) {
851
- if (existsSync(join(candidate, '.oauth-token'))) {
852
- claudeDirForProbe = candidate
853
- break
868
+ // Prefer the broker (canonical since #1336). It does the /v1/messages
869
+ // call server-side with its own timeout; we just await the local UDS
870
+ // round-trip. Reset Dates are revived at the client boundary
871
+ // (src/auth/broker/client.ts) so formatQuotaLine is safe.
872
+ let probe: QuotaResult | null = null
873
+ if (opts.brokerProbe) {
874
+ try {
875
+ probe = await opts.brokerProbe(QUOTA_BROKER_TIMEOUT_MS)
876
+ } catch {
877
+ probe = null
854
878
  }
855
879
  }
856
- if (!claudeDirForProbe) {
857
- return {
858
- status: 'degraded',
859
- label: 'Quota',
860
- detail: 'no OAuth token',
861
- nextStep: 'No OAuth token on disk — register a fleet account: `switchroom auth add <label> --from-oauth` then `switchroom auth use <label>` (RFC H)',
880
+
881
+ if (!probe) {
882
+ // Fallback: broker absent/unreachable (non-docker install, or a
883
+ // boot-time socket race). Direct probe — the per-agent token path
884
+ // is `accounts/default/.oauth-token`; fetchQuota's resolver only
885
+ // checks the top-level `.oauth-token`, so prefer that. Missing
886
+ // token is a setup issue, surfaced as the same degraded row.
887
+ let claudeDirForProbe: string | null = null
888
+ for (const candidate of [
889
+ claudeConfigDir,
890
+ join(claudeConfigDir, 'accounts', 'default'),
891
+ ]) {
892
+ if (existsSync(join(candidate, '.oauth-token'))) {
893
+ claudeDirForProbe = candidate
894
+ break
895
+ }
862
896
  }
897
+ if (!claudeDirForProbe) {
898
+ return {
899
+ status: 'degraded',
900
+ label: 'Quota',
901
+ detail: 'no OAuth token',
902
+ nextStep: 'No OAuth token on disk — register a fleet account: `switchroom auth add <label> --from-oauth` then `switchroom auth use <label>` (RFC H)',
903
+ }
904
+ }
905
+ probe = await fetchQuota({
906
+ claudeConfigDir: claudeDirForProbe,
907
+ fetchImpl,
908
+ timeoutMs: QUOTA_DIRECT_FALLBACK_TIMEOUT_MS,
909
+ })
863
910
  }
864
911
 
865
- const probe = await fetchQuota({
866
- claudeConfigDir: claudeDirForProbe,
867
- fetchImpl,
868
- timeoutMs: 1800,
869
- })
870
912
  if (!probe.ok) {
871
913
  // Auth rejection from /v1/messages is a strong signal — the same
872
914
  // endpoint claude itself uses. Other errors are surfaced verbatim
@@ -889,7 +931,7 @@ export async function probeQuota(
889
931
  }
890
932
  writeQuotaCache(result)
891
933
  return result
892
- })())
934
+ })(), QUOTA_PROBE_OUTER_TIMEOUT_MS)
893
935
  }
894
936
 
895
937
  // ─── Probe: Hindsight ────────────────────────────────────────────────────────
@@ -917,7 +959,9 @@ export async function probeHindsight(
917
959
  status: 'fail',
918
960
  label: 'Hindsight',
919
961
  detail: 'unreachable',
920
- nextStep: 'Hindsight server not responding on 127.0.0.1:18888 start it with `hindsight serve` or check `systemctl --user status hindsight`',
962
+ nextStep: process.env.SWITCHROOM_RUNTIME === 'docker'
963
+ ? 'Hindsight server not responding on 127.0.0.1:18888 — check the hindsight container (`docker ps` / `docker logs`); it must be reachable on the host network.'
964
+ : 'Hindsight server not responding on 127.0.0.1:18888 — start it with `hindsight serve` (or check `systemctl --user status hindsight`).',
921
965
  }
922
966
  }
923
967
 
@@ -1274,7 +1318,14 @@ export async function probeKernel(
1274
1318
  */
1275
1319
  export async function probeSkills(
1276
1320
  agentDir: string,
1277
- opts: { fs?: SkillsFsImpl; maxNamesShown?: number; agentName?: string } = {},
1321
+ opts: {
1322
+ fs?: SkillsFsImpl
1323
+ maxNamesShown?: number
1324
+ agentName?: string
1325
+ /** Override skills.d overlay dir. Defaults to `<agentDir>/skills.d`
1326
+ * on host installs; tests inject a path. */
1327
+ overlaySkillsDir?: string
1328
+ } = {},
1278
1329
  ): Promise<ProbeResult> {
1279
1330
  return withTimeout('Skills', (async (): Promise<ProbeResult> => {
1280
1331
  const fs = opts.fs ?? realSkillsFs
@@ -1315,8 +1366,32 @@ export async function probeSkills(
1315
1366
  continue
1316
1367
  }
1317
1368
  }
1369
+
1370
+ // Bucket entries by source: agent-overlay slugs come from filenames
1371
+ // under <agentRoot>/skills.d/<slug>.yaml (written by skill_install).
1372
+ // Everything else is operator-baseline from switchroom.yaml.
1373
+ const overlayDir = opts.overlaySkillsDir ?? join(agentDir, 'skills.d')
1374
+ const overlaySlugs = new Set<string>()
1375
+ if (fs.exists(overlayDir)) {
1376
+ let overlayEntries: string[] = []
1377
+ try {
1378
+ overlayEntries = fs.readdir(overlayDir)
1379
+ } catch {
1380
+ /* unreadable overlay — fall through with empty set */
1381
+ }
1382
+ for (const name of overlayEntries) {
1383
+ const m = name.match(/^(.+)\.ya?ml$/i)
1384
+ if (!m) continue
1385
+ overlaySlugs.add(m[1])
1386
+ }
1387
+ }
1388
+ const liveEntries = entries.filter(n => !dangling.includes(n)).sort()
1389
+ const switchroomSkills = liveEntries.filter(n => !overlaySlugs.has(n))
1390
+ const agentSkills = liveEntries.filter(n => overlaySlugs.has(n))
1391
+ const bucketed = renderBucketedSkills(switchroomSkills, agentSkills)
1392
+
1318
1393
  if (dangling.length === 0) {
1319
- return { status: 'ok', label: 'Skills', detail: `${entries.length} resolved` }
1394
+ return { status: 'ok', label: 'Skills', detail: bucketed }
1320
1395
  }
1321
1396
  const named = dangling.slice(0, max).join(', ')
1322
1397
  const more = dangling.length > max ? ` +${dangling.length - max} more` : ''
@@ -1324,12 +1399,21 @@ export async function probeSkills(
1324
1399
  return {
1325
1400
  status: 'degraded',
1326
1401
  label: 'Skills',
1327
- detail: `${dangling.length}/${entries.length} dangling: ${named}${more}`,
1402
+ detail: `${dangling.length}/${entries.length} dangling: ${named}${more} · ${bucketed}`,
1328
1403
  nextStep: `Run \`switchroom agent reconcile${reconcileTarget}\` to rebuild symlinks, or remove unused entries from switchroom.yaml`,
1329
1404
  }
1330
1405
  })())
1331
1406
  }
1332
1407
 
1408
+ /** Format the two source-buckets into a single mobile-readable line.
1409
+ * Empty buckets are omitted. `none` covers the all-dangling edge case. */
1410
+ function renderBucketedSkills(switchroom: string[], agent: string[]): string {
1411
+ const parts: string[] = []
1412
+ if (switchroom.length > 0) parts.push(`Switchroom: ${switchroom.join(', ')}`)
1413
+ if (agent.length > 0) parts.push(`Agent: ${agent.join(', ')}`)
1414
+ return parts.length === 0 ? 'none resolved' : parts.join(' · ')
1415
+ }
1416
+
1333
1417
  export interface SkillsFsImpl {
1334
1418
  readdir: (p: string) => string[]
1335
1419
  exists: (p: string) => boolean