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.
- package/README.md +32 -16
- package/dist/agent-scheduler/index.js +216 -97
- package/dist/auth-broker/index.js +176 -97
- package/dist/cli/drive-write-pretool.mjs +26 -11
- package/dist/cli/skill-validate-pretool.mjs +7209 -0
- package/dist/cli/switchroom.js +45571 -42642
- 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 +249 -119
- package/examples/personal-google-workspace-mcp/README.md +8 -3
- package/examples/switchroom.yaml +91 -42
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +76 -36
- package/profiles/_shared/agent-self-service.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +4 -2
- package/skills/file-bug/SKILL.md +6 -4
- package/skills/skill-creator/SKILL.md +52 -0
- package/skills/switchroom-cli/SKILL.md +20 -4
- package/skills/switchroom-install/SKILL.md +3 -3
- package/telegram-plugin/auth-snapshot-format.ts +9 -9
- package/telegram-plugin/card-format.ts +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +853 -414
- package/telegram-plugin/dist/server.js +162 -161
- package/telegram-plugin/format.ts +71 -0
- package/telegram-plugin/gateway/access-validator.test.ts +8 -8
- package/telegram-plugin/gateway/access-validator.ts +1 -1
- 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 +114 -30
- 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 +265 -22
- package/telegram-plugin/gateway/update-announce.ts +167 -0
- package/telegram-plugin/quota-check.ts +0 -195
- package/telegram-plugin/recent-outbound-dedup.ts +1 -1
- package/telegram-plugin/registry/turns-schema.ts +1 -1
- 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 +32 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
- package/telegram-plugin/tests/boot-probes.test.ts +90 -2
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
- package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
- package/telegram-plugin/tests/fleet-state.test.ts +3 -2
- 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/secret-detect-audit.test.ts +1 -1
- package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
- package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
- package/telegram-plugin/tests/secret-detect.test.ts +8 -8
- package/telegram-plugin/tests/telegram-format.test.ts +84 -1
- package/telegram-plugin/tests/update-announce.test.ts +154 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -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
|
// ---------------------------------------------------------------------------
|
|
@@ -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 (`[
|
|
6
|
-
* `["
|
|
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', ['
|
|
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: [
|
|
53
|
-
// Array.includes("
|
|
54
|
-
const result = validateStringArray('allowFrom', [
|
|
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('
|
|
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', ['
|
|
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. `[
|
|
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: "
|
|
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
|
|
|
@@ -1274,7 +1318,14 @@ export async function probeKernel(
|
|
|
1274
1318
|
*/
|
|
1275
1319
|
export async function probeSkills(
|
|
1276
1320
|
agentDir: string,
|
|
1277
|
-
opts: {
|
|
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:
|
|
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
|