switchroom 0.11.0 → 0.12.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 (62) hide show
  1. package/README.md +7 -6
  2. package/dist/agent-scheduler/index.js +218 -99
  3. package/dist/auth-broker/index.js +300 -99
  4. package/dist/cli/drive-write-pretool.mjs +45 -12
  5. package/dist/cli/switchroom.js +44972 -42457
  6. package/dist/cli/ui/index.html +1281 -0
  7. package/dist/host-control/main.js +3630 -311
  8. package/dist/vault/approvals/kernel-server.js +209 -100
  9. package/dist/vault/broker/server.js +220 -99
  10. package/examples/personal-google-workspace-mcp/README.md +8 -3
  11. package/examples/switchroom.yaml +91 -42
  12. package/package.json +2 -2
  13. package/profiles/_base/start.sh.hbs +76 -36
  14. package/profiles/default/CLAUDE.md.hbs +4 -2
  15. package/skills/file-bug/SKILL.md +6 -4
  16. package/skills/switchroom-cli/SKILL.md +20 -4
  17. package/skills/switchroom-install/SKILL.md +3 -3
  18. package/telegram-plugin/auth-snapshot-format.ts +4 -4
  19. package/telegram-plugin/auto-fallback-fleet.ts +4 -4
  20. package/telegram-plugin/card-format.ts +3 -3
  21. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  22. package/telegram-plugin/dist/gateway/gateway.js +1029 -628
  23. package/telegram-plugin/dist/server.js +162 -161
  24. package/telegram-plugin/format.ts +71 -0
  25. package/telegram-plugin/gateway/approval-card.test.ts +18 -18
  26. package/telegram-plugin/gateway/approval-card.ts +1 -1
  27. package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
  28. package/telegram-plugin/gateway/auth-command.ts +12 -2
  29. package/telegram-plugin/gateway/boot-card.ts +40 -3
  30. package/telegram-plugin/gateway/boot-probes.ts +71 -27
  31. package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
  32. package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
  33. package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
  34. package/telegram-plugin/gateway/gateway.ts +244 -46
  35. package/telegram-plugin/gateway/hostd-dispatch.ts +10 -2
  36. package/telegram-plugin/gateway/update-announce.ts +167 -0
  37. package/telegram-plugin/quota-check.ts +0 -195
  38. package/telegram-plugin/retry-api-call.ts +24 -0
  39. package/telegram-plugin/server.ts +8 -5
  40. package/telegram-plugin/tests/auth-add-flow.test.ts +31 -2
  41. package/telegram-plugin/tests/boot-probes.test.ts +53 -0
  42. package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
  43. package/telegram-plugin/tests/quota-check.test.ts +0 -409
  44. package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
  45. package/telegram-plugin/tests/telegram-format.test.ts +84 -1
  46. package/telegram-plugin/tests/update-announce.test.ts +154 -0
  47. package/telegram-plugin/welcome-text.ts +1 -8
  48. package/profiles/default/CLAUDE.md +0 -192
  49. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  50. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  51. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  53. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  54. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  55. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  56. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  57. package/telegram-plugin/first-paint.ts +0 -225
  58. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  59. package/telegram-plugin/server.js +0 -41795
  60. package/telegram-plugin/tests/html-balanced.ts +0 -63
  61. package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
  62. 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
  // ---------------------------------------------------------------------------
@@ -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" } };
@@ -30,6 +30,8 @@ export function createAuthBrokerClient(): {
30
30
  refreshAccount: (label: string) => broker.refreshAccount(label),
31
31
  setOverride: (agent: string, account: string | null) =>
32
32
  broker.setOverride(agent, account),
33
+ probeQuota: (accounts: readonly string[], timeoutMs?: number) =>
34
+ broker.probeQuota(accounts, timeoutMs),
33
35
  }
34
36
  return { client, close: () => broker.close() }
35
37
  }
@@ -220,6 +220,16 @@ export interface AuthBrokerClient {
220
220
  agent: string,
221
221
  account: string | null,
222
222
  ): Promise<{ agent: string; account: string | null }>
223
+ /**
224
+ * Live Anthropic quota probe via the broker (#1336). The broker
225
+ * uses its stored accessTokens to hit `/v1/messages` server-side
226
+ * and returns parsed rate-limit headers. Tokens never reach the
227
+ * caller. Per-label results are returned in input order.
228
+ */
229
+ probeQuota(
230
+ accounts: readonly string[],
231
+ timeoutMs?: number,
232
+ ): Promise<{ results: Array<{ label: string; result: import('../quota-check.js').QuotaResult }> }>
223
233
  }
224
234
 
225
235
  export interface AuthCommandContext {
@@ -250,8 +260,8 @@ export interface AuthCommandContext {
250
260
  * working without spinning up the Anthropic API path.
251
261
  *
252
262
  * Returns a parallel array (same length, same order as
253
- * `state.accounts`) of QuotaResult — the gateway passes
254
- * `accounts.map(a => fetchAccountQuota(a.label, {force: true}))`.
263
+ * `state.accounts`) of QuotaResult — sourced from the broker
264
+ * `probe-quota` op (#1336).
255
265
  */
256
266
  liveQuotas?: (
257
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
 
@@ -41,8 +41,8 @@ describe("buildDiffPreviewCard — suggest mode (default)", () => {
41
41
  const preview = buildDiffPreview(baseInput({ agentSummary: "Added Hiring section" }));
42
42
  const card = buildDiffPreviewCard({
43
43
  preview,
44
- suggestRequestId: "aabbccdd",
45
- writeRequestId: "11223344",
44
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
45
+ writeRequestId: "11223344112233441122334411223344",
46
46
  });
47
47
 
48
48
  // Body: title bold + all preview lines.
@@ -59,19 +59,19 @@ describe("buildDiffPreviewCard — suggest mode (default)", () => {
59
59
  expect(r[0]?.[0]?.text).toBe("📖 Open in Drive");
60
60
  expect(r[0]?.[0]?.url).toBe("https://docs.google.com/document/d/DOC1/edit");
61
61
  expect(r[0]?.[1]?.text).toBe("✅ Apply as suggestion");
62
- expect(r[0]?.[1]?.callback_data).toBe("apv:aabbccdd:once");
62
+ expect(r[0]?.[1]?.callback_data).toBe("apv:aabbccddaabbccddaabbccddaabbccdd:once");
63
63
  // Row 2: [Apply directly] [Cancel]
64
64
  expect(r[1]?.[0]?.text).toBe("⚠ Apply directly");
65
- expect(r[1]?.[0]?.callback_data).toBe("apv:11223344:once");
65
+ expect(r[1]?.[0]?.callback_data).toBe("apv:11223344112233441122334411223344:once");
66
66
  expect(r[1]?.[1]?.text).toBe("🚫 Cancel");
67
- expect(r[1]?.[1]?.callback_data).toBe("apv:aabbccdd:deny");
67
+ expect(r[1]?.[1]?.callback_data).toBe("apv:aabbccddaabbccddaabbccddaabbccdd:deny");
68
68
  });
69
69
 
70
70
  it("hides 'Apply directly' when writeRequestId is undefined", () => {
71
71
  const preview = buildDiffPreview(baseInput());
72
72
  const card = buildDiffPreviewCard({
73
73
  preview,
74
- suggestRequestId: "aabbccdd",
74
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
75
75
  });
76
76
  const flat = rows(card.reply_markup).flat();
77
77
  expect(flat.find((b) => b.text === "⚠ Apply directly")).toBeUndefined();
@@ -91,8 +91,8 @@ describe("buildDiffPreviewCard — write mode (opt-in via expand)", () => {
91
91
  // callback's deny channel — semantically Cancel is "don't grant
92
92
  // either scope" but reusing the suggest id keeps the existing
93
93
  // approval-callback handler stateless.
94
- suggestRequestId: "aabbccdd",
95
- writeRequestId: "11223344",
94
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
95
+ writeRequestId: "11223344112233441122334411223344",
96
96
  });
97
97
 
98
98
  const r = rows(card.reply_markup);
@@ -100,7 +100,7 @@ describe("buildDiffPreviewCard — write mode (opt-in via expand)", () => {
100
100
  expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeUndefined();
101
101
  const directly = flat.find((b) => b.text === "⚠ Apply directly");
102
102
  expect(directly).toBeDefined();
103
- expect(directly?.callback_data).toBe("apv:11223344:once");
103
+ expect(directly?.callback_data).toBe("apv:11223344112233441122334411223344:once");
104
104
  // Title icon swaps to ⚠.
105
105
  expect(card.text).toContain("⚠");
106
106
  });
@@ -119,7 +119,7 @@ describe("buildDiffPreviewCard — input validation", () => {
119
119
  expect(() =>
120
120
  buildDiffPreviewCard({
121
121
  preview,
122
- suggestRequestId: "aabbccdd",
122
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
123
123
  writeRequestId: "ABCDEF01", // wrong case
124
124
  }),
125
125
  ).toThrow(/8 hex chars/);
@@ -136,7 +136,7 @@ describe("buildDiffPreviewCard — fragility guards", () => {
136
136
  );
137
137
  const card = buildDiffPreviewCard({
138
138
  preview,
139
- suggestRequestId: "aabbccdd",
139
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
140
140
  });
141
141
  const flat = rows(card.reply_markup).flat();
142
142
  expect(flat.find((b) => b.text === "📖 Open in Drive")).toBeUndefined();
@@ -150,7 +150,7 @@ describe("buildDiffPreviewCard — fragility guards", () => {
150
150
  );
151
151
  const card = buildDiffPreviewCard({
152
152
  preview,
153
- suggestRequestId: "aabbccdd",
153
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
154
154
  });
155
155
  expect(card.text).not.toContain("<script>");
156
156
  expect(card.text).toContain("&lt;script&gt;");
@@ -162,7 +162,7 @@ describe("buildDiffPreviewCard — fragility guards", () => {
162
162
  );
163
163
  const card = buildDiffPreviewCard({
164
164
  preview,
165
- suggestRequestId: "aabbccdd",
165
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
166
166
  });
167
167
  expect(card.text).not.toMatch(/💬.*<b>/);
168
168
  expect(card.text).toContain("&lt;b&gt;");
@@ -175,8 +175,8 @@ describe("buildDiffPreviewCard — audit fidelity", () => {
175
175
  const preview = buildDiffPreview(input);
176
176
  const card = buildDiffPreviewCard({
177
177
  preview,
178
- suggestRequestId: "aabbccdd",
179
- writeRequestId: "11223344",
178
+ suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
179
+ writeRequestId: "11223344112233441122334411223344",
180
180
  });
181
181
  // The audit row captures both wrapper truth + agent framing,
182
182
  // exactly as surfaced on the card.
@@ -55,7 +55,7 @@ export interface DiffPreviewCardInput {
55
55
  * callback_data that the dispatcher rejects, but we'd rather fail
56
56
  * loudly at build time.
57
57
  */
58
- const REQUEST_ID_RE = /^[0-9a-f]{8}$/;
58
+ const REQUEST_ID_RE = /^[0-9a-f]{32}$/;
59
59
 
60
60
  /**
61
61
  * Fragility-guard from B2 review: the `create_doc` prep helper
@@ -66,7 +66,7 @@ function deps(overrides: Partial<DriveApprovalHandlerDeps> & { spy: Spy }): Driv
66
66
  ttl_ms: args.ttl_ms,
67
67
  approver_set: args.approver_set,
68
68
  });
69
- return { request_id: "aabbccdd", expires_at_ms: Date.now() + args.ttl_ms };
69
+ return { request_id: "aabbccddaabbccddaabbccddaabbccdd", expires_at_ms: Date.now() + args.ttl_ms };
70
70
  },
71
71
  postCard: async (args) => {
72
72
  spy.posted.push({
@@ -123,7 +123,7 @@ describe("handleRequestDriveApproval — happy path", () => {
123
123
  type: "drive_approval_posted",
124
124
  correlationId: "corr-1",
125
125
  ok: true,
126
- requestId: "aabbccdd",
126
+ requestId: "aabbccddaabbccddaabbccddaabbccdd",
127
127
  });
128
128
  expect(spy.sent[0]?.expiresAtMs).toBeGreaterThan(Date.now());
129
129
  });