switchroom 0.11.1 → 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.
- package/README.md +7 -6
- package/dist/agent-scheduler/index.js +216 -97
- package/dist/auth-broker/index.js +175 -96
- package/dist/cli/drive-write-pretool.mjs +26 -11
- package/dist/cli/switchroom.js +45153 -42663
- package/dist/cli/ui/index.html +1281 -0
- package/dist/host-control/main.js +3628 -309
- package/dist/vault/approvals/kernel-server.js +207 -98
- package/dist/vault/broker/server.js +218 -97
- package/examples/personal-google-workspace-mcp/README.md +8 -3
- package/examples/switchroom.yaml +91 -42
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +76 -36
- package/profiles/default/CLAUDE.md.hbs +4 -2
- package/skills/file-bug/SKILL.md +6 -4
- package/skills/switchroom-cli/SKILL.md +20 -4
- package/skills/switchroom-install/SKILL.md +3 -3
- package/telegram-plugin/auth-snapshot-format.ts +4 -4
- package/telegram-plugin/card-format.ts +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +795 -410
- package/telegram-plugin/dist/server.js +162 -161
- package/telegram-plugin/format.ts +71 -0
- package/telegram-plugin/gateway/approval-card.test.ts +18 -18
- package/telegram-plugin/gateway/approval-card.ts +1 -1
- package/telegram-plugin/gateway/auth-command.ts +2 -2
- package/telegram-plugin/gateway/boot-card.ts +40 -3
- package/telegram-plugin/gateway/boot-probes.ts +71 -27
- package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
- package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
- package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
- package/telegram-plugin/gateway/gateway.ts +193 -22
- package/telegram-plugin/gateway/update-announce.ts +167 -0
- package/telegram-plugin/quota-check.ts +0 -195
- package/telegram-plugin/retry-api-call.ts +24 -0
- package/telegram-plugin/server.ts +8 -5
- package/telegram-plugin/tests/auth-add-flow.test.ts +31 -2
- package/telegram-plugin/tests/boot-probes.test.ts +53 -0
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
- package/telegram-plugin/tests/quota-check.test.ts +0 -409
- package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
- package/telegram-plugin/tests/telegram-format.test.ts +84 -1
- package/telegram-plugin/tests/update-announce.test.ts +154 -0
- package/telegram-plugin/welcome-text.ts +1 -8
- package/profiles/default/CLAUDE.md +0 -192
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/telegram-plugin/first-paint.ts +0 -225
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/telegram-plugin/server.js +0 -41795
- package/telegram-plugin/tests/html-balanced.ts +0 -63
- package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
- 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(/&/g, '&')
|
|
411
|
+
.replace(/</g, '<')
|
|
412
|
+
.replace(/>/g, '>')
|
|
413
|
+
.replace(/"/g, '"')
|
|
414
|
+
.replace(/�*39;|�*27;|'/gi, "'")
|
|
415
|
+
.replace(/ /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: "
|
|
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:
|
|
28
|
-
expect(datas).toContain("apv:
|
|
29
|
-
expect(datas).toContain("apv:
|
|
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: "
|
|
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:
|
|
44
|
-
expect(datas).toContain("apv:
|
|
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: "
|
|
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:
|
|
62
|
-
.toEqual({ request_id: "
|
|
63
|
-
expect(parseApprovalCallback("apv:
|
|
64
|
-
.toEqual({ request_id: "
|
|
65
|
-
expect(parseApprovalCallback("apv:
|
|
66
|
-
.toEqual({ request_id: "
|
|
67
|
-
expect(parseApprovalCallback("apv:
|
|
68
|
-
.toEqual({ request_id: "
|
|
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:
|
|
75
|
-
expect(parseApprovalCallback("apv:
|
|
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]{
|
|
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
|
|
264
|
-
* `
|
|
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
|
-
|
|
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
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
//
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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:
|
|
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: "
|
|
45
|
-
writeRequestId: "
|
|
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:
|
|
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:
|
|
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:
|
|
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: "
|
|
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: "
|
|
95
|
-
writeRequestId: "
|
|
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:
|
|
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: "
|
|
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: "
|
|
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: "
|
|
153
|
+
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
154
154
|
});
|
|
155
155
|
expect(card.text).not.toContain("<script>");
|
|
156
156
|
expect(card.text).toContain("<script>");
|
|
@@ -162,7 +162,7 @@ describe("buildDiffPreviewCard — fragility guards", () => {
|
|
|
162
162
|
);
|
|
163
163
|
const card = buildDiffPreviewCard({
|
|
164
164
|
preview,
|
|
165
|
-
suggestRequestId: "
|
|
165
|
+
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
166
166
|
});
|
|
167
167
|
expect(card.text).not.toMatch(/💬.*<b>/);
|
|
168
168
|
expect(card.text).toContain("<b>");
|
|
@@ -175,8 +175,8 @@ describe("buildDiffPreviewCard — audit fidelity", () => {
|
|
|
175
175
|
const preview = buildDiffPreview(input);
|
|
176
176
|
const card = buildDiffPreviewCard({
|
|
177
177
|
preview,
|
|
178
|
-
suggestRequestId: "
|
|
179
|
-
writeRequestId: "
|
|
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]{
|
|
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: "
|
|
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: "
|
|
126
|
+
requestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
127
127
|
});
|
|
128
128
|
expect(spy.sent[0]?.expiresAtMs).toBeGreaterThan(Date.now());
|
|
129
129
|
});
|