@sechroom/cli 2026.7.28 → 2026.7.30
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/dist/index.js +2480 -760
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -428,6 +428,30 @@ async function requireToken(cfg) {
|
|
|
428
428
|
}
|
|
429
429
|
return cached.accessToken;
|
|
430
430
|
}
|
|
431
|
+
async function forceRefreshToken(cfg) {
|
|
432
|
+
if (process.env.SECHROOM_TOKEN)
|
|
433
|
+
throw new Error(
|
|
434
|
+
"SECHROOM_TOKEN was rejected (401) and cannot be refreshed \u2014 mint a fresh bearer and re-export SECHROOM_TOKEN."
|
|
435
|
+
);
|
|
436
|
+
const cached = readAccount(cfg.account);
|
|
437
|
+
if (!cached?.refreshToken)
|
|
438
|
+
throw new Error(
|
|
439
|
+
`Session for account "${cfg.account}" was rejected (401) and has no refresh token. Run \`sechroom login --account ${cfg.account}\` again.`
|
|
440
|
+
);
|
|
441
|
+
const meta = await discover(cfg.baseUrl);
|
|
442
|
+
const res = await fetch(meta.token_endpoint, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
445
|
+
body: new URLSearchParams({
|
|
446
|
+
grant_type: "refresh_token",
|
|
447
|
+
refresh_token: cached.refreshToken
|
|
448
|
+
})
|
|
449
|
+
});
|
|
450
|
+
if (!res.ok)
|
|
451
|
+
throw new Error(`Token refresh failed (${res.status}). Run \`sechroom login --account ${cfg.account}\` again.`);
|
|
452
|
+
persistTokenResponse(cfg.account, cfg.baseUrl, await res.json());
|
|
453
|
+
return readAccount(cfg.account).accessToken;
|
|
454
|
+
}
|
|
431
455
|
|
|
432
456
|
// src/client.ts
|
|
433
457
|
import createClient from "openapi-fetch";
|
|
@@ -903,9 +927,9 @@ function writeSkillsLock(dir, lock) {
|
|
|
903
927
|
mkdirSync2(dir, { recursive: true });
|
|
904
928
|
writeFileSync2(join2(dir, SKILLS_LOCK), JSON.stringify(lock, null, 2) + "\n");
|
|
905
929
|
}
|
|
906
|
-
function recordMaterialisedSkills(dir,
|
|
930
|
+
function recordMaterialisedSkills(dir, slug2, skills, meta = {}) {
|
|
907
931
|
const lock = readSkillsLock(dir);
|
|
908
|
-
lock[
|
|
932
|
+
lock[slug2] = { surface: meta.surface, skills: [...skills].sort() };
|
|
909
933
|
writeSkillsLock(dir, lock);
|
|
910
934
|
}
|
|
911
935
|
|
|
@@ -1429,7 +1453,7 @@ function runList(spec, cmd, opts) {
|
|
|
1429
1453
|
const out = targets.map((t) => {
|
|
1430
1454
|
const lock = readSkillsLock(t.dir);
|
|
1431
1455
|
const entries = Object.entries(lock).flatMap(
|
|
1432
|
-
([
|
|
1456
|
+
([slug2, e]) => (e.skills ?? []).map((name) => ({ slug: slug2, name, present: existsSync3(join4(t.dir, name)) }))
|
|
1433
1457
|
);
|
|
1434
1458
|
return { client: t.client, surface: t.surface, dir: t.dir, label: t.label, entries };
|
|
1435
1459
|
});
|
|
@@ -1448,7 +1472,7 @@ function runList(spec, cmd, opts) {
|
|
|
1448
1472
|
}
|
|
1449
1473
|
function runClean(spec, cmd, opts, slugArg) {
|
|
1450
1474
|
const g = cmd.optsWithGlobals();
|
|
1451
|
-
const
|
|
1475
|
+
const slug2 = slugArg || DEFAULT_SKILLS_SLUG;
|
|
1452
1476
|
let scope;
|
|
1453
1477
|
try {
|
|
1454
1478
|
scope = scopeOf(opts);
|
|
@@ -1468,7 +1492,7 @@ function runClean(spec, cmd, opts, slugArg) {
|
|
|
1468
1492
|
const missing = [];
|
|
1469
1493
|
for (const t of targets) {
|
|
1470
1494
|
const lock = readSkillsLock(t.dir);
|
|
1471
|
-
const entry = lock[
|
|
1495
|
+
const entry = lock[slug2];
|
|
1472
1496
|
if (!entry) {
|
|
1473
1497
|
missing.push(join4(t.dir, SKILLS_LOCK));
|
|
1474
1498
|
continue;
|
|
@@ -1481,16 +1505,16 @@ function runClean(spec, cmd, opts, slugArg) {
|
|
|
1481
1505
|
removed.push(name);
|
|
1482
1506
|
}
|
|
1483
1507
|
}
|
|
1484
|
-
delete lock[
|
|
1508
|
+
delete lock[slug2];
|
|
1485
1509
|
writeSkillsLock(t.dir, lock);
|
|
1486
1510
|
cleaned.push({ client: t.client, surface: t.surface, dir: t.dir, removed });
|
|
1487
1511
|
}
|
|
1488
1512
|
if (cleaned.length === 0) {
|
|
1489
|
-
return fail(`No materialised ${spec.kind}s recorded for '${
|
|
1513
|
+
return fail(`No materialised ${spec.kind}s recorded for '${slug2}' in ${missing.join(", ")}.`);
|
|
1490
1514
|
}
|
|
1491
|
-
if (json) return emit({ kind: spec.kind, client: selection, slug, cleaned, missing }, true);
|
|
1515
|
+
if (json) return emit({ kind: spec.kind, client: selection, slug: slug2, cleaned, missing }, true);
|
|
1492
1516
|
for (const c of cleaned) {
|
|
1493
|
-
console.log(style.green(`Removed ${c.removed.length} ${spec.kind}(s) for ${
|
|
1517
|
+
console.log(style.green(`Removed ${c.removed.length} ${spec.kind}(s) for ${slug2} from ${c.dir}`));
|
|
1494
1518
|
}
|
|
1495
1519
|
}
|
|
1496
1520
|
|
|
@@ -1518,8 +1542,8 @@ target:gpt-codex-agent), the dispatchable workers your loop skills call
|
|
|
1518
1542
|
}
|
|
1519
1543
|
|
|
1520
1544
|
// src/commands/channel.ts
|
|
1521
|
-
import { existsSync as
|
|
1522
|
-
import { dirname as
|
|
1545
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync8 } from "fs";
|
|
1546
|
+
import { dirname as dirname8, join as join11 } from "path";
|
|
1523
1547
|
import {
|
|
1524
1548
|
HttpTransportType,
|
|
1525
1549
|
HubConnectionBuilder
|
|
@@ -1528,8 +1552,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
1528
1552
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1529
1553
|
|
|
1530
1554
|
// src/commands/executor.ts
|
|
1531
|
-
import { existsSync as
|
|
1532
|
-
import { dirname as
|
|
1555
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "fs";
|
|
1556
|
+
import { dirname as dirname7, join as join10 } from "path";
|
|
1533
1557
|
|
|
1534
1558
|
// src/sem.ts
|
|
1535
1559
|
import { dirname as dirname2, join as join5 } from "path";
|
|
@@ -1693,239 +1717,2072 @@ function ensureSemIgnored(semPath) {
|
|
|
1693
1717
|
}
|
|
1694
1718
|
}
|
|
1695
1719
|
|
|
1696
|
-
// src/commands/
|
|
1697
|
-
import {
|
|
1698
|
-
import { delimiter, dirname as dirname4, join as join7 } from "path";
|
|
1720
|
+
// src/commands/executor-run.ts
|
|
1721
|
+
import { join as join9, resolve as resolve2 } from "path";
|
|
1699
1722
|
|
|
1700
|
-
// src/
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1723
|
+
// src/executor-run/claim.ts
|
|
1724
|
+
var SUCCESS = /* @__PURE__ */ new Set(["Claimed", "AlreadyHeld"]);
|
|
1725
|
+
async function claimNextTask(deps) {
|
|
1726
|
+
const { request, executorInstanceId } = deps;
|
|
1727
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve5) => setTimeout(resolve5, ms)));
|
|
1728
|
+
const idempotencyKey = deps.idempotencyKey ?? ((offer) => `executor-run:${offer.generationId}`);
|
|
1729
|
+
const tokenVersion = deps.tokenVersion ?? 1;
|
|
1730
|
+
const log = deps.log ?? (() => {
|
|
1731
|
+
});
|
|
1732
|
+
const base = `/me/executor-instances/${encodeURIComponent(executorInstanceId)}`;
|
|
1733
|
+
const offers = await request(`${base}/dispatch-offers`);
|
|
1734
|
+
if (offers.length === 0) return null;
|
|
1735
|
+
const head = offers[0];
|
|
1736
|
+
if (head.suggestedClaimDelayMs > 0) await sleep(head.suggestedClaimDelayMs);
|
|
1737
|
+
const next = await request(
|
|
1738
|
+
`${base}/dispatch-offers/claim-next`,
|
|
1739
|
+
{
|
|
1740
|
+
method: "POST",
|
|
1741
|
+
body: JSON.stringify({ idempotencyKey: idempotencyKey(head) })
|
|
1742
|
+
}
|
|
1743
|
+
);
|
|
1744
|
+
if (SUCCESS.has(next.outcome)) return toClaimed(next, tokenVersion, next.offer ?? head);
|
|
1745
|
+
if (next.outcome === "NoOffer") return null;
|
|
1746
|
+
log(
|
|
1747
|
+
`claim-next returned ${next.outcome}; falling through to direct per-generation claim`
|
|
1748
|
+
);
|
|
1749
|
+
for (const offer of offers) {
|
|
1750
|
+
const direct = await request(`/me/executor-task-claims`, {
|
|
1751
|
+
method: "POST",
|
|
1752
|
+
body: JSON.stringify({
|
|
1753
|
+
generationId: offer.generationId,
|
|
1754
|
+
executorInstanceId
|
|
1755
|
+
})
|
|
1756
|
+
});
|
|
1757
|
+
if (SUCCESS.has(direct.outcome)) return toClaimed(direct, tokenVersion, offer);
|
|
1712
1758
|
}
|
|
1759
|
+
return null;
|
|
1713
1760
|
}
|
|
1714
|
-
function
|
|
1715
|
-
const
|
|
1716
|
-
const
|
|
1717
|
-
|
|
1761
|
+
function toClaimed(result, tokenVersion, offer) {
|
|
1762
|
+
const lease = result.lease;
|
|
1763
|
+
const claimToken = result.claimToken;
|
|
1764
|
+
if (!lease?.id || !claimToken) return null;
|
|
1718
1765
|
return {
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
label: "Claude Desktop",
|
|
1728
|
-
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
1729
|
-
instruction: { surfaceKey: "claude-desktop", path: join6(claudeDir, "CLAUDE.md") }
|
|
1730
|
-
},
|
|
1731
|
-
codex: {
|
|
1732
|
-
key: "codex",
|
|
1733
|
-
label: "Codex CLI",
|
|
1734
|
-
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join6(codexHome, "config.toml"), format: "toml" },
|
|
1735
|
-
instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
|
|
1736
|
-
},
|
|
1737
|
-
cursor: {
|
|
1738
|
-
key: "cursor",
|
|
1739
|
-
label: "Cursor",
|
|
1740
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
1741
|
-
instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
|
|
1742
|
-
},
|
|
1743
|
-
antigravity: {
|
|
1744
|
-
key: "antigravity",
|
|
1745
|
-
label: "Google Antigravity",
|
|
1746
|
-
// FR-sechroom-247 — Antigravity reads MCP from a GLOBAL, home-relative
|
|
1747
|
-
// `~/.gemini/config/mcp_config.json` (not cwd; not affected by
|
|
1748
|
-
// CLAUDE_CONFIG_DIR / CODEX_HOME). The snippet — `serverUrl`-shaped, no
|
|
1749
|
-
// `type` — comes from the `antigravity` server surface, so we don't
|
|
1750
|
-
// hardcode it here. Instructions go in the project `AGENTS.md`
|
|
1751
|
-
// (cross-tool, shared with Codex/Cursor).
|
|
1752
|
-
mcp: { surfaceKey: "antigravity", sectionType: SectionType.McpConfig, path: join6(home, ".gemini", "config", "mcp_config.json"), format: "json" },
|
|
1753
|
-
instruction: { surfaceKey: "antigravity", path: join6(cwd, "AGENTS.md") }
|
|
1754
|
-
}
|
|
1766
|
+
outcome: result.outcome,
|
|
1767
|
+
leaseId: lease.id,
|
|
1768
|
+
claimToken,
|
|
1769
|
+
tokenVersion,
|
|
1770
|
+
memoryId: lease.memoryId,
|
|
1771
|
+
workspaceId: lease.workspaceId,
|
|
1772
|
+
generationId: lease.generationId,
|
|
1773
|
+
decompositionId: decompositionIdFrom(offer?.tags ?? [])
|
|
1755
1774
|
};
|
|
1756
1775
|
}
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
const home = homedir3();
|
|
1761
|
-
const detected = [];
|
|
1762
|
-
if (resolveClaudeTargets({}).some((t) => existsSync5(t.dir))) detected.push("claude-code");
|
|
1763
|
-
if (existsSync5(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
|
|
1764
|
-
if (resolveCodexHomes({}).some((d) => existsSync5(d))) detected.push("codex");
|
|
1765
|
-
if (existsSync5(join6(home, ".cursor")) || existsSync5(join6(cwd, ".cursor"))) detected.push("cursor");
|
|
1766
|
-
if (existsSync5(join6(home, ".gemini"))) detected.push("antigravity");
|
|
1767
|
-
return detected;
|
|
1776
|
+
function decompositionIdFrom(tags) {
|
|
1777
|
+
const tag = tags.find((value) => value.startsWith("wlp-decomposition:"));
|
|
1778
|
+
return tag?.slice("wlp-decomposition:".length) || void 0;
|
|
1768
1779
|
}
|
|
1769
1780
|
|
|
1770
|
-
// src/
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
};
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
const
|
|
1795
|
-
|
|
1781
|
+
// src/executor-run/codex.ts
|
|
1782
|
+
import {
|
|
1783
|
+
execFile,
|
|
1784
|
+
spawn
|
|
1785
|
+
} from "child_process";
|
|
1786
|
+
import { createInterface } from "readline";
|
|
1787
|
+
import { promisify } from "util";
|
|
1788
|
+
|
|
1789
|
+
// src/executor-run/usage.ts
|
|
1790
|
+
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5 } from "fs";
|
|
1791
|
+
import { dirname as dirname3 } from "path";
|
|
1792
|
+
function rateLimitMethod(method) {
|
|
1793
|
+
return /rate_?limits/i.test(method);
|
|
1794
|
+
}
|
|
1795
|
+
function parseRateLimitPayload(params) {
|
|
1796
|
+
const wrapper = object(params.rateLimits) ?? object(params.rate_limits) ?? params;
|
|
1797
|
+
const primary = parseWindow(wrapper.primary);
|
|
1798
|
+
const secondary = parseWindow(wrapper.secondary);
|
|
1799
|
+
if (!primary && !secondary) return void 0;
|
|
1800
|
+
return { primary, secondary };
|
|
1801
|
+
}
|
|
1802
|
+
function parseWindow(value) {
|
|
1803
|
+
const obj = object(value);
|
|
1804
|
+
if (!obj) return void 0;
|
|
1805
|
+
const usedPercent = numberOf(obj, "used_percent", "usedPercent");
|
|
1806
|
+
if (usedPercent === void 0) return void 0;
|
|
1807
|
+
return {
|
|
1808
|
+
usedPercent,
|
|
1809
|
+
windowMinutes: numberOf(obj, "window_minutes", "windowMinutes"),
|
|
1810
|
+
resetsInSeconds: numberOf(obj, "resets_in_seconds", "resetsInSeconds")
|
|
1811
|
+
};
|
|
1796
1812
|
}
|
|
1797
|
-
function
|
|
1798
|
-
|
|
1799
|
-
let added = 0;
|
|
1800
|
-
for (const [event, command] of Object.entries(commands)) {
|
|
1801
|
-
if (hasHookCommand(config2, event, command)) continue;
|
|
1802
|
-
const groups = config2.hooks[event] ??= [];
|
|
1803
|
-
groups.push({ hooks: [{ type: "command", command }] });
|
|
1804
|
-
added += 1;
|
|
1805
|
-
}
|
|
1806
|
-
return added;
|
|
1813
|
+
function object(value) {
|
|
1814
|
+
return value && typeof value === "object" ? value : void 0;
|
|
1807
1815
|
}
|
|
1808
|
-
function
|
|
1809
|
-
if (
|
|
1810
|
-
|
|
1811
|
-
if (!raw.trim()) return {};
|
|
1812
|
-
return JSON.parse(raw);
|
|
1816
|
+
function numberOf(obj, ...keys) {
|
|
1817
|
+
for (const key of keys) if (typeof obj[key] === "number") return obj[key];
|
|
1818
|
+
return void 0;
|
|
1813
1819
|
}
|
|
1814
|
-
function
|
|
1815
|
-
const
|
|
1816
|
-
|
|
1817
|
-
const
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1820
|
+
function rateLimitRemaining(state, nowMs) {
|
|
1821
|
+
const elapsedSeconds = Math.max(0, (nowMs - state.capturedAtMs) / 1e3);
|
|
1822
|
+
let binding;
|
|
1823
|
+
for (const window of ["primary", "secondary"]) {
|
|
1824
|
+
const captured = state[window];
|
|
1825
|
+
if (!captured) continue;
|
|
1826
|
+
if (captured.resetsInSeconds !== void 0 && captured.resetsInSeconds <= elapsedSeconds)
|
|
1827
|
+
continue;
|
|
1828
|
+
const remainingPercent = Math.max(0, 100 - captured.usedPercent);
|
|
1829
|
+
if (!binding || remainingPercent < binding.remainingPercent)
|
|
1830
|
+
binding = {
|
|
1831
|
+
remainingPercent,
|
|
1832
|
+
window,
|
|
1833
|
+
resetsInSeconds: captured.resetsInSeconds === void 0 ? void 0 : Math.round(captured.resetsInSeconds - elapsedSeconds)
|
|
1834
|
+
};
|
|
1822
1835
|
}
|
|
1823
|
-
return
|
|
1824
|
-
}
|
|
1825
|
-
function installClaudeCommands(claudeDir, commands, dryRun) {
|
|
1826
|
-
return installHooksJson(join7(claudeDir, "settings.json"), commands, dryRun);
|
|
1836
|
+
return binding;
|
|
1827
1837
|
}
|
|
1828
|
-
function
|
|
1829
|
-
return
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1838
|
+
function admissionDecision(state, reservePercent, nowMs) {
|
|
1839
|
+
if (!state) return { ok: true };
|
|
1840
|
+
const remaining = rateLimitRemaining(state, nowMs);
|
|
1841
|
+
if (!remaining) return { ok: true };
|
|
1842
|
+
if (remaining.remainingPercent > reservePercent) return { ok: true, remaining };
|
|
1843
|
+
const resets = remaining.resetsInSeconds === void 0 ? "" : `; resets in ~${remaining.resetsInSeconds}s`;
|
|
1844
|
+
return {
|
|
1845
|
+
ok: false,
|
|
1846
|
+
remaining,
|
|
1847
|
+
reason: `rate-limit remaining ${remaining.remainingPercent.toFixed(1)}% (${remaining.window} window) is at or below the ${reservePercent}% reserve${resets}`
|
|
1848
|
+
};
|
|
1833
1849
|
}
|
|
1834
|
-
function
|
|
1835
|
-
const
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1850
|
+
function formatTokenMeter(used, window) {
|
|
1851
|
+
const pct = window > 0 ? (used / window * 100).toFixed(1) : "?";
|
|
1852
|
+
return `${used.toLocaleString("en-US")} / ${window.toLocaleString("en-US")} (${pct}%)`;
|
|
1853
|
+
}
|
|
1854
|
+
var UsageTracker = class {
|
|
1855
|
+
constructor(options) {
|
|
1856
|
+
this.options = options;
|
|
1857
|
+
}
|
|
1858
|
+
options;
|
|
1859
|
+
totals = { tokensIn: 0, tokensOut: 0 };
|
|
1860
|
+
perTask = /* @__PURE__ */ new Map();
|
|
1861
|
+
latest;
|
|
1862
|
+
limits;
|
|
1863
|
+
deferred = false;
|
|
1864
|
+
/** Fold one `thread/tokenUsage/updated` parse into the instance running totals.
|
|
1865
|
+
* Payload counters are cumulative within a turn but reset across turns/resumes,
|
|
1866
|
+
* so totals accumulate deltas; a counter that shrank is a fresh turn's counter
|
|
1867
|
+
* and contributes its full value. */
|
|
1868
|
+
recordUsage(taskId, usage) {
|
|
1869
|
+
const previous = this.perTask.get(taskId);
|
|
1870
|
+
this.totals.tokensIn += delta(usage.tokensIn, previous?.tokensIn);
|
|
1871
|
+
this.totals.tokensOut += delta(usage.tokensOut, previous?.tokensOut);
|
|
1872
|
+
this.perTask.set(taskId, { tokensIn: usage.tokensIn, tokensOut: usage.tokensOut });
|
|
1873
|
+
this.latest = usage;
|
|
1874
|
+
this.append({
|
|
1875
|
+
type: "usage",
|
|
1876
|
+
taskId,
|
|
1877
|
+
modelId: usage.modelId,
|
|
1878
|
+
tokensIn: usage.tokensIn,
|
|
1879
|
+
tokensOut: usage.tokensOut,
|
|
1880
|
+
contextUsed: usage.contextUsed,
|
|
1881
|
+
contextWindow: usage.contextWindow,
|
|
1882
|
+
instanceTokensIn: this.totals.tokensIn,
|
|
1883
|
+
instanceTokensOut: this.totals.tokensOut
|
|
1884
|
+
});
|
|
1885
|
+
this.options.log(this.statusLine());
|
|
1886
|
+
}
|
|
1887
|
+
/** Fold a rate-limit payload (dedicated notification or embedded in a usage
|
|
1888
|
+
* payload) into the admission state. */
|
|
1889
|
+
recordRateLimits(payload) {
|
|
1890
|
+
this.limits = { ...payload, capturedAtMs: this.now() };
|
|
1891
|
+
const remaining = rateLimitRemaining(this.limits, this.now());
|
|
1892
|
+
this.append({
|
|
1893
|
+
type: "rate-limits",
|
|
1894
|
+
primary: payload.primary ?? null,
|
|
1895
|
+
secondary: payload.secondary ?? null,
|
|
1896
|
+
remainingPercent: remaining?.remainingPercent ?? null
|
|
1897
|
+
});
|
|
1898
|
+
this.options.log(
|
|
1899
|
+
`rate-limit update: ${remaining ? `remaining ${remaining.remainingPercent.toFixed(1)}% (${remaining.window} window)` : "all windows recovered"}`
|
|
1900
|
+
);
|
|
1840
1901
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
const
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1902
|
+
/** The claim-time admission verdict. Transitions (admit→defer, defer→admit)
|
|
1903
|
+
* land in the JSONL record; the loud per-cycle logging is the driver loop's. */
|
|
1904
|
+
admission() {
|
|
1905
|
+
const decision = admissionDecision(
|
|
1906
|
+
this.limits,
|
|
1907
|
+
this.options.reservePercent,
|
|
1908
|
+
this.now()
|
|
1909
|
+
);
|
|
1910
|
+
if (decision.ok !== !this.deferred) {
|
|
1911
|
+
this.deferred = !decision.ok;
|
|
1912
|
+
this.append({
|
|
1913
|
+
type: "admission",
|
|
1914
|
+
ok: decision.ok,
|
|
1915
|
+
reservePercent: this.options.reservePercent,
|
|
1916
|
+
reason: decision.ok ? null : decision.reason
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
return decision.ok ? { ok: true } : { ok: false, reason: decision.reason };
|
|
1850
1920
|
}
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1921
|
+
/** Per-instance running totals + turn meter + rate-limit remaining, for the
|
|
1922
|
+
* driver status output. */
|
|
1923
|
+
statusLine() {
|
|
1924
|
+
const parts = [
|
|
1925
|
+
`instance total in ${this.totals.tokensIn.toLocaleString("en-US")} out ${this.totals.tokensOut.toLocaleString("en-US")} across ${this.perTask.size} task(s)`
|
|
1926
|
+
];
|
|
1927
|
+
if (this.latest)
|
|
1928
|
+
parts.push(`turn ${formatTokenMeter(this.latest.contextUsed, this.latest.contextWindow)}`);
|
|
1929
|
+
const remaining = this.limits ? rateLimitRemaining(this.limits, this.now()) : void 0;
|
|
1930
|
+
parts.push(
|
|
1931
|
+
remaining ? `rate-limit remaining ${remaining.remainingPercent.toFixed(1)}%` : "rate-limit remaining unknown"
|
|
1932
|
+
);
|
|
1933
|
+
return `usage[${this.options.instanceKey}]: ${parts.join("; ")}`;
|
|
1862
1934
|
}
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
|
|
1870
|
-
const surfaces = detectHookSurfaces(cwd);
|
|
1871
|
-
return surfaces.length > 0 ? surfaces : ["claude", "codex"];
|
|
1872
|
-
}
|
|
1873
|
-
function describe(result, dryRun) {
|
|
1874
|
-
if (result.status === "current") return ` \u2713 ${result.path} (already configured)`;
|
|
1875
|
-
const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
|
|
1876
|
-
return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
|
|
1877
|
-
}
|
|
1878
|
-
var HOOK_SURFACE_LABEL = {
|
|
1879
|
-
claude: "Claude Code",
|
|
1880
|
-
codex: "Codex"
|
|
1881
|
-
};
|
|
1882
|
-
function installHookSurfaces(surfaces, opts) {
|
|
1883
|
-
const out = [];
|
|
1884
|
-
for (const surface of surfaces) {
|
|
1885
|
-
if (surface === "claude") {
|
|
1886
|
-
const path = join7(opts.claudeDir, "settings.json");
|
|
1887
|
-
out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
|
|
1888
|
-
} else {
|
|
1889
|
-
const hooksJson = installHooksJson(join7(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
|
|
1890
|
-
const featureFlag = installCodexFeatureFlag(join7(opts.codexHome, "config.toml"), opts.dryRun);
|
|
1891
|
-
out.push({ surface, results: [hooksJson, featureFlag] });
|
|
1892
|
-
}
|
|
1935
|
+
append(record) {
|
|
1936
|
+
this.options.appendRecord?.({
|
|
1937
|
+
ts: new Date(this.now()).toISOString(),
|
|
1938
|
+
instanceKey: this.options.instanceKey,
|
|
1939
|
+
...record
|
|
1940
|
+
});
|
|
1893
1941
|
}
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
function detectHookSurfaces(cwd) {
|
|
1897
|
-
const detected = detectInstalledClients(cwd);
|
|
1898
|
-
const surfaces = [];
|
|
1899
|
-
if (detected.includes("claude-code")) surfaces.push("claude");
|
|
1900
|
-
if (detected.includes("codex")) surfaces.push("codex");
|
|
1901
|
-
return surfaces;
|
|
1902
|
-
}
|
|
1903
|
-
function isSechroomOnPath() {
|
|
1904
|
-
const pathEnv = process.env.PATH ?? "";
|
|
1905
|
-
if (!pathEnv) return false;
|
|
1906
|
-
const names = process.platform === "win32" ? ["sechroom.cmd", "sechroom.exe", "sechroom.bat", "sechroom"] : ["sechroom"];
|
|
1907
|
-
for (const dir of pathEnv.split(delimiter)) {
|
|
1908
|
-
if (!dir) continue;
|
|
1909
|
-
for (const name of names) {
|
|
1910
|
-
if (existsSync6(join7(dir, name))) return true;
|
|
1911
|
-
}
|
|
1942
|
+
now() {
|
|
1943
|
+
return this.options.now?.() ?? Date.now();
|
|
1912
1944
|
}
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1945
|
+
};
|
|
1946
|
+
function delta(current, previous) {
|
|
1947
|
+
if (previous === void 0) return current;
|
|
1948
|
+
const d = current - previous;
|
|
1949
|
+
return d < 0 ? current : d;
|
|
1950
|
+
}
|
|
1951
|
+
function createUsageLogAppender(path, log) {
|
|
1952
|
+
let warned = false;
|
|
1953
|
+
let dirReady = false;
|
|
1954
|
+
return (record) => {
|
|
1955
|
+
try {
|
|
1956
|
+
if (!dirReady) {
|
|
1957
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
1958
|
+
dirReady = true;
|
|
1959
|
+
}
|
|
1960
|
+
appendFileSync2(path, `${JSON.stringify(record)}
|
|
1961
|
+
`);
|
|
1962
|
+
} catch (error) {
|
|
1963
|
+
if (warned) return;
|
|
1964
|
+
warned = true;
|
|
1965
|
+
log(`usage log append failed (${String(error)}) \u2014 continuing without ${path}`);
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// src/executor-run/codex.ts
|
|
1971
|
+
var execFileAsync = promisify(execFile);
|
|
1972
|
+
function shouldResumeTimedOutTurn(input) {
|
|
1973
|
+
return input.outcome === "timeout" && !input.hasPacket && Boolean(input.threadId) && (input.contextUsed > 0 || input.detailEventCount > 0 || input.lastAgentMessage.length > 0);
|
|
1974
|
+
}
|
|
1975
|
+
function parseCodexUsage(params, previous) {
|
|
1976
|
+
const info = object2(params.tokenUsageInfo) ?? params;
|
|
1977
|
+
const last = object2(info.last) ?? object2(info.lastTokenUsage) ?? object2(info.last_turn);
|
|
1978
|
+
const total = object2(info.total) ?? object2(info.totalTokenUsage) ?? info;
|
|
1979
|
+
const resumed = Boolean(params.resumed ?? info.resumed ?? last);
|
|
1980
|
+
const spend = resumed && last ? last : total;
|
|
1981
|
+
const tokensIn = numberOf2(spend, "input_tokens", "inputTokens", "input") ?? previous?.tokensIn ?? 0;
|
|
1982
|
+
const tokensOut = numberOf2(spend, "output_tokens", "outputTokens", "output") ?? previous?.tokensOut ?? 0;
|
|
1983
|
+
const contextUsed = numberOf2(last ?? spend, "total_tokens", "totalTokens", "total") ?? tokensIn + tokensOut;
|
|
1984
|
+
const contextWindow = numberOf2(params, "modelContextWindow", "contextWindow") ?? numberOf2(info, "modelContextWindow", "contextWindow") ?? previous?.contextWindow ?? 0;
|
|
1985
|
+
const modelId = stringOf(params, "model", "modelId") ?? stringOf(info, "model", "modelId") ?? previous?.modelId ?? null;
|
|
1986
|
+
return { tokensIn, tokensOut, contextUsed, contextWindow, modelId };
|
|
1987
|
+
}
|
|
1988
|
+
function object2(value) {
|
|
1989
|
+
return value && typeof value === "object" ? value : void 0;
|
|
1990
|
+
}
|
|
1991
|
+
function numberOf2(obj, ...keys) {
|
|
1992
|
+
for (const key of keys) if (typeof obj[key] === "number") return obj[key];
|
|
1993
|
+
return void 0;
|
|
1994
|
+
}
|
|
1995
|
+
function stringOf(obj, ...keys) {
|
|
1996
|
+
for (const key of keys) if (typeof obj[key] === "string") return obj[key];
|
|
1997
|
+
return void 0;
|
|
1998
|
+
}
|
|
1999
|
+
function renderUsage(usage) {
|
|
2000
|
+
return `tokens ${formatTokenMeter(usage.contextUsed, usage.contextWindow)}, thread total ${(usage.tokensIn + usage.tokensOut).toLocaleString("en-US")}`;
|
|
2001
|
+
}
|
|
2002
|
+
function mapThreadItem(msg) {
|
|
2003
|
+
const method = msg.method ?? "";
|
|
2004
|
+
if (!method.includes("item")) return void 0;
|
|
2005
|
+
const type = String(msg.params?.type ?? msg.params?.itemType ?? method);
|
|
2006
|
+
const text2 = JSON.stringify({ method, type, id: msg.params?.id ?? null });
|
|
2007
|
+
if (/approval/i.test(type)) return { kind: "Approval", text: text2 };
|
|
2008
|
+
if (/commandExecution|fileChange|contextCompaction|agentMessage|userMessage/i.test(type))
|
|
2009
|
+
return { kind: /agentMessage|userMessage/i.test(type) ? "Raw" : "Parsed", text: text2 };
|
|
2010
|
+
return void 0;
|
|
2011
|
+
}
|
|
2012
|
+
function verdictForTelemetry(status) {
|
|
2013
|
+
if (status === "completed") return "pass";
|
|
2014
|
+
if (status === "needs_approval" || status === "cancelled" || status === "canceled") return "blocked";
|
|
2015
|
+
return "soft-fail";
|
|
2016
|
+
}
|
|
2017
|
+
var CodexAppServer = class {
|
|
2018
|
+
constructor(options) {
|
|
2019
|
+
this.options = options;
|
|
2020
|
+
}
|
|
2021
|
+
options;
|
|
2022
|
+
child;
|
|
2023
|
+
nextId = 1;
|
|
2024
|
+
pending = /* @__PURE__ */ new Map();
|
|
2025
|
+
onServerRequest;
|
|
2026
|
+
onNotification;
|
|
2027
|
+
exited;
|
|
2028
|
+
cliVersion;
|
|
2029
|
+
/** Spawn the child + initialize. Idempotent per instance. */
|
|
2030
|
+
async start() {
|
|
2031
|
+
if (this.child) return;
|
|
2032
|
+
this.cliVersion ??= await pinCodexVersion(this.options.codexBin, this.options.log);
|
|
2033
|
+
const child = spawn(this.options.codexBin, ["app-server", "--stdio"], {
|
|
2034
|
+
cwd: this.options.cwd,
|
|
2035
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2036
|
+
});
|
|
2037
|
+
this.child = child;
|
|
2038
|
+
child.on("exit", (code) => {
|
|
2039
|
+
this.exited = { code };
|
|
2040
|
+
const failure = new Error(`codex app-server exited (${code ?? "signal"})`);
|
|
2041
|
+
for (const waiter of this.pending.values()) waiter.reject(failure);
|
|
2042
|
+
this.pending.clear();
|
|
2043
|
+
});
|
|
2044
|
+
child.stderr.on(
|
|
2045
|
+
"data",
|
|
2046
|
+
(chunk) => this.options.log(`codex! ${chunk.toString().trimEnd()}`)
|
|
2047
|
+
);
|
|
2048
|
+
createInterface({ input: child.stdout }).on(
|
|
2049
|
+
"line",
|
|
2050
|
+
(line) => this.onLine(line)
|
|
2051
|
+
);
|
|
2052
|
+
await this.request("initialize", {
|
|
2053
|
+
clientInfo: { name: "sechroom-executor-run", version: "0" },
|
|
2054
|
+
capabilities: { experimentalApi: true }
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
get alive() {
|
|
2058
|
+
return this.child !== void 0 && this.exited === void 0;
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Run one task as one thread+turn; resolves when the server reports
|
|
2062
|
+
* `turn/completed` (a server→client REQUEST, per the Looper-verified protocol),
|
|
2063
|
+
* the child dies, or the timeout lapses.
|
|
2064
|
+
*/
|
|
2065
|
+
async runTask(prompt, telemetry) {
|
|
2066
|
+
let packet;
|
|
2067
|
+
let lastAgentMessage = "";
|
|
2068
|
+
let threadId = "";
|
|
2069
|
+
let turnId = "";
|
|
2070
|
+
let usage;
|
|
2071
|
+
const detailEvents = [];
|
|
2072
|
+
const modelId = this.options.model ?? null;
|
|
2073
|
+
const event = (kind, text2 = null) => ({
|
|
2074
|
+
taskId: telemetry?.taskId ?? "",
|
|
2075
|
+
kind,
|
|
2076
|
+
tokensIn: null,
|
|
2077
|
+
tokensOut: null,
|
|
2078
|
+
contextUsed: null,
|
|
2079
|
+
contextWindow: null,
|
|
2080
|
+
text: text2,
|
|
2081
|
+
approvalState: null,
|
|
2082
|
+
verdict: null,
|
|
2083
|
+
modelId,
|
|
2084
|
+
executorInstanceId: this.options.executorInstanceId,
|
|
2085
|
+
leaseId: telemetry?.leaseId ?? null,
|
|
2086
|
+
turnId: turnId || null
|
|
2087
|
+
});
|
|
2088
|
+
this.onNotification = (msg) => {
|
|
2089
|
+
if (msg.method === "thread/tokenUsage/updated") {
|
|
2090
|
+
usage = parseCodexUsage(msg.params ?? {}, usage);
|
|
2091
|
+
this.options.log(renderUsage(usage));
|
|
2092
|
+
this.options.onUsage?.(telemetry?.taskId ?? "", usage);
|
|
2093
|
+
this.tapRateLimits(msg.params ?? {});
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
const mapped = mapThreadItem(msg);
|
|
2097
|
+
if (mapped) detailEvents.push(event(mapped.kind, mapped.text));
|
|
2098
|
+
};
|
|
2099
|
+
const terminal = new Promise((resolve5) => {
|
|
2100
|
+
this.onServerRequest = (msg) => {
|
|
2101
|
+
const method = msg.method ?? "";
|
|
2102
|
+
if (terminalMethod(method)) {
|
|
2103
|
+
this.respond(msg.id, {});
|
|
2104
|
+
resolve5("completed");
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
if (method === "item/tool/call") {
|
|
2108
|
+
const name = String(msg.params?.name ?? msg.params?.tool ?? "");
|
|
2109
|
+
const args = msg.params?.arguments ?? msg.params?.input ?? {};
|
|
2110
|
+
if (name === "sechroom_closeout") {
|
|
2111
|
+
packet = {
|
|
2112
|
+
terminal_status: String(args.terminal_status ?? "completed"),
|
|
2113
|
+
summary: String(args.summary ?? ""),
|
|
2114
|
+
evidence: Array.isArray(args.evidence) ? args.evidence.map(String) : void 0
|
|
2115
|
+
};
|
|
2116
|
+
this.respond(msg.id, toolText("closeout accepted"));
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
if (name === "sechroom_lifecycle_signal") {
|
|
2120
|
+
detailEvents.push(event("Parsed", `phase:${String(args.phase ?? "?")}:${String(args.status ?? "?")}`));
|
|
2121
|
+
this.options.log(
|
|
2122
|
+
`lifecycle ${String(args.phase ?? "?")}:${String(args.status ?? "?")} \u2014 ${String(args.summary ?? "")}`
|
|
2123
|
+
);
|
|
2124
|
+
this.respond(msg.id, toolText("ok"));
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
this.options.log(`unknown dynamic tool '${name}' \u2014 acknowledged empty`);
|
|
2128
|
+
this.respond(msg.id, toolText("unsupported tool"));
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
if (method.endsWith("requestApproval") || method.includes("elicitation")) {
|
|
2132
|
+
detailEvents.push({ ...event("Approval", method), approvalState: "denied" });
|
|
2133
|
+
this.options.log(
|
|
2134
|
+
`approval requested (${method}) under approvalPolicy=never \u2014 DENIED (unattended run)`
|
|
2135
|
+
);
|
|
2136
|
+
this.respond(msg.id, { decision: "denied" });
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
this.respond(msg.id, {});
|
|
2140
|
+
};
|
|
2141
|
+
});
|
|
2142
|
+
try {
|
|
2143
|
+
const started = await this.request("thread/start", {
|
|
2144
|
+
cwd: this.options.cwd,
|
|
2145
|
+
approvalPolicy: "never",
|
|
2146
|
+
sandbox: this.options.sandbox,
|
|
2147
|
+
ephemeral: false,
|
|
2148
|
+
developerInstructions: prompt,
|
|
2149
|
+
dynamicTools: dynamicToolDefinitions()
|
|
2150
|
+
});
|
|
2151
|
+
threadId = String(
|
|
2152
|
+
started.threadId ?? started.thread?.id ?? ""
|
|
2153
|
+
);
|
|
2154
|
+
const turn = await this.request("turn/start", {
|
|
2155
|
+
threadId,
|
|
2156
|
+
...this.options.model ? { model: this.options.model } : {},
|
|
2157
|
+
input: [{ type: "text", text: "Begin the task now." }]
|
|
2158
|
+
});
|
|
2159
|
+
turnId = String(
|
|
2160
|
+
turn.turnId ?? turn.turn?.id ?? ""
|
|
2161
|
+
);
|
|
2162
|
+
} catch (e) {
|
|
2163
|
+
return { status: "crashed", reason: String(e) };
|
|
2164
|
+
}
|
|
2165
|
+
let outcome = await Promise.race([
|
|
2166
|
+
terminal,
|
|
2167
|
+
this.exitAsResult(),
|
|
2168
|
+
timeout(this.options.turnTimeoutMs)
|
|
2169
|
+
]);
|
|
2170
|
+
if (shouldResumeTimedOutTurn({
|
|
2171
|
+
outcome,
|
|
2172
|
+
hasPacket: Boolean(packet),
|
|
2173
|
+
threadId,
|
|
2174
|
+
contextUsed: usage?.contextUsed ?? 0,
|
|
2175
|
+
detailEventCount: detailEvents.length,
|
|
2176
|
+
lastAgentMessage
|
|
2177
|
+
})) {
|
|
2178
|
+
this.options.log(
|
|
2179
|
+
`fresh turn timed out after ${this.options.turnTimeoutMs}ms with substantial work \u2014 resuming thread ${threadId}`
|
|
2180
|
+
);
|
|
2181
|
+
try {
|
|
2182
|
+
await this.request("turn/interrupt", { threadId, turnId });
|
|
2183
|
+
await this.request("thread/resume", { threadId });
|
|
2184
|
+
const resumed = await this.request("turn/start", {
|
|
2185
|
+
threadId,
|
|
2186
|
+
...this.options.model ? { model: this.options.model } : {},
|
|
2187
|
+
input: [
|
|
2188
|
+
{
|
|
2189
|
+
type: "text",
|
|
2190
|
+
text: "Resume the interrupted work from this saved thread. Finish the task and call sechroom_closeout."
|
|
2191
|
+
}
|
|
2192
|
+
]
|
|
2193
|
+
});
|
|
2194
|
+
turnId = String(
|
|
2195
|
+
resumed.turnId ?? resumed.turn?.id ?? ""
|
|
2196
|
+
);
|
|
2197
|
+
outcome = await Promise.race([
|
|
2198
|
+
terminal,
|
|
2199
|
+
this.exitAsResult(),
|
|
2200
|
+
timeout(this.options.resumeTurnTimeoutMs)
|
|
2201
|
+
]);
|
|
2202
|
+
} catch (error) {
|
|
2203
|
+
return { status: "crashed", reason: `thread resume failed: ${String(error)}` };
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
if (outcome === "timeout")
|
|
2207
|
+
return packet ? { status: "completed", packet, lastAgentMessage } : { status: "timeout" };
|
|
2208
|
+
if (outcome !== "completed")
|
|
2209
|
+
return { status: "crashed", reason: String(outcome) };
|
|
2210
|
+
if (!packet && threadId) {
|
|
2211
|
+
try {
|
|
2212
|
+
await this.request("turn/start", {
|
|
2213
|
+
threadId,
|
|
2214
|
+
input: [
|
|
2215
|
+
{
|
|
2216
|
+
type: "text",
|
|
2217
|
+
text: "The turn ended without a sechroom_closeout call. Call sechroom_closeout NOW with terminal_status, summary, and evidence."
|
|
2218
|
+
}
|
|
2219
|
+
]
|
|
2220
|
+
});
|
|
2221
|
+
await Promise.race([terminal, this.exitAsResult(), timeout(6e4)]);
|
|
2222
|
+
} catch {
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
await this.emitTurnTelemetry(telemetry, event, detailEvents, usage, packet).catch(
|
|
2226
|
+
(error) => this.options.log(`telemetry emit failed (non-fatal): ${String(error)}`)
|
|
2227
|
+
);
|
|
2228
|
+
return { status: "completed", packet, lastAgentMessage };
|
|
2229
|
+
}
|
|
2230
|
+
async emitTurnTelemetry(context, makeEvent, details, usage, packet) {
|
|
2231
|
+
if (!context?.decompositionId) {
|
|
2232
|
+
this.options.log("telemetry absent: claimed task carried no wlp-decomposition tag");
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
const verdict = verdictForTelemetry(packet?.terminal_status);
|
|
2236
|
+
const terminal = {
|
|
2237
|
+
...makeEvent("Terminal", packet?.summary ?? null),
|
|
2238
|
+
tokensIn: usage?.tokensIn ?? null,
|
|
2239
|
+
tokensOut: usage?.tokensOut ?? null,
|
|
2240
|
+
contextUsed: usage?.contextUsed ?? null,
|
|
2241
|
+
contextWindow: usage?.contextWindow ?? null,
|
|
2242
|
+
modelId: usage?.modelId ?? this.options.model ?? null,
|
|
2243
|
+
verdict
|
|
2244
|
+
};
|
|
2245
|
+
await this.options.emitTelemetry(
|
|
2246
|
+
context.decompositionId,
|
|
2247
|
+
verdict === "pass" ? [terminal] : [...details, terminal]
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
/** turn/interrupt — the graceful-shutdown drain boundary. Best-effort. */
|
|
2251
|
+
async interrupt() {
|
|
2252
|
+
try {
|
|
2253
|
+
await this.request("turn/interrupt", {});
|
|
2254
|
+
} catch {
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
stop() {
|
|
2258
|
+
this.child?.kill("SIGTERM");
|
|
2259
|
+
this.child = void 0;
|
|
2260
|
+
}
|
|
2261
|
+
request(method, params) {
|
|
2262
|
+
const child = this.child;
|
|
2263
|
+
if (!child || this.exited)
|
|
2264
|
+
return Promise.reject(new Error("codex app-server is not running"));
|
|
2265
|
+
const id = this.nextId++;
|
|
2266
|
+
return new Promise((resolve5, reject) => {
|
|
2267
|
+
this.pending.set(id, { resolve: resolve5, reject });
|
|
2268
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
respond(id, result) {
|
|
2272
|
+
if (id === void 0 || !this.child) return;
|
|
2273
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
|
|
2274
|
+
}
|
|
2275
|
+
onLine(line) {
|
|
2276
|
+
if (!line.trim()) return;
|
|
2277
|
+
let msg;
|
|
2278
|
+
try {
|
|
2279
|
+
msg = JSON.parse(line);
|
|
2280
|
+
} catch {
|
|
2281
|
+
this.options.log(`codex? ${line}`);
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
if (msg.id !== void 0 && (msg.result !== void 0 || msg.error)) {
|
|
2285
|
+
const waiter = this.pending.get(msg.id);
|
|
2286
|
+
if (waiter) {
|
|
2287
|
+
this.pending.delete(msg.id);
|
|
2288
|
+
if (msg.error)
|
|
2289
|
+
waiter.reject(new Error(msg.error.message ?? "app-server error"));
|
|
2290
|
+
else waiter.resolve(msg.result);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
if (msg.method && msg.id !== void 0) {
|
|
2295
|
+
this.onServerRequest?.(msg);
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
if (msg.method && rateLimitMethod(msg.method)) this.tapRateLimits(msg.params ?? {});
|
|
2299
|
+
if (msg.method) this.onNotification?.(msg);
|
|
2300
|
+
if (msg.method && terminalMethod(msg.method)) {
|
|
2301
|
+
this.onServerRequest?.(msg);
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
tapRateLimits(params) {
|
|
2306
|
+
const parsed = parseRateLimitPayload(params);
|
|
2307
|
+
if (parsed) this.options.onRateLimits?.(parsed);
|
|
2308
|
+
}
|
|
2309
|
+
exitAsResult() {
|
|
2310
|
+
return new Promise((resolve5) => {
|
|
2311
|
+
this.child?.once(
|
|
2312
|
+
"exit",
|
|
2313
|
+
(code) => resolve5(`app-server exited (${code ?? "signal"})`)
|
|
2314
|
+
);
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
};
|
|
2318
|
+
async function pinCodexVersion(bin, log) {
|
|
2319
|
+
try {
|
|
2320
|
+
const { stdout } = await execFileAsync(bin, ["--version"]);
|
|
2321
|
+
const version = stdout.trim();
|
|
2322
|
+
log(`codex version: ${version}`);
|
|
2323
|
+
return version;
|
|
2324
|
+
} catch (error) {
|
|
2325
|
+
log(
|
|
2326
|
+
`warning: could not pin codex version: ${error instanceof Error ? error.message : String(error)}`
|
|
2327
|
+
);
|
|
2328
|
+
return void 0;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
function toolText(text2) {
|
|
2332
|
+
return { success: true, contentItems: [{ type: "inputText", text: text2 }] };
|
|
2333
|
+
}
|
|
2334
|
+
function terminalMethod(method) {
|
|
2335
|
+
return method === "turn/completed" || method.endsWith("/turn/completed");
|
|
2336
|
+
}
|
|
2337
|
+
function timeout(ms) {
|
|
2338
|
+
return new Promise((resolve5) => setTimeout(() => resolve5("timeout"), ms).unref?.());
|
|
2339
|
+
}
|
|
2340
|
+
function dynamicToolDefinitions() {
|
|
2341
|
+
return [
|
|
2342
|
+
{
|
|
2343
|
+
name: "sechroom_closeout",
|
|
2344
|
+
description: "Submit the task's terminal closeout. REQUIRED before ending the turn: call with the honest terminal_status, a summary of what was done, and evidence (files changed, tests run, checks).",
|
|
2345
|
+
inputSchema: {
|
|
2346
|
+
type: "object",
|
|
2347
|
+
properties: {
|
|
2348
|
+
terminal_status: {
|
|
2349
|
+
type: "string",
|
|
2350
|
+
enum: ["completed", "needs_approval", "error", "cancelled"]
|
|
2351
|
+
},
|
|
2352
|
+
summary: { type: "string" },
|
|
2353
|
+
evidence: { type: "array", items: { type: "string" } }
|
|
2354
|
+
},
|
|
2355
|
+
required: ["terminal_status", "summary"]
|
|
2356
|
+
}
|
|
2357
|
+
},
|
|
2358
|
+
{
|
|
2359
|
+
name: "sechroom_lifecycle_signal",
|
|
2360
|
+
description: "Report phase progress: call with phase (start|work|verify|closeout) and status (started|completed|skipped|blocked) at each phase boundary.",
|
|
2361
|
+
inputSchema: {
|
|
2362
|
+
type: "object",
|
|
2363
|
+
properties: {
|
|
2364
|
+
phase: { type: "string" },
|
|
2365
|
+
status: { type: "string" },
|
|
2366
|
+
summary: { type: "string" }
|
|
2367
|
+
},
|
|
2368
|
+
required: ["phase", "status", "summary"]
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
];
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// src/executor-run/delivery.ts
|
|
2375
|
+
import { execFile as execFile2 } from "child_process";
|
|
2376
|
+
function createGitRunner(rootDir) {
|
|
2377
|
+
return (bin, args) => new Promise((resolve5) => {
|
|
2378
|
+
execFile2(
|
|
2379
|
+
bin,
|
|
2380
|
+
bin === "git" ? ["-C", rootDir, ...args] : args,
|
|
2381
|
+
{ cwd: rootDir, maxBuffer: 10 * 1024 * 1024 },
|
|
2382
|
+
(error, stdout, stderr) => resolve5({ ok: !error, stdout: String(stdout), stderr: String(stderr) })
|
|
2383
|
+
);
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
function porcelainPaths(stdout) {
|
|
2387
|
+
return stdout.split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 3).map((line) => {
|
|
2388
|
+
const path = line.slice(3);
|
|
2389
|
+
const arrow = path.indexOf(" -> ");
|
|
2390
|
+
return arrow >= 0 ? path.slice(arrow + 4) : path;
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
async function snapshotRoot(git) {
|
|
2394
|
+
const branch = await git("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2395
|
+
const status = await git("git", ["status", "--porcelain"]);
|
|
2396
|
+
return {
|
|
2397
|
+
baseBranch: branch.ok ? branch.stdout.trim() : "HEAD",
|
|
2398
|
+
dirtyPaths: status.ok ? porcelainPaths(status.stdout) : []
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
async function checkRootReady(git, allowDirty) {
|
|
2402
|
+
const snapshot = await snapshotRoot(git);
|
|
2403
|
+
if (snapshot.dirtyPaths.length === 0 || allowDirty)
|
|
2404
|
+
return { ok: true, snapshot };
|
|
2405
|
+
return {
|
|
2406
|
+
ok: false,
|
|
2407
|
+
snapshot,
|
|
2408
|
+
reason: `root has ${snapshot.dirtyPaths.length} uncommitted path(s) (e.g. ${snapshot.dirtyPaths[0]}) \u2014 refusing to claim; commit/clean it or pass --allow-dirty-root`
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
async function deliverTurn(git, options) {
|
|
2412
|
+
const status = await git("git", ["status", "--porcelain"]);
|
|
2413
|
+
if (!status.ok)
|
|
2414
|
+
return { delivered: false, note: `delivery skipped \u2014 git status failed: ${status.stderr.trim()}` };
|
|
2415
|
+
const preDirty = new Set(options.snapshot.dirtyPaths);
|
|
2416
|
+
const turnPaths = porcelainPaths(status.stdout).filter((p) => !preDirty.has(p));
|
|
2417
|
+
if (turnPaths.length === 0)
|
|
2418
|
+
return { delivered: false, note: "no file changes produced by the turn \u2014 nothing to deliver" };
|
|
2419
|
+
const branch = await freeBranchName(git, `task/${slug(options.taskId)}`);
|
|
2420
|
+
const created = await git("git", ["checkout", "-b", branch]);
|
|
2421
|
+
if (!created.ok)
|
|
2422
|
+
return {
|
|
2423
|
+
delivered: false,
|
|
2424
|
+
note: `delivery FAILED \u2014 could not create branch ${branch}: ${created.stderr.trim()} (changes remain uncommitted in the root)`
|
|
2425
|
+
};
|
|
2426
|
+
const notes = [];
|
|
2427
|
+
try {
|
|
2428
|
+
const added = await git("git", ["add", "--", ...turnPaths]);
|
|
2429
|
+
if (!added.ok) return failBack(`git add failed: ${added.stderr.trim()}`);
|
|
2430
|
+
const committed = await git("git", [
|
|
2431
|
+
"commit",
|
|
2432
|
+
"-m",
|
|
2433
|
+
commitMessage(options)
|
|
2434
|
+
]);
|
|
2435
|
+
if (!committed.ok) return failBack(`git commit failed: ${committed.stderr.trim()}`);
|
|
2436
|
+
const sha = (await git("git", ["rev-parse", "--short", "HEAD"])).stdout.trim();
|
|
2437
|
+
const pushed = await git("git", ["push", "-u", "origin", branch]);
|
|
2438
|
+
if (!pushed.ok)
|
|
2439
|
+
notes.push(`push failed (${firstLine(pushed.stderr)}) \u2014 branch is local-only`);
|
|
2440
|
+
let prUrl;
|
|
2441
|
+
if (options.raisePr && pushed.ok) {
|
|
2442
|
+
const pr = await git("gh", [
|
|
2443
|
+
"pr",
|
|
2444
|
+
"create",
|
|
2445
|
+
"--head",
|
|
2446
|
+
branch,
|
|
2447
|
+
"--title",
|
|
2448
|
+
`task(${options.taskId}): ${options.title}`,
|
|
2449
|
+
"--body",
|
|
2450
|
+
prBody(options, sha)
|
|
2451
|
+
]);
|
|
2452
|
+
if (pr.ok) prUrl = firstLine(pr.stdout);
|
|
2453
|
+
else notes.push(`PR raise failed (${firstLine(pr.stderr)}) \u2014 raise manually from ${branch}`);
|
|
2454
|
+
}
|
|
2455
|
+
notes.unshift(
|
|
2456
|
+
`delivered ${turnPaths.length} path(s) to ${branch} @ ${sha}${prUrl ? ` \u2014 PR ${prUrl}` : ""}`
|
|
2457
|
+
);
|
|
2458
|
+
return { delivered: true, branch, sha, prUrl, note: notes.join("; ") };
|
|
2459
|
+
} finally {
|
|
2460
|
+
const back = await git("git", ["checkout", options.snapshot.baseBranch]);
|
|
2461
|
+
if (!back.ok)
|
|
2462
|
+
options.log(
|
|
2463
|
+
`delivery: could not return root to ${options.snapshot.baseBranch}: ${back.stderr.trim()}`
|
|
2464
|
+
);
|
|
2465
|
+
}
|
|
2466
|
+
function failBack(reason) {
|
|
2467
|
+
return { delivered: false, branch, note: `delivery FAILED \u2014 ${reason}` };
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
async function freeBranchName(git, base) {
|
|
2471
|
+
for (let i = 0; ; i++) {
|
|
2472
|
+
const candidate = i === 0 ? base : `${base}-${i + 1}`;
|
|
2473
|
+
const exists = await git("git", [
|
|
2474
|
+
"rev-parse",
|
|
2475
|
+
"--verify",
|
|
2476
|
+
"--quiet",
|
|
2477
|
+
`refs/heads/${candidate}`
|
|
2478
|
+
]);
|
|
2479
|
+
if (!exists.ok) return candidate;
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
function slug(taskId) {
|
|
2483
|
+
return taskId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2484
|
+
}
|
|
2485
|
+
function commitMessage(options) {
|
|
2486
|
+
return `task(${options.taskId}): ${options.title} [verdict:${options.verdict}]
|
|
2487
|
+
|
|
2488
|
+
Driven-executor delivery (FR-sechroom-496): work produced by the sandboxed codex turn, landed by the driver.
|
|
2489
|
+
|
|
2490
|
+
Delivered-By: sechroom executor run (${options.deliveredBy})`;
|
|
2491
|
+
}
|
|
2492
|
+
function prBody(options, sha) {
|
|
2493
|
+
return `Driven-executor delivery for WLP task \`${options.taskId}\` (verdict: ${options.verdict}, commit ${sha}).
|
|
2494
|
+
|
|
2495
|
+
Work produced by a sandboxed codex turn and landed by the unsandboxed driver (FR-sechroom-496). Review against the task's acceptance before merge.`;
|
|
2496
|
+
}
|
|
2497
|
+
function firstLine(text2) {
|
|
2498
|
+
return text2.trim().split("\n")[0] ?? "";
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// src/executor-run/request.ts
|
|
2502
|
+
var AuthExpiredError = class extends Error {
|
|
2503
|
+
constructor(message) {
|
|
2504
|
+
super(message);
|
|
2505
|
+
this.name = "AuthExpiredError";
|
|
2506
|
+
}
|
|
2507
|
+
};
|
|
2508
|
+
var HttpError = class extends Error {
|
|
2509
|
+
constructor(status, method, path, body) {
|
|
2510
|
+
super(`${method} ${path} failed (${status}): ${body}`);
|
|
2511
|
+
this.status = status;
|
|
2512
|
+
this.method = method;
|
|
2513
|
+
this.path = path;
|
|
2514
|
+
this.body = body;
|
|
2515
|
+
this.name = "HttpError";
|
|
2516
|
+
}
|
|
2517
|
+
status;
|
|
2518
|
+
method;
|
|
2519
|
+
path;
|
|
2520
|
+
body;
|
|
2521
|
+
};
|
|
2522
|
+
function createAuthedRequest(cfg, deps = {}) {
|
|
2523
|
+
const getToken = deps.getToken ?? requireToken;
|
|
2524
|
+
const refresh = deps.refreshToken ?? forceRefreshToken;
|
|
2525
|
+
const doFetch = deps.fetch ?? fetch;
|
|
2526
|
+
const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
|
|
2527
|
+
...init,
|
|
2528
|
+
headers: {
|
|
2529
|
+
authorization: `Bearer ${token}`,
|
|
2530
|
+
tenant: cfg.tenant,
|
|
2531
|
+
"content-type": "application/json",
|
|
2532
|
+
"x-sechroom-surface": "cli",
|
|
2533
|
+
...init?.headers
|
|
2534
|
+
}
|
|
2535
|
+
});
|
|
2536
|
+
return async (path, init) => {
|
|
2537
|
+
const method = init?.method ?? "GET";
|
|
2538
|
+
let token;
|
|
2539
|
+
try {
|
|
2540
|
+
token = await getToken(cfg);
|
|
2541
|
+
} catch (error) {
|
|
2542
|
+
throw new AuthExpiredError(
|
|
2543
|
+
error instanceof Error ? error.message : String(error)
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
let response = await call(path, init, token);
|
|
2547
|
+
if (response.status === 401) {
|
|
2548
|
+
let fresh;
|
|
2549
|
+
try {
|
|
2550
|
+
fresh = await refresh(cfg);
|
|
2551
|
+
} catch (error) {
|
|
2552
|
+
throw new AuthExpiredError(
|
|
2553
|
+
error instanceof Error ? error.message : String(error)
|
|
2554
|
+
);
|
|
2555
|
+
}
|
|
2556
|
+
response = await call(path, init, fresh);
|
|
2557
|
+
if (response.status === 401)
|
|
2558
|
+
throw new AuthExpiredError(
|
|
2559
|
+
`${method} ${path} still 401 after token refresh \u2014 re-authenticate (\`sechroom login\`).`
|
|
2560
|
+
);
|
|
2561
|
+
}
|
|
2562
|
+
if (!response.ok)
|
|
2563
|
+
throw new HttpError(
|
|
2564
|
+
response.status,
|
|
2565
|
+
method,
|
|
2566
|
+
path,
|
|
2567
|
+
await safeText(response)
|
|
2568
|
+
);
|
|
2569
|
+
return await response.json();
|
|
2570
|
+
};
|
|
2571
|
+
}
|
|
2572
|
+
async function safeText(response) {
|
|
2573
|
+
try {
|
|
2574
|
+
return await response.text();
|
|
2575
|
+
} catch {
|
|
2576
|
+
return "";
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
// src/executor-run/driver.ts
|
|
2581
|
+
function verdictFor(terminalStatus) {
|
|
2582
|
+
switch (terminalStatus) {
|
|
2583
|
+
case "completed":
|
|
2584
|
+
return "pass";
|
|
2585
|
+
case "needs_approval":
|
|
2586
|
+
case "cancelled":
|
|
2587
|
+
case "canceled":
|
|
2588
|
+
return "blocked";
|
|
2589
|
+
case "error":
|
|
2590
|
+
default:
|
|
2591
|
+
return "soft-fail";
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
async function runDriverLoop(ports, options) {
|
|
2595
|
+
const summary = { processed: 0, completed: 0, abandoned: 0 };
|
|
2596
|
+
let admissionDeferred = false;
|
|
2597
|
+
while (!options.stopping()) {
|
|
2598
|
+
if (ports.checkAdmission) {
|
|
2599
|
+
const admission = await ports.checkAdmission();
|
|
2600
|
+
if (!admission.ok) {
|
|
2601
|
+
admissionDeferred = true;
|
|
2602
|
+
ports.log(
|
|
2603
|
+
`ADMISSION DEFERRED \u2014 not claiming: ${admission.reason ?? "usage budget exhausted"}`
|
|
2604
|
+
);
|
|
2605
|
+
await ports.waitForWake(options.pollMs);
|
|
2606
|
+
continue;
|
|
2607
|
+
}
|
|
2608
|
+
if (admissionDeferred) {
|
|
2609
|
+
admissionDeferred = false;
|
|
2610
|
+
ports.log("admission recovered \u2014 resuming claims");
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
if (ports.checkRootReady) {
|
|
2614
|
+
const ready = await ports.checkRootReady();
|
|
2615
|
+
if (!ready.ok) {
|
|
2616
|
+
ports.log(`root not ready \u2014 not claiming: ${ready.reason ?? "unknown"}`);
|
|
2617
|
+
await ports.waitForWake(options.pollMs);
|
|
2618
|
+
continue;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
const claim = await ports.claimNext();
|
|
2622
|
+
if (!claim) {
|
|
2623
|
+
await ports.waitForWake(options.pollMs);
|
|
2624
|
+
continue;
|
|
2625
|
+
}
|
|
2626
|
+
summary.processed++;
|
|
2627
|
+
ports.log(`claimed ${claim.memoryId} (lease ${claim.leaseId})`);
|
|
2628
|
+
const task = await ports.loadTask(claim.memoryId);
|
|
2629
|
+
const stopHeartbeat = ports.startLeaseHeartbeat(claim);
|
|
2630
|
+
let result;
|
|
2631
|
+
try {
|
|
2632
|
+
result = await ports.runTurn(task, claim);
|
|
2633
|
+
} catch (e) {
|
|
2634
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
2635
|
+
result = { status: "crashed", reason: String(e) };
|
|
2636
|
+
} finally {
|
|
2637
|
+
stopHeartbeat();
|
|
2638
|
+
}
|
|
2639
|
+
if (result.status === "crashed" || result.status === "timeout") {
|
|
2640
|
+
summary.abandoned++;
|
|
2641
|
+
ports.log(
|
|
2642
|
+
`ABANDONED ${claim.memoryId}: ${result.status === "timeout" ? "turn timed out" : result.reason} \u2014 lease will expire and the task re-offers (work may re-run).`
|
|
2643
|
+
);
|
|
2644
|
+
} else {
|
|
2645
|
+
const verdict = verdictFor(result.packet?.terminal_status);
|
|
2646
|
+
let text2 = closeoutText(task, result);
|
|
2647
|
+
if (ports.deliver) {
|
|
2648
|
+
try {
|
|
2649
|
+
const delivery = await ports.deliver(claim, task, verdict);
|
|
2650
|
+
ports.log(`delivery: ${delivery.note}`);
|
|
2651
|
+
text2 += `
|
|
2652
|
+
|
|
2653
|
+
Delivery: ${delivery.note}`;
|
|
2654
|
+
} catch (e) {
|
|
2655
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
2656
|
+
ports.log(`delivery threw (continuing to completion): ${String(e)}`);
|
|
2657
|
+
text2 += `
|
|
2658
|
+
|
|
2659
|
+
Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
try {
|
|
2663
|
+
const done = await ports.completeLease(
|
|
2664
|
+
claim,
|
|
2665
|
+
verdict,
|
|
2666
|
+
text2,
|
|
2667
|
+
`${task.title} \u2014 driven closeout`
|
|
2668
|
+
);
|
|
2669
|
+
summary.completed++;
|
|
2670
|
+
ports.log(
|
|
2671
|
+
`completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
|
|
2672
|
+
);
|
|
2673
|
+
} catch (e) {
|
|
2674
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
2675
|
+
summary.abandoned++;
|
|
2676
|
+
ports.log(
|
|
2677
|
+
`COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
|
|
2678
|
+
);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
if (options.once) break;
|
|
2682
|
+
}
|
|
2683
|
+
return summary;
|
|
2684
|
+
}
|
|
2685
|
+
function closeoutText(task, result) {
|
|
2686
|
+
if (!result.packet)
|
|
2687
|
+
return `Driven codex run ended without a sechroom_closeout packet (soft-fail). Last agent message:
|
|
2688
|
+
|
|
2689
|
+
${result.lastAgentMessage || "(none)"}`;
|
|
2690
|
+
const evidence = result.packet.evidence?.length ? `
|
|
2691
|
+
|
|
2692
|
+
Evidence:
|
|
2693
|
+
${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
|
|
2694
|
+
return `${result.packet.summary}${evidence}
|
|
2695
|
+
|
|
2696
|
+
(terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
|
|
2697
|
+
}
|
|
2698
|
+
function startLeaseHeartbeat(beat, log, intervalMs = 3e4, timers = {}) {
|
|
2699
|
+
const schedule = timers.setInterval ?? setInterval;
|
|
2700
|
+
const cancel = timers.clearInterval ?? clearInterval;
|
|
2701
|
+
const timer = schedule(() => {
|
|
2702
|
+
void beat().catch(
|
|
2703
|
+
(e) => log(`lease heartbeat failed (retrying next beat): ${String(e)}`)
|
|
2704
|
+
);
|
|
2705
|
+
}, intervalMs);
|
|
2706
|
+
timer.unref?.();
|
|
2707
|
+
return () => cancel(timer);
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// src/executor-run/fleet.ts
|
|
2711
|
+
import { spawn as spawn2 } from "child_process";
|
|
2712
|
+
import { readFile } from "fs/promises";
|
|
2713
|
+
import { resolve } from "path";
|
|
2714
|
+
async function readFleetConfig(path) {
|
|
2715
|
+
const parsed = JSON.parse(
|
|
2716
|
+
await readFile(resolve(path), "utf8")
|
|
2717
|
+
);
|
|
2718
|
+
if (!Array.isArray(parsed.instances) || parsed.instances.length === 0)
|
|
2719
|
+
throw new Error("fleet config must contain a non-empty 'instances' array");
|
|
2720
|
+
const keys = /* @__PURE__ */ new Set();
|
|
2721
|
+
for (const entry of parsed.instances) {
|
|
2722
|
+
if (!entry || typeof entry.root !== "string" || typeof entry.instanceKey !== "string")
|
|
2723
|
+
throw new Error(
|
|
2724
|
+
"each fleet instance requires string 'root' and 'instanceKey'"
|
|
2725
|
+
);
|
|
2726
|
+
if (keys.has(entry.instanceKey))
|
|
2727
|
+
throw new Error(`duplicate fleet instanceKey '${entry.instanceKey}'`);
|
|
2728
|
+
keys.add(entry.instanceKey);
|
|
2729
|
+
}
|
|
2730
|
+
if (parsed.node !== void 0) {
|
|
2731
|
+
const node = parsed.node;
|
|
2732
|
+
if (!node || typeof node.instanceKey !== "string" || typeof node.connector !== "string")
|
|
2733
|
+
throw new Error(
|
|
2734
|
+
"fleet 'node' requires string 'instanceKey' and 'connector'"
|
|
2735
|
+
);
|
|
2736
|
+
}
|
|
2737
|
+
return parsed;
|
|
2738
|
+
}
|
|
2739
|
+
function entryArgs(entry, parentId) {
|
|
2740
|
+
const args = [
|
|
2741
|
+
"executor",
|
|
2742
|
+
"run",
|
|
2743
|
+
"--root",
|
|
2744
|
+
resolve(entry.root),
|
|
2745
|
+
"--instance-key",
|
|
2746
|
+
entry.instanceKey
|
|
2747
|
+
];
|
|
2748
|
+
const value = (flag, v) => {
|
|
2749
|
+
if (v !== void 0) args.push(flag, String(v));
|
|
2750
|
+
};
|
|
2751
|
+
value("--parent-id", parentId);
|
|
2752
|
+
value("--lane", entry.lane);
|
|
2753
|
+
value("--model", entry.model);
|
|
2754
|
+
value("--connector", entry.connector);
|
|
2755
|
+
value("--ttl", entry.ttl);
|
|
2756
|
+
value("--poll-interval", entry.pollInterval);
|
|
2757
|
+
value("--heartbeat-interval", entry.heartbeatInterval);
|
|
2758
|
+
value("--turn-timeout", entry.turnTimeout);
|
|
2759
|
+
value("--resume-turn-timeout", entry.resumeTurnTimeout);
|
|
2760
|
+
value("--drain-timeout", entry.drainTimeout);
|
|
2761
|
+
value("--codex-bin", entry.codexBin);
|
|
2762
|
+
value("--sandbox", entry.sandbox);
|
|
2763
|
+
value("--usage-reserve", entry.usageReserve);
|
|
2764
|
+
return args;
|
|
2765
|
+
}
|
|
2766
|
+
function superviseFleet(config2, options = {}) {
|
|
2767
|
+
const log = options.log ?? ((line) => process.stderr.write(`${line}
|
|
2768
|
+
`));
|
|
2769
|
+
const states = /* @__PURE__ */ new Map();
|
|
2770
|
+
const children = /* @__PURE__ */ new Map();
|
|
2771
|
+
let stopping = false;
|
|
2772
|
+
let resolveDone;
|
|
2773
|
+
const done = new Promise((resolvePromise) => {
|
|
2774
|
+
resolveDone = resolvePromise;
|
|
2775
|
+
});
|
|
2776
|
+
const status = () => log(
|
|
2777
|
+
`[fleet] ${[...states].map(([key, state]) => `${key}=${state}`).join(" ")}`
|
|
2778
|
+
);
|
|
2779
|
+
const spawnEntry = options.spawnEntry ?? ((entry, args) => {
|
|
2780
|
+
const script = process.argv[1];
|
|
2781
|
+
if (!script) throw new Error("cannot locate the sechroom CLI entrypoint");
|
|
2782
|
+
return spawn2(process.execPath, [script, ...args], {
|
|
2783
|
+
cwd: resolve(entry.root),
|
|
2784
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2785
|
+
env: process.env
|
|
2786
|
+
});
|
|
2787
|
+
});
|
|
2788
|
+
for (const entry of config2.instances) {
|
|
2789
|
+
const child = spawnEntry(entry, entryArgs(entry, options.parentId));
|
|
2790
|
+
children.set(entry.instanceKey, child);
|
|
2791
|
+
states.set(entry.instanceKey, "live");
|
|
2792
|
+
const prefix = (text2) => {
|
|
2793
|
+
for (const line of text2.replace(/\n$/, "").split("\n"))
|
|
2794
|
+
log(`[${entry.instanceKey}] ${line}`);
|
|
2795
|
+
};
|
|
2796
|
+
const concrete = child;
|
|
2797
|
+
concrete.stdout?.on("data", (chunk) => prefix(String(chunk)));
|
|
2798
|
+
concrete.stderr?.on("data", (chunk) => prefix(String(chunk)));
|
|
2799
|
+
child.on("exit", (code, signal) => {
|
|
2800
|
+
states.set(entry.instanceKey, "exited");
|
|
2801
|
+
log(`[${entry.instanceKey}] exited (${signal ?? code ?? "unknown"})`);
|
|
2802
|
+
status();
|
|
2803
|
+
if ([...states.values()].every((state) => state === "exited"))
|
|
2804
|
+
resolveDone();
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
status();
|
|
2808
|
+
return {
|
|
2809
|
+
done,
|
|
2810
|
+
shutdown(signal = "SIGINT") {
|
|
2811
|
+
if (stopping) return done;
|
|
2812
|
+
stopping = true;
|
|
2813
|
+
for (const [key, child] of children) {
|
|
2814
|
+
if (states.get(key) !== "exited") {
|
|
2815
|
+
states.set(key, "stopping");
|
|
2816
|
+
child.kill(signal);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
status();
|
|
2820
|
+
return done;
|
|
2821
|
+
},
|
|
2822
|
+
states
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// src/commands/telemetry.ts
|
|
2827
|
+
import {
|
|
2828
|
+
existsSync as existsSync7,
|
|
2829
|
+
mkdirSync as mkdirSync7,
|
|
2830
|
+
readFileSync as readFileSync5,
|
|
2831
|
+
rmSync as rmSync3,
|
|
2832
|
+
writeFileSync as writeFileSync6
|
|
2833
|
+
} from "fs";
|
|
2834
|
+
import { dirname as dirname6, join as join8 } from "path";
|
|
2835
|
+
|
|
2836
|
+
// src/commands/hook-install.ts
|
|
2837
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
|
|
2838
|
+
import { delimiter, dirname as dirname5, join as join7 } from "path";
|
|
2839
|
+
|
|
2840
|
+
// src/setup/clients.ts
|
|
2841
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2842
|
+
import { homedir as homedir3 } from "os";
|
|
2843
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
2844
|
+
function claudeDesktopConfigPath(home) {
|
|
2845
|
+
switch (process.platform) {
|
|
2846
|
+
case "darwin":
|
|
2847
|
+
return join6(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
2848
|
+
case "win32":
|
|
2849
|
+
return join6(process.env.APPDATA ?? join6(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
2850
|
+
default:
|
|
2851
|
+
return join6(home, ".config", "Claude", "claude_desktop_config.json");
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
function clientTargets(cwd, opts = {}) {
|
|
2855
|
+
const home = homedir3();
|
|
2856
|
+
const claudeDir = opts.claudeDir ?? join6(home, ".claude");
|
|
2857
|
+
const codexHome = opts.codexHome ?? join6(home, ".codex");
|
|
2858
|
+
return {
|
|
2859
|
+
"claude-code": {
|
|
2860
|
+
key: "claude-code",
|
|
2861
|
+
label: "Claude Code",
|
|
2862
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".mcp.json"), format: "json" },
|
|
2863
|
+
instruction: { surfaceKey: "claude-code", path: join6(cwd, "CLAUDE.md") }
|
|
2864
|
+
},
|
|
2865
|
+
"claude-desktop": {
|
|
2866
|
+
key: "claude-desktop",
|
|
2867
|
+
label: "Claude Desktop",
|
|
2868
|
+
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
2869
|
+
instruction: { surfaceKey: "claude-desktop", path: join6(claudeDir, "CLAUDE.md") }
|
|
2870
|
+
},
|
|
2871
|
+
codex: {
|
|
2872
|
+
key: "codex",
|
|
2873
|
+
label: "Codex CLI",
|
|
2874
|
+
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join6(codexHome, "config.toml"), format: "toml" },
|
|
2875
|
+
instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
|
|
2876
|
+
},
|
|
2877
|
+
cursor: {
|
|
2878
|
+
key: "cursor",
|
|
2879
|
+
label: "Cursor",
|
|
2880
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
2881
|
+
instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
|
|
2882
|
+
},
|
|
2883
|
+
antigravity: {
|
|
2884
|
+
key: "antigravity",
|
|
2885
|
+
label: "Google Antigravity",
|
|
2886
|
+
// FR-sechroom-247 — Antigravity reads MCP from a GLOBAL, home-relative
|
|
2887
|
+
// `~/.gemini/config/mcp_config.json` (not cwd; not affected by
|
|
2888
|
+
// CLAUDE_CONFIG_DIR / CODEX_HOME). The snippet — `serverUrl`-shaped, no
|
|
2889
|
+
// `type` — comes from the `antigravity` server surface, so we don't
|
|
2890
|
+
// hardcode it here. Instructions go in the project `AGENTS.md`
|
|
2891
|
+
// (cross-tool, shared with Codex/Cursor).
|
|
2892
|
+
mcp: { surfaceKey: "antigravity", sectionType: SectionType.McpConfig, path: join6(home, ".gemini", "config", "mcp_config.json"), format: "json" },
|
|
2893
|
+
instruction: { surfaceKey: "antigravity", path: join6(cwd, "AGENTS.md") }
|
|
2894
|
+
}
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor", "antigravity"];
|
|
2898
|
+
var DEFAULT_CLIENT_KEY = "claude-code";
|
|
2899
|
+
function detectInstalledClients(cwd) {
|
|
2900
|
+
const home = homedir3();
|
|
2901
|
+
const detected = [];
|
|
2902
|
+
if (resolveClaudeTargets({}).some((t) => existsSync5(t.dir))) detected.push("claude-code");
|
|
2903
|
+
if (existsSync5(dirname4(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
|
|
2904
|
+
if (resolveCodexHomes({}).some((d) => existsSync5(d))) detected.push("codex");
|
|
2905
|
+
if (existsSync5(join6(home, ".cursor")) || existsSync5(join6(cwd, ".cursor"))) detected.push("cursor");
|
|
2906
|
+
if (existsSync5(join6(home, ".gemini"))) detected.push("antigravity");
|
|
2907
|
+
return detected;
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
// src/commands/hook-install.ts
|
|
2911
|
+
var CLAUDE_HOOK_COMMANDS = {
|
|
2912
|
+
SessionStart: "sechroom hook session-start",
|
|
2913
|
+
PreCompact: "sechroom hook pre-compact",
|
|
2914
|
+
SessionEnd: "sechroom hook session-end",
|
|
2915
|
+
// WLP telemetry tap (D-WLP-10 + FR-352 Tier 1) — per-turn executor self-report. The one
|
|
2916
|
+
// `telemetry hook` verb dispatches on hook_event_name: Stop/SubagentStop → parsed (token/context) +
|
|
2917
|
+
// terminal (turn end), Notification/PermissionDenied → approval. No-op (exit 0) unless this checkout
|
|
2918
|
+
// is bound via `sechroom telemetry bind`, so it's safe to wire for every Claude install; an event a
|
|
2919
|
+
// given Claude Code version doesn't know is inert (never fires). Claude-only.
|
|
2920
|
+
Stop: "sechroom telemetry hook",
|
|
2921
|
+
SubagentStop: "sechroom telemetry hook",
|
|
2922
|
+
Notification: "sechroom telemetry hook",
|
|
2923
|
+
PermissionDenied: "sechroom telemetry hook"
|
|
2924
|
+
};
|
|
2925
|
+
var CODEX_HOOK_COMMANDS = {
|
|
2926
|
+
SessionStart: "sechroom hook session-start",
|
|
2927
|
+
PreCompact: "sechroom hook pre-compact",
|
|
2928
|
+
Stop: "sechroom hook session-end --debounce-minutes 10"
|
|
2929
|
+
};
|
|
2930
|
+
function hookCommandsForSurface(surface) {
|
|
2931
|
+
return surface === "claude" ? CLAUDE_HOOK_COMMANDS : CODEX_HOOK_COMMANDS;
|
|
2932
|
+
}
|
|
2933
|
+
function hasHookCommand(config2, event, command) {
|
|
2934
|
+
const groups = config2.hooks?.[event] ?? [];
|
|
2935
|
+
return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
|
|
2936
|
+
}
|
|
2937
|
+
function mergeHooks(config2, commands) {
|
|
2938
|
+
config2.hooks ??= {};
|
|
2939
|
+
let added = 0;
|
|
2940
|
+
for (const [event, command] of Object.entries(commands)) {
|
|
2941
|
+
if (hasHookCommand(config2, event, command)) continue;
|
|
2942
|
+
const groups = config2.hooks[event] ??= [];
|
|
2943
|
+
groups.push({ hooks: [{ type: "command", command }] });
|
|
2944
|
+
added += 1;
|
|
2945
|
+
}
|
|
2946
|
+
return added;
|
|
2947
|
+
}
|
|
2948
|
+
function readJsonConfig2(path) {
|
|
2949
|
+
if (!existsSync6(path)) return {};
|
|
2950
|
+
const raw = readFileSync4(path, "utf8");
|
|
2951
|
+
if (!raw.trim()) return {};
|
|
2952
|
+
return JSON.parse(raw);
|
|
2953
|
+
}
|
|
2954
|
+
function installHooksJson(path, commands, dryRun) {
|
|
2955
|
+
const existed = existsSync6(path) && readFileSync4(path, "utf8").trim().length > 0;
|
|
2956
|
+
const config2 = readJsonConfig2(path);
|
|
2957
|
+
const added = mergeHooks(config2, commands);
|
|
2958
|
+
if (added === 0 && existed) return { path, status: "current" };
|
|
2959
|
+
if (!dryRun) {
|
|
2960
|
+
mkdirSync6(dirname5(path), { recursive: true });
|
|
2961
|
+
writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
|
|
2962
|
+
}
|
|
2963
|
+
return { path, status: existed ? "merged" : "created" };
|
|
2964
|
+
}
|
|
2965
|
+
function installClaudeCommands(claudeDir, commands, dryRun) {
|
|
2966
|
+
return installHooksJson(join7(claudeDir, "settings.json"), commands, dryRun);
|
|
2967
|
+
}
|
|
2968
|
+
function installCodexCommands(codexHome, commands, dryRun) {
|
|
2969
|
+
return [
|
|
2970
|
+
installHooksJson(join7(codexHome, "hooks.json"), commands, dryRun),
|
|
2971
|
+
installCodexFeatureFlag(join7(codexHome, "config.toml"), dryRun)
|
|
2972
|
+
];
|
|
2973
|
+
}
|
|
2974
|
+
function ensureCodexFeaturesHooks(content) {
|
|
2975
|
+
const lines = content.split("\n");
|
|
2976
|
+
const headerIdx = lines.findIndex((l) => l.trim() === "[features]");
|
|
2977
|
+
if (headerIdx === -1) {
|
|
2978
|
+
const base = content.length === 0 || content.endsWith("\n") ? content : content + "\n";
|
|
2979
|
+
return { next: base + "\n[features]\nhooks = true\n", changed: true };
|
|
2980
|
+
}
|
|
2981
|
+
for (let i = headerIdx + 1; i < lines.length; i += 1) {
|
|
2982
|
+
const trimmed = lines[i].trim();
|
|
2983
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) break;
|
|
2984
|
+
const m = lines[i].match(/^(\s*)hooks(\s*)=(\s*)(.*)$/);
|
|
2985
|
+
if (!m) continue;
|
|
2986
|
+
const value = m[4].replace(/\s*#.*$/, "").trim();
|
|
2987
|
+
if (value === "true") return { next: content, changed: false };
|
|
2988
|
+
lines[i] = `${m[1]}hooks${m[2]}=${m[3]}true`;
|
|
2989
|
+
return { next: lines.join("\n"), changed: true };
|
|
2990
|
+
}
|
|
2991
|
+
lines.splice(headerIdx + 1, 0, "hooks = true");
|
|
2992
|
+
return { next: lines.join("\n"), changed: true };
|
|
2993
|
+
}
|
|
2994
|
+
function installCodexFeatureFlag(path, dryRun) {
|
|
2995
|
+
const existed = existsSync6(path);
|
|
2996
|
+
const content = existed ? readFileSync4(path, "utf8") : "";
|
|
2997
|
+
const { next, changed } = ensureCodexFeaturesHooks(content);
|
|
2998
|
+
if (!changed) return { path, status: "current" };
|
|
2999
|
+
if (!dryRun) {
|
|
3000
|
+
mkdirSync6(dirname5(path), { recursive: true });
|
|
3001
|
+
writeFileSync5(path, next);
|
|
3002
|
+
}
|
|
3003
|
+
return { path, status: existed ? "merged" : "created" };
|
|
3004
|
+
}
|
|
3005
|
+
function resolveSurfaces(surface, cwd) {
|
|
3006
|
+
if (surface === "claude") return ["claude"];
|
|
3007
|
+
if (surface === "codex") return ["codex"];
|
|
3008
|
+
if (surface === "both") return ["claude", "codex"];
|
|
3009
|
+
if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
|
|
3010
|
+
const surfaces = detectHookSurfaces(cwd);
|
|
3011
|
+
return surfaces.length > 0 ? surfaces : ["claude", "codex"];
|
|
3012
|
+
}
|
|
3013
|
+
function describe(result, dryRun) {
|
|
3014
|
+
if (result.status === "current") return ` \u2713 ${result.path} (already configured)`;
|
|
3015
|
+
const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
|
|
3016
|
+
return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
|
|
3017
|
+
}
|
|
3018
|
+
var HOOK_SURFACE_LABEL = {
|
|
3019
|
+
claude: "Claude Code",
|
|
3020
|
+
codex: "Codex"
|
|
3021
|
+
};
|
|
3022
|
+
function installHookSurfaces(surfaces, opts) {
|
|
3023
|
+
const out = [];
|
|
3024
|
+
for (const surface of surfaces) {
|
|
3025
|
+
if (surface === "claude") {
|
|
3026
|
+
const path = join7(opts.claudeDir, "settings.json");
|
|
3027
|
+
out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
|
|
3028
|
+
} else {
|
|
3029
|
+
const hooksJson = installHooksJson(join7(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
|
|
3030
|
+
const featureFlag = installCodexFeatureFlag(join7(opts.codexHome, "config.toml"), opts.dryRun);
|
|
3031
|
+
out.push({ surface, results: [hooksJson, featureFlag] });
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
return out;
|
|
3035
|
+
}
|
|
3036
|
+
function detectHookSurfaces(cwd) {
|
|
3037
|
+
const detected = detectInstalledClients(cwd);
|
|
3038
|
+
const surfaces = [];
|
|
3039
|
+
if (detected.includes("claude-code")) surfaces.push("claude");
|
|
3040
|
+
if (detected.includes("codex")) surfaces.push("codex");
|
|
3041
|
+
return surfaces;
|
|
3042
|
+
}
|
|
3043
|
+
function isSechroomOnPath() {
|
|
3044
|
+
const pathEnv = process.env.PATH ?? "";
|
|
3045
|
+
if (!pathEnv) return false;
|
|
3046
|
+
const names = process.platform === "win32" ? ["sechroom.cmd", "sechroom.exe", "sechroom.bat", "sechroom"] : ["sechroom"];
|
|
3047
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
3048
|
+
if (!dir) continue;
|
|
3049
|
+
for (const name of names) {
|
|
3050
|
+
if (existsSync6(join7(dir, name))) return true;
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
return false;
|
|
3054
|
+
}
|
|
3055
|
+
function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
|
|
3056
|
+
if (isSechroomOnPath()) return false;
|
|
3057
|
+
write(
|
|
3058
|
+
"\n\u26A0 `sechroom` isn't on your PATH. The hooks run a bare `sechroom hook \u2026` command\n when your agent fires them, so a non-global install (npx / local) will fail at\n that point. Install globally so the command resolves:\n npm i -g @sechroom/cli\n"
|
|
3059
|
+
);
|
|
3060
|
+
return true;
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
// src/commands/telemetry.ts
|
|
3064
|
+
function registerTelemetry(program2) {
|
|
3065
|
+
const telemetry = program2.command("telemetry").description(
|
|
3066
|
+
"Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
|
|
3067
|
+
);
|
|
3068
|
+
telemetry.command("emit").description(
|
|
3069
|
+
"POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
|
|
3070
|
+
).requiredOption(
|
|
3071
|
+
"--decomposition <id>",
|
|
3072
|
+
"Decomposition id whose run this event belongs to"
|
|
3073
|
+
).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
|
|
3074
|
+
"--kind <kind>",
|
|
3075
|
+
"Event kind: raw | parsed | approval | terminal"
|
|
3076
|
+
).option(
|
|
3077
|
+
"--tokens-in <n>",
|
|
3078
|
+
"Cumulative input tokens (spend meter)",
|
|
3079
|
+
parseIntOpt
|
|
3080
|
+
).option(
|
|
3081
|
+
"--tokens-out <n>",
|
|
3082
|
+
"Cumulative output tokens (spend meter)",
|
|
3083
|
+
parseIntOpt
|
|
3084
|
+
).option(
|
|
3085
|
+
"--context-used <n>",
|
|
3086
|
+
"Context tokens currently used (occupancy meter)",
|
|
3087
|
+
parseIntOpt
|
|
3088
|
+
).option(
|
|
3089
|
+
"--context-window <n>",
|
|
3090
|
+
"Context window size (occupancy meter)",
|
|
3091
|
+
parseIntOpt
|
|
3092
|
+
).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
|
|
3093
|
+
"--verdict <v>",
|
|
3094
|
+
"Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
|
|
3095
|
+
).action(async (opts, cmd) => {
|
|
3096
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
3097
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3098
|
+
const event = {
|
|
3099
|
+
taskId: opts.task,
|
|
3100
|
+
kind: normalizeKind(opts.kind),
|
|
3101
|
+
tokensIn: opts.tokensIn ?? null,
|
|
3102
|
+
tokensOut: opts.tokensOut ?? null,
|
|
3103
|
+
contextUsed: opts.contextUsed ?? null,
|
|
3104
|
+
contextWindow: opts.contextWindow ?? null,
|
|
3105
|
+
text: opts.text ?? null,
|
|
3106
|
+
approvalState: opts.approval ?? null,
|
|
3107
|
+
verdict: opts.verdict ?? null
|
|
3108
|
+
};
|
|
3109
|
+
let body;
|
|
3110
|
+
try {
|
|
3111
|
+
body = await postTelemetry(cfg, opts.decomposition, [event]);
|
|
3112
|
+
} catch (e) {
|
|
3113
|
+
return fail(`Telemetry emit failed: ${e.message}`);
|
|
3114
|
+
}
|
|
3115
|
+
if (json) {
|
|
3116
|
+
emit(body, true);
|
|
3117
|
+
} else {
|
|
3118
|
+
process.stderr.write(
|
|
3119
|
+
style.green("telemetry emitted") + style.dim(
|
|
3120
|
+
` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
|
|
3121
|
+
`
|
|
3122
|
+
)
|
|
3123
|
+
);
|
|
3124
|
+
}
|
|
3125
|
+
});
|
|
3126
|
+
telemetry.command("show <decompositionId>").description(
|
|
3127
|
+
"Read a run's telemetry \u2014 per-task meters + the raw/parsed/approval/terminal timeline (GET /decompositions/{id}/run/telemetry). Returns hasTelemetry:false, not an error, before any event is ingested \u2014 so an unstarted run and a stalled one read differently. The read side of this group; `emit` is the source side. (FR-sechroom-442 slice 1 step 4; mirrors the work_plan_run_telemetry MCP tool.)"
|
|
3128
|
+
).action(async (decompositionId, _opts, cmd) => {
|
|
3129
|
+
const globals = cmd.optsWithGlobals();
|
|
3130
|
+
const cfg = resolveConfig(globals);
|
|
3131
|
+
const data = await runApi("Reading run telemetry", async () => {
|
|
3132
|
+
const client = await makeClient(cfg);
|
|
3133
|
+
return client.GET("/decompositions/{id}/run/telemetry", {
|
|
3134
|
+
params: { path: { id: decompositionId } }
|
|
3135
|
+
});
|
|
3136
|
+
});
|
|
3137
|
+
emitAction(
|
|
3138
|
+
data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
|
|
3139
|
+
data,
|
|
3140
|
+
globals.json
|
|
3141
|
+
);
|
|
3142
|
+
});
|
|
3143
|
+
telemetry.command("bind").description(
|
|
3144
|
+
"Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
|
|
3145
|
+
).requiredOption(
|
|
3146
|
+
"--decomposition <id>",
|
|
3147
|
+
"Decomposition id this session executes"
|
|
3148
|
+
).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
|
|
3149
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
3150
|
+
const dir = join8(process.cwd(), ".sechroom");
|
|
3151
|
+
mkdirSync7(dir, { recursive: true });
|
|
3152
|
+
const path = join8(dir, BINDING_FILE);
|
|
3153
|
+
const binding = {
|
|
3154
|
+
decompositionId: opts.decomposition,
|
|
3155
|
+
taskId: opts.task
|
|
3156
|
+
};
|
|
3157
|
+
writeFileSync6(path, JSON.stringify(binding, null, 2) + "\n");
|
|
3158
|
+
ensureStateDirIgnored(process.cwd());
|
|
3159
|
+
if (json) {
|
|
3160
|
+
emit({ bound: true, ...binding, path }, true);
|
|
3161
|
+
} else {
|
|
3162
|
+
process.stdout.write(
|
|
3163
|
+
style.green("telemetry bound") + style.dim(
|
|
3164
|
+
` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
|
|
3165
|
+
`
|
|
3166
|
+
)
|
|
3167
|
+
);
|
|
3168
|
+
}
|
|
3169
|
+
});
|
|
3170
|
+
telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
|
|
3171
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
3172
|
+
const path = join8(process.cwd(), ".sechroom", BINDING_FILE);
|
|
3173
|
+
const existed = existsSync7(path);
|
|
3174
|
+
if (existed) rmSync3(path);
|
|
3175
|
+
if (json) emit({ unbound: existed, path }, true);
|
|
3176
|
+
else
|
|
3177
|
+
process.stdout.write(
|
|
3178
|
+
existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
|
|
3179
|
+
);
|
|
3180
|
+
});
|
|
3181
|
+
telemetry.command("hook").description(
|
|
3182
|
+
"Per-turn telemetry self-report for Claude Code hooks \u2014 Stop/SubagentStop \u2192 parsed + terminal, Notification/PermissionDenied \u2192 approval (reads stdin; no-op unless bound). Fail-soft."
|
|
3183
|
+
).action(async (_opts, cmd) => {
|
|
3184
|
+
try {
|
|
3185
|
+
const raw = await readStdin();
|
|
3186
|
+
const input = parseHookInput(raw);
|
|
3187
|
+
const cwd = input.cwd ?? process.cwd();
|
|
3188
|
+
const binding = findBinding(cwd);
|
|
3189
|
+
if (!binding) return process.exit(0);
|
|
3190
|
+
const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
|
|
3191
|
+
const events = buildHookEvents(input, usage, binding.taskId);
|
|
3192
|
+
if (events.length === 0) return process.exit(0);
|
|
3193
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3194
|
+
await postTelemetry(cfg, binding.decompositionId, events);
|
|
3195
|
+
return process.exit(0);
|
|
3196
|
+
} catch {
|
|
3197
|
+
return process.exit(0);
|
|
3198
|
+
}
|
|
3199
|
+
});
|
|
3200
|
+
telemetry.command("install").description(
|
|
3201
|
+
"Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
|
|
3202
|
+
).option(
|
|
3203
|
+
"--scope <scope>",
|
|
3204
|
+
"global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
|
|
3205
|
+
).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
|
|
3206
|
+
const g = cmd.optsWithGlobals();
|
|
3207
|
+
const dryRun = Boolean(opts.dryRun);
|
|
3208
|
+
const cwd = process.cwd();
|
|
3209
|
+
let scope;
|
|
3210
|
+
try {
|
|
3211
|
+
scope = opts.local ? "project" : resolveScope(opts.scope);
|
|
3212
|
+
} catch (err2) {
|
|
3213
|
+
process.stderr.write(`${err2.message}
|
|
3214
|
+
`);
|
|
3215
|
+
return process.exit(2);
|
|
3216
|
+
}
|
|
3217
|
+
const targets = resolveClaudeTargets({
|
|
3218
|
+
override: g.claudeConfigDir,
|
|
3219
|
+
scope,
|
|
3220
|
+
cwd
|
|
3221
|
+
});
|
|
3222
|
+
const commands = {
|
|
3223
|
+
Stop: "sechroom telemetry hook",
|
|
3224
|
+
SubagentStop: "sechroom telemetry hook",
|
|
3225
|
+
Notification: "sechroom telemetry hook",
|
|
3226
|
+
PermissionDenied: "sechroom telemetry hook"
|
|
3227
|
+
};
|
|
3228
|
+
try {
|
|
3229
|
+
const multi = targets.length > 1;
|
|
3230
|
+
const results = targets.map((t) => {
|
|
3231
|
+
const r = installClaudeCommands(t.dir, commands, dryRun);
|
|
3232
|
+
process.stdout.write(
|
|
3233
|
+
`${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
|
|
3234
|
+
`
|
|
3235
|
+
);
|
|
3236
|
+
process.stdout.write(describe(r, dryRun) + "\n");
|
|
3237
|
+
return r;
|
|
3238
|
+
});
|
|
3239
|
+
if (dryRun) {
|
|
3240
|
+
process.stdout.write("\n(dry run \u2014 no files were written.)\n");
|
|
3241
|
+
} else if (results.every((r) => r.status === "current")) {
|
|
3242
|
+
process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
|
|
3243
|
+
} else {
|
|
3244
|
+
process.stdout.write(
|
|
3245
|
+
"\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
|
|
3246
|
+
);
|
|
3247
|
+
}
|
|
3248
|
+
} catch (err2) {
|
|
3249
|
+
process.stderr.write(
|
|
3250
|
+
`telemetry install failed: ${err2.message}
|
|
3251
|
+
`
|
|
3252
|
+
);
|
|
3253
|
+
return process.exit(1);
|
|
3254
|
+
}
|
|
3255
|
+
warnIfSechroomNotOnPath();
|
|
3256
|
+
return process.exit(0);
|
|
3257
|
+
});
|
|
3258
|
+
}
|
|
3259
|
+
var BINDING_FILE = "telemetry.json";
|
|
3260
|
+
async function postTelemetry(cfg, decompositionId, events) {
|
|
3261
|
+
const token = await requireToken(cfg);
|
|
3262
|
+
const resp = await fetch(
|
|
3263
|
+
`${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
|
|
3264
|
+
{
|
|
3265
|
+
method: "POST",
|
|
3266
|
+
headers: {
|
|
3267
|
+
authorization: `Bearer ${token}`,
|
|
3268
|
+
tenant: cfg.tenant,
|
|
3269
|
+
"content-type": "application/json",
|
|
3270
|
+
"x-sechroom-surface": "cli"
|
|
3271
|
+
},
|
|
3272
|
+
body: JSON.stringify({ events })
|
|
3273
|
+
}
|
|
3274
|
+
);
|
|
3275
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
|
3276
|
+
return await resp.json();
|
|
3277
|
+
}
|
|
3278
|
+
function findBinding(start) {
|
|
3279
|
+
let dir = start;
|
|
3280
|
+
for (; ; ) {
|
|
3281
|
+
const path = join8(dir, ".sechroom", BINDING_FILE);
|
|
3282
|
+
if (existsSync7(path)) {
|
|
3283
|
+
try {
|
|
3284
|
+
const b = JSON.parse(
|
|
3285
|
+
readFileSync5(path, "utf8")
|
|
3286
|
+
);
|
|
3287
|
+
if (b.decompositionId && b.taskId)
|
|
3288
|
+
return { decompositionId: b.decompositionId, taskId: b.taskId };
|
|
3289
|
+
} catch {
|
|
3290
|
+
}
|
|
3291
|
+
return null;
|
|
3292
|
+
}
|
|
3293
|
+
const parent = dirname6(dir);
|
|
3294
|
+
if (parent === dir) return null;
|
|
3295
|
+
dir = parent;
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
function parseTranscript(path) {
|
|
3299
|
+
if (!existsSync7(path)) return null;
|
|
3300
|
+
let tokensIn = 0;
|
|
3301
|
+
let tokensOut = 0;
|
|
3302
|
+
let contextUsed = 0;
|
|
3303
|
+
let model = "";
|
|
3304
|
+
for (const line of readFileSync5(path, "utf8").split("\n")) {
|
|
3305
|
+
if (!line.trim()) continue;
|
|
3306
|
+
let obj;
|
|
3307
|
+
try {
|
|
3308
|
+
obj = JSON.parse(line);
|
|
3309
|
+
} catch {
|
|
3310
|
+
continue;
|
|
3311
|
+
}
|
|
3312
|
+
const usage = obj.type === "assistant" ? obj.message?.usage : void 0;
|
|
3313
|
+
if (!usage) continue;
|
|
3314
|
+
const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
3315
|
+
tokensIn += input;
|
|
3316
|
+
tokensOut += usage.output_tokens ?? 0;
|
|
3317
|
+
contextUsed = input;
|
|
3318
|
+
if (obj.message?.model) model = obj.message.model;
|
|
3319
|
+
}
|
|
3320
|
+
if (tokensIn === 0 && tokensOut === 0) return null;
|
|
3321
|
+
return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed), modelId: model || null };
|
|
3322
|
+
}
|
|
3323
|
+
function windowFor(model, contextUsed = 0) {
|
|
3324
|
+
const m = model.toLowerCase();
|
|
3325
|
+
if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
|
|
3326
|
+
return contextUsed > 2e5 ? 1e6 : 2e5;
|
|
3327
|
+
}
|
|
3328
|
+
function buildHookEvents(input, usage, taskId) {
|
|
3329
|
+
const events = [];
|
|
3330
|
+
const base = (kind, over) => ({
|
|
3331
|
+
taskId,
|
|
3332
|
+
kind,
|
|
3333
|
+
tokensIn: null,
|
|
3334
|
+
tokensOut: null,
|
|
3335
|
+
contextUsed: null,
|
|
3336
|
+
contextWindow: null,
|
|
3337
|
+
text: null,
|
|
3338
|
+
approvalState: null,
|
|
3339
|
+
verdict: null,
|
|
3340
|
+
modelId: null,
|
|
3341
|
+
...over
|
|
3342
|
+
});
|
|
3343
|
+
if (usage) {
|
|
3344
|
+
events.push(
|
|
3345
|
+
base("Parsed", {
|
|
3346
|
+
tokensIn: usage.tokensIn,
|
|
3347
|
+
tokensOut: usage.tokensOut,
|
|
3348
|
+
contextUsed: usage.contextUsed,
|
|
3349
|
+
contextWindow: usage.contextWindow,
|
|
3350
|
+
modelId: usage.modelId
|
|
3351
|
+
})
|
|
3352
|
+
);
|
|
3353
|
+
}
|
|
3354
|
+
switch (input.hook_event_name) {
|
|
3355
|
+
case "PermissionDenied":
|
|
3356
|
+
events.push(
|
|
3357
|
+
base("Approval", {
|
|
3358
|
+
approvalState: "denied",
|
|
3359
|
+
text: input.tool_name ?? input.message ?? null
|
|
3360
|
+
})
|
|
3361
|
+
);
|
|
3362
|
+
break;
|
|
3363
|
+
case "Notification":
|
|
3364
|
+
if (isPermissionNotification(input))
|
|
3365
|
+
events.push(base("Approval", { text: input.message ?? null }));
|
|
3366
|
+
break;
|
|
3367
|
+
case "Stop":
|
|
3368
|
+
case "SubagentStop":
|
|
3369
|
+
events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
|
|
3370
|
+
break;
|
|
3371
|
+
}
|
|
3372
|
+
return events;
|
|
3373
|
+
}
|
|
3374
|
+
function isPermissionNotification(input) {
|
|
3375
|
+
const t = (input.notification_type ?? input.type ?? "").toLowerCase();
|
|
3376
|
+
if (t) return t.includes("permission");
|
|
3377
|
+
return (input.message ?? "").toLowerCase().includes("permission");
|
|
3378
|
+
}
|
|
3379
|
+
async function readStdin() {
|
|
3380
|
+
if (process.stdin.isTTY) return "";
|
|
3381
|
+
const chunks = [];
|
|
3382
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
3383
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3384
|
+
}
|
|
3385
|
+
function parseHookInput(raw) {
|
|
3386
|
+
if (!raw.trim()) return {};
|
|
3387
|
+
try {
|
|
3388
|
+
return JSON.parse(raw);
|
|
3389
|
+
} catch {
|
|
3390
|
+
return {};
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
var KINDS = {
|
|
3394
|
+
raw: "Raw",
|
|
3395
|
+
parsed: "Parsed",
|
|
3396
|
+
approval: "Approval",
|
|
3397
|
+
terminal: "Terminal"
|
|
3398
|
+
};
|
|
3399
|
+
function normalizeKind(k) {
|
|
3400
|
+
const v = KINDS[k.toLowerCase()];
|
|
3401
|
+
if (!v)
|
|
3402
|
+
fail(
|
|
3403
|
+
`Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
|
|
3404
|
+
);
|
|
3405
|
+
return v;
|
|
3406
|
+
}
|
|
3407
|
+
function parseIntOpt(v) {
|
|
3408
|
+
const n = Number.parseInt(v, 10);
|
|
3409
|
+
if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
|
|
3410
|
+
return n;
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
// src/commands/executor-run.ts
|
|
3414
|
+
function registerExecutorRunCommand(executor) {
|
|
3415
|
+
executor.command("fleet").description(
|
|
3416
|
+
"Run multiple isolated driven codex executors from one config file"
|
|
3417
|
+
).requiredOption("--config <file>", "JSON fleet config").action(async (opts, cmd) => {
|
|
3418
|
+
const config2 = await readFleetConfig(String(opts.config));
|
|
3419
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3420
|
+
const log = (line) => process.stderr.write(style.dim(`[fleet] ${line}
|
|
3421
|
+
`));
|
|
3422
|
+
let nodeId;
|
|
3423
|
+
let stopNodeHeartbeat;
|
|
3424
|
+
if (config2.node) {
|
|
3425
|
+
const node = await registerFleetNode(cfg, config2.node);
|
|
3426
|
+
nodeId = node.id;
|
|
3427
|
+
const nodeTtl = config2.node.ttl ?? 120;
|
|
3428
|
+
log(`node registered \u2014 ${node.id} (${config2.node.instanceKey})`);
|
|
3429
|
+
stopNodeHeartbeat = startExecutorHeartbeat(
|
|
3430
|
+
() => refreshExecutorInstance(cfg, node.id, nodeTtl),
|
|
3431
|
+
Math.max(10, Math.floor(nodeTtl * 0.66)) * 1e3,
|
|
3432
|
+
{ onError: (e) => log(`node refresh failed: ${String(e)}`) }
|
|
3433
|
+
);
|
|
3434
|
+
}
|
|
3435
|
+
const fleet = superviseFleet(config2, { parentId: nodeId });
|
|
3436
|
+
const stop = () => void fleet.shutdown("SIGINT");
|
|
3437
|
+
process.once("SIGINT", stop);
|
|
3438
|
+
process.once("SIGTERM", stop);
|
|
3439
|
+
try {
|
|
3440
|
+
await fleet.done;
|
|
3441
|
+
} finally {
|
|
3442
|
+
process.off("SIGINT", stop);
|
|
3443
|
+
process.off("SIGTERM", stop);
|
|
3444
|
+
stopNodeHeartbeat?.();
|
|
3445
|
+
if (nodeId) {
|
|
3446
|
+
try {
|
|
3447
|
+
await deregisterInstance(cfg, nodeId);
|
|
3448
|
+
log(`node deregistered \u2014 ${nodeId}`);
|
|
3449
|
+
} catch (e) {
|
|
3450
|
+
log(
|
|
3451
|
+
`node deregister failed (${String(e)}) \u2014 advertisement will expire by TTL`
|
|
3452
|
+
);
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
});
|
|
3457
|
+
executor.command("run").description(
|
|
3458
|
+
"Run a driven codex executor: claim, execute, heartbeat, and complete dispatched tasks unattended"
|
|
3459
|
+
).option("--runtime <kind>", "Runtime to drive (only codex today)", "codex").option("--model <model>", "codex model override").option("--sandbox <mode>", "codex sandbox mode", "workspace-write").option(
|
|
3460
|
+
"--root <dir>",
|
|
3461
|
+
"Working directory for the spawned codex turns (e.g. ../sechroom_4)",
|
|
3462
|
+
process.cwd()
|
|
3463
|
+
).option(
|
|
3464
|
+
"--codex-bin <bin>",
|
|
3465
|
+
"codex binary (or CODEX_BIN)",
|
|
3466
|
+
process.env.CODEX_BIN ?? "codex"
|
|
3467
|
+
).option(
|
|
3468
|
+
"--instance-key <key>",
|
|
3469
|
+
"Registration identity (defaults to installed executor.json)"
|
|
3470
|
+
).option(
|
|
3471
|
+
"--lane <lane>",
|
|
3472
|
+
"Affinity lane + completion source (defaults to instance key)"
|
|
3473
|
+
).option(
|
|
3474
|
+
"--connector <id>",
|
|
3475
|
+
"Approved connector id (defaults to installed executor.json)"
|
|
3476
|
+
).option(
|
|
3477
|
+
"--parent-id <id>",
|
|
3478
|
+
"Fleet node this child enrolls under (D-WLP-55 containment; set by the fleet supervisor)"
|
|
3479
|
+
).option("--ttl <seconds>", "Advertisement TTL (30-600)").option("--once", "Process a single task, then exit", false).option(
|
|
3480
|
+
"--no-deliver",
|
|
3481
|
+
"Skip driver-side delivery (branch/commit/push of the turn's changes)"
|
|
3482
|
+
).option(
|
|
3483
|
+
"--allow-dirty-root",
|
|
3484
|
+
"Claim even when the root has uncommitted changes (they are fenced out of the delivery commit)",
|
|
3485
|
+
false
|
|
3486
|
+
).option("--no-pr", "Deliver without raising a PR (branch + push only)").option("--poll-interval <seconds>", "Offer reconciliation interval", "5").option("--heartbeat-interval <seconds>", "Lease heartbeat cadence", "30").option("--turn-timeout <seconds>", "Fresh turn timeout", "300").option("--resume-turn-timeout <seconds>", "Resumed turn timeout", "1200").option(
|
|
3487
|
+
"--drain-timeout <seconds>",
|
|
3488
|
+
"On shutdown, seconds to let an in-flight turn finish before interrupting",
|
|
3489
|
+
"30"
|
|
3490
|
+
).option(
|
|
3491
|
+
"--usage-reserve <percent>",
|
|
3492
|
+
"Rate-limit reserve: defer claiming new tasks while remaining is at or below this percent",
|
|
3493
|
+
"2"
|
|
3494
|
+
).action(async (opts, cmd) => {
|
|
3495
|
+
if (String(opts.runtime).toLowerCase() !== "codex")
|
|
3496
|
+
fail(
|
|
3497
|
+
"executor run drives runtime codex only (claude-code stays attached)"
|
|
3498
|
+
);
|
|
3499
|
+
const located = readExecutorState();
|
|
3500
|
+
if (!located)
|
|
3501
|
+
fail(
|
|
3502
|
+
"executor run requires an installed executor advertisement; run `sechroom executor install` first."
|
|
3503
|
+
);
|
|
3504
|
+
if (located.state.runtime !== "codex")
|
|
3505
|
+
fail(
|
|
3506
|
+
`this checkout's executor advertisement is runtime '${located.state.runtime}' \u2014 reinstall with --runtime codex.`
|
|
3507
|
+
);
|
|
3508
|
+
located.state = {
|
|
3509
|
+
...located.state,
|
|
3510
|
+
instanceKey: opts.instanceKey ? String(opts.instanceKey) : located.state.instanceKey,
|
|
3511
|
+
laneId: opts.lane ? String(opts.lane) : located.state.laneId,
|
|
3512
|
+
connectorId: opts.connector ? String(opts.connector) : located.state.connectorId,
|
|
3513
|
+
parentId: opts.parentId ? String(opts.parentId) : located.state.parentId,
|
|
3514
|
+
ttlSeconds: opts.ttl ? Number.parseInt(String(opts.ttl), 10) : located.state.ttlSeconds
|
|
3515
|
+
};
|
|
3516
|
+
const heartbeatMs = Number.parseInt(String(opts.heartbeatInterval), 10) * 1e3;
|
|
3517
|
+
if (heartbeatMs >= 12e4)
|
|
3518
|
+
fail("--heartbeat-interval must be shorter than the 120s lease TTL");
|
|
3519
|
+
const usageReserve = Number.parseFloat(String(opts.usageReserve));
|
|
3520
|
+
if (!Number.isFinite(usageReserve) || usageReserve < 0 || usageReserve >= 100)
|
|
3521
|
+
fail("--usage-reserve must be a percent in [0, 100)");
|
|
3522
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3523
|
+
const instance = await ensureExecutorInstance(cfg, located);
|
|
3524
|
+
const log = (line) => process.stderr.write(style.dim(`[run] ${line}
|
|
3525
|
+
`));
|
|
3526
|
+
const request = createAuthedRequest(cfg);
|
|
3527
|
+
const rootDir = resolve2(String(opts.root));
|
|
3528
|
+
const usageLogPath = join9(
|
|
3529
|
+
rootDir,
|
|
3530
|
+
".sechroom",
|
|
3531
|
+
`executor-usage-${located.state.instanceKey.replace(/[^\w.-]/g, "-")}.jsonl`
|
|
3532
|
+
);
|
|
3533
|
+
const usageTracker = new UsageTracker({
|
|
3534
|
+
instanceKey: located.state.instanceKey,
|
|
3535
|
+
reservePercent: usageReserve,
|
|
3536
|
+
log,
|
|
3537
|
+
appendRecord: createUsageLogAppender(usageLogPath, log)
|
|
3538
|
+
});
|
|
3539
|
+
const appServer = new CodexAppServer({
|
|
3540
|
+
codexBin: String(opts.codexBin),
|
|
3541
|
+
cwd: rootDir,
|
|
3542
|
+
model: opts.model ? String(opts.model) : void 0,
|
|
3543
|
+
sandbox: String(opts.sandbox),
|
|
3544
|
+
turnTimeoutMs: Number.parseInt(String(opts.turnTimeout), 10) * 1e3,
|
|
3545
|
+
resumeTurnTimeoutMs: Number.parseInt(String(opts.resumeTurnTimeout), 10) * 1e3,
|
|
3546
|
+
log,
|
|
3547
|
+
executorInstanceId: instance.id,
|
|
3548
|
+
emitTelemetry: (decompositionId, events) => postTelemetry(cfg, decompositionId, events).then(() => void 0),
|
|
3549
|
+
onUsage: (taskId, usage) => usageTracker.recordUsage(taskId, usage),
|
|
3550
|
+
onRateLimits: (limits) => usageTracker.recordRateLimits(limits)
|
|
3551
|
+
});
|
|
3552
|
+
await appServer.start();
|
|
3553
|
+
log(`codex app-server up (${String(opts.codexBin)})`);
|
|
3554
|
+
let stopping = false;
|
|
3555
|
+
let turnInFlight = false;
|
|
3556
|
+
const requestStop = () => {
|
|
3557
|
+
if (stopping) return;
|
|
3558
|
+
stopping = true;
|
|
3559
|
+
wake();
|
|
3560
|
+
log("shutdown requested \u2014 finishing up");
|
|
3561
|
+
if (turnInFlight) {
|
|
3562
|
+
const drainMs = Number.parseInt(String(opts.drainTimeout), 10) * 1e3;
|
|
3563
|
+
setTimeout(() => void appServer.interrupt(), drainMs).unref?.();
|
|
3564
|
+
}
|
|
3565
|
+
};
|
|
3566
|
+
process.once("SIGINT", requestStop);
|
|
3567
|
+
process.once("SIGTERM", requestStop);
|
|
3568
|
+
let wake = () => {
|
|
3569
|
+
};
|
|
3570
|
+
const wakeSignal = () => new Promise((resolve5) => {
|
|
3571
|
+
wake = resolve5;
|
|
3572
|
+
});
|
|
3573
|
+
let connStop = async () => {
|
|
3574
|
+
};
|
|
3575
|
+
try {
|
|
3576
|
+
const conn = await openConnection(cfg, () => wake(), instance.id);
|
|
3577
|
+
connStop = () => conn.stop();
|
|
3578
|
+
} catch (e) {
|
|
3579
|
+
log(`SignalR wake leg unavailable (${String(e)}) \u2014 poll-only`);
|
|
3580
|
+
}
|
|
3581
|
+
const stopAdvertisementHeartbeat = startExecutorHeartbeat(
|
|
3582
|
+
() => request(
|
|
3583
|
+
`/me/executor-instances/${encodeURIComponent(instance.id)}/refresh`,
|
|
3584
|
+
{
|
|
3585
|
+
method: "POST",
|
|
3586
|
+
body: JSON.stringify({ ttlSeconds: located.state.ttlSeconds })
|
|
3587
|
+
}
|
|
3588
|
+
),
|
|
3589
|
+
located.state.refreshAfterSeconds * 1e3,
|
|
3590
|
+
{ onError: (e) => log(`advertisement refresh failed: ${String(e)}`) }
|
|
3591
|
+
);
|
|
3592
|
+
const gitRunner = createGitRunner(rootDir);
|
|
3593
|
+
let rootSnapshot;
|
|
3594
|
+
const deliveryPorts = opts.deliver === false ? {} : {
|
|
3595
|
+
checkRootReady: async () => {
|
|
3596
|
+
const ready = await checkRootReady(
|
|
3597
|
+
gitRunner,
|
|
3598
|
+
Boolean(opts.allowDirtyRoot)
|
|
3599
|
+
);
|
|
3600
|
+
rootSnapshot = ready.snapshot;
|
|
3601
|
+
return { ok: ready.ok, reason: ready.reason };
|
|
3602
|
+
},
|
|
3603
|
+
deliver: (claim, task, verdict) => deliverTurn(gitRunner, {
|
|
3604
|
+
taskId: claim.memoryId,
|
|
3605
|
+
title: task.title,
|
|
3606
|
+
verdict,
|
|
3607
|
+
snapshot: rootSnapshot ?? {
|
|
3608
|
+
baseBranch: "HEAD",
|
|
3609
|
+
dirtyPaths: []
|
|
3610
|
+
},
|
|
3611
|
+
deliveredBy: located.state.laneId ?? located.state.instanceKey,
|
|
3612
|
+
raisePr: opts.pr !== false,
|
|
3613
|
+
log
|
|
3614
|
+
})
|
|
3615
|
+
};
|
|
3616
|
+
const ports = {
|
|
3617
|
+
...deliveryPorts,
|
|
3618
|
+
checkAdmission: async () => usageTracker.admission(),
|
|
3619
|
+
claimNext: () => claimNext(request, instance.id, log),
|
|
3620
|
+
loadTask: (memoryId) => loadTask(request, memoryId, log),
|
|
3621
|
+
startLeaseHeartbeat: (claim) => startLeaseHeartbeat(
|
|
3622
|
+
() => request(
|
|
3623
|
+
`/me/executor-task-leases/${encodeURIComponent(claim.leaseId)}/heartbeat`,
|
|
3624
|
+
{
|
|
3625
|
+
method: "POST",
|
|
3626
|
+
body: JSON.stringify({
|
|
3627
|
+
claimToken: claim.claimToken,
|
|
3628
|
+
tokenVersion: claim.tokenVersion
|
|
3629
|
+
})
|
|
3630
|
+
}
|
|
3631
|
+
),
|
|
3632
|
+
log,
|
|
3633
|
+
heartbeatMs
|
|
3634
|
+
),
|
|
3635
|
+
runTurn: async (task, claim) => {
|
|
3636
|
+
if (!appServer.alive) {
|
|
3637
|
+
log("codex app-server died between tasks \u2014 respawning");
|
|
3638
|
+
await appServer.start();
|
|
3639
|
+
}
|
|
3640
|
+
turnInFlight = true;
|
|
3641
|
+
try {
|
|
3642
|
+
return await appServer.runTask(taskPrompt(task), {
|
|
3643
|
+
taskId: claim.memoryId,
|
|
3644
|
+
leaseId: claim.leaseId,
|
|
3645
|
+
decompositionId: claim.decompositionId
|
|
3646
|
+
});
|
|
3647
|
+
} finally {
|
|
3648
|
+
turnInFlight = false;
|
|
3649
|
+
}
|
|
3650
|
+
},
|
|
3651
|
+
completeLease: (claim, verdict, text2, title) => request(
|
|
3652
|
+
`/me/executor-task-leases/${encodeURIComponent(claim.leaseId)}/complete`,
|
|
3653
|
+
{
|
|
3654
|
+
method: "POST",
|
|
3655
|
+
body: JSON.stringify({
|
|
3656
|
+
executorInstanceId: instance.id,
|
|
3657
|
+
claimToken: claim.claimToken,
|
|
3658
|
+
tokenVersion: claim.tokenVersion,
|
|
3659
|
+
verdict,
|
|
3660
|
+
text: text2,
|
|
3661
|
+
source: located.state.laneId ?? located.state.instanceKey,
|
|
3662
|
+
title
|
|
3663
|
+
})
|
|
3664
|
+
}
|
|
3665
|
+
),
|
|
3666
|
+
log,
|
|
3667
|
+
waitForWake: (ms) => Promise.race([
|
|
3668
|
+
new Promise((resolve5) => {
|
|
3669
|
+
setTimeout(resolve5, ms).unref?.();
|
|
3670
|
+
}),
|
|
3671
|
+
wakeSignal()
|
|
3672
|
+
])
|
|
3673
|
+
};
|
|
3674
|
+
log(
|
|
3675
|
+
`driven executor live \u2014 instance ${located.state.instanceKey}, lane ${located.state.laneId ?? located.state.instanceKey}${opts.once ? ", single-task mode" : ""}`
|
|
3676
|
+
);
|
|
3677
|
+
log(`usage log \u2192 ${usageLogPath}; admission reserve ${usageReserve}%`);
|
|
3678
|
+
try {
|
|
3679
|
+
const summary = await runDriverLoop(ports, {
|
|
3680
|
+
once: Boolean(opts.once),
|
|
3681
|
+
pollMs: Number.parseInt(String(opts.pollInterval), 10) * 1e3,
|
|
3682
|
+
stopping: () => stopping,
|
|
3683
|
+
source: located.state.laneId ?? located.state.instanceKey
|
|
3684
|
+
});
|
|
3685
|
+
log(
|
|
3686
|
+
`done \u2014 processed ${summary.processed}, completed ${summary.completed}, abandoned ${summary.abandoned}`
|
|
3687
|
+
);
|
|
3688
|
+
} catch (error) {
|
|
3689
|
+
if (error instanceof AuthExpiredError) {
|
|
3690
|
+
process.stderr.write(
|
|
3691
|
+
style.dim(`[run] auth expired: ${error.message}
|
|
3692
|
+
`)
|
|
3693
|
+
);
|
|
3694
|
+
process.exitCode = 1;
|
|
3695
|
+
} else {
|
|
3696
|
+
throw error;
|
|
3697
|
+
}
|
|
3698
|
+
} finally {
|
|
3699
|
+
stopAdvertisementHeartbeat();
|
|
3700
|
+
await connStop().catch(() => {
|
|
3701
|
+
});
|
|
3702
|
+
try {
|
|
3703
|
+
await request(
|
|
3704
|
+
`/me/executor-instances/${encodeURIComponent(instance.id)}`,
|
|
3705
|
+
{ method: "DELETE", body: JSON.stringify({}) }
|
|
3706
|
+
);
|
|
3707
|
+
log("deregistered");
|
|
3708
|
+
} catch (e) {
|
|
3709
|
+
log(
|
|
3710
|
+
`deregister failed (${String(e)}) \u2014 advertisement will expire by TTL`
|
|
3711
|
+
);
|
|
3712
|
+
}
|
|
3713
|
+
appServer.stop();
|
|
3714
|
+
}
|
|
3715
|
+
});
|
|
3716
|
+
}
|
|
3717
|
+
async function claimNext(request, instanceId, log) {
|
|
3718
|
+
const claimed = await claimNextTask({
|
|
3719
|
+
request,
|
|
3720
|
+
executorInstanceId: instanceId,
|
|
3721
|
+
log
|
|
3722
|
+
});
|
|
3723
|
+
if (!claimed) return void 0;
|
|
3724
|
+
return {
|
|
3725
|
+
memoryId: claimed.memoryId,
|
|
3726
|
+
leaseId: claimed.leaseId,
|
|
3727
|
+
claimToken: claimed.claimToken,
|
|
3728
|
+
tokenVersion: claimed.tokenVersion,
|
|
3729
|
+
decompositionId: claimed.decompositionId
|
|
3730
|
+
};
|
|
3731
|
+
}
|
|
3732
|
+
async function loadTask(request, memoryId, log) {
|
|
3733
|
+
const card = await request(
|
|
3734
|
+
`/tasks/${encodeURIComponent(memoryId)}/card`
|
|
3735
|
+
);
|
|
3736
|
+
let packText = "";
|
|
3737
|
+
const pointer = card.contextPack;
|
|
3738
|
+
if (pointer?.slug && pointer.version) {
|
|
3739
|
+
try {
|
|
3740
|
+
const pkg = await request(
|
|
3741
|
+
`/bundles/${encodeURIComponent(pointer.slug)}/versions/${encodeURIComponent(pointer.version)}/package`
|
|
3742
|
+
);
|
|
3743
|
+
packText = (pkg.components ?? []).map((c) => `### ${c.title ?? "context"}
|
|
3744
|
+
${c.body}`).join("\n\n");
|
|
3745
|
+
} catch (error) {
|
|
3746
|
+
log(
|
|
3747
|
+
`warning: task ${card.taskId} context pack (${pointer.slug}@${pointer.version}) failed to resolve (${String(error)}) \u2014 running on card body only`
|
|
3748
|
+
);
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
return {
|
|
3752
|
+
title: card.title ?? memoryId,
|
|
3753
|
+
text: assemblePrompt(card, packText)
|
|
3754
|
+
};
|
|
3755
|
+
}
|
|
3756
|
+
function assemblePrompt(card, packText) {
|
|
3757
|
+
const sections = [
|
|
3758
|
+
`# Task: ${card.title}`,
|
|
3759
|
+
`## Objective
|
|
3760
|
+
${card.task.objective}`,
|
|
3761
|
+
`## Acceptance
|
|
3762
|
+
${card.task.acceptance}`,
|
|
3763
|
+
`## Boundaries
|
|
3764
|
+
${card.task.boundaries}`,
|
|
3765
|
+
`## Closeout
|
|
3766
|
+
${card.task.closeout}`
|
|
3767
|
+
];
|
|
3768
|
+
if (packText) sections.push(`## Context pack
|
|
3769
|
+
${packText}`);
|
|
3770
|
+
return sections.join("\n\n");
|
|
3771
|
+
}
|
|
3772
|
+
function taskPrompt(task) {
|
|
3773
|
+
return `You are a driven executor working ONE dispatched Work Layer task.
|
|
3774
|
+
|
|
3775
|
+
${task.text}
|
|
3776
|
+
|
|
3777
|
+
Call sechroom_lifecycle_signal at each phase boundary (start/work/verify/closeout). When the work is done \u2014 or honestly cannot be \u2014 end by calling sechroom_closeout with the true terminal_status, a summary, and evidence. Never end the turn without calling sechroom_closeout.`;
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
// src/commands/executor.ts
|
|
3781
|
+
function executorSubscriptionInput(name) {
|
|
3782
|
+
return {
|
|
3783
|
+
name,
|
|
3784
|
+
enabled: true,
|
|
3785
|
+
filter: { tags: ["kind:task"], workspaceScope: [] }
|
|
1929
3786
|
};
|
|
1930
3787
|
}
|
|
1931
3788
|
function executorRegistrationInput(state, deliverySubscriptionId) {
|
|
@@ -1938,7 +3795,10 @@ function executorRegistrationInput(state, deliverySubscriptionId) {
|
|
|
1938
3795
|
deliverySubscriptionId,
|
|
1939
3796
|
connectorId: state.connectorId,
|
|
1940
3797
|
claimedCapabilityKeys: state.capabilityKeys,
|
|
3798
|
+
claimPolicy: parseClaimPolicy(state.claimPolicy),
|
|
3799
|
+
claimTags: state.claimTags ?? [],
|
|
1941
3800
|
toolSetRef: null,
|
|
3801
|
+
parentId: state.parentId ?? null,
|
|
1942
3802
|
ttlSeconds: state.ttlSeconds
|
|
1943
3803
|
};
|
|
1944
3804
|
}
|
|
@@ -1973,6 +3833,13 @@ function registerExecutor(program2) {
|
|
|
1973
3833
|
).option("--runtime <kind>", "claude-code | codex").option("--surface <surface>", "claude | codex").option(
|
|
1974
3834
|
"--capability <key...>",
|
|
1975
3835
|
"Capability operation keys claimed by this instance"
|
|
3836
|
+
).option(
|
|
3837
|
+
"--claim-policy <policy>",
|
|
3838
|
+
"open | restricted (restricted only claims preferred/own-lane/allow-listed-tag work)",
|
|
3839
|
+
"open"
|
|
3840
|
+
).option(
|
|
3841
|
+
"--claim-tag <tag...>",
|
|
3842
|
+
"Task tag this instance accepts under --claim-policy restricted"
|
|
1976
3843
|
).option(
|
|
1977
3844
|
"--relay <id>",
|
|
1978
3845
|
"Relay identity shared by sibling instances",
|
|
@@ -2043,13 +3910,14 @@ function registerExecutor(program2) {
|
|
|
2043
3910
|
if (!opts.yes && !canPrompt())
|
|
2044
3911
|
fail("non-interactive executor install requires --yes");
|
|
2045
3912
|
parseRuntimeKind(runtime);
|
|
3913
|
+
parseClaimPolicy(opts.claimPolicy);
|
|
2046
3914
|
if (!["claude", "codex"].includes(surface))
|
|
2047
3915
|
fail("surface must be claude or codex");
|
|
2048
3916
|
if (opts.refreshAfter >= opts.ttl)
|
|
2049
3917
|
fail("refresh-after must be shorter than the TTL");
|
|
2050
3918
|
const sem = readSem();
|
|
2051
|
-
const checkout = sem ?
|
|
2052
|
-
const statePath =
|
|
3919
|
+
const checkout = sem ? dirname7(dirname7(sem.path)) : process.cwd();
|
|
3920
|
+
const statePath = join10(checkout, ".sechroom", EXECUTOR_STATE);
|
|
2053
3921
|
const state = {
|
|
2054
3922
|
schemaVersion: 1,
|
|
2055
3923
|
instanceKey,
|
|
@@ -2057,20 +3925,22 @@ function registerExecutor(program2) {
|
|
|
2057
3925
|
runtime: runtime.toLowerCase() === "codex" ? "codex" : "claude-code",
|
|
2058
3926
|
connectorId: connector,
|
|
2059
3927
|
capabilityKeys: capabilities ?? [],
|
|
3928
|
+
claimPolicy: (opts.claimPolicy ?? "open").toLowerCase() === "restricted" ? "restricted" : "open",
|
|
3929
|
+
claimTags: opts.claimTag ?? [],
|
|
2060
3930
|
relayId: opts.relay,
|
|
2061
3931
|
subscriptionName: opts.subscriptionName,
|
|
2062
3932
|
ttlSeconds: opts.ttl,
|
|
2063
3933
|
refreshAfterSeconds: opts.refreshAfter
|
|
2064
3934
|
};
|
|
2065
3935
|
if (!opts.dryRun) {
|
|
2066
|
-
|
|
2067
|
-
|
|
3936
|
+
mkdirSync8(dirname7(statePath), { recursive: true });
|
|
3937
|
+
writeFileSync7(statePath, JSON.stringify(state, null, 2) + "\n");
|
|
2068
3938
|
ensureStateDirIgnored(checkout);
|
|
2069
3939
|
}
|
|
2070
3940
|
const configuredClaudeDirs = globals.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR ? resolveClaudeTargets({ override: globals.claudeConfigDir }).map(
|
|
2071
3941
|
(target) => target.dir
|
|
2072
|
-
) : [
|
|
2073
|
-
const configuredCodexHomes = globals.codexHome || process.env.CODEX_HOME ? resolveCodexHomes({ override: globals.codexHome }) : [
|
|
3942
|
+
) : [join10(checkout, ".claude")];
|
|
3943
|
+
const configuredCodexHomes = globals.codexHome || process.env.CODEX_HOME ? resolveCodexHomes({ override: globals.codexHome }) : [join10(checkout, ".codex")];
|
|
2074
3944
|
const hookTargets = surface === "claude" ? configuredClaudeDirs : configuredCodexHomes;
|
|
2075
3945
|
for (const target of hookTargets) {
|
|
2076
3946
|
const results = surface === "claude" ? [
|
|
@@ -2114,7 +3984,7 @@ function registerExecutor(program2) {
|
|
|
2114
3984
|
);
|
|
2115
3985
|
delete located.state.instanceId;
|
|
2116
3986
|
delete located.state.lastRefreshAt;
|
|
2117
|
-
|
|
3987
|
+
writeFileSync7(
|
|
2118
3988
|
located.path,
|
|
2119
3989
|
JSON.stringify(located.state, null, 2) + "\n"
|
|
2120
3990
|
);
|
|
@@ -2170,7 +4040,17 @@ function registerExecutor(program2) {
|
|
|
2170
4040
|
).option(
|
|
2171
4041
|
"--capability <key...>",
|
|
2172
4042
|
"Capability operation keys claimed by this instance"
|
|
2173
|
-
).option(
|
|
4043
|
+
).option(
|
|
4044
|
+
"--claim-policy <policy>",
|
|
4045
|
+
"open | restricted (restricted only claims preferred/own-lane/allow-listed-tag work)",
|
|
4046
|
+
"open"
|
|
4047
|
+
).option(
|
|
4048
|
+
"--claim-tag <tag...>",
|
|
4049
|
+
"Task tag this instance accepts under --claim-policy restricted"
|
|
4050
|
+
).option("--tool-set-ref <ref>", "Optional governed tool-set reference").option(
|
|
4051
|
+
"--parent-id <id>",
|
|
4052
|
+
"Fleet node this instance enrolls under (D-WLP-55 containment; sets the roster parentId)"
|
|
4053
|
+
).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).action(async (opts, cmd) => {
|
|
2174
4054
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2175
4055
|
const subscription = await api(
|
|
2176
4056
|
cfg,
|
|
@@ -2200,7 +4080,10 @@ function registerExecutor(program2) {
|
|
|
2200
4080
|
deliverySubscriptionId: subscription.id,
|
|
2201
4081
|
connectorId: opts.connector,
|
|
2202
4082
|
claimedCapabilityKeys: opts.capability ?? [],
|
|
4083
|
+
claimPolicy: parseClaimPolicy(opts.claimPolicy),
|
|
4084
|
+
claimTags: opts.claimTag ?? [],
|
|
2203
4085
|
toolSetRef: opts.toolSetRef ?? null,
|
|
4086
|
+
parentId: opts.parentId ?? null,
|
|
2204
4087
|
ttlSeconds: opts.ttl
|
|
2205
4088
|
})
|
|
2206
4089
|
}
|
|
@@ -2253,6 +4136,7 @@ function registerExecutor(program2) {
|
|
|
2253
4136
|
);
|
|
2254
4137
|
emit(data, Boolean(cmd.optsWithGlobals().json));
|
|
2255
4138
|
});
|
|
4139
|
+
registerExecutorRunCommand(executor);
|
|
2256
4140
|
}
|
|
2257
4141
|
function parseRuntimeKind(value) {
|
|
2258
4142
|
switch (value.trim().toLowerCase()) {
|
|
@@ -2265,6 +4149,17 @@ function parseRuntimeKind(value) {
|
|
|
2265
4149
|
return fail("runtime must be claude-code or codex");
|
|
2266
4150
|
}
|
|
2267
4151
|
}
|
|
4152
|
+
function parseClaimPolicy(value) {
|
|
4153
|
+
switch ((value ?? "open").trim().toLowerCase()) {
|
|
4154
|
+
case "":
|
|
4155
|
+
case "open":
|
|
4156
|
+
return "Open";
|
|
4157
|
+
case "restricted":
|
|
4158
|
+
return "Restricted";
|
|
4159
|
+
default:
|
|
4160
|
+
return fail("claim-policy must be open or restricted");
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
2268
4163
|
function parseTransport(value) {
|
|
2269
4164
|
switch (value.trim().toLowerCase()) {
|
|
2270
4165
|
case "push":
|
|
@@ -2285,18 +4180,53 @@ async function refreshExecutorInstance(cfg, id, ttlSeconds) {
|
|
|
2285
4180
|
}
|
|
2286
4181
|
);
|
|
2287
4182
|
}
|
|
2288
|
-
async function registerInstance(cfg, state) {
|
|
4183
|
+
async function registerInstance(cfg, state) {
|
|
4184
|
+
const subscription = await api(
|
|
4185
|
+
cfg,
|
|
4186
|
+
"/me/delivery-subscriptions/signalr",
|
|
4187
|
+
{
|
|
4188
|
+
method: "POST",
|
|
4189
|
+
body: JSON.stringify(executorSubscriptionInput(state.subscriptionName))
|
|
4190
|
+
}
|
|
4191
|
+
);
|
|
4192
|
+
return api(cfg, "/me/executor-instances", {
|
|
4193
|
+
method: "POST",
|
|
4194
|
+
body: JSON.stringify(executorRegistrationInput(state, subscription.id))
|
|
4195
|
+
});
|
|
4196
|
+
}
|
|
4197
|
+
async function registerFleetNode(cfg, node) {
|
|
4198
|
+
const subscriptionName = node.subscriptionName ?? "executor-dispatch";
|
|
2289
4199
|
const subscription = await api(
|
|
2290
4200
|
cfg,
|
|
2291
4201
|
"/me/delivery-subscriptions/signalr",
|
|
2292
4202
|
{
|
|
2293
4203
|
method: "POST",
|
|
2294
|
-
body: JSON.stringify(executorSubscriptionInput(
|
|
4204
|
+
body: JSON.stringify(executorSubscriptionInput(subscriptionName))
|
|
2295
4205
|
}
|
|
2296
4206
|
);
|
|
2297
4207
|
return api(cfg, "/me/executor-instances", {
|
|
2298
4208
|
method: "POST",
|
|
2299
|
-
body: JSON.stringify(
|
|
4209
|
+
body: JSON.stringify({
|
|
4210
|
+
relayId: node.relay ?? "sechroom-cli-fleet",
|
|
4211
|
+
instanceKey: node.instanceKey,
|
|
4212
|
+
laneId: node.lane ?? node.instanceKey,
|
|
4213
|
+
runtimeKind: "Node",
|
|
4214
|
+
activationMode: "Attached",
|
|
4215
|
+
deliverySubscriptionId: subscription.id,
|
|
4216
|
+
connectorId: node.connector,
|
|
4217
|
+
claimedCapabilityKeys: [],
|
|
4218
|
+
claimPolicy: "Open",
|
|
4219
|
+
claimTags: [],
|
|
4220
|
+
toolSetRef: null,
|
|
4221
|
+
parentId: null,
|
|
4222
|
+
ttlSeconds: node.ttl ?? 120
|
|
4223
|
+
})
|
|
4224
|
+
});
|
|
4225
|
+
}
|
|
4226
|
+
async function deregisterInstance(cfg, id) {
|
|
4227
|
+
await api(cfg, `/me/executor-instances/${encodeURIComponent(id)}`, {
|
|
4228
|
+
method: "DELETE",
|
|
4229
|
+
body: JSON.stringify({})
|
|
2300
4230
|
});
|
|
2301
4231
|
}
|
|
2302
4232
|
async function ensureExecutorInstance(cfg, located) {
|
|
@@ -2305,19 +4235,19 @@ async function ensureExecutorInstance(cfg, located) {
|
|
|
2305
4235
|
const data = await registerInstance(cfg, state);
|
|
2306
4236
|
state.instanceId = data.id;
|
|
2307
4237
|
state.lastRefreshAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2308
|
-
|
|
4238
|
+
writeFileSync7(path, JSON.stringify(state, null, 2) + "\n");
|
|
2309
4239
|
return data;
|
|
2310
4240
|
}
|
|
2311
4241
|
function readExecutorState(start = process.cwd()) {
|
|
2312
4242
|
const semPath = resolveSemPathForRead(start);
|
|
2313
4243
|
const sem = semPath ? readSem(semPath) : void 0;
|
|
2314
|
-
const path =
|
|
2315
|
-
sem ?
|
|
4244
|
+
const path = join10(
|
|
4245
|
+
sem ? dirname7(sem.path) : join10(start, ".sechroom"),
|
|
2316
4246
|
EXECUTOR_STATE
|
|
2317
4247
|
);
|
|
2318
|
-
if (!
|
|
4248
|
+
if (!existsSync8(path)) return void 0;
|
|
2319
4249
|
return {
|
|
2320
|
-
state: JSON.parse(
|
|
4250
|
+
state: JSON.parse(readFileSync6(path, "utf8")),
|
|
2321
4251
|
path
|
|
2322
4252
|
};
|
|
2323
4253
|
}
|
|
@@ -2349,11 +4279,11 @@ function parseInteger(value) {
|
|
|
2349
4279
|
return parsed;
|
|
2350
4280
|
}
|
|
2351
4281
|
function holdHeartbeat(tick, intervalMs) {
|
|
2352
|
-
return new Promise((
|
|
4282
|
+
return new Promise((resolve5, reject) => {
|
|
2353
4283
|
const timer = setInterval(() => void tick().catch(reject), intervalMs);
|
|
2354
4284
|
const stop = () => {
|
|
2355
4285
|
clearInterval(timer);
|
|
2356
|
-
|
|
4286
|
+
resolve5();
|
|
2357
4287
|
};
|
|
2358
4288
|
process.once("SIGINT", stop);
|
|
2359
4289
|
process.once("SIGTERM", stop);
|
|
@@ -2504,7 +4434,7 @@ function registerChannel(program2) {
|
|
|
2504
4434
|
"MCP server + subscription name (idempotent per name)",
|
|
2505
4435
|
"sechroom-channel"
|
|
2506
4436
|
).option("--dry-run", "Print what would change; write nothing").action((opts) => {
|
|
2507
|
-
const path =
|
|
4437
|
+
const path = join11(process.cwd(), ".mcp.json");
|
|
2508
4438
|
const dryRun = Boolean(opts.dryRun);
|
|
2509
4439
|
const args = ["channel", "mcp"];
|
|
2510
4440
|
const entry = { command: "sechroom", args };
|
|
@@ -2514,8 +4444,8 @@ function registerChannel(program2) {
|
|
|
2514
4444
|
const status = JSON.stringify(existing) === JSON.stringify(entry) ? "current" : existing ? "updated" : "created";
|
|
2515
4445
|
if (status !== "current" && !dryRun) {
|
|
2516
4446
|
config2.mcpServers[opts.name] = entry;
|
|
2517
|
-
|
|
2518
|
-
|
|
4447
|
+
mkdirSync9(dirname8(path), { recursive: true });
|
|
4448
|
+
writeFileSync8(path, JSON.stringify(config2, null, 2) + "\n");
|
|
2519
4449
|
}
|
|
2520
4450
|
const verb = status === "current" ? "already configured" : dryRun ? `would ${status === "created" ? "create" : "update"}` : status;
|
|
2521
4451
|
process.stdout.write(`${style.green("channel")} ${path} (${verb})
|
|
@@ -2606,7 +4536,7 @@ function startExecutorHeartbeat(refresh, intervalMilliseconds, dependencies = {}
|
|
|
2606
4536
|
}
|
|
2607
4537
|
async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {}) {
|
|
2608
4538
|
const request = dependencies.request ?? api;
|
|
2609
|
-
const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((
|
|
4539
|
+
const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve5) => setTimeout(resolve5, milliseconds)));
|
|
2610
4540
|
const idempotencyKey = dependencies.idempotencyKey ?? ((offer) => `channel:${offer.generationId}`);
|
|
2611
4541
|
const state = dependencies.state ?? {};
|
|
2612
4542
|
for (; ; ) {
|
|
@@ -2651,8 +4581,8 @@ async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {})
|
|
|
2651
4581
|
}
|
|
2652
4582
|
}
|
|
2653
4583
|
function readMcpConfig(path) {
|
|
2654
|
-
if (!
|
|
2655
|
-
const raw =
|
|
4584
|
+
if (!existsSync9(path)) return {};
|
|
4585
|
+
const raw = readFileSync7(path, "utf8");
|
|
2656
4586
|
if (!raw.trim()) return {};
|
|
2657
4587
|
try {
|
|
2658
4588
|
return JSON.parse(raw);
|
|
@@ -2680,9 +4610,9 @@ async function openConnection(cfg, onEvent, executorInstanceId) {
|
|
|
2680
4610
|
return conn;
|
|
2681
4611
|
}
|
|
2682
4612
|
function holdOpen(conn) {
|
|
2683
|
-
return new Promise((
|
|
4613
|
+
return new Promise((resolve5) => {
|
|
2684
4614
|
const stop = () => {
|
|
2685
|
-
void conn.stop().finally(
|
|
4615
|
+
void conn.stop().finally(resolve5);
|
|
2686
4616
|
};
|
|
2687
4617
|
process.on("SIGINT", stop);
|
|
2688
4618
|
process.on("SIGTERM", stop);
|
|
@@ -2808,20 +4738,20 @@ Examples:
|
|
|
2808
4738
|
}
|
|
2809
4739
|
|
|
2810
4740
|
// src/commands/checkpoint.ts
|
|
2811
|
-
import { mkdirSync as
|
|
2812
|
-
import { dirname as
|
|
4741
|
+
import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync10 } from "fs";
|
|
4742
|
+
import { dirname as dirname10, join as join13 } from "path";
|
|
2813
4743
|
|
|
2814
4744
|
// src/commands/hook.ts
|
|
2815
4745
|
import { createHash as createHash2 } from "crypto";
|
|
2816
|
-
import { existsSync as
|
|
2817
|
-
import { dirname as
|
|
2818
|
-
async function
|
|
4746
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync10, readFileSync as readFileSync8, statSync as statSync2, writeFileSync as writeFileSync9 } from "fs";
|
|
4747
|
+
import { dirname as dirname9, join as join12 } from "path";
|
|
4748
|
+
async function readStdin2() {
|
|
2819
4749
|
if (process.stdin.isTTY) return "";
|
|
2820
4750
|
const chunks = [];
|
|
2821
4751
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
2822
4752
|
return Buffer.concat(chunks).toString("utf8");
|
|
2823
4753
|
}
|
|
2824
|
-
function
|
|
4754
|
+
function parseHookInput2(raw) {
|
|
2825
4755
|
if (!raw.trim()) return {};
|
|
2826
4756
|
try {
|
|
2827
4757
|
return JSON.parse(raw);
|
|
@@ -2838,13 +4768,13 @@ function resolveLane(flagLane, cwd) {
|
|
|
2838
4768
|
if (!base) return void 0;
|
|
2839
4769
|
return applyWorktreeLaneSuffix(base, start);
|
|
2840
4770
|
}
|
|
2841
|
-
var INTENT_FILE =
|
|
4771
|
+
var INTENT_FILE = join12(".sechroom", "continuity.json");
|
|
2842
4772
|
function resolveIntentPath(start) {
|
|
2843
4773
|
let dir = start;
|
|
2844
4774
|
for (; ; ) {
|
|
2845
|
-
const candidate =
|
|
2846
|
-
if (
|
|
2847
|
-
const parent =
|
|
4775
|
+
const candidate = join12(dir, INTENT_FILE);
|
|
4776
|
+
if (existsSync10(candidate)) return candidate;
|
|
4777
|
+
const parent = dirname9(dir);
|
|
2848
4778
|
if (parent === dir) return void 0;
|
|
2849
4779
|
dir = parent;
|
|
2850
4780
|
}
|
|
@@ -2853,7 +4783,7 @@ function readIntent(start) {
|
|
|
2853
4783
|
const path = resolveIntentPath(start);
|
|
2854
4784
|
if (!path) return void 0;
|
|
2855
4785
|
try {
|
|
2856
|
-
return JSON.parse(
|
|
4786
|
+
return JSON.parse(readFileSync8(path, "utf8"));
|
|
2857
4787
|
} catch {
|
|
2858
4788
|
return void 0;
|
|
2859
4789
|
}
|
|
@@ -2895,14 +4825,14 @@ async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScop
|
|
|
2895
4825
|
}
|
|
2896
4826
|
function ledgerPath(start) {
|
|
2897
4827
|
const intent = resolveIntentPath(start);
|
|
2898
|
-
const dir = intent ?
|
|
2899
|
-
return
|
|
4828
|
+
const dir = intent ? dirname9(intent) : join12(start, ".sechroom");
|
|
4829
|
+
return join12(dir, ".checkpoint-state.json");
|
|
2900
4830
|
}
|
|
2901
4831
|
function readLedger(start) {
|
|
2902
4832
|
try {
|
|
2903
4833
|
const p = ledgerPath(start);
|
|
2904
|
-
if (!
|
|
2905
|
-
return JSON.parse(
|
|
4834
|
+
if (!existsSync10(p)) return {};
|
|
4835
|
+
return JSON.parse(readFileSync8(p, "utf8"));
|
|
2906
4836
|
} catch {
|
|
2907
4837
|
return {};
|
|
2908
4838
|
}
|
|
@@ -2949,13 +4879,13 @@ function recordPush(start, intent) {
|
|
|
2949
4879
|
} catch {
|
|
2950
4880
|
mtimeMs = void 0;
|
|
2951
4881
|
}
|
|
2952
|
-
|
|
4882
|
+
mkdirSync10(dirname9(p), { recursive: true });
|
|
2953
4883
|
const ledger = {
|
|
2954
4884
|
lastEpochMs: Date.now(),
|
|
2955
4885
|
lastMtimeMs: mtimeMs,
|
|
2956
4886
|
lastHash: intentHash(intent)
|
|
2957
4887
|
};
|
|
2958
|
-
|
|
4888
|
+
writeFileSync9(p, JSON.stringify(ledger) + "\n");
|
|
2959
4889
|
} catch {
|
|
2960
4890
|
}
|
|
2961
4891
|
}
|
|
@@ -3015,8 +4945,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
|
|
|
3015
4945
|
);
|
|
3016
4946
|
hook.command("session-start").description("Resume the checkout's lane and emit continuity context for a SessionStart hook").option("--lane <laneId>", "Override the resolved lane (else SECHROOM_LANE, else ./.sechroom/lane.json code-lane)").option("--surface <surface>", "Target surface: claude | codex (output is identical for session-start)", "claude").option("--max-artifacts <n>", "Cap artifacts in the resume bundle").action(async (opts, cmd) => {
|
|
3017
4947
|
try {
|
|
3018
|
-
const raw = await
|
|
3019
|
-
const input =
|
|
4948
|
+
const raw = await readStdin2();
|
|
4949
|
+
const input = parseHookInput2(raw);
|
|
3020
4950
|
const lane = resolveLane(opts.lane, input.cwd);
|
|
3021
4951
|
if (!lane) return process.exit(0);
|
|
3022
4952
|
const semPath = resolveSemPathForRead(input.cwd ?? process.cwd());
|
|
@@ -3041,8 +4971,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
|
|
|
3041
4971
|
});
|
|
3042
4972
|
hook.command("pre-compact").description("Save a continuity snapshot from the agent-maintained intent file on a PreCompact hook").option("--lane <laneId>", "Override the resolved lane (else SECHROOM_LANE, else ./.sechroom/lane.json code-lane)").option("--scope <scope>", "Snapshot scope (else the intent file's `scope`, else 'compaction')").option("--surface <surface>", "Target surface: claude | codex (lifecycle-only on both)", "claude").action(async (opts, cmd) => {
|
|
3043
4973
|
try {
|
|
3044
|
-
const raw = await
|
|
3045
|
-
const input =
|
|
4974
|
+
const raw = await readStdin2();
|
|
4975
|
+
const input = parseHookInput2(raw);
|
|
3046
4976
|
const cwd = input.cwd ?? process.cwd();
|
|
3047
4977
|
await saveSnapshotFromIntent(cmd, cwd, opts.lane, opts.scope, "compaction", { skipIfUnchanged: true });
|
|
3048
4978
|
return process.exit(0);
|
|
@@ -3055,8 +4985,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
|
|
|
3055
4985
|
"skip if a hook checkpoint ran within this many minutes \u2014 for high-frequency triggers like Codex Stop (Claude SessionEnd passes none)"
|
|
3056
4986
|
).action(async (opts, cmd) => {
|
|
3057
4987
|
try {
|
|
3058
|
-
const raw = await
|
|
3059
|
-
const input =
|
|
4988
|
+
const raw = await readStdin2();
|
|
4989
|
+
const input = parseHookInput2(raw);
|
|
3060
4990
|
const cwd = input.cwd ?? process.cwd();
|
|
3061
4991
|
const debounce = opts.debounceMinutes != null ? Number(opts.debounceMinutes) : 0;
|
|
3062
4992
|
if (debounce > 0 && recentlyCheckpointed(cwd, debounce)) return process.exit(0);
|
|
@@ -3208,10 +5138,10 @@ Examples:
|
|
|
3208
5138
|
const client = await makeClient(cfg);
|
|
3209
5139
|
return client.POST("/continuity/snapshots", { body });
|
|
3210
5140
|
});
|
|
3211
|
-
const path = resolveIntentPath(cwd) ??
|
|
5141
|
+
const path = resolveIntentPath(cwd) ?? join13(cwd, INTENT_FILE);
|
|
3212
5142
|
const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
|
|
3213
|
-
|
|
3214
|
-
|
|
5143
|
+
mkdirSync11(dirname10(path), { recursive: true });
|
|
5144
|
+
writeFileSync10(path, JSON.stringify(fileBody, null, 2) + "\n");
|
|
3215
5145
|
recordPush(cwd, merged);
|
|
3216
5146
|
if (json) {
|
|
3217
5147
|
emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
|
|
@@ -3225,7 +5155,7 @@ Examples:
|
|
|
3225
5155
|
}
|
|
3226
5156
|
|
|
3227
5157
|
// src/commands/close.ts
|
|
3228
|
-
import { readFileSync as
|
|
5158
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
3229
5159
|
var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
|
|
3230
5160
|
function registerClose(program2) {
|
|
3231
5161
|
program2.command("close").description(
|
|
@@ -3266,7 +5196,7 @@ Examples:
|
|
|
3266
5196
|
);
|
|
3267
5197
|
let bodyText;
|
|
3268
5198
|
try {
|
|
3269
|
-
bodyText = opts.file ?
|
|
5199
|
+
bodyText = opts.file ? readFileSync9(opts.file, "utf8") : readFileSync9(0, "utf8");
|
|
3270
5200
|
} catch {
|
|
3271
5201
|
fail(
|
|
3272
5202
|
opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
|
|
@@ -3557,7 +5487,7 @@ Examples:
|
|
|
3557
5487
|
}
|
|
3558
5488
|
|
|
3559
5489
|
// src/commands/work-plan.ts
|
|
3560
|
-
import { readFile } from "fs/promises";
|
|
5490
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
3561
5491
|
function registerWorkPlan(program2) {
|
|
3562
5492
|
const workPlan = program2.command("work-plan").description(
|
|
3563
5493
|
"Drive a work plan: create one from a brief, then execute / accept / reject"
|
|
@@ -3572,6 +5502,9 @@ Examples:
|
|
|
3572
5502
|
$ sechroom work-plan get-plan wlp_XXXX
|
|
3573
5503
|
$ sechroom work-plan execute wlp_XXXX
|
|
3574
5504
|
$ sechroom work-plan publish-context-pack wlp_XXXX
|
|
5505
|
+
$ sechroom work-plan resume wlp_XXXX
|
|
5506
|
+
$ sechroom work-plan return-for-revision wlp_XXXX --notes "Split the migration task"
|
|
5507
|
+
$ sechroom work-plan list --status Accepted --brief mem_XXXX
|
|
3575
5508
|
$ sechroom work-plan accept wlp_XXXX
|
|
3576
5509
|
$ sechroom work-plan reject wlp_XXXX --reason "wrong shape"`
|
|
3577
5510
|
);
|
|
@@ -3598,19 +5531,16 @@ Examples:
|
|
|
3598
5531
|
"--file <path>",
|
|
3599
5532
|
"JSON file containing { project, tasks, source? }; use - for stdin"
|
|
3600
5533
|
).action(async (briefId, opts, cmd) => {
|
|
3601
|
-
const raw = opts.file === "-" ? await
|
|
5534
|
+
const raw = opts.file === "-" ? await readStdin3() : await readFile2(opts.file, "utf8");
|
|
3602
5535
|
const body = parsePlanInput(raw, opts.file);
|
|
3603
5536
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3604
|
-
const data = await runApi(
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
});
|
|
3612
|
-
}
|
|
3613
|
-
);
|
|
5537
|
+
const data = await runApi("Creating work plan from tasks", async () => {
|
|
5538
|
+
const client = await makeClient(cfg);
|
|
5539
|
+
return client.POST("/work-briefs/{id}/decompose-from-plan", {
|
|
5540
|
+
params: { path: { id: briefId } },
|
|
5541
|
+
body
|
|
5542
|
+
});
|
|
5543
|
+
});
|
|
3614
5544
|
emitAction(
|
|
3615
5545
|
`created ${style.bold(data.suggestionId)} from ${data.taskCount} hand-authored task(s)`,
|
|
3616
5546
|
data,
|
|
@@ -3623,7 +5553,7 @@ Examples:
|
|
|
3623
5553
|
"--file <path>",
|
|
3624
5554
|
"JSON file containing { tasks, gates? } (the AppendTasksInput shape); use - for stdin"
|
|
3625
5555
|
).action(async (decompositionId, opts, cmd) => {
|
|
3626
|
-
const raw = opts.file === "-" ? await
|
|
5556
|
+
const raw = opts.file === "-" ? await readStdin3() : await readFile2(opts.file, "utf8");
|
|
3627
5557
|
const body = parseAppendInput(raw, opts.file);
|
|
3628
5558
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3629
5559
|
const data = await runApi("Appending tasks to work plan", async () => {
|
|
@@ -3689,6 +5619,70 @@ Examples:
|
|
|
3689
5619
|
cmd.optsWithGlobals().json
|
|
3690
5620
|
);
|
|
3691
5621
|
});
|
|
5622
|
+
workPlan.command("resume <decompositionId>").description(
|
|
5623
|
+
"Resume a failed work-plan decomposition (POST /decompositions/{id}/resume)"
|
|
5624
|
+
).action(async (decompositionId, _opts, cmd) => {
|
|
5625
|
+
const globals = cmd.optsWithGlobals();
|
|
5626
|
+
const cfg = resolveConfig(globals);
|
|
5627
|
+
const data = await runApi("Resuming work plan", async () => {
|
|
5628
|
+
const client = await makeClient(cfg);
|
|
5629
|
+
return client.POST("/decompositions/{id}/resume", {
|
|
5630
|
+
params: { path: { id: decompositionId } },
|
|
5631
|
+
body: {}
|
|
5632
|
+
});
|
|
5633
|
+
});
|
|
5634
|
+
emitAction(
|
|
5635
|
+
`resumed work plan ${style.bold(decompositionId)} \u2192 ${data.status}`,
|
|
5636
|
+
data,
|
|
5637
|
+
globals.json
|
|
5638
|
+
);
|
|
5639
|
+
});
|
|
5640
|
+
workPlan.command("return-for-revision <decompositionId>").description(
|
|
5641
|
+
"Return a candidate work plan for revision (POST /decompositions/{id}/return-for-revision)"
|
|
5642
|
+
).requiredOption(
|
|
5643
|
+
"--notes <text>",
|
|
5644
|
+
"Revision notes for the next decomposition attempt"
|
|
5645
|
+
).action(async (decompositionId, opts, cmd) => {
|
|
5646
|
+
const globals = cmd.optsWithGlobals();
|
|
5647
|
+
const cfg = resolveConfig(globals);
|
|
5648
|
+
const data = await runApi(
|
|
5649
|
+
"Returning work plan for revision",
|
|
5650
|
+
async () => {
|
|
5651
|
+
const client = await makeClient(cfg);
|
|
5652
|
+
return client.POST("/decompositions/{id}/return-for-revision", {
|
|
5653
|
+
params: { path: { id: decompositionId } },
|
|
5654
|
+
body: { notes: opts.notes }
|
|
5655
|
+
});
|
|
5656
|
+
}
|
|
5657
|
+
);
|
|
5658
|
+
emitAction(
|
|
5659
|
+
`returned ${style.bold(decompositionId)} \u2192 ${style.bold(data.newSuggestionId)}`,
|
|
5660
|
+
data,
|
|
5661
|
+
globals.json
|
|
5662
|
+
);
|
|
5663
|
+
});
|
|
5664
|
+
workPlan.command("list").description("List work plans, newest-first (GET /decompositions)").option("--status <status>", "Filter by decomposition status").option("--brief <briefId>", "Filter by work-brief memory id").option("--page <n>", "1-based page number", (v) => Number.parseInt(v, 10)).option("--page-size <n>", "Page size", (v) => Number.parseInt(v, 10)).action(async (opts, cmd) => {
|
|
5665
|
+
const globals = cmd.optsWithGlobals();
|
|
5666
|
+
const cfg = resolveConfig(globals);
|
|
5667
|
+
const data = await runApi("Listing work plans", async () => {
|
|
5668
|
+
const client = await makeClient(cfg);
|
|
5669
|
+
return client.GET("/decompositions", {
|
|
5670
|
+
params: {
|
|
5671
|
+
query: {
|
|
5672
|
+
status: opts.status,
|
|
5673
|
+
briefId: opts.brief,
|
|
5674
|
+
page: opts.page,
|
|
5675
|
+
pageSize: opts.pageSize
|
|
5676
|
+
}
|
|
5677
|
+
}
|
|
5678
|
+
});
|
|
5679
|
+
});
|
|
5680
|
+
emitAction(
|
|
5681
|
+
`listed ${style.bold(String(data.items.length))} of ${data.count} work plan(s)`,
|
|
5682
|
+
data,
|
|
5683
|
+
globals.json
|
|
5684
|
+
);
|
|
5685
|
+
});
|
|
3692
5686
|
workPlan.command("accept <decompositionId>").description(
|
|
3693
5687
|
"Accept a Pending work plan \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
|
|
3694
5688
|
).action(async (decompositionId, _opts, cmd) => {
|
|
@@ -3750,7 +5744,7 @@ function parsePlanInput(raw, sourceName = "plan input") {
|
|
|
3750
5744
|
throw new Error(`${sourceName} must contain an object with a tasks array`);
|
|
3751
5745
|
return value;
|
|
3752
5746
|
}
|
|
3753
|
-
async function
|
|
5747
|
+
async function readStdin3() {
|
|
3754
5748
|
const chunks = [];
|
|
3755
5749
|
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
3756
5750
|
return Buffer.concat(chunks).toString("utf8");
|
|
@@ -4267,8 +6261,8 @@ Examples:
|
|
|
4267
6261
|
|
|
4268
6262
|
// src/setup/apply.ts
|
|
4269
6263
|
import { createHash as createHash3 } from "crypto";
|
|
4270
|
-
import { mkdirSync as
|
|
4271
|
-
import { dirname as
|
|
6264
|
+
import { mkdirSync as mkdirSync12, readFileSync as readFileSync10, writeFileSync as writeFileSync11, existsSync as existsSync11 } from "fs";
|
|
6265
|
+
import { dirname as dirname11 } from "path";
|
|
4272
6266
|
var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
|
|
4273
6267
|
var MARKER_END = "<!-- @sechroom/cli:end";
|
|
4274
6268
|
function normalizeBody(s) {
|
|
@@ -4321,22 +6315,22 @@ function parseManagedBlock(content, block) {
|
|
|
4321
6315
|
return null;
|
|
4322
6316
|
}
|
|
4323
6317
|
function ensureDir2(path) {
|
|
4324
|
-
|
|
6318
|
+
mkdirSync12(dirname11(path), { recursive: true });
|
|
4325
6319
|
}
|
|
4326
6320
|
function readOr(path, fallback) {
|
|
4327
6321
|
try {
|
|
4328
|
-
return
|
|
6322
|
+
return readFileSync10(path, "utf8");
|
|
4329
6323
|
} catch {
|
|
4330
6324
|
return fallback;
|
|
4331
6325
|
}
|
|
4332
6326
|
}
|
|
4333
6327
|
function mergeMcpJson(path, snippet, dryRun) {
|
|
4334
6328
|
const incoming = JSON.parse(snippet);
|
|
4335
|
-
const existed =
|
|
6329
|
+
const existed = existsSync11(path);
|
|
4336
6330
|
let current = {};
|
|
4337
6331
|
if (existed) {
|
|
4338
6332
|
try {
|
|
4339
|
-
current = JSON.parse(
|
|
6333
|
+
current = JSON.parse(readFileSync10(path, "utf8"));
|
|
4340
6334
|
} catch {
|
|
4341
6335
|
return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
|
|
4342
6336
|
}
|
|
@@ -4344,26 +6338,26 @@ function mergeMcpJson(path, snippet, dryRun) {
|
|
|
4344
6338
|
current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
|
|
4345
6339
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
4346
6340
|
ensureDir2(path);
|
|
4347
|
-
|
|
6341
|
+
writeFileSync11(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
4348
6342
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
4349
6343
|
}
|
|
4350
6344
|
function mergeCodexToml(path, snippet, dryRun) {
|
|
4351
|
-
const existed =
|
|
6345
|
+
const existed = existsSync11(path);
|
|
4352
6346
|
let body = readOr(path, "");
|
|
4353
6347
|
body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
|
|
4354
6348
|
const trimmed = body.trim();
|
|
4355
6349
|
const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
|
|
4356
6350
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
4357
6351
|
ensureDir2(path);
|
|
4358
|
-
|
|
6352
|
+
writeFileSync11(path, next, { mode: 384 });
|
|
4359
6353
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
4360
6354
|
}
|
|
4361
6355
|
function writeInstructionBlock(path, write, dryRun) {
|
|
4362
|
-
const existed =
|
|
6356
|
+
const existed = existsSync11(path);
|
|
4363
6357
|
const next = computeBlockFile(readOr(path, ""), write);
|
|
4364
6358
|
if (dryRun) return { kind: "instruction", path, status: "dry-run" };
|
|
4365
6359
|
ensureDir2(path);
|
|
4366
|
-
|
|
6360
|
+
writeFileSync11(path, next);
|
|
4367
6361
|
return { kind: "instruction", path, status: existed ? "merged" : "created" };
|
|
4368
6362
|
}
|
|
4369
6363
|
function computeBlockFile(current, write) {
|
|
@@ -4404,7 +6398,7 @@ function applyBlock(path, write, mode, dryRun) {
|
|
|
4404
6398
|
const next = computeBlockFile(current, write);
|
|
4405
6399
|
if (!dryRun) {
|
|
4406
6400
|
ensureDir2(proposedPath);
|
|
4407
|
-
|
|
6401
|
+
writeFileSync11(proposedPath, next);
|
|
4408
6402
|
}
|
|
4409
6403
|
return {
|
|
4410
6404
|
kind: "instruction",
|
|
@@ -4534,8 +6528,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
|
|
|
4534
6528
|
}
|
|
4535
6529
|
|
|
4536
6530
|
// src/setup/skills-offer.ts
|
|
4537
|
-
import { mkdirSync as
|
|
4538
|
-
import { join as
|
|
6531
|
+
import { mkdirSync as mkdirSync13, writeFileSync as writeFileSync12 } from "fs";
|
|
6532
|
+
import { join as join14 } from "path";
|
|
4539
6533
|
|
|
4540
6534
|
// src/setup/lane-pin.ts
|
|
4541
6535
|
var CODE_LANE_PREFIX_BY_CLIENT = {
|
|
@@ -4651,8 +6645,8 @@ Found ${summary} available to you for ${surface}.
|
|
|
4651
6645
|
if (skills.length > 0) {
|
|
4652
6646
|
const written = [];
|
|
4653
6647
|
for (const s of skills) {
|
|
4654
|
-
|
|
4655
|
-
|
|
6648
|
+
mkdirSync13(join14(sDir, s.name), { recursive: true });
|
|
6649
|
+
writeFileSync12(join14(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
|
|
4656
6650
|
written.push(s.name);
|
|
4657
6651
|
}
|
|
4658
6652
|
recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
@@ -4660,11 +6654,11 @@ Found ${summary} available to you for ${surface}.
|
|
|
4660
6654
|
`);
|
|
4661
6655
|
}
|
|
4662
6656
|
if (agents.length > 0) {
|
|
4663
|
-
|
|
6657
|
+
mkdirSync13(aDir, { recursive: true });
|
|
4664
6658
|
const written = [];
|
|
4665
6659
|
for (const a of agents) {
|
|
4666
6660
|
const file = `${a.name}.md`;
|
|
4667
|
-
|
|
6661
|
+
writeFileSync12(join14(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
|
|
4668
6662
|
written.push(file);
|
|
4669
6663
|
}
|
|
4670
6664
|
recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
@@ -4999,12 +6993,12 @@ Examples:
|
|
|
4999
6993
|
});
|
|
5000
6994
|
emit(data, cmd.optsWithGlobals().json);
|
|
5001
6995
|
});
|
|
5002
|
-
namespace.command("show <slug>").description("Show a namespace's details (GET /mcp-aggregator/namespaces/{slug}). For the tool list it exposes, point an OpenAPI client at /t/{tenant}/namespaces/{slug}/api/openapi.json.").action(async (
|
|
6996
|
+
namespace.command("show <slug>").description("Show a namespace's details (GET /mcp-aggregator/namespaces/{slug}). For the tool list it exposes, point an OpenAPI client at /t/{tenant}/namespaces/{slug}/api/openapi.json.").action(async (slug2, _opts, cmd) => {
|
|
5003
6997
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
5004
6998
|
const data = await runApi("Fetching namespace", async () => {
|
|
5005
6999
|
const client = await makeClient(cfg);
|
|
5006
7000
|
return client.GET("/mcp-aggregator/namespaces/{slug}", {
|
|
5007
|
-
params: { path: { slug } }
|
|
7001
|
+
params: { path: { slug: slug2 } }
|
|
5008
7002
|
});
|
|
5009
7003
|
});
|
|
5010
7004
|
emit(data, cmd.optsWithGlobals().json);
|
|
@@ -5013,11 +7007,11 @@ Examples:
|
|
|
5013
7007
|
"--client <list>",
|
|
5014
7008
|
`comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`,
|
|
5015
7009
|
DEFAULT_CLIENT_KEY
|
|
5016
|
-
).option("--dry-run", "print what would be written without writing", false).action(async (
|
|
7010
|
+
).option("--dry-run", "print what would be written without writing", false).action(async (slug2, opts, cmd) => {
|
|
5017
7011
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
5018
7012
|
const setup = await withSpinner(
|
|
5019
7013
|
"Fetching setup descriptors",
|
|
5020
|
-
() => fetchSetup(cfg,
|
|
7014
|
+
() => fetchSetup(cfg, slug2)
|
|
5021
7015
|
);
|
|
5022
7016
|
const targets = clientTargets(process.cwd());
|
|
5023
7017
|
const keys = resolveClientKeys(opts.client);
|
|
@@ -5035,25 +7029,25 @@ Examples:
|
|
|
5035
7029
|
if (!json) printActions(target, actions);
|
|
5036
7030
|
}
|
|
5037
7031
|
if (json) {
|
|
5038
|
-
emit({ namespace:
|
|
7032
|
+
emit({ namespace: slug2, dryRun: Boolean(opts.dryRun), clients: result }, true);
|
|
5039
7033
|
return;
|
|
5040
7034
|
}
|
|
5041
7035
|
process.stdout.write(
|
|
5042
7036
|
opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : `
|
|
5043
|
-
Wired to namespace '${
|
|
7037
|
+
Wired to namespace '${slug2}'. Restart your AI client (or reload MCP) to pick it up.
|
|
5044
7038
|
`
|
|
5045
7039
|
);
|
|
5046
7040
|
});
|
|
5047
7041
|
}
|
|
5048
7042
|
|
|
5049
7043
|
// src/commands/onboard.ts
|
|
5050
|
-
import { existsSync as
|
|
5051
|
-
import { basename as basename2, join as
|
|
7044
|
+
import { existsSync as existsSync13 } from "fs";
|
|
7045
|
+
import { basename as basename2, join as join16 } from "path";
|
|
5052
7046
|
|
|
5053
7047
|
// src/commands/fanout.ts
|
|
5054
7048
|
import { spawnSync } from "child_process";
|
|
5055
|
-
import { existsSync as
|
|
5056
|
-
import { isAbsolute, join as
|
|
7049
|
+
import { existsSync as existsSync12, readFileSync as readFileSync11, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
|
|
7050
|
+
import { isAbsolute, join as join15, resolve as resolve3 } from "path";
|
|
5057
7051
|
var ICON = {
|
|
5058
7052
|
refresh: "\u21BB",
|
|
5059
7053
|
bind: "+",
|
|
@@ -5061,7 +7055,7 @@ var ICON = {
|
|
|
5061
7055
|
"skip-unbound": "\u26A0"
|
|
5062
7056
|
};
|
|
5063
7057
|
function resolveChildDir(path, root) {
|
|
5064
|
-
return isAbsolute(path) ? path :
|
|
7058
|
+
return isAbsolute(path) ? path : resolve3(root, path);
|
|
5065
7059
|
}
|
|
5066
7060
|
function discoverChildren(root) {
|
|
5067
7061
|
let names;
|
|
@@ -5073,21 +7067,21 @@ function discoverChildren(root) {
|
|
|
5073
7067
|
const out = [];
|
|
5074
7068
|
for (const name of names.sort()) {
|
|
5075
7069
|
if (name.startsWith(".") || name === "node_modules") continue;
|
|
5076
|
-
const dir =
|
|
7070
|
+
const dir = join15(root, name);
|
|
5077
7071
|
try {
|
|
5078
7072
|
if (!statSync3(dir).isDirectory()) continue;
|
|
5079
7073
|
} catch {
|
|
5080
7074
|
continue;
|
|
5081
7075
|
}
|
|
5082
|
-
if (
|
|
7076
|
+
if (existsSync12(join15(dir, ".git")) || committedBindingPath(dir)) out.push(name);
|
|
5083
7077
|
}
|
|
5084
7078
|
return out;
|
|
5085
7079
|
}
|
|
5086
7080
|
function readManifest(path) {
|
|
5087
|
-
if (!
|
|
7081
|
+
if (!existsSync12(path)) return null;
|
|
5088
7082
|
let parsed;
|
|
5089
7083
|
try {
|
|
5090
|
-
parsed = JSON.parse(
|
|
7084
|
+
parsed = JSON.parse(readFileSync11(path, "utf8"));
|
|
5091
7085
|
} catch (err2) {
|
|
5092
7086
|
throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
|
|
5093
7087
|
}
|
|
@@ -5453,10 +7447,10 @@ async function chooseScope(scopeFlag, yes) {
|
|
|
5453
7447
|
}
|
|
5454
7448
|
async function planRecurseChild(entry, root, client, opts) {
|
|
5455
7449
|
const dir = resolveChildDir(entry.path, root);
|
|
5456
|
-
if (!
|
|
7450
|
+
if (!existsSync13(dir)) {
|
|
5457
7451
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
5458
7452
|
}
|
|
5459
|
-
if (
|
|
7453
|
+
if (existsSync13(join16(dir, ".sechroom.json"))) {
|
|
5460
7454
|
return {
|
|
5461
7455
|
label: entry.path,
|
|
5462
7456
|
dir,
|
|
@@ -5529,7 +7523,7 @@ This fan-out will pin the same lane in every repo:
|
|
|
5529
7523
|
async function runRecurse(cfg, g, opts) {
|
|
5530
7524
|
const { yes, dryRun, json } = opts;
|
|
5531
7525
|
const root = process.cwd();
|
|
5532
|
-
const manifestPath =
|
|
7526
|
+
const manifestPath = join16(root, ".sechroom", "repos.json");
|
|
5533
7527
|
const fromManifest = readManifest(manifestPath);
|
|
5534
7528
|
const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
|
|
5535
7529
|
const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
|
|
@@ -6062,31 +8056,31 @@ Examples:
|
|
|
6062
8056
|
|
|
6063
8057
|
// src/commands/reset.ts
|
|
6064
8058
|
import { homedir as homedir4 } from "os";
|
|
6065
|
-
import { join as
|
|
6066
|
-
import { existsSync as
|
|
8059
|
+
import { join as join17 } from "path";
|
|
8060
|
+
import { existsSync as existsSync14, readFileSync as readFileSync12, rmSync as rmSync4 } from "fs";
|
|
6067
8061
|
var SKILLS_LOCK2 = ".sechroom-skills.json";
|
|
6068
|
-
var localSkillsDir = () =>
|
|
6069
|
-
var globalSkillsDir = () =>
|
|
6070
|
-
var localAgentsDir = () =>
|
|
6071
|
-
var globalAgentsDir = () =>
|
|
8062
|
+
var localSkillsDir = () => join17(process.cwd(), ".claude", "skills");
|
|
8063
|
+
var globalSkillsDir = () => join17(homedir4(), ".claude", "skills");
|
|
8064
|
+
var localAgentsDir = () => join17(process.cwd(), ".claude", "agents");
|
|
8065
|
+
var globalAgentsDir = () => join17(homedir4(), ".claude", "agents");
|
|
6072
8066
|
function removeMaterialisedSkills(dir) {
|
|
6073
8067
|
const removed = [];
|
|
6074
|
-
const lockPath =
|
|
6075
|
-
if (!
|
|
8068
|
+
const lockPath = join17(dir, SKILLS_LOCK2);
|
|
8069
|
+
if (!existsSync14(lockPath)) return removed;
|
|
6076
8070
|
try {
|
|
6077
|
-
const lock = JSON.parse(
|
|
8071
|
+
const lock = JSON.parse(readFileSync12(lockPath, "utf8"));
|
|
6078
8072
|
for (const entry of Object.values(lock)) {
|
|
6079
8073
|
for (const name of entry.skills ?? []) {
|
|
6080
|
-
const p =
|
|
6081
|
-
if (
|
|
6082
|
-
|
|
8074
|
+
const p = join17(dir, name);
|
|
8075
|
+
if (existsSync14(p)) {
|
|
8076
|
+
rmSync4(p, { recursive: true, force: true });
|
|
6083
8077
|
removed.push(p);
|
|
6084
8078
|
}
|
|
6085
8079
|
}
|
|
6086
8080
|
}
|
|
6087
8081
|
} catch {
|
|
6088
8082
|
}
|
|
6089
|
-
|
|
8083
|
+
rmSync4(lockPath, { force: true });
|
|
6090
8084
|
removed.push(lockPath);
|
|
6091
8085
|
return removed;
|
|
6092
8086
|
}
|
|
@@ -6123,19 +8117,19 @@ function registerReset(program2) {
|
|
|
6123
8117
|
}
|
|
6124
8118
|
}
|
|
6125
8119
|
const removed = [];
|
|
6126
|
-
const stateDir =
|
|
6127
|
-
if (
|
|
6128
|
-
|
|
8120
|
+
const stateDir = join17(process.cwd(), ".sechroom");
|
|
8121
|
+
if (existsSync14(stateDir)) {
|
|
8122
|
+
rmSync4(stateDir, { recursive: true, force: true });
|
|
6129
8123
|
removed.push(stateDir);
|
|
6130
8124
|
}
|
|
6131
|
-
const legacyCfg =
|
|
6132
|
-
if (
|
|
6133
|
-
|
|
8125
|
+
const legacyCfg = join17(process.cwd(), ".sechroom.json");
|
|
8126
|
+
if (existsSync14(legacyCfg)) {
|
|
8127
|
+
rmSync4(legacyCfg, { force: true });
|
|
6134
8128
|
removed.push(legacyCfg);
|
|
6135
8129
|
}
|
|
6136
|
-
const legacySem =
|
|
6137
|
-
if (
|
|
6138
|
-
|
|
8130
|
+
const legacySem = join17(process.cwd(), ".sem");
|
|
8131
|
+
if (existsSync14(legacySem)) {
|
|
8132
|
+
rmSync4(legacySem, { force: true });
|
|
6139
8133
|
removed.push(legacySem);
|
|
6140
8134
|
}
|
|
6141
8135
|
removed.push(...removeMaterialisedSkills(localSkillsDir()));
|
|
@@ -6160,8 +8154,8 @@ function registerReset(program2) {
|
|
|
6160
8154
|
}
|
|
6161
8155
|
|
|
6162
8156
|
// src/commands/skills.ts
|
|
6163
|
-
import { existsSync as
|
|
6164
|
-
import { join as
|
|
8157
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync14, statSync as statSync4, writeFileSync as writeFileSync13 } from "fs";
|
|
8158
|
+
import { join as join18 } from "path";
|
|
6165
8159
|
function filenameFromDisposition(header) {
|
|
6166
8160
|
if (!header) return void 0;
|
|
6167
8161
|
const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header);
|
|
@@ -6169,11 +8163,11 @@ function filenameFromDisposition(header) {
|
|
|
6169
8163
|
}
|
|
6170
8164
|
function resolveOutputPath(output, serverFilename) {
|
|
6171
8165
|
const filename = serverFilename || "skills.zip";
|
|
6172
|
-
if (!output) return
|
|
6173
|
-
const looksLikeDir = output.endsWith("/") ||
|
|
8166
|
+
if (!output) return join18(process.cwd(), filename);
|
|
8167
|
+
const looksLikeDir = output.endsWith("/") || existsSync15(output) && statSync4(output).isDirectory();
|
|
6174
8168
|
if (looksLikeDir) {
|
|
6175
|
-
|
|
6176
|
-
return
|
|
8169
|
+
mkdirSync14(output, { recursive: true });
|
|
8170
|
+
return join18(output, filename);
|
|
6177
8171
|
}
|
|
6178
8172
|
return output;
|
|
6179
8173
|
}
|
|
@@ -6204,7 +8198,7 @@ async function downloadZip(label, call, output) {
|
|
|
6204
8198
|
const buf = Buffer.from(res.data);
|
|
6205
8199
|
const filename = filenameFromDisposition(res.response.headers.get("content-disposition")) ?? "skills.zip";
|
|
6206
8200
|
const path = resolveOutputPath(output, filename);
|
|
6207
|
-
|
|
8201
|
+
writeFileSync13(path, buf);
|
|
6208
8202
|
return { path, bytes: buf.length, filename };
|
|
6209
8203
|
}
|
|
6210
8204
|
function registerSkills(program2) {
|
|
@@ -6378,12 +8372,12 @@ clients select all; an unconfigured machine preserves the legacy Claude default.
|
|
|
6378
8372
|
}
|
|
6379
8373
|
|
|
6380
8374
|
// src/commands/sweep.ts
|
|
6381
|
-
import { existsSync as
|
|
6382
|
-
import { dirname as
|
|
6383
|
-
var DEFAULT_MANIFEST =
|
|
8375
|
+
import { existsSync as existsSync16 } from "fs";
|
|
8376
|
+
import { dirname as dirname12, join as join19, resolve as resolve4 } from "path";
|
|
8377
|
+
var DEFAULT_MANIFEST = join19(".sechroom", "repos.json");
|
|
6384
8378
|
function planEntry(entry, root) {
|
|
6385
8379
|
const dir = resolveChildDir(entry.path, root);
|
|
6386
|
-
if (!
|
|
8380
|
+
if (!existsSync16(dir)) {
|
|
6387
8381
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
6388
8382
|
}
|
|
6389
8383
|
if (committedBindingPath(dir)) {
|
|
@@ -6436,400 +8430,44 @@ Per repo (paths resolve relative to the manifest's root):
|
|
|
6436
8430
|
${ICON["skip-unbound"]} no workspace unbound + no workspaceId in manifest \u2192 skipped (add one, or onboard manually)
|
|
6437
8431
|
|
|
6438
8432
|
Examples:
|
|
6439
|
-
$ sechroom sweep --dry-run preview every repo's disposition, run nothing
|
|
6440
|
-
$ sechroom sweep onboard the whole tree from the root
|
|
6441
|
-
$ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
|
|
6442
|
-
).action((opts, cmd) => {
|
|
6443
|
-
const g = cmd.optsWithGlobals();
|
|
6444
|
-
const json = Boolean(g.json);
|
|
6445
|
-
const dryRun = Boolean(opts.dryRun);
|
|
6446
|
-
const manifestPath = resolve2(opts.manifest);
|
|
6447
|
-
let repos;
|
|
6448
|
-
try {
|
|
6449
|
-
repos = readManifest(manifestPath);
|
|
6450
|
-
} catch (err2) {
|
|
6451
|
-
fail(err2 instanceof Error ? err2.message : String(err2));
|
|
6452
|
-
}
|
|
6453
|
-
if (repos === null) {
|
|
6454
|
-
fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
|
|
6455
|
-
}
|
|
6456
|
-
if (repos.length === 0) {
|
|
6457
|
-
if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
|
|
6458
|
-
else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
|
|
6459
|
-
`);
|
|
6460
|
-
return;
|
|
6461
|
-
}
|
|
6462
|
-
const root = dirname10(dirname10(manifestPath));
|
|
6463
|
-
const plans = repos.map((entry) => planEntry(entry, root));
|
|
6464
|
-
if (!json) {
|
|
6465
|
-
process.stderr.write(
|
|
6466
|
-
`${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
|
|
6467
|
-
`
|
|
6468
|
-
);
|
|
6469
|
-
}
|
|
6470
|
-
const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
|
|
6471
|
-
if (json) {
|
|
6472
|
-
process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
|
|
6473
|
-
return;
|
|
6474
|
-
}
|
|
6475
|
-
summarizeFanout(results, { dryRun });
|
|
6476
|
-
});
|
|
6477
|
-
}
|
|
6478
|
-
|
|
6479
|
-
// src/commands/telemetry.ts
|
|
6480
|
-
import {
|
|
6481
|
-
existsSync as existsSync16,
|
|
6482
|
-
mkdirSync as mkdirSync13,
|
|
6483
|
-
readFileSync as readFileSync12,
|
|
6484
|
-
rmSync as rmSync4,
|
|
6485
|
-
writeFileSync as writeFileSync13
|
|
6486
|
-
} from "fs";
|
|
6487
|
-
import { dirname as dirname11, join as join18 } from "path";
|
|
6488
|
-
function registerTelemetry(program2) {
|
|
6489
|
-
const telemetry = program2.command("telemetry").description(
|
|
6490
|
-
"Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
|
|
6491
|
-
);
|
|
6492
|
-
telemetry.command("emit").description(
|
|
6493
|
-
"POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
|
|
6494
|
-
).requiredOption(
|
|
6495
|
-
"--decomposition <id>",
|
|
6496
|
-
"Decomposition id whose run this event belongs to"
|
|
6497
|
-
).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
|
|
6498
|
-
"--kind <kind>",
|
|
6499
|
-
"Event kind: raw | parsed | approval | terminal"
|
|
6500
|
-
).option(
|
|
6501
|
-
"--tokens-in <n>",
|
|
6502
|
-
"Cumulative input tokens (spend meter)",
|
|
6503
|
-
parseIntOpt
|
|
6504
|
-
).option(
|
|
6505
|
-
"--tokens-out <n>",
|
|
6506
|
-
"Cumulative output tokens (spend meter)",
|
|
6507
|
-
parseIntOpt
|
|
6508
|
-
).option(
|
|
6509
|
-
"--context-used <n>",
|
|
6510
|
-
"Context tokens currently used (occupancy meter)",
|
|
6511
|
-
parseIntOpt
|
|
6512
|
-
).option(
|
|
6513
|
-
"--context-window <n>",
|
|
6514
|
-
"Context window size (occupancy meter)",
|
|
6515
|
-
parseIntOpt
|
|
6516
|
-
).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
|
|
6517
|
-
"--verdict <v>",
|
|
6518
|
-
"Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
|
|
6519
|
-
).action(async (opts, cmd) => {
|
|
6520
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
6521
|
-
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
6522
|
-
const event = {
|
|
6523
|
-
taskId: opts.task,
|
|
6524
|
-
kind: normalizeKind(opts.kind),
|
|
6525
|
-
tokensIn: opts.tokensIn ?? null,
|
|
6526
|
-
tokensOut: opts.tokensOut ?? null,
|
|
6527
|
-
contextUsed: opts.contextUsed ?? null,
|
|
6528
|
-
contextWindow: opts.contextWindow ?? null,
|
|
6529
|
-
text: opts.text ?? null,
|
|
6530
|
-
approvalState: opts.approval ?? null,
|
|
6531
|
-
verdict: opts.verdict ?? null
|
|
6532
|
-
};
|
|
6533
|
-
let body;
|
|
6534
|
-
try {
|
|
6535
|
-
body = await postTelemetry(cfg, opts.decomposition, [event]);
|
|
6536
|
-
} catch (e) {
|
|
6537
|
-
return fail(`Telemetry emit failed: ${e.message}`);
|
|
6538
|
-
}
|
|
6539
|
-
if (json) {
|
|
6540
|
-
emit(body, true);
|
|
6541
|
-
} else {
|
|
6542
|
-
process.stderr.write(
|
|
6543
|
-
style.green("telemetry emitted") + style.dim(
|
|
6544
|
-
` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
|
|
6545
|
-
`
|
|
6546
|
-
)
|
|
6547
|
-
);
|
|
6548
|
-
}
|
|
6549
|
-
});
|
|
6550
|
-
telemetry.command("show <decompositionId>").description(
|
|
6551
|
-
"Read a run's telemetry \u2014 per-task meters + the raw/parsed/approval/terminal timeline (GET /decompositions/{id}/run/telemetry). Returns hasTelemetry:false, not an error, before any event is ingested \u2014 so an unstarted run and a stalled one read differently. The read side of this group; `emit` is the source side. (FR-sechroom-442 slice 1 step 4; mirrors the work_plan_run_telemetry MCP tool.)"
|
|
6552
|
-
).action(async (decompositionId, _opts, cmd) => {
|
|
6553
|
-
const globals = cmd.optsWithGlobals();
|
|
6554
|
-
const cfg = resolveConfig(globals);
|
|
6555
|
-
const data = await runApi("Reading run telemetry", async () => {
|
|
6556
|
-
const client = await makeClient(cfg);
|
|
6557
|
-
return client.GET("/decompositions/{id}/run/telemetry", {
|
|
6558
|
-
params: { path: { id: decompositionId } }
|
|
6559
|
-
});
|
|
6560
|
-
});
|
|
6561
|
-
emitAction(
|
|
6562
|
-
data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
|
|
6563
|
-
data,
|
|
6564
|
-
globals.json
|
|
6565
|
-
);
|
|
6566
|
-
});
|
|
6567
|
-
telemetry.command("bind").description(
|
|
6568
|
-
"Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
|
|
6569
|
-
).requiredOption(
|
|
6570
|
-
"--decomposition <id>",
|
|
6571
|
-
"Decomposition id this session executes"
|
|
6572
|
-
).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
|
|
6573
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
6574
|
-
const dir = join18(process.cwd(), ".sechroom");
|
|
6575
|
-
mkdirSync13(dir, { recursive: true });
|
|
6576
|
-
const path = join18(dir, BINDING_FILE);
|
|
6577
|
-
const binding = {
|
|
6578
|
-
decompositionId: opts.decomposition,
|
|
6579
|
-
taskId: opts.task
|
|
6580
|
-
};
|
|
6581
|
-
writeFileSync13(path, JSON.stringify(binding, null, 2) + "\n");
|
|
6582
|
-
ensureStateDirIgnored(process.cwd());
|
|
6583
|
-
if (json) {
|
|
6584
|
-
emit({ bound: true, ...binding, path }, true);
|
|
6585
|
-
} else {
|
|
6586
|
-
process.stdout.write(
|
|
6587
|
-
style.green("telemetry bound") + style.dim(
|
|
6588
|
-
` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
|
|
6589
|
-
`
|
|
6590
|
-
)
|
|
6591
|
-
);
|
|
6592
|
-
}
|
|
6593
|
-
});
|
|
6594
|
-
telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
|
|
6595
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
6596
|
-
const path = join18(process.cwd(), ".sechroom", BINDING_FILE);
|
|
6597
|
-
const existed = existsSync16(path);
|
|
6598
|
-
if (existed) rmSync4(path);
|
|
6599
|
-
if (json) emit({ unbound: existed, path }, true);
|
|
6600
|
-
else
|
|
6601
|
-
process.stdout.write(
|
|
6602
|
-
existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
|
|
6603
|
-
);
|
|
6604
|
-
});
|
|
6605
|
-
telemetry.command("hook").description(
|
|
6606
|
-
"Per-turn telemetry self-report for Claude Code hooks \u2014 Stop/SubagentStop \u2192 parsed + terminal, Notification/PermissionDenied \u2192 approval (reads stdin; no-op unless bound). Fail-soft."
|
|
6607
|
-
).action(async (_opts, cmd) => {
|
|
6608
|
-
try {
|
|
6609
|
-
const raw = await readStdin3();
|
|
6610
|
-
const input = parseHookInput2(raw);
|
|
6611
|
-
const cwd = input.cwd ?? process.cwd();
|
|
6612
|
-
const binding = findBinding(cwd);
|
|
6613
|
-
if (!binding) return process.exit(0);
|
|
6614
|
-
const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
|
|
6615
|
-
const events = buildHookEvents(input, usage, binding.taskId);
|
|
6616
|
-
if (events.length === 0) return process.exit(0);
|
|
6617
|
-
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
6618
|
-
await postTelemetry(cfg, binding.decompositionId, events);
|
|
6619
|
-
return process.exit(0);
|
|
6620
|
-
} catch {
|
|
6621
|
-
return process.exit(0);
|
|
6622
|
-
}
|
|
6623
|
-
});
|
|
6624
|
-
telemetry.command("install").description(
|
|
6625
|
-
"Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
|
|
6626
|
-
).option(
|
|
6627
|
-
"--scope <scope>",
|
|
6628
|
-
"global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
|
|
6629
|
-
).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
|
|
8433
|
+
$ sechroom sweep --dry-run preview every repo's disposition, run nothing
|
|
8434
|
+
$ sechroom sweep onboard the whole tree from the root
|
|
8435
|
+
$ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
|
|
8436
|
+
).action((opts, cmd) => {
|
|
6630
8437
|
const g = cmd.optsWithGlobals();
|
|
8438
|
+
const json = Boolean(g.json);
|
|
6631
8439
|
const dryRun = Boolean(opts.dryRun);
|
|
6632
|
-
const
|
|
6633
|
-
let
|
|
8440
|
+
const manifestPath = resolve4(opts.manifest);
|
|
8441
|
+
let repos;
|
|
6634
8442
|
try {
|
|
6635
|
-
|
|
8443
|
+
repos = readManifest(manifestPath);
|
|
6636
8444
|
} catch (err2) {
|
|
6637
|
-
|
|
8445
|
+
fail(err2 instanceof Error ? err2.message : String(err2));
|
|
8446
|
+
}
|
|
8447
|
+
if (repos === null) {
|
|
8448
|
+
fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
|
|
8449
|
+
}
|
|
8450
|
+
if (repos.length === 0) {
|
|
8451
|
+
if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
|
|
8452
|
+
else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
|
|
6638
8453
|
`);
|
|
6639
|
-
return
|
|
8454
|
+
return;
|
|
6640
8455
|
}
|
|
6641
|
-
const
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
cwd
|
|
6645
|
-
});
|
|
6646
|
-
const commands = {
|
|
6647
|
-
Stop: "sechroom telemetry hook",
|
|
6648
|
-
SubagentStop: "sechroom telemetry hook",
|
|
6649
|
-
Notification: "sechroom telemetry hook",
|
|
6650
|
-
PermissionDenied: "sechroom telemetry hook"
|
|
6651
|
-
};
|
|
6652
|
-
try {
|
|
6653
|
-
const multi = targets.length > 1;
|
|
6654
|
-
const results = targets.map((t) => {
|
|
6655
|
-
const r = installClaudeCommands(t.dir, commands, dryRun);
|
|
6656
|
-
process.stdout.write(
|
|
6657
|
-
`${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
|
|
6658
|
-
`
|
|
6659
|
-
);
|
|
6660
|
-
process.stdout.write(describe(r, dryRun) + "\n");
|
|
6661
|
-
return r;
|
|
6662
|
-
});
|
|
6663
|
-
if (dryRun) {
|
|
6664
|
-
process.stdout.write("\n(dry run \u2014 no files were written.)\n");
|
|
6665
|
-
} else if (results.every((r) => r.status === "current")) {
|
|
6666
|
-
process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
|
|
6667
|
-
} else {
|
|
6668
|
-
process.stdout.write(
|
|
6669
|
-
"\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
|
|
6670
|
-
);
|
|
6671
|
-
}
|
|
6672
|
-
} catch (err2) {
|
|
8456
|
+
const root = dirname12(dirname12(manifestPath));
|
|
8457
|
+
const plans = repos.map((entry) => planEntry(entry, root));
|
|
8458
|
+
if (!json) {
|
|
6673
8459
|
process.stderr.write(
|
|
6674
|
-
`
|
|
8460
|
+
`${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
|
|
6675
8461
|
`
|
|
6676
8462
|
);
|
|
6677
|
-
return process.exit(1);
|
|
6678
|
-
}
|
|
6679
|
-
warnIfSechroomNotOnPath();
|
|
6680
|
-
return process.exit(0);
|
|
6681
|
-
});
|
|
6682
|
-
}
|
|
6683
|
-
var BINDING_FILE = "telemetry.json";
|
|
6684
|
-
async function postTelemetry(cfg, decompositionId, events) {
|
|
6685
|
-
const token = await requireToken(cfg);
|
|
6686
|
-
const resp = await fetch(
|
|
6687
|
-
`${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
|
|
6688
|
-
{
|
|
6689
|
-
method: "POST",
|
|
6690
|
-
headers: {
|
|
6691
|
-
authorization: `Bearer ${token}`,
|
|
6692
|
-
tenant: cfg.tenant,
|
|
6693
|
-
"content-type": "application/json",
|
|
6694
|
-
"x-sechroom-surface": "cli"
|
|
6695
|
-
},
|
|
6696
|
-
body: JSON.stringify({ events })
|
|
6697
|
-
}
|
|
6698
|
-
);
|
|
6699
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
|
6700
|
-
return await resp.json();
|
|
6701
|
-
}
|
|
6702
|
-
function findBinding(start) {
|
|
6703
|
-
let dir = start;
|
|
6704
|
-
for (; ; ) {
|
|
6705
|
-
const path = join18(dir, ".sechroom", BINDING_FILE);
|
|
6706
|
-
if (existsSync16(path)) {
|
|
6707
|
-
try {
|
|
6708
|
-
const b = JSON.parse(
|
|
6709
|
-
readFileSync12(path, "utf8")
|
|
6710
|
-
);
|
|
6711
|
-
if (b.decompositionId && b.taskId)
|
|
6712
|
-
return { decompositionId: b.decompositionId, taskId: b.taskId };
|
|
6713
|
-
} catch {
|
|
6714
|
-
}
|
|
6715
|
-
return null;
|
|
6716
8463
|
}
|
|
6717
|
-
const
|
|
6718
|
-
if (
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
}
|
|
6722
|
-
function parseTranscript(path) {
|
|
6723
|
-
if (!existsSync16(path)) return null;
|
|
6724
|
-
let tokensIn = 0;
|
|
6725
|
-
let tokensOut = 0;
|
|
6726
|
-
let contextUsed = 0;
|
|
6727
|
-
let model = "";
|
|
6728
|
-
for (const line of readFileSync12(path, "utf8").split("\n")) {
|
|
6729
|
-
if (!line.trim()) continue;
|
|
6730
|
-
let obj;
|
|
6731
|
-
try {
|
|
6732
|
-
obj = JSON.parse(line);
|
|
6733
|
-
} catch {
|
|
6734
|
-
continue;
|
|
8464
|
+
const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
|
|
8465
|
+
if (json) {
|
|
8466
|
+
process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
|
|
8467
|
+
return;
|
|
6735
8468
|
}
|
|
6736
|
-
|
|
6737
|
-
if (!usage) continue;
|
|
6738
|
-
const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
6739
|
-
tokensIn += input;
|
|
6740
|
-
tokensOut += usage.output_tokens ?? 0;
|
|
6741
|
-
contextUsed = input;
|
|
6742
|
-
if (obj.message?.model) model = obj.message.model;
|
|
6743
|
-
}
|
|
6744
|
-
if (tokensIn === 0 && tokensOut === 0) return null;
|
|
6745
|
-
return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed) };
|
|
6746
|
-
}
|
|
6747
|
-
function windowFor(model, contextUsed = 0) {
|
|
6748
|
-
const m = model.toLowerCase();
|
|
6749
|
-
if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
|
|
6750
|
-
return contextUsed > 2e5 ? 1e6 : 2e5;
|
|
6751
|
-
}
|
|
6752
|
-
function buildHookEvents(input, usage, taskId) {
|
|
6753
|
-
const events = [];
|
|
6754
|
-
const base = (kind, over) => ({
|
|
6755
|
-
taskId,
|
|
6756
|
-
kind,
|
|
6757
|
-
tokensIn: null,
|
|
6758
|
-
tokensOut: null,
|
|
6759
|
-
contextUsed: null,
|
|
6760
|
-
contextWindow: null,
|
|
6761
|
-
text: null,
|
|
6762
|
-
approvalState: null,
|
|
6763
|
-
verdict: null,
|
|
6764
|
-
...over
|
|
8469
|
+
summarizeFanout(results, { dryRun });
|
|
6765
8470
|
});
|
|
6766
|
-
if (usage) {
|
|
6767
|
-
events.push(
|
|
6768
|
-
base("Parsed", {
|
|
6769
|
-
tokensIn: usage.tokensIn,
|
|
6770
|
-
tokensOut: usage.tokensOut,
|
|
6771
|
-
contextUsed: usage.contextUsed,
|
|
6772
|
-
contextWindow: usage.contextWindow
|
|
6773
|
-
})
|
|
6774
|
-
);
|
|
6775
|
-
}
|
|
6776
|
-
switch (input.hook_event_name) {
|
|
6777
|
-
case "PermissionDenied":
|
|
6778
|
-
events.push(
|
|
6779
|
-
base("Approval", {
|
|
6780
|
-
approvalState: "denied",
|
|
6781
|
-
text: input.tool_name ?? input.message ?? null
|
|
6782
|
-
})
|
|
6783
|
-
);
|
|
6784
|
-
break;
|
|
6785
|
-
case "Notification":
|
|
6786
|
-
if (isPermissionNotification(input))
|
|
6787
|
-
events.push(base("Approval", { text: input.message ?? null }));
|
|
6788
|
-
break;
|
|
6789
|
-
case "Stop":
|
|
6790
|
-
case "SubagentStop":
|
|
6791
|
-
events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
|
|
6792
|
-
break;
|
|
6793
|
-
}
|
|
6794
|
-
return events;
|
|
6795
|
-
}
|
|
6796
|
-
function isPermissionNotification(input) {
|
|
6797
|
-
const t = (input.notification_type ?? input.type ?? "").toLowerCase();
|
|
6798
|
-
if (t) return t.includes("permission");
|
|
6799
|
-
return (input.message ?? "").toLowerCase().includes("permission");
|
|
6800
|
-
}
|
|
6801
|
-
async function readStdin3() {
|
|
6802
|
-
if (process.stdin.isTTY) return "";
|
|
6803
|
-
const chunks = [];
|
|
6804
|
-
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
6805
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
6806
|
-
}
|
|
6807
|
-
function parseHookInput2(raw) {
|
|
6808
|
-
if (!raw.trim()) return {};
|
|
6809
|
-
try {
|
|
6810
|
-
return JSON.parse(raw);
|
|
6811
|
-
} catch {
|
|
6812
|
-
return {};
|
|
6813
|
-
}
|
|
6814
|
-
}
|
|
6815
|
-
var KINDS = {
|
|
6816
|
-
raw: "Raw",
|
|
6817
|
-
parsed: "Parsed",
|
|
6818
|
-
approval: "Approval",
|
|
6819
|
-
terminal: "Terminal"
|
|
6820
|
-
};
|
|
6821
|
-
function normalizeKind(k) {
|
|
6822
|
-
const v = KINDS[k.toLowerCase()];
|
|
6823
|
-
if (!v)
|
|
6824
|
-
fail(
|
|
6825
|
-
`Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
|
|
6826
|
-
);
|
|
6827
|
-
return v;
|
|
6828
|
-
}
|
|
6829
|
-
function parseIntOpt(v) {
|
|
6830
|
-
const n = Number.parseInt(v, 10);
|
|
6831
|
-
if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
|
|
6832
|
-
return n;
|
|
6833
8471
|
}
|
|
6834
8472
|
|
|
6835
8473
|
// src/commands/worklog.ts
|
|
@@ -7037,11 +8675,43 @@ function registerWorkBrief(program2) {
|
|
|
7037
8675
|
Examples:
|
|
7038
8676
|
$ sechroom work-brief pause mem_XXXX --reason-code operator-hold --source claude-code-chris
|
|
7039
8677
|
$ sechroom work-brief resume mem_XXXX --reason-code operator-resume --source claude-code-chris --reason "Ready to continue"
|
|
7040
|
-
$ sechroom work-brief cancel mem_XXXX --reason-code operator-stopped --source claude-code-chris --reason "Work no longer required"
|
|
8678
|
+
$ sechroom work-brief cancel mem_XXXX --reason-code operator-stopped --source claude-code-chris --reason "Work no longer required"
|
|
8679
|
+
$ sechroom work-brief park mem_XXXX --source claude-code-chris
|
|
8680
|
+
$ sechroom work-brief unpark mem_XXXX --source claude-code-chris
|
|
8681
|
+
$ sechroom work-brief supersede mem_XXXX --source claude-code-chris`
|
|
7041
8682
|
);
|
|
7042
8683
|
registerLifecycleAction(workBrief, "pause");
|
|
7043
8684
|
registerLifecycleAction(workBrief, "resume");
|
|
7044
8685
|
registerLifecycleAction(workBrief, "cancel");
|
|
8686
|
+
registerStatusAction(workBrief, "park", "status:parked");
|
|
8687
|
+
registerStatusAction(workBrief, "unpark", "status:ready_for_decomposition");
|
|
8688
|
+
registerStatusAction(workBrief, "supersede", "status:superseded");
|
|
8689
|
+
}
|
|
8690
|
+
function registerStatusAction(workBrief, action, to) {
|
|
8691
|
+
workBrief.command(`${action} <briefId>`).description(
|
|
8692
|
+
`${capitalize(action)} a work brief via its governed status transition`
|
|
8693
|
+
).requiredOption(
|
|
8694
|
+
"--source <source>",
|
|
8695
|
+
"Calling surface or lane recorded on the contribution"
|
|
8696
|
+
).action(async (briefId, opts, cmd) => {
|
|
8697
|
+
const globals = cmd.optsWithGlobals();
|
|
8698
|
+
const cfg = resolveConfig(globals);
|
|
8699
|
+
const data = await runApi(
|
|
8700
|
+
`${capitalize(action)}ing work brief`,
|
|
8701
|
+
async () => {
|
|
8702
|
+
const client = await makeClient(cfg);
|
|
8703
|
+
return client.POST("/work-briefs/{id}/status", {
|
|
8704
|
+
params: { path: { id: briefId } },
|
|
8705
|
+
body: { id: briefId, to, source: opts.source }
|
|
8706
|
+
});
|
|
8707
|
+
}
|
|
8708
|
+
);
|
|
8709
|
+
emitAction(
|
|
8710
|
+
`${action} work brief ${style.bold(briefId)} \u2192 ${data.to}`,
|
|
8711
|
+
data,
|
|
8712
|
+
globals.json
|
|
8713
|
+
);
|
|
8714
|
+
});
|
|
7045
8715
|
}
|
|
7046
8716
|
function registerLifecycleAction(workBrief, action) {
|
|
7047
8717
|
const presentParticiple = action === "pause" ? "Pausing" : action === "resume" ? "Resuming" : "Cancelling";
|
|
@@ -7079,17 +8749,24 @@ function capitalize(value) {
|
|
|
7079
8749
|
}
|
|
7080
8750
|
|
|
7081
8751
|
// src/commands/work-task.ts
|
|
8752
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
7082
8753
|
function registerWorkTask(program2) {
|
|
7083
|
-
const workTask = program2.command("work-task").description(
|
|
8754
|
+
const workTask = program2.command("work-task").description(
|
|
8755
|
+
"Read work tasks and close them out (list \xB7 card \xB7 mark-no-residue)"
|
|
8756
|
+
);
|
|
7084
8757
|
workTask.addHelpText(
|
|
7085
8758
|
"after",
|
|
7086
8759
|
`
|
|
7087
8760
|
Examples:
|
|
7088
8761
|
$ sechroom work-task list --status in-progress --lane claude-code-chris
|
|
7089
8762
|
$ sechroom work-task card mem_XXXX
|
|
7090
|
-
$ sechroom work-task mark-no-residue mem_XXXX --decomposition wlp_XXXX
|
|
8763
|
+
$ sechroom work-task mark-no-residue mem_XXXX --decomposition wlp_XXXX
|
|
8764
|
+
$ sechroom work-task residue-produce mem_XXXX --file residue.json`
|
|
7091
8765
|
);
|
|
7092
|
-
workTask.command("list").description("List work tasks, newest-first (GET /work-tasks)").option("--shape <shape>", "Filter: bare | managed").option(
|
|
8766
|
+
workTask.command("list").description("List work tasks, newest-first (GET /work-tasks)").option("--shape <shape>", "Filter: bare | managed").option(
|
|
8767
|
+
"--lane <lane>",
|
|
8768
|
+
"Filter by dispatch-lane value, e.g. claude-code-chris"
|
|
8769
|
+
).option("--status <status>", "Filter by status value, e.g. in-progress").option("--page <n>", "1-based page number", (v) => Number.parseInt(v, 10)).option(
|
|
7093
8770
|
"--page-size <n>",
|
|
7094
8771
|
"Page size (default 50, capped 200)",
|
|
7095
8772
|
(v) => Number.parseInt(v, 10)
|
|
@@ -7116,7 +8793,9 @@ Examples:
|
|
|
7116
8793
|
globals.json
|
|
7117
8794
|
);
|
|
7118
8795
|
});
|
|
7119
|
-
workTask.command("card <taskId>").description(
|
|
8796
|
+
workTask.command("card <taskId>").description(
|
|
8797
|
+
"Read one task's runnable card \u2014 its full working instructions (GET /tasks/{id}/card)"
|
|
8798
|
+
).action(async (taskId, _opts, cmd) => {
|
|
7120
8799
|
const globals = cmd.optsWithGlobals();
|
|
7121
8800
|
const cfg = resolveConfig(globals);
|
|
7122
8801
|
const data = await runApi("Reading task card", async () => {
|
|
@@ -7127,26 +8806,67 @@ Examples:
|
|
|
7127
8806
|
});
|
|
7128
8807
|
emitAction(`read card ${style.bold(taskId)}`, data, globals.json);
|
|
7129
8808
|
});
|
|
7130
|
-
workTask.command("
|
|
7131
|
-
"
|
|
8809
|
+
workTask.command("residue-produce <taskId>").description(
|
|
8810
|
+
"Produce typed residue for a work task (POST /work-tasks/produce-residue)"
|
|
7132
8811
|
).requiredOption(
|
|
7133
|
-
"--
|
|
7134
|
-
"
|
|
8812
|
+
"--file <path>",
|
|
8813
|
+
"JSON file containing a residues array; use - for stdin"
|
|
7135
8814
|
).action(async (taskId, opts, cmd) => {
|
|
8815
|
+
const raw = opts.file === "-" ? await readStdin4() : await readFile3(opts.file, "utf8");
|
|
8816
|
+
const body = parseResidueInput(raw, opts.file, taskId);
|
|
7136
8817
|
const globals = cmd.optsWithGlobals();
|
|
7137
8818
|
const cfg = resolveConfig(globals);
|
|
7138
|
-
const data = await runApi("
|
|
8819
|
+
const data = await runApi("Producing task residue", async () => {
|
|
7139
8820
|
const client = await makeClient(cfg);
|
|
7140
|
-
return client.POST("/work-tasks/
|
|
7141
|
-
body: { taskId, decompositionId: opts.decomposition }
|
|
7142
|
-
});
|
|
8821
|
+
return client.POST("/work-tasks/produce-residue", { body });
|
|
7143
8822
|
});
|
|
7144
8823
|
emitAction(
|
|
7145
|
-
`
|
|
8824
|
+
`produced ${style.bold(String(data.produced.length))} residue item(s) for ${style.bold(taskId)}`,
|
|
7146
8825
|
data,
|
|
7147
8826
|
globals.json
|
|
7148
8827
|
);
|
|
7149
8828
|
});
|
|
8829
|
+
workTask.command("mark-no-residue <taskId>").description(
|
|
8830
|
+
"Mark a task as having produced no residue \u2014 the explicit no-residue marker that lets a lane finish a run it drove end-to-end (POST /work-tasks/mark-no-residue)"
|
|
8831
|
+
).requiredOption(
|
|
8832
|
+
"--decomposition <id>",
|
|
8833
|
+
"The work plan id (wlp_\u2026) the task belongs to \u2014 the marker is keyed (decompositionId, taskId)"
|
|
8834
|
+
).action(
|
|
8835
|
+
async (taskId, opts, cmd) => {
|
|
8836
|
+
const globals = cmd.optsWithGlobals();
|
|
8837
|
+
const cfg = resolveConfig(globals);
|
|
8838
|
+
const data = await runApi("Marking task no-residue", async () => {
|
|
8839
|
+
const client = await makeClient(cfg);
|
|
8840
|
+
return client.POST("/work-tasks/mark-no-residue", {
|
|
8841
|
+
body: { taskId, decompositionId: opts.decomposition }
|
|
8842
|
+
});
|
|
8843
|
+
});
|
|
8844
|
+
emitAction(
|
|
8845
|
+
`marked ${style.bold(taskId)} no-residue (minted: ${data.minted})`,
|
|
8846
|
+
data,
|
|
8847
|
+
globals.json
|
|
8848
|
+
);
|
|
8849
|
+
}
|
|
8850
|
+
);
|
|
8851
|
+
}
|
|
8852
|
+
function parseResidueInput(raw, sourceName, taskId) {
|
|
8853
|
+
let value;
|
|
8854
|
+
try {
|
|
8855
|
+
value = JSON.parse(raw);
|
|
8856
|
+
} catch (error) {
|
|
8857
|
+
throw new Error(
|
|
8858
|
+
`${sourceName} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
8859
|
+
);
|
|
8860
|
+
}
|
|
8861
|
+
const residues = Array.isArray(value) ? value : value && typeof value === "object" ? value.residues : void 0;
|
|
8862
|
+
if (!Array.isArray(residues) || residues.length === 0)
|
|
8863
|
+
throw new Error(`${sourceName} must contain a non-empty residues array`);
|
|
8864
|
+
return { taskId, residues };
|
|
8865
|
+
}
|
|
8866
|
+
async function readStdin4() {
|
|
8867
|
+
const chunks = [];
|
|
8868
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
8869
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
7150
8870
|
}
|
|
7151
8871
|
|
|
7152
8872
|
// src/index.ts
|