@sechroom/cli 2026.7.27 → 2026.7.29
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 +2231 -612
- 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,14 +1717,770 @@ function ensureSemIgnored(semPath) {
|
|
|
1693
1717
|
}
|
|
1694
1718
|
}
|
|
1695
1719
|
|
|
1720
|
+
// src/commands/executor-run.ts
|
|
1721
|
+
import { join as join9, resolve as resolve2 } from "path";
|
|
1722
|
+
|
|
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);
|
|
1758
|
+
}
|
|
1759
|
+
return null;
|
|
1760
|
+
}
|
|
1761
|
+
function toClaimed(result, tokenVersion, offer) {
|
|
1762
|
+
const lease = result.lease;
|
|
1763
|
+
const claimToken = result.claimToken;
|
|
1764
|
+
if (!lease?.id || !claimToken) return null;
|
|
1765
|
+
return {
|
|
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 ?? [])
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
function decompositionIdFrom(tags) {
|
|
1777
|
+
const tag = tags.find((value) => value.startsWith("wlp-decomposition:"));
|
|
1778
|
+
return tag?.slice("wlp-decomposition:".length) || void 0;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
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
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
function object(value) {
|
|
1814
|
+
return value && typeof value === "object" ? value : void 0;
|
|
1815
|
+
}
|
|
1816
|
+
function numberOf(obj, ...keys) {
|
|
1817
|
+
for (const key of keys) if (typeof obj[key] === "number") return obj[key];
|
|
1818
|
+
return void 0;
|
|
1819
|
+
}
|
|
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
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
return binding;
|
|
1837
|
+
}
|
|
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
|
+
};
|
|
1849
|
+
}
|
|
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
|
+
);
|
|
1901
|
+
}
|
|
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 };
|
|
1920
|
+
}
|
|
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("; ")}`;
|
|
1934
|
+
}
|
|
1935
|
+
append(record) {
|
|
1936
|
+
this.options.appendRecord?.({
|
|
1937
|
+
ts: new Date(this.now()).toISOString(),
|
|
1938
|
+
instanceKey: this.options.instanceKey,
|
|
1939
|
+
...record
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
now() {
|
|
1943
|
+
return this.options.now?.() ?? Date.now();
|
|
1944
|
+
}
|
|
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/fleet.ts
|
|
2375
|
+
import { readFile } from "fs/promises";
|
|
2376
|
+
import { resolve } from "path";
|
|
2377
|
+
import { spawn as spawn2 } from "child_process";
|
|
2378
|
+
async function readFleetConfig(path) {
|
|
2379
|
+
const parsed = JSON.parse(await readFile(resolve(path), "utf8"));
|
|
2380
|
+
if (!Array.isArray(parsed.instances) || parsed.instances.length === 0)
|
|
2381
|
+
throw new Error("fleet config must contain a non-empty 'instances' array");
|
|
2382
|
+
const keys = /* @__PURE__ */ new Set();
|
|
2383
|
+
for (const entry of parsed.instances) {
|
|
2384
|
+
if (!entry || typeof entry.root !== "string" || typeof entry.instanceKey !== "string")
|
|
2385
|
+
throw new Error("each fleet instance requires string 'root' and 'instanceKey'");
|
|
2386
|
+
if (keys.has(entry.instanceKey)) throw new Error(`duplicate fleet instanceKey '${entry.instanceKey}'`);
|
|
2387
|
+
keys.add(entry.instanceKey);
|
|
2388
|
+
}
|
|
2389
|
+
return parsed;
|
|
2390
|
+
}
|
|
2391
|
+
function entryArgs(entry) {
|
|
2392
|
+
const args = ["executor", "run", "--root", resolve(entry.root), "--instance-key", entry.instanceKey];
|
|
2393
|
+
const value = (flag, v) => {
|
|
2394
|
+
if (v !== void 0) args.push(flag, String(v));
|
|
2395
|
+
};
|
|
2396
|
+
value("--lane", entry.lane);
|
|
2397
|
+
value("--model", entry.model);
|
|
2398
|
+
value("--connector", entry.connector);
|
|
2399
|
+
value("--ttl", entry.ttl);
|
|
2400
|
+
value("--poll-interval", entry.pollInterval);
|
|
2401
|
+
value("--heartbeat-interval", entry.heartbeatInterval);
|
|
2402
|
+
value("--turn-timeout", entry.turnTimeout);
|
|
2403
|
+
value("--resume-turn-timeout", entry.resumeTurnTimeout);
|
|
2404
|
+
value("--drain-timeout", entry.drainTimeout);
|
|
2405
|
+
value("--codex-bin", entry.codexBin);
|
|
2406
|
+
value("--sandbox", entry.sandbox);
|
|
2407
|
+
value("--usage-reserve", entry.usageReserve);
|
|
2408
|
+
return args;
|
|
2409
|
+
}
|
|
2410
|
+
function superviseFleet(config2, options = {}) {
|
|
2411
|
+
const log = options.log ?? ((line) => process.stderr.write(`${line}
|
|
2412
|
+
`));
|
|
2413
|
+
const states = /* @__PURE__ */ new Map();
|
|
2414
|
+
const children = /* @__PURE__ */ new Map();
|
|
2415
|
+
let stopping = false;
|
|
2416
|
+
let resolveDone;
|
|
2417
|
+
const done = new Promise((resolvePromise) => {
|
|
2418
|
+
resolveDone = resolvePromise;
|
|
2419
|
+
});
|
|
2420
|
+
const status = () => log(`[fleet] ${[...states].map(([key, state]) => `${key}=${state}`).join(" ")}`);
|
|
2421
|
+
const spawnEntry = options.spawnEntry ?? ((entry, args) => {
|
|
2422
|
+
const script = process.argv[1];
|
|
2423
|
+
if (!script) throw new Error("cannot locate the sechroom CLI entrypoint");
|
|
2424
|
+
return spawn2(process.execPath, [script, ...args], {
|
|
2425
|
+
cwd: resolve(entry.root),
|
|
2426
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2427
|
+
env: process.env
|
|
2428
|
+
});
|
|
2429
|
+
});
|
|
2430
|
+
for (const entry of config2.instances) {
|
|
2431
|
+
const child = spawnEntry(entry, entryArgs(entry));
|
|
2432
|
+
children.set(entry.instanceKey, child);
|
|
2433
|
+
states.set(entry.instanceKey, "live");
|
|
2434
|
+
const prefix = (text2) => {
|
|
2435
|
+
for (const line of text2.replace(/\n$/, "").split("\n")) log(`[${entry.instanceKey}] ${line}`);
|
|
2436
|
+
};
|
|
2437
|
+
const concrete = child;
|
|
2438
|
+
concrete.stdout?.on("data", (chunk) => prefix(String(chunk)));
|
|
2439
|
+
concrete.stderr?.on("data", (chunk) => prefix(String(chunk)));
|
|
2440
|
+
child.on("exit", (code, signal) => {
|
|
2441
|
+
states.set(entry.instanceKey, "exited");
|
|
2442
|
+
log(`[${entry.instanceKey}] exited (${signal ?? code ?? "unknown"})`);
|
|
2443
|
+
status();
|
|
2444
|
+
if ([...states.values()].every((state) => state === "exited")) resolveDone();
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
status();
|
|
2448
|
+
return {
|
|
2449
|
+
done,
|
|
2450
|
+
shutdown(signal = "SIGINT") {
|
|
2451
|
+
if (stopping) return done;
|
|
2452
|
+
stopping = true;
|
|
2453
|
+
for (const [key, child] of children) {
|
|
2454
|
+
if (states.get(key) !== "exited") {
|
|
2455
|
+
states.set(key, "stopping");
|
|
2456
|
+
child.kill(signal);
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
status();
|
|
2460
|
+
return done;
|
|
2461
|
+
},
|
|
2462
|
+
states
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// src/commands/telemetry.ts
|
|
2467
|
+
import {
|
|
2468
|
+
existsSync as existsSync7,
|
|
2469
|
+
mkdirSync as mkdirSync7,
|
|
2470
|
+
readFileSync as readFileSync5,
|
|
2471
|
+
rmSync as rmSync3,
|
|
2472
|
+
writeFileSync as writeFileSync6
|
|
2473
|
+
} from "fs";
|
|
2474
|
+
import { dirname as dirname6, join as join8 } from "path";
|
|
2475
|
+
|
|
1696
2476
|
// src/commands/hook-install.ts
|
|
1697
|
-
import { existsSync as existsSync6, mkdirSync as
|
|
1698
|
-
import { delimiter, dirname as
|
|
2477
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
|
|
2478
|
+
import { delimiter, dirname as dirname5, join as join7 } from "path";
|
|
1699
2479
|
|
|
1700
2480
|
// src/setup/clients.ts
|
|
1701
2481
|
import { existsSync as existsSync5 } from "fs";
|
|
1702
2482
|
import { homedir as homedir3 } from "os";
|
|
1703
|
-
import { dirname as
|
|
2483
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
1704
2484
|
function claudeDesktopConfigPath(home) {
|
|
1705
2485
|
switch (process.platform) {
|
|
1706
2486
|
case "darwin":
|
|
@@ -1760,7 +2540,7 @@ function detectInstalledClients(cwd) {
|
|
|
1760
2540
|
const home = homedir3();
|
|
1761
2541
|
const detected = [];
|
|
1762
2542
|
if (resolveClaudeTargets({}).some((t) => existsSync5(t.dir))) detected.push("claude-code");
|
|
1763
|
-
if (existsSync5(
|
|
2543
|
+
if (existsSync5(dirname4(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
|
|
1764
2544
|
if (resolveCodexHomes({}).some((d) => existsSync5(d))) detected.push("codex");
|
|
1765
2545
|
if (existsSync5(join6(home, ".cursor")) || existsSync5(join6(cwd, ".cursor"))) detected.push("cursor");
|
|
1766
2546
|
if (existsSync5(join6(home, ".gemini"))) detected.push("antigravity");
|
|
@@ -1817,7 +2597,7 @@ function installHooksJson(path, commands, dryRun) {
|
|
|
1817
2597
|
const added = mergeHooks(config2, commands);
|
|
1818
2598
|
if (added === 0 && existed) return { path, status: "current" };
|
|
1819
2599
|
if (!dryRun) {
|
|
1820
|
-
|
|
2600
|
+
mkdirSync6(dirname5(path), { recursive: true });
|
|
1821
2601
|
writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
|
|
1822
2602
|
}
|
|
1823
2603
|
return { path, status: existed ? "merged" : "created" };
|
|
@@ -1857,7 +2637,7 @@ function installCodexFeatureFlag(path, dryRun) {
|
|
|
1857
2637
|
const { next, changed } = ensureCodexFeaturesHooks(content);
|
|
1858
2638
|
if (!changed) return { path, status: "current" };
|
|
1859
2639
|
if (!dryRun) {
|
|
1860
|
-
|
|
2640
|
+
mkdirSync6(dirname5(path), { recursive: true });
|
|
1861
2641
|
writeFileSync5(path, next);
|
|
1862
2642
|
}
|
|
1863
2643
|
return { path, status: existed ? "merged" : "created" };
|
|
@@ -1920,65 +2700,1077 @@ function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
|
|
|
1920
2700
|
return true;
|
|
1921
2701
|
}
|
|
1922
2702
|
|
|
1923
|
-
// src/commands/
|
|
1924
|
-
function
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
enabled: true,
|
|
1928
|
-
filter: { tags: ["kind:task"], workspaceScope: [] }
|
|
1929
|
-
};
|
|
1930
|
-
}
|
|
1931
|
-
function executorRegistrationInput(state, deliverySubscriptionId) {
|
|
1932
|
-
return {
|
|
1933
|
-
relayId: state.relayId,
|
|
1934
|
-
instanceKey: state.instanceKey,
|
|
1935
|
-
laneId: state.laneId ?? state.instanceKey,
|
|
1936
|
-
runtimeKind: parseRuntimeKind(state.runtime),
|
|
1937
|
-
activationMode: "Attached",
|
|
1938
|
-
deliverySubscriptionId,
|
|
1939
|
-
connectorId: state.connectorId,
|
|
1940
|
-
claimedCapabilityKeys: state.capabilityKeys,
|
|
1941
|
-
toolSetRef: null,
|
|
1942
|
-
ttlSeconds: state.ttlSeconds
|
|
1943
|
-
};
|
|
1944
|
-
}
|
|
1945
|
-
var EXECUTOR_STATE = "executor.json";
|
|
1946
|
-
var EXECUTOR_PULSE_COMMAND = "sechroom executor hook-pulse";
|
|
1947
|
-
var EXECUTOR_STOP_COMMAND = "sechroom executor hook-stop";
|
|
1948
|
-
var CLAUDE_EXECUTOR_HOOKS = {
|
|
1949
|
-
SessionStart: EXECUTOR_PULSE_COMMAND,
|
|
1950
|
-
UserPromptSubmit: EXECUTOR_PULSE_COMMAND,
|
|
1951
|
-
PreToolUse: EXECUTOR_PULSE_COMMAND,
|
|
1952
|
-
Stop: EXECUTOR_PULSE_COMMAND,
|
|
1953
|
-
SessionEnd: EXECUTOR_STOP_COMMAND
|
|
1954
|
-
};
|
|
1955
|
-
var CODEX_EXECUTOR_HOOKS = {
|
|
1956
|
-
SessionStart: EXECUTOR_PULSE_COMMAND,
|
|
1957
|
-
UserPromptSubmit: EXECUTOR_PULSE_COMMAND,
|
|
1958
|
-
PreToolUse: EXECUTOR_PULSE_COMMAND,
|
|
1959
|
-
Stop: EXECUTOR_PULSE_COMMAND
|
|
1960
|
-
};
|
|
1961
|
-
function registerExecutor(program2) {
|
|
1962
|
-
const executor = program2.command("executor").description(
|
|
1963
|
-
"Register and operate a local Claude Code/Codex executor advertisement"
|
|
2703
|
+
// src/commands/telemetry.ts
|
|
2704
|
+
function registerTelemetry(program2) {
|
|
2705
|
+
const telemetry = program2.command("telemetry").description(
|
|
2706
|
+
"Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
|
|
1964
2707
|
);
|
|
1965
|
-
|
|
1966
|
-
"
|
|
1967
|
-
).
|
|
1968
|
-
"--
|
|
1969
|
-
"
|
|
1970
|
-
).
|
|
1971
|
-
"--
|
|
1972
|
-
"
|
|
1973
|
-
).option("--runtime <kind>", "claude-code | codex").option("--surface <surface>", "claude | codex").option(
|
|
1974
|
-
"--capability <key...>",
|
|
1975
|
-
"Capability operation keys claimed by this instance"
|
|
2708
|
+
telemetry.command("emit").description(
|
|
2709
|
+
"POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
|
|
2710
|
+
).requiredOption(
|
|
2711
|
+
"--decomposition <id>",
|
|
2712
|
+
"Decomposition id whose run this event belongs to"
|
|
2713
|
+
).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
|
|
2714
|
+
"--kind <kind>",
|
|
2715
|
+
"Event kind: raw | parsed | approval | terminal"
|
|
1976
2716
|
).option(
|
|
1977
|
-
"--
|
|
1978
|
-
"
|
|
1979
|
-
|
|
2717
|
+
"--tokens-in <n>",
|
|
2718
|
+
"Cumulative input tokens (spend meter)",
|
|
2719
|
+
parseIntOpt
|
|
1980
2720
|
).option(
|
|
1981
|
-
"--
|
|
2721
|
+
"--tokens-out <n>",
|
|
2722
|
+
"Cumulative output tokens (spend meter)",
|
|
2723
|
+
parseIntOpt
|
|
2724
|
+
).option(
|
|
2725
|
+
"--context-used <n>",
|
|
2726
|
+
"Context tokens currently used (occupancy meter)",
|
|
2727
|
+
parseIntOpt
|
|
2728
|
+
).option(
|
|
2729
|
+
"--context-window <n>",
|
|
2730
|
+
"Context window size (occupancy meter)",
|
|
2731
|
+
parseIntOpt
|
|
2732
|
+
).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
|
|
2733
|
+
"--verdict <v>",
|
|
2734
|
+
"Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
|
|
2735
|
+
).action(async (opts, cmd) => {
|
|
2736
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
2737
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2738
|
+
const event = {
|
|
2739
|
+
taskId: opts.task,
|
|
2740
|
+
kind: normalizeKind(opts.kind),
|
|
2741
|
+
tokensIn: opts.tokensIn ?? null,
|
|
2742
|
+
tokensOut: opts.tokensOut ?? null,
|
|
2743
|
+
contextUsed: opts.contextUsed ?? null,
|
|
2744
|
+
contextWindow: opts.contextWindow ?? null,
|
|
2745
|
+
text: opts.text ?? null,
|
|
2746
|
+
approvalState: opts.approval ?? null,
|
|
2747
|
+
verdict: opts.verdict ?? null
|
|
2748
|
+
};
|
|
2749
|
+
let body;
|
|
2750
|
+
try {
|
|
2751
|
+
body = await postTelemetry(cfg, opts.decomposition, [event]);
|
|
2752
|
+
} catch (e) {
|
|
2753
|
+
return fail(`Telemetry emit failed: ${e.message}`);
|
|
2754
|
+
}
|
|
2755
|
+
if (json) {
|
|
2756
|
+
emit(body, true);
|
|
2757
|
+
} else {
|
|
2758
|
+
process.stderr.write(
|
|
2759
|
+
style.green("telemetry emitted") + style.dim(
|
|
2760
|
+
` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
|
|
2761
|
+
`
|
|
2762
|
+
)
|
|
2763
|
+
);
|
|
2764
|
+
}
|
|
2765
|
+
});
|
|
2766
|
+
telemetry.command("show <decompositionId>").description(
|
|
2767
|
+
"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.)"
|
|
2768
|
+
).action(async (decompositionId, _opts, cmd) => {
|
|
2769
|
+
const globals = cmd.optsWithGlobals();
|
|
2770
|
+
const cfg = resolveConfig(globals);
|
|
2771
|
+
const data = await runApi("Reading run telemetry", async () => {
|
|
2772
|
+
const client = await makeClient(cfg);
|
|
2773
|
+
return client.GET("/decompositions/{id}/run/telemetry", {
|
|
2774
|
+
params: { path: { id: decompositionId } }
|
|
2775
|
+
});
|
|
2776
|
+
});
|
|
2777
|
+
emitAction(
|
|
2778
|
+
data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
|
|
2779
|
+
data,
|
|
2780
|
+
globals.json
|
|
2781
|
+
);
|
|
2782
|
+
});
|
|
2783
|
+
telemetry.command("bind").description(
|
|
2784
|
+
"Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
|
|
2785
|
+
).requiredOption(
|
|
2786
|
+
"--decomposition <id>",
|
|
2787
|
+
"Decomposition id this session executes"
|
|
2788
|
+
).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
|
|
2789
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
2790
|
+
const dir = join8(process.cwd(), ".sechroom");
|
|
2791
|
+
mkdirSync7(dir, { recursive: true });
|
|
2792
|
+
const path = join8(dir, BINDING_FILE);
|
|
2793
|
+
const binding = {
|
|
2794
|
+
decompositionId: opts.decomposition,
|
|
2795
|
+
taskId: opts.task
|
|
2796
|
+
};
|
|
2797
|
+
writeFileSync6(path, JSON.stringify(binding, null, 2) + "\n");
|
|
2798
|
+
ensureStateDirIgnored(process.cwd());
|
|
2799
|
+
if (json) {
|
|
2800
|
+
emit({ bound: true, ...binding, path }, true);
|
|
2801
|
+
} else {
|
|
2802
|
+
process.stdout.write(
|
|
2803
|
+
style.green("telemetry bound") + style.dim(
|
|
2804
|
+
` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
|
|
2805
|
+
`
|
|
2806
|
+
)
|
|
2807
|
+
);
|
|
2808
|
+
}
|
|
2809
|
+
});
|
|
2810
|
+
telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
|
|
2811
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
2812
|
+
const path = join8(process.cwd(), ".sechroom", BINDING_FILE);
|
|
2813
|
+
const existed = existsSync7(path);
|
|
2814
|
+
if (existed) rmSync3(path);
|
|
2815
|
+
if (json) emit({ unbound: existed, path }, true);
|
|
2816
|
+
else
|
|
2817
|
+
process.stdout.write(
|
|
2818
|
+
existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
|
|
2819
|
+
);
|
|
2820
|
+
});
|
|
2821
|
+
telemetry.command("hook").description(
|
|
2822
|
+
"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."
|
|
2823
|
+
).action(async (_opts, cmd) => {
|
|
2824
|
+
try {
|
|
2825
|
+
const raw = await readStdin();
|
|
2826
|
+
const input = parseHookInput(raw);
|
|
2827
|
+
const cwd = input.cwd ?? process.cwd();
|
|
2828
|
+
const binding = findBinding(cwd);
|
|
2829
|
+
if (!binding) return process.exit(0);
|
|
2830
|
+
const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
|
|
2831
|
+
const events = buildHookEvents(input, usage, binding.taskId);
|
|
2832
|
+
if (events.length === 0) return process.exit(0);
|
|
2833
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2834
|
+
await postTelemetry(cfg, binding.decompositionId, events);
|
|
2835
|
+
return process.exit(0);
|
|
2836
|
+
} catch {
|
|
2837
|
+
return process.exit(0);
|
|
2838
|
+
}
|
|
2839
|
+
});
|
|
2840
|
+
telemetry.command("install").description(
|
|
2841
|
+
"Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
|
|
2842
|
+
).option(
|
|
2843
|
+
"--scope <scope>",
|
|
2844
|
+
"global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
|
|
2845
|
+
).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
|
|
2846
|
+
const g = cmd.optsWithGlobals();
|
|
2847
|
+
const dryRun = Boolean(opts.dryRun);
|
|
2848
|
+
const cwd = process.cwd();
|
|
2849
|
+
let scope;
|
|
2850
|
+
try {
|
|
2851
|
+
scope = opts.local ? "project" : resolveScope(opts.scope);
|
|
2852
|
+
} catch (err2) {
|
|
2853
|
+
process.stderr.write(`${err2.message}
|
|
2854
|
+
`);
|
|
2855
|
+
return process.exit(2);
|
|
2856
|
+
}
|
|
2857
|
+
const targets = resolveClaudeTargets({
|
|
2858
|
+
override: g.claudeConfigDir,
|
|
2859
|
+
scope,
|
|
2860
|
+
cwd
|
|
2861
|
+
});
|
|
2862
|
+
const commands = {
|
|
2863
|
+
Stop: "sechroom telemetry hook",
|
|
2864
|
+
SubagentStop: "sechroom telemetry hook",
|
|
2865
|
+
Notification: "sechroom telemetry hook",
|
|
2866
|
+
PermissionDenied: "sechroom telemetry hook"
|
|
2867
|
+
};
|
|
2868
|
+
try {
|
|
2869
|
+
const multi = targets.length > 1;
|
|
2870
|
+
const results = targets.map((t) => {
|
|
2871
|
+
const r = installClaudeCommands(t.dir, commands, dryRun);
|
|
2872
|
+
process.stdout.write(
|
|
2873
|
+
`${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
|
|
2874
|
+
`
|
|
2875
|
+
);
|
|
2876
|
+
process.stdout.write(describe(r, dryRun) + "\n");
|
|
2877
|
+
return r;
|
|
2878
|
+
});
|
|
2879
|
+
if (dryRun) {
|
|
2880
|
+
process.stdout.write("\n(dry run \u2014 no files were written.)\n");
|
|
2881
|
+
} else if (results.every((r) => r.status === "current")) {
|
|
2882
|
+
process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
|
|
2883
|
+
} else {
|
|
2884
|
+
process.stdout.write(
|
|
2885
|
+
"\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
|
|
2886
|
+
);
|
|
2887
|
+
}
|
|
2888
|
+
} catch (err2) {
|
|
2889
|
+
process.stderr.write(
|
|
2890
|
+
`telemetry install failed: ${err2.message}
|
|
2891
|
+
`
|
|
2892
|
+
);
|
|
2893
|
+
return process.exit(1);
|
|
2894
|
+
}
|
|
2895
|
+
warnIfSechroomNotOnPath();
|
|
2896
|
+
return process.exit(0);
|
|
2897
|
+
});
|
|
2898
|
+
}
|
|
2899
|
+
var BINDING_FILE = "telemetry.json";
|
|
2900
|
+
async function postTelemetry(cfg, decompositionId, events) {
|
|
2901
|
+
const token = await requireToken(cfg);
|
|
2902
|
+
const resp = await fetch(
|
|
2903
|
+
`${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
|
|
2904
|
+
{
|
|
2905
|
+
method: "POST",
|
|
2906
|
+
headers: {
|
|
2907
|
+
authorization: `Bearer ${token}`,
|
|
2908
|
+
tenant: cfg.tenant,
|
|
2909
|
+
"content-type": "application/json",
|
|
2910
|
+
"x-sechroom-surface": "cli"
|
|
2911
|
+
},
|
|
2912
|
+
body: JSON.stringify({ events })
|
|
2913
|
+
}
|
|
2914
|
+
);
|
|
2915
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
|
2916
|
+
return await resp.json();
|
|
2917
|
+
}
|
|
2918
|
+
function findBinding(start) {
|
|
2919
|
+
let dir = start;
|
|
2920
|
+
for (; ; ) {
|
|
2921
|
+
const path = join8(dir, ".sechroom", BINDING_FILE);
|
|
2922
|
+
if (existsSync7(path)) {
|
|
2923
|
+
try {
|
|
2924
|
+
const b = JSON.parse(
|
|
2925
|
+
readFileSync5(path, "utf8")
|
|
2926
|
+
);
|
|
2927
|
+
if (b.decompositionId && b.taskId)
|
|
2928
|
+
return { decompositionId: b.decompositionId, taskId: b.taskId };
|
|
2929
|
+
} catch {
|
|
2930
|
+
}
|
|
2931
|
+
return null;
|
|
2932
|
+
}
|
|
2933
|
+
const parent = dirname6(dir);
|
|
2934
|
+
if (parent === dir) return null;
|
|
2935
|
+
dir = parent;
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
function parseTranscript(path) {
|
|
2939
|
+
if (!existsSync7(path)) return null;
|
|
2940
|
+
let tokensIn = 0;
|
|
2941
|
+
let tokensOut = 0;
|
|
2942
|
+
let contextUsed = 0;
|
|
2943
|
+
let model = "";
|
|
2944
|
+
for (const line of readFileSync5(path, "utf8").split("\n")) {
|
|
2945
|
+
if (!line.trim()) continue;
|
|
2946
|
+
let obj;
|
|
2947
|
+
try {
|
|
2948
|
+
obj = JSON.parse(line);
|
|
2949
|
+
} catch {
|
|
2950
|
+
continue;
|
|
2951
|
+
}
|
|
2952
|
+
const usage = obj.type === "assistant" ? obj.message?.usage : void 0;
|
|
2953
|
+
if (!usage) continue;
|
|
2954
|
+
const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
2955
|
+
tokensIn += input;
|
|
2956
|
+
tokensOut += usage.output_tokens ?? 0;
|
|
2957
|
+
contextUsed = input;
|
|
2958
|
+
if (obj.message?.model) model = obj.message.model;
|
|
2959
|
+
}
|
|
2960
|
+
if (tokensIn === 0 && tokensOut === 0) return null;
|
|
2961
|
+
return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed), modelId: model || null };
|
|
2962
|
+
}
|
|
2963
|
+
function windowFor(model, contextUsed = 0) {
|
|
2964
|
+
const m = model.toLowerCase();
|
|
2965
|
+
if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
|
|
2966
|
+
return contextUsed > 2e5 ? 1e6 : 2e5;
|
|
2967
|
+
}
|
|
2968
|
+
function buildHookEvents(input, usage, taskId) {
|
|
2969
|
+
const events = [];
|
|
2970
|
+
const base = (kind, over) => ({
|
|
2971
|
+
taskId,
|
|
2972
|
+
kind,
|
|
2973
|
+
tokensIn: null,
|
|
2974
|
+
tokensOut: null,
|
|
2975
|
+
contextUsed: null,
|
|
2976
|
+
contextWindow: null,
|
|
2977
|
+
text: null,
|
|
2978
|
+
approvalState: null,
|
|
2979
|
+
verdict: null,
|
|
2980
|
+
modelId: null,
|
|
2981
|
+
...over
|
|
2982
|
+
});
|
|
2983
|
+
if (usage) {
|
|
2984
|
+
events.push(
|
|
2985
|
+
base("Parsed", {
|
|
2986
|
+
tokensIn: usage.tokensIn,
|
|
2987
|
+
tokensOut: usage.tokensOut,
|
|
2988
|
+
contextUsed: usage.contextUsed,
|
|
2989
|
+
contextWindow: usage.contextWindow,
|
|
2990
|
+
modelId: usage.modelId
|
|
2991
|
+
})
|
|
2992
|
+
);
|
|
2993
|
+
}
|
|
2994
|
+
switch (input.hook_event_name) {
|
|
2995
|
+
case "PermissionDenied":
|
|
2996
|
+
events.push(
|
|
2997
|
+
base("Approval", {
|
|
2998
|
+
approvalState: "denied",
|
|
2999
|
+
text: input.tool_name ?? input.message ?? null
|
|
3000
|
+
})
|
|
3001
|
+
);
|
|
3002
|
+
break;
|
|
3003
|
+
case "Notification":
|
|
3004
|
+
if (isPermissionNotification(input))
|
|
3005
|
+
events.push(base("Approval", { text: input.message ?? null }));
|
|
3006
|
+
break;
|
|
3007
|
+
case "Stop":
|
|
3008
|
+
case "SubagentStop":
|
|
3009
|
+
events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
|
|
3010
|
+
break;
|
|
3011
|
+
}
|
|
3012
|
+
return events;
|
|
3013
|
+
}
|
|
3014
|
+
function isPermissionNotification(input) {
|
|
3015
|
+
const t = (input.notification_type ?? input.type ?? "").toLowerCase();
|
|
3016
|
+
if (t) return t.includes("permission");
|
|
3017
|
+
return (input.message ?? "").toLowerCase().includes("permission");
|
|
3018
|
+
}
|
|
3019
|
+
async function readStdin() {
|
|
3020
|
+
if (process.stdin.isTTY) return "";
|
|
3021
|
+
const chunks = [];
|
|
3022
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
3023
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3024
|
+
}
|
|
3025
|
+
function parseHookInput(raw) {
|
|
3026
|
+
if (!raw.trim()) return {};
|
|
3027
|
+
try {
|
|
3028
|
+
return JSON.parse(raw);
|
|
3029
|
+
} catch {
|
|
3030
|
+
return {};
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
var KINDS = {
|
|
3034
|
+
raw: "Raw",
|
|
3035
|
+
parsed: "Parsed",
|
|
3036
|
+
approval: "Approval",
|
|
3037
|
+
terminal: "Terminal"
|
|
3038
|
+
};
|
|
3039
|
+
function normalizeKind(k) {
|
|
3040
|
+
const v = KINDS[k.toLowerCase()];
|
|
3041
|
+
if (!v)
|
|
3042
|
+
fail(
|
|
3043
|
+
`Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
|
|
3044
|
+
);
|
|
3045
|
+
return v;
|
|
3046
|
+
}
|
|
3047
|
+
function parseIntOpt(v) {
|
|
3048
|
+
const n = Number.parseInt(v, 10);
|
|
3049
|
+
if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
|
|
3050
|
+
return n;
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
// src/executor-run/delivery.ts
|
|
3054
|
+
import { execFile as execFile2 } from "child_process";
|
|
3055
|
+
function createGitRunner(rootDir) {
|
|
3056
|
+
return (bin, args) => new Promise((resolve5) => {
|
|
3057
|
+
execFile2(
|
|
3058
|
+
bin,
|
|
3059
|
+
bin === "git" ? ["-C", rootDir, ...args] : args,
|
|
3060
|
+
{ cwd: rootDir, maxBuffer: 10 * 1024 * 1024 },
|
|
3061
|
+
(error, stdout, stderr) => resolve5({ ok: !error, stdout: String(stdout), stderr: String(stderr) })
|
|
3062
|
+
);
|
|
3063
|
+
});
|
|
3064
|
+
}
|
|
3065
|
+
function porcelainPaths(stdout) {
|
|
3066
|
+
return stdout.split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 3).map((line) => {
|
|
3067
|
+
const path = line.slice(3);
|
|
3068
|
+
const arrow = path.indexOf(" -> ");
|
|
3069
|
+
return arrow >= 0 ? path.slice(arrow + 4) : path;
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
async function snapshotRoot(git) {
|
|
3073
|
+
const branch = await git("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
3074
|
+
const status = await git("git", ["status", "--porcelain"]);
|
|
3075
|
+
return {
|
|
3076
|
+
baseBranch: branch.ok ? branch.stdout.trim() : "HEAD",
|
|
3077
|
+
dirtyPaths: status.ok ? porcelainPaths(status.stdout) : []
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
3080
|
+
async function checkRootReady(git, allowDirty) {
|
|
3081
|
+
const snapshot = await snapshotRoot(git);
|
|
3082
|
+
if (snapshot.dirtyPaths.length === 0 || allowDirty)
|
|
3083
|
+
return { ok: true, snapshot };
|
|
3084
|
+
return {
|
|
3085
|
+
ok: false,
|
|
3086
|
+
snapshot,
|
|
3087
|
+
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`
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
async function deliverTurn(git, options) {
|
|
3091
|
+
const status = await git("git", ["status", "--porcelain"]);
|
|
3092
|
+
if (!status.ok)
|
|
3093
|
+
return { delivered: false, note: `delivery skipped \u2014 git status failed: ${status.stderr.trim()}` };
|
|
3094
|
+
const preDirty = new Set(options.snapshot.dirtyPaths);
|
|
3095
|
+
const turnPaths = porcelainPaths(status.stdout).filter((p) => !preDirty.has(p));
|
|
3096
|
+
if (turnPaths.length === 0)
|
|
3097
|
+
return { delivered: false, note: "no file changes produced by the turn \u2014 nothing to deliver" };
|
|
3098
|
+
const branch = await freeBranchName(git, `task/${slug(options.taskId)}`);
|
|
3099
|
+
const created = await git("git", ["checkout", "-b", branch]);
|
|
3100
|
+
if (!created.ok)
|
|
3101
|
+
return {
|
|
3102
|
+
delivered: false,
|
|
3103
|
+
note: `delivery FAILED \u2014 could not create branch ${branch}: ${created.stderr.trim()} (changes remain uncommitted in the root)`
|
|
3104
|
+
};
|
|
3105
|
+
const notes = [];
|
|
3106
|
+
try {
|
|
3107
|
+
const added = await git("git", ["add", "--", ...turnPaths]);
|
|
3108
|
+
if (!added.ok) return failBack(`git add failed: ${added.stderr.trim()}`);
|
|
3109
|
+
const committed = await git("git", [
|
|
3110
|
+
"commit",
|
|
3111
|
+
"-m",
|
|
3112
|
+
commitMessage(options)
|
|
3113
|
+
]);
|
|
3114
|
+
if (!committed.ok) return failBack(`git commit failed: ${committed.stderr.trim()}`);
|
|
3115
|
+
const sha = (await git("git", ["rev-parse", "--short", "HEAD"])).stdout.trim();
|
|
3116
|
+
const pushed = await git("git", ["push", "-u", "origin", branch]);
|
|
3117
|
+
if (!pushed.ok)
|
|
3118
|
+
notes.push(`push failed (${firstLine(pushed.stderr)}) \u2014 branch is local-only`);
|
|
3119
|
+
let prUrl;
|
|
3120
|
+
if (options.raisePr && pushed.ok) {
|
|
3121
|
+
const pr = await git("gh", [
|
|
3122
|
+
"pr",
|
|
3123
|
+
"create",
|
|
3124
|
+
"--head",
|
|
3125
|
+
branch,
|
|
3126
|
+
"--title",
|
|
3127
|
+
`task(${options.taskId}): ${options.title}`,
|
|
3128
|
+
"--body",
|
|
3129
|
+
prBody(options, sha)
|
|
3130
|
+
]);
|
|
3131
|
+
if (pr.ok) prUrl = firstLine(pr.stdout);
|
|
3132
|
+
else notes.push(`PR raise failed (${firstLine(pr.stderr)}) \u2014 raise manually from ${branch}`);
|
|
3133
|
+
}
|
|
3134
|
+
notes.unshift(
|
|
3135
|
+
`delivered ${turnPaths.length} path(s) to ${branch} @ ${sha}${prUrl ? ` \u2014 PR ${prUrl}` : ""}`
|
|
3136
|
+
);
|
|
3137
|
+
return { delivered: true, branch, sha, prUrl, note: notes.join("; ") };
|
|
3138
|
+
} finally {
|
|
3139
|
+
const back = await git("git", ["checkout", options.snapshot.baseBranch]);
|
|
3140
|
+
if (!back.ok)
|
|
3141
|
+
options.log(
|
|
3142
|
+
`delivery: could not return root to ${options.snapshot.baseBranch}: ${back.stderr.trim()}`
|
|
3143
|
+
);
|
|
3144
|
+
}
|
|
3145
|
+
function failBack(reason) {
|
|
3146
|
+
return { delivered: false, branch, note: `delivery FAILED \u2014 ${reason}` };
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
async function freeBranchName(git, base) {
|
|
3150
|
+
for (let i = 0; ; i++) {
|
|
3151
|
+
const candidate = i === 0 ? base : `${base}-${i + 1}`;
|
|
3152
|
+
const exists = await git("git", [
|
|
3153
|
+
"rev-parse",
|
|
3154
|
+
"--verify",
|
|
3155
|
+
"--quiet",
|
|
3156
|
+
`refs/heads/${candidate}`
|
|
3157
|
+
]);
|
|
3158
|
+
if (!exists.ok) return candidate;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
function slug(taskId) {
|
|
3162
|
+
return taskId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3163
|
+
}
|
|
3164
|
+
function commitMessage(options) {
|
|
3165
|
+
return `task(${options.taskId}): ${options.title} [verdict:${options.verdict}]
|
|
3166
|
+
|
|
3167
|
+
Driven-executor delivery (FR-sechroom-496): work produced by the sandboxed codex turn, landed by the driver.
|
|
3168
|
+
|
|
3169
|
+
Delivered-By: sechroom executor run (${options.deliveredBy})`;
|
|
3170
|
+
}
|
|
3171
|
+
function prBody(options, sha) {
|
|
3172
|
+
return `Driven-executor delivery for WLP task \`${options.taskId}\` (verdict: ${options.verdict}, commit ${sha}).
|
|
3173
|
+
|
|
3174
|
+
Work produced by a sandboxed codex turn and landed by the unsandboxed driver (FR-sechroom-496). Review against the task's acceptance before merge.`;
|
|
3175
|
+
}
|
|
3176
|
+
function firstLine(text2) {
|
|
3177
|
+
return text2.trim().split("\n")[0] ?? "";
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
// src/executor-run/request.ts
|
|
3181
|
+
var AuthExpiredError = class extends Error {
|
|
3182
|
+
constructor(message) {
|
|
3183
|
+
super(message);
|
|
3184
|
+
this.name = "AuthExpiredError";
|
|
3185
|
+
}
|
|
3186
|
+
};
|
|
3187
|
+
var HttpError = class extends Error {
|
|
3188
|
+
constructor(status, method, path, body) {
|
|
3189
|
+
super(`${method} ${path} failed (${status}): ${body}`);
|
|
3190
|
+
this.status = status;
|
|
3191
|
+
this.method = method;
|
|
3192
|
+
this.path = path;
|
|
3193
|
+
this.body = body;
|
|
3194
|
+
this.name = "HttpError";
|
|
3195
|
+
}
|
|
3196
|
+
status;
|
|
3197
|
+
method;
|
|
3198
|
+
path;
|
|
3199
|
+
body;
|
|
3200
|
+
};
|
|
3201
|
+
function createAuthedRequest(cfg, deps = {}) {
|
|
3202
|
+
const getToken = deps.getToken ?? requireToken;
|
|
3203
|
+
const refresh = deps.refreshToken ?? forceRefreshToken;
|
|
3204
|
+
const doFetch = deps.fetch ?? fetch;
|
|
3205
|
+
const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
|
|
3206
|
+
...init,
|
|
3207
|
+
headers: {
|
|
3208
|
+
authorization: `Bearer ${token}`,
|
|
3209
|
+
tenant: cfg.tenant,
|
|
3210
|
+
"content-type": "application/json",
|
|
3211
|
+
"x-sechroom-surface": "cli",
|
|
3212
|
+
...init?.headers
|
|
3213
|
+
}
|
|
3214
|
+
});
|
|
3215
|
+
return async (path, init) => {
|
|
3216
|
+
const method = init?.method ?? "GET";
|
|
3217
|
+
let token;
|
|
3218
|
+
try {
|
|
3219
|
+
token = await getToken(cfg);
|
|
3220
|
+
} catch (error) {
|
|
3221
|
+
throw new AuthExpiredError(
|
|
3222
|
+
error instanceof Error ? error.message : String(error)
|
|
3223
|
+
);
|
|
3224
|
+
}
|
|
3225
|
+
let response = await call(path, init, token);
|
|
3226
|
+
if (response.status === 401) {
|
|
3227
|
+
let fresh;
|
|
3228
|
+
try {
|
|
3229
|
+
fresh = await refresh(cfg);
|
|
3230
|
+
} catch (error) {
|
|
3231
|
+
throw new AuthExpiredError(
|
|
3232
|
+
error instanceof Error ? error.message : String(error)
|
|
3233
|
+
);
|
|
3234
|
+
}
|
|
3235
|
+
response = await call(path, init, fresh);
|
|
3236
|
+
if (response.status === 401)
|
|
3237
|
+
throw new AuthExpiredError(
|
|
3238
|
+
`${method} ${path} still 401 after token refresh \u2014 re-authenticate (\`sechroom login\`).`
|
|
3239
|
+
);
|
|
3240
|
+
}
|
|
3241
|
+
if (!response.ok)
|
|
3242
|
+
throw new HttpError(
|
|
3243
|
+
response.status,
|
|
3244
|
+
method,
|
|
3245
|
+
path,
|
|
3246
|
+
await safeText(response)
|
|
3247
|
+
);
|
|
3248
|
+
return await response.json();
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
async function safeText(response) {
|
|
3252
|
+
try {
|
|
3253
|
+
return await response.text();
|
|
3254
|
+
} catch {
|
|
3255
|
+
return "";
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// src/executor-run/driver.ts
|
|
3260
|
+
function verdictFor(terminalStatus) {
|
|
3261
|
+
switch (terminalStatus) {
|
|
3262
|
+
case "completed":
|
|
3263
|
+
return "pass";
|
|
3264
|
+
case "needs_approval":
|
|
3265
|
+
case "cancelled":
|
|
3266
|
+
case "canceled":
|
|
3267
|
+
return "blocked";
|
|
3268
|
+
case "error":
|
|
3269
|
+
default:
|
|
3270
|
+
return "soft-fail";
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
async function runDriverLoop(ports, options) {
|
|
3274
|
+
const summary = { processed: 0, completed: 0, abandoned: 0 };
|
|
3275
|
+
let admissionDeferred = false;
|
|
3276
|
+
while (!options.stopping()) {
|
|
3277
|
+
if (ports.checkAdmission) {
|
|
3278
|
+
const admission = await ports.checkAdmission();
|
|
3279
|
+
if (!admission.ok) {
|
|
3280
|
+
admissionDeferred = true;
|
|
3281
|
+
ports.log(
|
|
3282
|
+
`ADMISSION DEFERRED \u2014 not claiming: ${admission.reason ?? "usage budget exhausted"}`
|
|
3283
|
+
);
|
|
3284
|
+
await ports.waitForWake(options.pollMs);
|
|
3285
|
+
continue;
|
|
3286
|
+
}
|
|
3287
|
+
if (admissionDeferred) {
|
|
3288
|
+
admissionDeferred = false;
|
|
3289
|
+
ports.log("admission recovered \u2014 resuming claims");
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
if (ports.checkRootReady) {
|
|
3293
|
+
const ready = await ports.checkRootReady();
|
|
3294
|
+
if (!ready.ok) {
|
|
3295
|
+
ports.log(`root not ready \u2014 not claiming: ${ready.reason ?? "unknown"}`);
|
|
3296
|
+
await ports.waitForWake(options.pollMs);
|
|
3297
|
+
continue;
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
const claim = await ports.claimNext();
|
|
3301
|
+
if (!claim) {
|
|
3302
|
+
await ports.waitForWake(options.pollMs);
|
|
3303
|
+
continue;
|
|
3304
|
+
}
|
|
3305
|
+
summary.processed++;
|
|
3306
|
+
ports.log(`claimed ${claim.memoryId} (lease ${claim.leaseId})`);
|
|
3307
|
+
const task = await ports.loadTask(claim.memoryId);
|
|
3308
|
+
const stopHeartbeat = ports.startLeaseHeartbeat(claim);
|
|
3309
|
+
let result;
|
|
3310
|
+
try {
|
|
3311
|
+
result = await ports.runTurn(task, claim);
|
|
3312
|
+
} catch (e) {
|
|
3313
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
3314
|
+
result = { status: "crashed", reason: String(e) };
|
|
3315
|
+
} finally {
|
|
3316
|
+
stopHeartbeat();
|
|
3317
|
+
}
|
|
3318
|
+
if (result.status === "crashed" || result.status === "timeout") {
|
|
3319
|
+
summary.abandoned++;
|
|
3320
|
+
ports.log(
|
|
3321
|
+
`ABANDONED ${claim.memoryId}: ${result.status === "timeout" ? "turn timed out" : result.reason} \u2014 lease will expire and the task re-offers (work may re-run).`
|
|
3322
|
+
);
|
|
3323
|
+
} else {
|
|
3324
|
+
const verdict = verdictFor(result.packet?.terminal_status);
|
|
3325
|
+
let text2 = closeoutText(task, result);
|
|
3326
|
+
if (ports.deliver) {
|
|
3327
|
+
try {
|
|
3328
|
+
const delivery = await ports.deliver(claim, task, verdict);
|
|
3329
|
+
ports.log(`delivery: ${delivery.note}`);
|
|
3330
|
+
text2 += `
|
|
3331
|
+
|
|
3332
|
+
Delivery: ${delivery.note}`;
|
|
3333
|
+
} catch (e) {
|
|
3334
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
3335
|
+
ports.log(`delivery threw (continuing to completion): ${String(e)}`);
|
|
3336
|
+
text2 += `
|
|
3337
|
+
|
|
3338
|
+
Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
try {
|
|
3342
|
+
const done = await ports.completeLease(
|
|
3343
|
+
claim,
|
|
3344
|
+
verdict,
|
|
3345
|
+
text2,
|
|
3346
|
+
`${task.title} \u2014 driven closeout`
|
|
3347
|
+
);
|
|
3348
|
+
summary.completed++;
|
|
3349
|
+
ports.log(
|
|
3350
|
+
`completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
|
|
3351
|
+
);
|
|
3352
|
+
} catch (e) {
|
|
3353
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
3354
|
+
summary.abandoned++;
|
|
3355
|
+
ports.log(
|
|
3356
|
+
`COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
|
|
3357
|
+
);
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
if (options.once) break;
|
|
3361
|
+
}
|
|
3362
|
+
return summary;
|
|
3363
|
+
}
|
|
3364
|
+
function closeoutText(task, result) {
|
|
3365
|
+
if (!result.packet)
|
|
3366
|
+
return `Driven codex run ended without a sechroom_closeout packet (soft-fail). Last agent message:
|
|
3367
|
+
|
|
3368
|
+
${result.lastAgentMessage || "(none)"}`;
|
|
3369
|
+
const evidence = result.packet.evidence?.length ? `
|
|
3370
|
+
|
|
3371
|
+
Evidence:
|
|
3372
|
+
${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
|
|
3373
|
+
return `${result.packet.summary}${evidence}
|
|
3374
|
+
|
|
3375
|
+
(terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
|
|
3376
|
+
}
|
|
3377
|
+
function startLeaseHeartbeat(beat, log, intervalMs = 3e4, timers = {}) {
|
|
3378
|
+
const schedule = timers.setInterval ?? setInterval;
|
|
3379
|
+
const cancel = timers.clearInterval ?? clearInterval;
|
|
3380
|
+
const timer = schedule(() => {
|
|
3381
|
+
void beat().catch(
|
|
3382
|
+
(e) => log(`lease heartbeat failed (retrying next beat): ${String(e)}`)
|
|
3383
|
+
);
|
|
3384
|
+
}, intervalMs);
|
|
3385
|
+
timer.unref?.();
|
|
3386
|
+
return () => cancel(timer);
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// src/commands/executor-run.ts
|
|
3390
|
+
function registerExecutorRunCommand(executor) {
|
|
3391
|
+
executor.command("fleet").description("Run multiple isolated driven codex executors from one config file").requiredOption("--config <file>", "JSON fleet config").action(async (opts) => {
|
|
3392
|
+
const fleet = superviseFleet(await readFleetConfig(String(opts.config)));
|
|
3393
|
+
const stop = () => void fleet.shutdown("SIGINT");
|
|
3394
|
+
process.once("SIGINT", stop);
|
|
3395
|
+
process.once("SIGTERM", stop);
|
|
3396
|
+
try {
|
|
3397
|
+
await fleet.done;
|
|
3398
|
+
} finally {
|
|
3399
|
+
process.off("SIGINT", stop);
|
|
3400
|
+
process.off("SIGTERM", stop);
|
|
3401
|
+
}
|
|
3402
|
+
});
|
|
3403
|
+
executor.command("run").description(
|
|
3404
|
+
"Run a driven codex executor: claim, execute, heartbeat, and complete dispatched tasks unattended"
|
|
3405
|
+
).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(
|
|
3406
|
+
"--root <dir>",
|
|
3407
|
+
"Working directory for the spawned codex turns (e.g. ../sechroom_4)",
|
|
3408
|
+
process.cwd()
|
|
3409
|
+
).option(
|
|
3410
|
+
"--codex-bin <bin>",
|
|
3411
|
+
"codex binary (or CODEX_BIN)",
|
|
3412
|
+
process.env.CODEX_BIN ?? "codex"
|
|
3413
|
+
).option(
|
|
3414
|
+
"--instance-key <key>",
|
|
3415
|
+
"Registration identity (defaults to installed executor.json)"
|
|
3416
|
+
).option(
|
|
3417
|
+
"--lane <lane>",
|
|
3418
|
+
"Affinity lane + completion source (defaults to instance key)"
|
|
3419
|
+
).option(
|
|
3420
|
+
"--connector <id>",
|
|
3421
|
+
"Approved connector id (defaults to installed executor.json)"
|
|
3422
|
+
).option("--ttl <seconds>", "Advertisement TTL (30-600)").option("--once", "Process a single task, then exit", false).option(
|
|
3423
|
+
"--no-deliver",
|
|
3424
|
+
"Skip driver-side delivery (branch/commit/push of the turn's changes)"
|
|
3425
|
+
).option(
|
|
3426
|
+
"--allow-dirty-root",
|
|
3427
|
+
"Claim even when the root has uncommitted changes (they are fenced out of the delivery commit)",
|
|
3428
|
+
false
|
|
3429
|
+
).option("--no-pr", "Deliver without raising a PR (branch + push only)").option("--poll-interval <seconds>", "Offer reconciliation interval", "5").option(
|
|
3430
|
+
"--heartbeat-interval <seconds>",
|
|
3431
|
+
"Lease heartbeat cadence",
|
|
3432
|
+
"30"
|
|
3433
|
+
).option("--turn-timeout <seconds>", "Fresh turn timeout", "300").option("--resume-turn-timeout <seconds>", "Resumed turn timeout", "1200").option(
|
|
3434
|
+
"--drain-timeout <seconds>",
|
|
3435
|
+
"On shutdown, seconds to let an in-flight turn finish before interrupting",
|
|
3436
|
+
"30"
|
|
3437
|
+
).option(
|
|
3438
|
+
"--usage-reserve <percent>",
|
|
3439
|
+
"Rate-limit reserve: defer claiming new tasks while remaining is at or below this percent",
|
|
3440
|
+
"2"
|
|
3441
|
+
).action(async (opts, cmd) => {
|
|
3442
|
+
if (String(opts.runtime).toLowerCase() !== "codex")
|
|
3443
|
+
fail("executor run drives runtime codex only (claude-code stays attached)");
|
|
3444
|
+
const located = readExecutorState();
|
|
3445
|
+
if (!located)
|
|
3446
|
+
fail(
|
|
3447
|
+
"executor run requires an installed executor advertisement; run `sechroom executor install` first."
|
|
3448
|
+
);
|
|
3449
|
+
if (located.state.runtime !== "codex")
|
|
3450
|
+
fail(
|
|
3451
|
+
`this checkout's executor advertisement is runtime '${located.state.runtime}' \u2014 reinstall with --runtime codex.`
|
|
3452
|
+
);
|
|
3453
|
+
located.state = {
|
|
3454
|
+
...located.state,
|
|
3455
|
+
instanceKey: opts.instanceKey ? String(opts.instanceKey) : located.state.instanceKey,
|
|
3456
|
+
laneId: opts.lane ? String(opts.lane) : located.state.laneId,
|
|
3457
|
+
connectorId: opts.connector ? String(opts.connector) : located.state.connectorId,
|
|
3458
|
+
ttlSeconds: opts.ttl ? Number.parseInt(String(opts.ttl), 10) : located.state.ttlSeconds
|
|
3459
|
+
};
|
|
3460
|
+
const heartbeatMs = Number.parseInt(String(opts.heartbeatInterval), 10) * 1e3;
|
|
3461
|
+
if (heartbeatMs >= 12e4)
|
|
3462
|
+
fail("--heartbeat-interval must be shorter than the 120s lease TTL");
|
|
3463
|
+
const usageReserve = Number.parseFloat(String(opts.usageReserve));
|
|
3464
|
+
if (!Number.isFinite(usageReserve) || usageReserve < 0 || usageReserve >= 100)
|
|
3465
|
+
fail("--usage-reserve must be a percent in [0, 100)");
|
|
3466
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3467
|
+
const instance = await ensureExecutorInstance(cfg, located);
|
|
3468
|
+
const log = (line) => process.stderr.write(style.dim(`[run] ${line}
|
|
3469
|
+
`));
|
|
3470
|
+
const request = createAuthedRequest(cfg);
|
|
3471
|
+
const rootDir = resolve2(String(opts.root));
|
|
3472
|
+
const usageLogPath = join9(
|
|
3473
|
+
rootDir,
|
|
3474
|
+
".sechroom",
|
|
3475
|
+
`executor-usage-${located.state.instanceKey.replace(/[^\w.-]/g, "-")}.jsonl`
|
|
3476
|
+
);
|
|
3477
|
+
const usageTracker = new UsageTracker({
|
|
3478
|
+
instanceKey: located.state.instanceKey,
|
|
3479
|
+
reservePercent: usageReserve,
|
|
3480
|
+
log,
|
|
3481
|
+
appendRecord: createUsageLogAppender(usageLogPath, log)
|
|
3482
|
+
});
|
|
3483
|
+
const appServer = new CodexAppServer({
|
|
3484
|
+
codexBin: String(opts.codexBin),
|
|
3485
|
+
cwd: rootDir,
|
|
3486
|
+
model: opts.model ? String(opts.model) : void 0,
|
|
3487
|
+
sandbox: String(opts.sandbox),
|
|
3488
|
+
turnTimeoutMs: Number.parseInt(String(opts.turnTimeout), 10) * 1e3,
|
|
3489
|
+
resumeTurnTimeoutMs: Number.parseInt(String(opts.resumeTurnTimeout), 10) * 1e3,
|
|
3490
|
+
log,
|
|
3491
|
+
executorInstanceId: instance.id,
|
|
3492
|
+
emitTelemetry: (decompositionId, events) => postTelemetry(cfg, decompositionId, events).then(() => void 0),
|
|
3493
|
+
onUsage: (taskId, usage) => usageTracker.recordUsage(taskId, usage),
|
|
3494
|
+
onRateLimits: (limits) => usageTracker.recordRateLimits(limits)
|
|
3495
|
+
});
|
|
3496
|
+
await appServer.start();
|
|
3497
|
+
log(`codex app-server up (${String(opts.codexBin)})`);
|
|
3498
|
+
let stopping = false;
|
|
3499
|
+
let turnInFlight = false;
|
|
3500
|
+
const requestStop = () => {
|
|
3501
|
+
if (stopping) return;
|
|
3502
|
+
stopping = true;
|
|
3503
|
+
wake();
|
|
3504
|
+
log("shutdown requested \u2014 finishing up");
|
|
3505
|
+
if (turnInFlight) {
|
|
3506
|
+
const drainMs = Number.parseInt(String(opts.drainTimeout), 10) * 1e3;
|
|
3507
|
+
setTimeout(() => void appServer.interrupt(), drainMs).unref?.();
|
|
3508
|
+
}
|
|
3509
|
+
};
|
|
3510
|
+
process.once("SIGINT", requestStop);
|
|
3511
|
+
process.once("SIGTERM", requestStop);
|
|
3512
|
+
let wake = () => {
|
|
3513
|
+
};
|
|
3514
|
+
const wakeSignal = () => new Promise((resolve5) => {
|
|
3515
|
+
wake = resolve5;
|
|
3516
|
+
});
|
|
3517
|
+
let connStop = async () => {
|
|
3518
|
+
};
|
|
3519
|
+
try {
|
|
3520
|
+
const conn = await openConnection(cfg, () => wake(), instance.id);
|
|
3521
|
+
connStop = () => conn.stop();
|
|
3522
|
+
} catch (e) {
|
|
3523
|
+
log(`SignalR wake leg unavailable (${String(e)}) \u2014 poll-only`);
|
|
3524
|
+
}
|
|
3525
|
+
const stopAdvertisementHeartbeat = startExecutorHeartbeat(
|
|
3526
|
+
() => request(
|
|
3527
|
+
`/me/executor-instances/${encodeURIComponent(instance.id)}/refresh`,
|
|
3528
|
+
{
|
|
3529
|
+
method: "POST",
|
|
3530
|
+
body: JSON.stringify({ ttlSeconds: located.state.ttlSeconds })
|
|
3531
|
+
}
|
|
3532
|
+
),
|
|
3533
|
+
located.state.refreshAfterSeconds * 1e3,
|
|
3534
|
+
{ onError: (e) => log(`advertisement refresh failed: ${String(e)}`) }
|
|
3535
|
+
);
|
|
3536
|
+
const gitRunner = createGitRunner(rootDir);
|
|
3537
|
+
let rootSnapshot;
|
|
3538
|
+
const deliveryPorts = opts.deliver === false ? {} : {
|
|
3539
|
+
checkRootReady: async () => {
|
|
3540
|
+
const ready = await checkRootReady(
|
|
3541
|
+
gitRunner,
|
|
3542
|
+
Boolean(opts.allowDirtyRoot)
|
|
3543
|
+
);
|
|
3544
|
+
rootSnapshot = ready.snapshot;
|
|
3545
|
+
return { ok: ready.ok, reason: ready.reason };
|
|
3546
|
+
},
|
|
3547
|
+
deliver: (claim, task, verdict) => deliverTurn(gitRunner, {
|
|
3548
|
+
taskId: claim.memoryId,
|
|
3549
|
+
title: task.title,
|
|
3550
|
+
verdict,
|
|
3551
|
+
snapshot: rootSnapshot ?? {
|
|
3552
|
+
baseBranch: "HEAD",
|
|
3553
|
+
dirtyPaths: []
|
|
3554
|
+
},
|
|
3555
|
+
deliveredBy: located.state.laneId ?? located.state.instanceKey,
|
|
3556
|
+
raisePr: opts.pr !== false,
|
|
3557
|
+
log
|
|
3558
|
+
})
|
|
3559
|
+
};
|
|
3560
|
+
const ports = {
|
|
3561
|
+
...deliveryPorts,
|
|
3562
|
+
checkAdmission: async () => usageTracker.admission(),
|
|
3563
|
+
claimNext: () => claimNext(request, instance.id, log),
|
|
3564
|
+
loadTask: (memoryId) => loadTask(request, memoryId, log),
|
|
3565
|
+
startLeaseHeartbeat: (claim) => startLeaseHeartbeat(
|
|
3566
|
+
() => request(
|
|
3567
|
+
`/me/executor-task-leases/${encodeURIComponent(claim.leaseId)}/heartbeat`,
|
|
3568
|
+
{
|
|
3569
|
+
method: "POST",
|
|
3570
|
+
body: JSON.stringify({
|
|
3571
|
+
claimToken: claim.claimToken,
|
|
3572
|
+
tokenVersion: claim.tokenVersion
|
|
3573
|
+
})
|
|
3574
|
+
}
|
|
3575
|
+
),
|
|
3576
|
+
log,
|
|
3577
|
+
heartbeatMs
|
|
3578
|
+
),
|
|
3579
|
+
runTurn: async (task, claim) => {
|
|
3580
|
+
if (!appServer.alive) {
|
|
3581
|
+
log("codex app-server died between tasks \u2014 respawning");
|
|
3582
|
+
await appServer.start();
|
|
3583
|
+
}
|
|
3584
|
+
turnInFlight = true;
|
|
3585
|
+
try {
|
|
3586
|
+
return await appServer.runTask(taskPrompt(task), {
|
|
3587
|
+
taskId: claim.memoryId,
|
|
3588
|
+
leaseId: claim.leaseId,
|
|
3589
|
+
decompositionId: claim.decompositionId
|
|
3590
|
+
});
|
|
3591
|
+
} finally {
|
|
3592
|
+
turnInFlight = false;
|
|
3593
|
+
}
|
|
3594
|
+
},
|
|
3595
|
+
completeLease: (claim, verdict, text2, title) => request(
|
|
3596
|
+
`/me/executor-task-leases/${encodeURIComponent(claim.leaseId)}/complete`,
|
|
3597
|
+
{
|
|
3598
|
+
method: "POST",
|
|
3599
|
+
body: JSON.stringify({
|
|
3600
|
+
executorInstanceId: instance.id,
|
|
3601
|
+
claimToken: claim.claimToken,
|
|
3602
|
+
tokenVersion: claim.tokenVersion,
|
|
3603
|
+
verdict,
|
|
3604
|
+
text: text2,
|
|
3605
|
+
source: located.state.laneId ?? located.state.instanceKey,
|
|
3606
|
+
title
|
|
3607
|
+
})
|
|
3608
|
+
}
|
|
3609
|
+
),
|
|
3610
|
+
log,
|
|
3611
|
+
waitForWake: (ms) => Promise.race([
|
|
3612
|
+
new Promise((resolve5) => {
|
|
3613
|
+
setTimeout(resolve5, ms).unref?.();
|
|
3614
|
+
}),
|
|
3615
|
+
wakeSignal()
|
|
3616
|
+
])
|
|
3617
|
+
};
|
|
3618
|
+
log(
|
|
3619
|
+
`driven executor live \u2014 instance ${located.state.instanceKey}, lane ${located.state.laneId ?? located.state.instanceKey}${opts.once ? ", single-task mode" : ""}`
|
|
3620
|
+
);
|
|
3621
|
+
log(`usage log \u2192 ${usageLogPath}; admission reserve ${usageReserve}%`);
|
|
3622
|
+
try {
|
|
3623
|
+
const summary = await runDriverLoop(ports, {
|
|
3624
|
+
once: Boolean(opts.once),
|
|
3625
|
+
pollMs: Number.parseInt(String(opts.pollInterval), 10) * 1e3,
|
|
3626
|
+
stopping: () => stopping,
|
|
3627
|
+
source: located.state.laneId ?? located.state.instanceKey
|
|
3628
|
+
});
|
|
3629
|
+
log(
|
|
3630
|
+
`done \u2014 processed ${summary.processed}, completed ${summary.completed}, abandoned ${summary.abandoned}`
|
|
3631
|
+
);
|
|
3632
|
+
} catch (error) {
|
|
3633
|
+
if (error instanceof AuthExpiredError) {
|
|
3634
|
+
process.stderr.write(
|
|
3635
|
+
style.dim(`[run] auth expired: ${error.message}
|
|
3636
|
+
`)
|
|
3637
|
+
);
|
|
3638
|
+
process.exitCode = 1;
|
|
3639
|
+
} else {
|
|
3640
|
+
throw error;
|
|
3641
|
+
}
|
|
3642
|
+
} finally {
|
|
3643
|
+
stopAdvertisementHeartbeat();
|
|
3644
|
+
await connStop().catch(() => {
|
|
3645
|
+
});
|
|
3646
|
+
try {
|
|
3647
|
+
await request(
|
|
3648
|
+
`/me/executor-instances/${encodeURIComponent(instance.id)}`,
|
|
3649
|
+
{ method: "DELETE", body: JSON.stringify({}) }
|
|
3650
|
+
);
|
|
3651
|
+
log("deregistered");
|
|
3652
|
+
} catch (e) {
|
|
3653
|
+
log(`deregister failed (${String(e)}) \u2014 advertisement will expire by TTL`);
|
|
3654
|
+
}
|
|
3655
|
+
appServer.stop();
|
|
3656
|
+
}
|
|
3657
|
+
});
|
|
3658
|
+
}
|
|
3659
|
+
async function claimNext(request, instanceId, log) {
|
|
3660
|
+
const claimed = await claimNextTask({ request, executorInstanceId: instanceId, log });
|
|
3661
|
+
if (!claimed) return void 0;
|
|
3662
|
+
return {
|
|
3663
|
+
memoryId: claimed.memoryId,
|
|
3664
|
+
leaseId: claimed.leaseId,
|
|
3665
|
+
claimToken: claimed.claimToken,
|
|
3666
|
+
tokenVersion: claimed.tokenVersion,
|
|
3667
|
+
decompositionId: claimed.decompositionId
|
|
3668
|
+
};
|
|
3669
|
+
}
|
|
3670
|
+
async function loadTask(request, memoryId, log) {
|
|
3671
|
+
const card = await request(
|
|
3672
|
+
`/tasks/${encodeURIComponent(memoryId)}/card`
|
|
3673
|
+
);
|
|
3674
|
+
let packText = "";
|
|
3675
|
+
const pointer = card.contextPack;
|
|
3676
|
+
if (pointer?.slug && pointer.version) {
|
|
3677
|
+
try {
|
|
3678
|
+
const pkg = await request(
|
|
3679
|
+
`/bundles/${encodeURIComponent(pointer.slug)}/versions/${encodeURIComponent(pointer.version)}/package`
|
|
3680
|
+
);
|
|
3681
|
+
packText = (pkg.components ?? []).map((c) => `### ${c.title ?? "context"}
|
|
3682
|
+
${c.body}`).join("\n\n");
|
|
3683
|
+
} catch (error) {
|
|
3684
|
+
log(
|
|
3685
|
+
`warning: task ${card.taskId} context pack (${pointer.slug}@${pointer.version}) failed to resolve (${String(error)}) \u2014 running on card body only`
|
|
3686
|
+
);
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
return { title: card.title ?? memoryId, text: assemblePrompt(card, packText) };
|
|
3690
|
+
}
|
|
3691
|
+
function assemblePrompt(card, packText) {
|
|
3692
|
+
const sections = [
|
|
3693
|
+
`# Task: ${card.title}`,
|
|
3694
|
+
`## Objective
|
|
3695
|
+
${card.task.objective}`,
|
|
3696
|
+
`## Acceptance
|
|
3697
|
+
${card.task.acceptance}`,
|
|
3698
|
+
`## Boundaries
|
|
3699
|
+
${card.task.boundaries}`,
|
|
3700
|
+
`## Closeout
|
|
3701
|
+
${card.task.closeout}`
|
|
3702
|
+
];
|
|
3703
|
+
if (packText) sections.push(`## Context pack
|
|
3704
|
+
${packText}`);
|
|
3705
|
+
return sections.join("\n\n");
|
|
3706
|
+
}
|
|
3707
|
+
function taskPrompt(task) {
|
|
3708
|
+
return `You are a driven executor working ONE dispatched Work Layer task.
|
|
3709
|
+
|
|
3710
|
+
${task.text}
|
|
3711
|
+
|
|
3712
|
+
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.`;
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
// src/commands/executor.ts
|
|
3716
|
+
function executorSubscriptionInput(name) {
|
|
3717
|
+
return {
|
|
3718
|
+
name,
|
|
3719
|
+
enabled: true,
|
|
3720
|
+
filter: { tags: ["kind:task"], workspaceScope: [] }
|
|
3721
|
+
};
|
|
3722
|
+
}
|
|
3723
|
+
function executorRegistrationInput(state, deliverySubscriptionId) {
|
|
3724
|
+
return {
|
|
3725
|
+
relayId: state.relayId,
|
|
3726
|
+
instanceKey: state.instanceKey,
|
|
3727
|
+
laneId: state.laneId ?? state.instanceKey,
|
|
3728
|
+
runtimeKind: parseRuntimeKind(state.runtime),
|
|
3729
|
+
activationMode: "Attached",
|
|
3730
|
+
deliverySubscriptionId,
|
|
3731
|
+
connectorId: state.connectorId,
|
|
3732
|
+
claimedCapabilityKeys: state.capabilityKeys,
|
|
3733
|
+
toolSetRef: null,
|
|
3734
|
+
ttlSeconds: state.ttlSeconds
|
|
3735
|
+
};
|
|
3736
|
+
}
|
|
3737
|
+
var EXECUTOR_STATE = "executor.json";
|
|
3738
|
+
var EXECUTOR_PULSE_COMMAND = "sechroom executor hook-pulse";
|
|
3739
|
+
var EXECUTOR_STOP_COMMAND = "sechroom executor hook-stop";
|
|
3740
|
+
var CLAUDE_EXECUTOR_HOOKS = {
|
|
3741
|
+
SessionStart: EXECUTOR_PULSE_COMMAND,
|
|
3742
|
+
UserPromptSubmit: EXECUTOR_PULSE_COMMAND,
|
|
3743
|
+
PreToolUse: EXECUTOR_PULSE_COMMAND,
|
|
3744
|
+
Stop: EXECUTOR_PULSE_COMMAND,
|
|
3745
|
+
SessionEnd: EXECUTOR_STOP_COMMAND
|
|
3746
|
+
};
|
|
3747
|
+
var CODEX_EXECUTOR_HOOKS = {
|
|
3748
|
+
SessionStart: EXECUTOR_PULSE_COMMAND,
|
|
3749
|
+
UserPromptSubmit: EXECUTOR_PULSE_COMMAND,
|
|
3750
|
+
PreToolUse: EXECUTOR_PULSE_COMMAND,
|
|
3751
|
+
Stop: EXECUTOR_PULSE_COMMAND
|
|
3752
|
+
};
|
|
3753
|
+
function registerExecutor(program2) {
|
|
3754
|
+
const executor = program2.command("executor").description(
|
|
3755
|
+
"Register and operate a local Claude Code/Codex executor advertisement"
|
|
3756
|
+
);
|
|
3757
|
+
executor.command("install").description(
|
|
3758
|
+
"Configure this checkout's harness to advertise itself as a WLP executor"
|
|
3759
|
+
).option("--connector <id>", "Approved local-session ConnectorDefinition id").option(
|
|
3760
|
+
"--instance-key <key>",
|
|
3761
|
+
"Stable executor identity (defaults to .sechroom/lane.json code-lane)"
|
|
3762
|
+
).option(
|
|
3763
|
+
"--lane-id <lane>",
|
|
3764
|
+
"Canonical affinity lane (defaults to .sechroom/lane.json code-lane)"
|
|
3765
|
+
).option("--runtime <kind>", "claude-code | codex").option("--surface <surface>", "claude | codex").option(
|
|
3766
|
+
"--capability <key...>",
|
|
3767
|
+
"Capability operation keys claimed by this instance"
|
|
3768
|
+
).option(
|
|
3769
|
+
"--relay <id>",
|
|
3770
|
+
"Relay identity shared by sibling instances",
|
|
3771
|
+
"sechroom-cli-local"
|
|
3772
|
+
).option(
|
|
3773
|
+
"--subscription-name <name>",
|
|
1982
3774
|
"SignalR delivery binding name",
|
|
1983
3775
|
"executor-dispatch"
|
|
1984
3776
|
).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 600).option(
|
|
@@ -2048,8 +3840,8 @@ function registerExecutor(program2) {
|
|
|
2048
3840
|
if (opts.refreshAfter >= opts.ttl)
|
|
2049
3841
|
fail("refresh-after must be shorter than the TTL");
|
|
2050
3842
|
const sem = readSem();
|
|
2051
|
-
const checkout = sem ?
|
|
2052
|
-
const statePath =
|
|
3843
|
+
const checkout = sem ? dirname7(dirname7(sem.path)) : process.cwd();
|
|
3844
|
+
const statePath = join10(checkout, ".sechroom", EXECUTOR_STATE);
|
|
2053
3845
|
const state = {
|
|
2054
3846
|
schemaVersion: 1,
|
|
2055
3847
|
instanceKey,
|
|
@@ -2063,14 +3855,14 @@ function registerExecutor(program2) {
|
|
|
2063
3855
|
refreshAfterSeconds: opts.refreshAfter
|
|
2064
3856
|
};
|
|
2065
3857
|
if (!opts.dryRun) {
|
|
2066
|
-
|
|
2067
|
-
|
|
3858
|
+
mkdirSync8(dirname7(statePath), { recursive: true });
|
|
3859
|
+
writeFileSync7(statePath, JSON.stringify(state, null, 2) + "\n");
|
|
2068
3860
|
ensureStateDirIgnored(checkout);
|
|
2069
3861
|
}
|
|
2070
3862
|
const configuredClaudeDirs = globals.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR ? resolveClaudeTargets({ override: globals.claudeConfigDir }).map(
|
|
2071
3863
|
(target) => target.dir
|
|
2072
|
-
) : [
|
|
2073
|
-
const configuredCodexHomes = globals.codexHome || process.env.CODEX_HOME ? resolveCodexHomes({ override: globals.codexHome }) : [
|
|
3864
|
+
) : [join10(checkout, ".claude")];
|
|
3865
|
+
const configuredCodexHomes = globals.codexHome || process.env.CODEX_HOME ? resolveCodexHomes({ override: globals.codexHome }) : [join10(checkout, ".codex")];
|
|
2074
3866
|
const hookTargets = surface === "claude" ? configuredClaudeDirs : configuredCodexHomes;
|
|
2075
3867
|
for (const target of hookTargets) {
|
|
2076
3868
|
const results = surface === "claude" ? [
|
|
@@ -2114,7 +3906,7 @@ function registerExecutor(program2) {
|
|
|
2114
3906
|
);
|
|
2115
3907
|
delete located.state.instanceId;
|
|
2116
3908
|
delete located.state.lastRefreshAt;
|
|
2117
|
-
|
|
3909
|
+
writeFileSync7(
|
|
2118
3910
|
located.path,
|
|
2119
3911
|
JSON.stringify(located.state, null, 2) + "\n"
|
|
2120
3912
|
);
|
|
@@ -2253,6 +4045,7 @@ function registerExecutor(program2) {
|
|
|
2253
4045
|
);
|
|
2254
4046
|
emit(data, Boolean(cmd.optsWithGlobals().json));
|
|
2255
4047
|
});
|
|
4048
|
+
registerExecutorRunCommand(executor);
|
|
2256
4049
|
}
|
|
2257
4050
|
function parseRuntimeKind(value) {
|
|
2258
4051
|
switch (value.trim().toLowerCase()) {
|
|
@@ -2305,19 +4098,19 @@ async function ensureExecutorInstance(cfg, located) {
|
|
|
2305
4098
|
const data = await registerInstance(cfg, state);
|
|
2306
4099
|
state.instanceId = data.id;
|
|
2307
4100
|
state.lastRefreshAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2308
|
-
|
|
4101
|
+
writeFileSync7(path, JSON.stringify(state, null, 2) + "\n");
|
|
2309
4102
|
return data;
|
|
2310
4103
|
}
|
|
2311
4104
|
function readExecutorState(start = process.cwd()) {
|
|
2312
4105
|
const semPath = resolveSemPathForRead(start);
|
|
2313
4106
|
const sem = semPath ? readSem(semPath) : void 0;
|
|
2314
|
-
const path =
|
|
2315
|
-
sem ?
|
|
4107
|
+
const path = join10(
|
|
4108
|
+
sem ? dirname7(sem.path) : join10(start, ".sechroom"),
|
|
2316
4109
|
EXECUTOR_STATE
|
|
2317
4110
|
);
|
|
2318
|
-
if (!
|
|
4111
|
+
if (!existsSync8(path)) return void 0;
|
|
2319
4112
|
return {
|
|
2320
|
-
state: JSON.parse(
|
|
4113
|
+
state: JSON.parse(readFileSync6(path, "utf8")),
|
|
2321
4114
|
path
|
|
2322
4115
|
};
|
|
2323
4116
|
}
|
|
@@ -2349,11 +4142,11 @@ function parseInteger(value) {
|
|
|
2349
4142
|
return parsed;
|
|
2350
4143
|
}
|
|
2351
4144
|
function holdHeartbeat(tick, intervalMs) {
|
|
2352
|
-
return new Promise((
|
|
4145
|
+
return new Promise((resolve5, reject) => {
|
|
2353
4146
|
const timer = setInterval(() => void tick().catch(reject), intervalMs);
|
|
2354
4147
|
const stop = () => {
|
|
2355
4148
|
clearInterval(timer);
|
|
2356
|
-
|
|
4149
|
+
resolve5();
|
|
2357
4150
|
};
|
|
2358
4151
|
process.once("SIGINT", stop);
|
|
2359
4152
|
process.once("SIGTERM", stop);
|
|
@@ -2504,7 +4297,7 @@ function registerChannel(program2) {
|
|
|
2504
4297
|
"MCP server + subscription name (idempotent per name)",
|
|
2505
4298
|
"sechroom-channel"
|
|
2506
4299
|
).option("--dry-run", "Print what would change; write nothing").action((opts) => {
|
|
2507
|
-
const path =
|
|
4300
|
+
const path = join11(process.cwd(), ".mcp.json");
|
|
2508
4301
|
const dryRun = Boolean(opts.dryRun);
|
|
2509
4302
|
const args = ["channel", "mcp"];
|
|
2510
4303
|
const entry = { command: "sechroom", args };
|
|
@@ -2514,8 +4307,8 @@ function registerChannel(program2) {
|
|
|
2514
4307
|
const status = JSON.stringify(existing) === JSON.stringify(entry) ? "current" : existing ? "updated" : "created";
|
|
2515
4308
|
if (status !== "current" && !dryRun) {
|
|
2516
4309
|
config2.mcpServers[opts.name] = entry;
|
|
2517
|
-
|
|
2518
|
-
|
|
4310
|
+
mkdirSync9(dirname8(path), { recursive: true });
|
|
4311
|
+
writeFileSync8(path, JSON.stringify(config2, null, 2) + "\n");
|
|
2519
4312
|
}
|
|
2520
4313
|
const verb = status === "current" ? "already configured" : dryRun ? `would ${status === "created" ? "create" : "update"}` : status;
|
|
2521
4314
|
process.stdout.write(`${style.green("channel")} ${path} (${verb})
|
|
@@ -2606,7 +4399,7 @@ function startExecutorHeartbeat(refresh, intervalMilliseconds, dependencies = {}
|
|
|
2606
4399
|
}
|
|
2607
4400
|
async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {}) {
|
|
2608
4401
|
const request = dependencies.request ?? api;
|
|
2609
|
-
const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((
|
|
4402
|
+
const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve5) => setTimeout(resolve5, milliseconds)));
|
|
2610
4403
|
const idempotencyKey = dependencies.idempotencyKey ?? ((offer) => `channel:${offer.generationId}`);
|
|
2611
4404
|
const state = dependencies.state ?? {};
|
|
2612
4405
|
for (; ; ) {
|
|
@@ -2651,8 +4444,8 @@ async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {})
|
|
|
2651
4444
|
}
|
|
2652
4445
|
}
|
|
2653
4446
|
function readMcpConfig(path) {
|
|
2654
|
-
if (!
|
|
2655
|
-
const raw =
|
|
4447
|
+
if (!existsSync9(path)) return {};
|
|
4448
|
+
const raw = readFileSync7(path, "utf8");
|
|
2656
4449
|
if (!raw.trim()) return {};
|
|
2657
4450
|
try {
|
|
2658
4451
|
return JSON.parse(raw);
|
|
@@ -2680,9 +4473,9 @@ async function openConnection(cfg, onEvent, executorInstanceId) {
|
|
|
2680
4473
|
return conn;
|
|
2681
4474
|
}
|
|
2682
4475
|
function holdOpen(conn) {
|
|
2683
|
-
return new Promise((
|
|
4476
|
+
return new Promise((resolve5) => {
|
|
2684
4477
|
const stop = () => {
|
|
2685
|
-
void conn.stop().finally(
|
|
4478
|
+
void conn.stop().finally(resolve5);
|
|
2686
4479
|
};
|
|
2687
4480
|
process.on("SIGINT", stop);
|
|
2688
4481
|
process.on("SIGTERM", stop);
|
|
@@ -2808,20 +4601,20 @@ Examples:
|
|
|
2808
4601
|
}
|
|
2809
4602
|
|
|
2810
4603
|
// src/commands/checkpoint.ts
|
|
2811
|
-
import { mkdirSync as
|
|
2812
|
-
import { dirname as
|
|
4604
|
+
import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync10 } from "fs";
|
|
4605
|
+
import { dirname as dirname10, join as join13 } from "path";
|
|
2813
4606
|
|
|
2814
4607
|
// src/commands/hook.ts
|
|
2815
4608
|
import { createHash as createHash2 } from "crypto";
|
|
2816
|
-
import { existsSync as
|
|
2817
|
-
import { dirname as
|
|
2818
|
-
async function
|
|
4609
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync10, readFileSync as readFileSync8, statSync as statSync2, writeFileSync as writeFileSync9 } from "fs";
|
|
4610
|
+
import { dirname as dirname9, join as join12 } from "path";
|
|
4611
|
+
async function readStdin2() {
|
|
2819
4612
|
if (process.stdin.isTTY) return "";
|
|
2820
4613
|
const chunks = [];
|
|
2821
4614
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
2822
4615
|
return Buffer.concat(chunks).toString("utf8");
|
|
2823
4616
|
}
|
|
2824
|
-
function
|
|
4617
|
+
function parseHookInput2(raw) {
|
|
2825
4618
|
if (!raw.trim()) return {};
|
|
2826
4619
|
try {
|
|
2827
4620
|
return JSON.parse(raw);
|
|
@@ -2838,13 +4631,13 @@ function resolveLane(flagLane, cwd) {
|
|
|
2838
4631
|
if (!base) return void 0;
|
|
2839
4632
|
return applyWorktreeLaneSuffix(base, start);
|
|
2840
4633
|
}
|
|
2841
|
-
var INTENT_FILE =
|
|
4634
|
+
var INTENT_FILE = join12(".sechroom", "continuity.json");
|
|
2842
4635
|
function resolveIntentPath(start) {
|
|
2843
4636
|
let dir = start;
|
|
2844
4637
|
for (; ; ) {
|
|
2845
|
-
const candidate =
|
|
2846
|
-
if (
|
|
2847
|
-
const parent =
|
|
4638
|
+
const candidate = join12(dir, INTENT_FILE);
|
|
4639
|
+
if (existsSync10(candidate)) return candidate;
|
|
4640
|
+
const parent = dirname9(dir);
|
|
2848
4641
|
if (parent === dir) return void 0;
|
|
2849
4642
|
dir = parent;
|
|
2850
4643
|
}
|
|
@@ -2853,7 +4646,7 @@ function readIntent(start) {
|
|
|
2853
4646
|
const path = resolveIntentPath(start);
|
|
2854
4647
|
if (!path) return void 0;
|
|
2855
4648
|
try {
|
|
2856
|
-
return JSON.parse(
|
|
4649
|
+
return JSON.parse(readFileSync8(path, "utf8"));
|
|
2857
4650
|
} catch {
|
|
2858
4651
|
return void 0;
|
|
2859
4652
|
}
|
|
@@ -2895,14 +4688,14 @@ async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScop
|
|
|
2895
4688
|
}
|
|
2896
4689
|
function ledgerPath(start) {
|
|
2897
4690
|
const intent = resolveIntentPath(start);
|
|
2898
|
-
const dir = intent ?
|
|
2899
|
-
return
|
|
4691
|
+
const dir = intent ? dirname9(intent) : join12(start, ".sechroom");
|
|
4692
|
+
return join12(dir, ".checkpoint-state.json");
|
|
2900
4693
|
}
|
|
2901
4694
|
function readLedger(start) {
|
|
2902
4695
|
try {
|
|
2903
4696
|
const p = ledgerPath(start);
|
|
2904
|
-
if (!
|
|
2905
|
-
return JSON.parse(
|
|
4697
|
+
if (!existsSync10(p)) return {};
|
|
4698
|
+
return JSON.parse(readFileSync8(p, "utf8"));
|
|
2906
4699
|
} catch {
|
|
2907
4700
|
return {};
|
|
2908
4701
|
}
|
|
@@ -2949,13 +4742,13 @@ function recordPush(start, intent) {
|
|
|
2949
4742
|
} catch {
|
|
2950
4743
|
mtimeMs = void 0;
|
|
2951
4744
|
}
|
|
2952
|
-
|
|
4745
|
+
mkdirSync10(dirname9(p), { recursive: true });
|
|
2953
4746
|
const ledger = {
|
|
2954
4747
|
lastEpochMs: Date.now(),
|
|
2955
4748
|
lastMtimeMs: mtimeMs,
|
|
2956
4749
|
lastHash: intentHash(intent)
|
|
2957
4750
|
};
|
|
2958
|
-
|
|
4751
|
+
writeFileSync9(p, JSON.stringify(ledger) + "\n");
|
|
2959
4752
|
} catch {
|
|
2960
4753
|
}
|
|
2961
4754
|
}
|
|
@@ -3015,8 +4808,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
|
|
|
3015
4808
|
);
|
|
3016
4809
|
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
4810
|
try {
|
|
3018
|
-
const raw = await
|
|
3019
|
-
const input =
|
|
4811
|
+
const raw = await readStdin2();
|
|
4812
|
+
const input = parseHookInput2(raw);
|
|
3020
4813
|
const lane = resolveLane(opts.lane, input.cwd);
|
|
3021
4814
|
if (!lane) return process.exit(0);
|
|
3022
4815
|
const semPath = resolveSemPathForRead(input.cwd ?? process.cwd());
|
|
@@ -3041,8 +4834,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
|
|
|
3041
4834
|
});
|
|
3042
4835
|
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
4836
|
try {
|
|
3044
|
-
const raw = await
|
|
3045
|
-
const input =
|
|
4837
|
+
const raw = await readStdin2();
|
|
4838
|
+
const input = parseHookInput2(raw);
|
|
3046
4839
|
const cwd = input.cwd ?? process.cwd();
|
|
3047
4840
|
await saveSnapshotFromIntent(cmd, cwd, opts.lane, opts.scope, "compaction", { skipIfUnchanged: true });
|
|
3048
4841
|
return process.exit(0);
|
|
@@ -3055,8 +4848,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
|
|
|
3055
4848
|
"skip if a hook checkpoint ran within this many minutes \u2014 for high-frequency triggers like Codex Stop (Claude SessionEnd passes none)"
|
|
3056
4849
|
).action(async (opts, cmd) => {
|
|
3057
4850
|
try {
|
|
3058
|
-
const raw = await
|
|
3059
|
-
const input =
|
|
4851
|
+
const raw = await readStdin2();
|
|
4852
|
+
const input = parseHookInput2(raw);
|
|
3060
4853
|
const cwd = input.cwd ?? process.cwd();
|
|
3061
4854
|
const debounce = opts.debounceMinutes != null ? Number(opts.debounceMinutes) : 0;
|
|
3062
4855
|
if (debounce > 0 && recentlyCheckpointed(cwd, debounce)) return process.exit(0);
|
|
@@ -3208,10 +5001,10 @@ Examples:
|
|
|
3208
5001
|
const client = await makeClient(cfg);
|
|
3209
5002
|
return client.POST("/continuity/snapshots", { body });
|
|
3210
5003
|
});
|
|
3211
|
-
const path = resolveIntentPath(cwd) ??
|
|
5004
|
+
const path = resolveIntentPath(cwd) ?? join13(cwd, INTENT_FILE);
|
|
3212
5005
|
const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
|
|
3213
|
-
|
|
3214
|
-
|
|
5006
|
+
mkdirSync11(dirname10(path), { recursive: true });
|
|
5007
|
+
writeFileSync10(path, JSON.stringify(fileBody, null, 2) + "\n");
|
|
3215
5008
|
recordPush(cwd, merged);
|
|
3216
5009
|
if (json) {
|
|
3217
5010
|
emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
|
|
@@ -3225,7 +5018,7 @@ Examples:
|
|
|
3225
5018
|
}
|
|
3226
5019
|
|
|
3227
5020
|
// src/commands/close.ts
|
|
3228
|
-
import { readFileSync as
|
|
5021
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
3229
5022
|
var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
|
|
3230
5023
|
function registerClose(program2) {
|
|
3231
5024
|
program2.command("close").description(
|
|
@@ -3266,7 +5059,7 @@ Examples:
|
|
|
3266
5059
|
);
|
|
3267
5060
|
let bodyText;
|
|
3268
5061
|
try {
|
|
3269
|
-
bodyText = opts.file ?
|
|
5062
|
+
bodyText = opts.file ? readFileSync9(opts.file, "utf8") : readFileSync9(0, "utf8");
|
|
3270
5063
|
} catch {
|
|
3271
5064
|
fail(
|
|
3272
5065
|
opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
|
|
@@ -3557,7 +5350,7 @@ Examples:
|
|
|
3557
5350
|
}
|
|
3558
5351
|
|
|
3559
5352
|
// src/commands/work-plan.ts
|
|
3560
|
-
import { readFile } from "fs/promises";
|
|
5353
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
3561
5354
|
function registerWorkPlan(program2) {
|
|
3562
5355
|
const workPlan = program2.command("work-plan").description(
|
|
3563
5356
|
"Drive a work plan: create one from a brief, then execute / accept / reject"
|
|
@@ -3568,9 +5361,13 @@ function registerWorkPlan(program2) {
|
|
|
3568
5361
|
Examples:
|
|
3569
5362
|
$ sechroom work-plan create mem_XXXX
|
|
3570
5363
|
$ sechroom work-plan create-from-tasks mem_XXXX --file plan.json
|
|
5364
|
+
$ sechroom work-plan append wlp_XXXX --file append.json
|
|
3571
5365
|
$ sechroom work-plan get-plan wlp_XXXX
|
|
3572
5366
|
$ sechroom work-plan execute wlp_XXXX
|
|
3573
5367
|
$ sechroom work-plan publish-context-pack wlp_XXXX
|
|
5368
|
+
$ sechroom work-plan resume wlp_XXXX
|
|
5369
|
+
$ sechroom work-plan return-for-revision wlp_XXXX --notes "Split the migration task"
|
|
5370
|
+
$ sechroom work-plan list --status Accepted --brief mem_XXXX
|
|
3574
5371
|
$ sechroom work-plan accept wlp_XXXX
|
|
3575
5372
|
$ sechroom work-plan reject wlp_XXXX --reason "wrong shape"`
|
|
3576
5373
|
);
|
|
@@ -3580,38 +5377,57 @@ Examples:
|
|
|
3580
5377
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3581
5378
|
const data = await runApi("Creating work plan", async () => {
|
|
3582
5379
|
const client = await makeClient(cfg);
|
|
3583
|
-
return client.POST("/work-briefs/{id}/decompose", {
|
|
5380
|
+
return client.POST("/work-briefs/{id}/decompose", {
|
|
5381
|
+
params: { path: { id: briefId } },
|
|
5382
|
+
body: { id: briefId }
|
|
5383
|
+
});
|
|
5384
|
+
});
|
|
5385
|
+
emitAction(
|
|
5386
|
+
`created work plan from ${style.bold(briefId)} \u2192 ${style.bold(data.suggestionId)}`,
|
|
5387
|
+
data,
|
|
5388
|
+
cmd.optsWithGlobals().json
|
|
5389
|
+
);
|
|
5390
|
+
});
|
|
5391
|
+
workPlan.command("create-from-tasks <briefId>").description(
|
|
5392
|
+
"Create a work plan from a hand-authored JSON task list; tasks may carry Unix-millisecond wakeAtMs"
|
|
5393
|
+
).requiredOption(
|
|
5394
|
+
"--file <path>",
|
|
5395
|
+
"JSON file containing { project, tasks, source? }; use - for stdin"
|
|
5396
|
+
).action(async (briefId, opts, cmd) => {
|
|
5397
|
+
const raw = opts.file === "-" ? await readStdin3() : await readFile2(opts.file, "utf8");
|
|
5398
|
+
const body = parsePlanInput(raw, opts.file);
|
|
5399
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
5400
|
+
const data = await runApi("Creating work plan from tasks", async () => {
|
|
5401
|
+
const client = await makeClient(cfg);
|
|
5402
|
+
return client.POST("/work-briefs/{id}/decompose-from-plan", {
|
|
3584
5403
|
params: { path: { id: briefId } },
|
|
3585
|
-
body
|
|
5404
|
+
body
|
|
3586
5405
|
});
|
|
3587
5406
|
});
|
|
3588
5407
|
emitAction(
|
|
3589
|
-
`created
|
|
5408
|
+
`created ${style.bold(data.suggestionId)} from ${data.taskCount} hand-authored task(s)`,
|
|
3590
5409
|
data,
|
|
3591
5410
|
cmd.optsWithGlobals().json
|
|
3592
5411
|
);
|
|
3593
5412
|
});
|
|
3594
|
-
workPlan.command("
|
|
3595
|
-
"
|
|
5413
|
+
workPlan.command("append <decompositionId>").description(
|
|
5414
|
+
"Append hand-authored task(s) to a running work plan; they land proposed until ratified (POST /decompositions/{id}/append-tasks)"
|
|
3596
5415
|
).requiredOption(
|
|
3597
5416
|
"--file <path>",
|
|
3598
|
-
"JSON file containing {
|
|
3599
|
-
).action(async (
|
|
3600
|
-
const raw = opts.file === "-" ? await
|
|
3601
|
-
const body =
|
|
5417
|
+
"JSON file containing { tasks, gates? } (the AppendTasksInput shape); use - for stdin"
|
|
5418
|
+
).action(async (decompositionId, opts, cmd) => {
|
|
5419
|
+
const raw = opts.file === "-" ? await readStdin3() : await readFile2(opts.file, "utf8");
|
|
5420
|
+
const body = parseAppendInput(raw, opts.file);
|
|
3602
5421
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3603
|
-
const data = await runApi(
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
});
|
|
3611
|
-
}
|
|
3612
|
-
);
|
|
5422
|
+
const data = await runApi("Appending tasks to work plan", async () => {
|
|
5423
|
+
const client = await makeClient(cfg);
|
|
5424
|
+
return client.POST("/decompositions/{id}/append-tasks", {
|
|
5425
|
+
params: { path: { id: decompositionId } },
|
|
5426
|
+
body
|
|
5427
|
+
});
|
|
5428
|
+
});
|
|
3613
5429
|
emitAction(
|
|
3614
|
-
`
|
|
5430
|
+
`appended ${style.bold(String(data.appendedTaskIds.length))} task(s) to ${style.bold(decompositionId)}`,
|
|
3615
5431
|
data,
|
|
3616
5432
|
cmd.optsWithGlobals().json
|
|
3617
5433
|
);
|
|
@@ -3666,6 +5482,70 @@ Examples:
|
|
|
3666
5482
|
cmd.optsWithGlobals().json
|
|
3667
5483
|
);
|
|
3668
5484
|
});
|
|
5485
|
+
workPlan.command("resume <decompositionId>").description(
|
|
5486
|
+
"Resume a failed work-plan decomposition (POST /decompositions/{id}/resume)"
|
|
5487
|
+
).action(async (decompositionId, _opts, cmd) => {
|
|
5488
|
+
const globals = cmd.optsWithGlobals();
|
|
5489
|
+
const cfg = resolveConfig(globals);
|
|
5490
|
+
const data = await runApi("Resuming work plan", async () => {
|
|
5491
|
+
const client = await makeClient(cfg);
|
|
5492
|
+
return client.POST("/decompositions/{id}/resume", {
|
|
5493
|
+
params: { path: { id: decompositionId } },
|
|
5494
|
+
body: {}
|
|
5495
|
+
});
|
|
5496
|
+
});
|
|
5497
|
+
emitAction(
|
|
5498
|
+
`resumed work plan ${style.bold(decompositionId)} \u2192 ${data.status}`,
|
|
5499
|
+
data,
|
|
5500
|
+
globals.json
|
|
5501
|
+
);
|
|
5502
|
+
});
|
|
5503
|
+
workPlan.command("return-for-revision <decompositionId>").description(
|
|
5504
|
+
"Return a candidate work plan for revision (POST /decompositions/{id}/return-for-revision)"
|
|
5505
|
+
).requiredOption(
|
|
5506
|
+
"--notes <text>",
|
|
5507
|
+
"Revision notes for the next decomposition attempt"
|
|
5508
|
+
).action(async (decompositionId, opts, cmd) => {
|
|
5509
|
+
const globals = cmd.optsWithGlobals();
|
|
5510
|
+
const cfg = resolveConfig(globals);
|
|
5511
|
+
const data = await runApi(
|
|
5512
|
+
"Returning work plan for revision",
|
|
5513
|
+
async () => {
|
|
5514
|
+
const client = await makeClient(cfg);
|
|
5515
|
+
return client.POST("/decompositions/{id}/return-for-revision", {
|
|
5516
|
+
params: { path: { id: decompositionId } },
|
|
5517
|
+
body: { notes: opts.notes }
|
|
5518
|
+
});
|
|
5519
|
+
}
|
|
5520
|
+
);
|
|
5521
|
+
emitAction(
|
|
5522
|
+
`returned ${style.bold(decompositionId)} \u2192 ${style.bold(data.newSuggestionId)}`,
|
|
5523
|
+
data,
|
|
5524
|
+
globals.json
|
|
5525
|
+
);
|
|
5526
|
+
});
|
|
5527
|
+
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) => {
|
|
5528
|
+
const globals = cmd.optsWithGlobals();
|
|
5529
|
+
const cfg = resolveConfig(globals);
|
|
5530
|
+
const data = await runApi("Listing work plans", async () => {
|
|
5531
|
+
const client = await makeClient(cfg);
|
|
5532
|
+
return client.GET("/decompositions", {
|
|
5533
|
+
params: {
|
|
5534
|
+
query: {
|
|
5535
|
+
status: opts.status,
|
|
5536
|
+
briefId: opts.brief,
|
|
5537
|
+
page: opts.page,
|
|
5538
|
+
pageSize: opts.pageSize
|
|
5539
|
+
}
|
|
5540
|
+
}
|
|
5541
|
+
});
|
|
5542
|
+
});
|
|
5543
|
+
emitAction(
|
|
5544
|
+
`listed ${style.bold(String(data.items.length))} of ${data.count} work plan(s)`,
|
|
5545
|
+
data,
|
|
5546
|
+
globals.json
|
|
5547
|
+
);
|
|
5548
|
+
});
|
|
3669
5549
|
workPlan.command("accept <decompositionId>").description(
|
|
3670
5550
|
"Accept a Pending work plan \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
|
|
3671
5551
|
).action(async (decompositionId, _opts, cmd) => {
|
|
@@ -3701,6 +5581,19 @@ Examples:
|
|
|
3701
5581
|
);
|
|
3702
5582
|
});
|
|
3703
5583
|
}
|
|
5584
|
+
function parseAppendInput(raw, sourceName = "append input") {
|
|
5585
|
+
let value;
|
|
5586
|
+
try {
|
|
5587
|
+
value = JSON.parse(raw);
|
|
5588
|
+
} catch (error) {
|
|
5589
|
+
throw new Error(
|
|
5590
|
+
`${sourceName} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
5591
|
+
);
|
|
5592
|
+
}
|
|
5593
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.tasks))
|
|
5594
|
+
throw new Error(`${sourceName} must contain an object with a tasks array`);
|
|
5595
|
+
return value;
|
|
5596
|
+
}
|
|
3704
5597
|
function parsePlanInput(raw, sourceName = "plan input") {
|
|
3705
5598
|
let value;
|
|
3706
5599
|
try {
|
|
@@ -3714,7 +5607,7 @@ function parsePlanInput(raw, sourceName = "plan input") {
|
|
|
3714
5607
|
throw new Error(`${sourceName} must contain an object with a tasks array`);
|
|
3715
5608
|
return value;
|
|
3716
5609
|
}
|
|
3717
|
-
async function
|
|
5610
|
+
async function readStdin3() {
|
|
3718
5611
|
const chunks = [];
|
|
3719
5612
|
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
3720
5613
|
return Buffer.concat(chunks).toString("utf8");
|
|
@@ -4231,8 +6124,8 @@ Examples:
|
|
|
4231
6124
|
|
|
4232
6125
|
// src/setup/apply.ts
|
|
4233
6126
|
import { createHash as createHash3 } from "crypto";
|
|
4234
|
-
import { mkdirSync as
|
|
4235
|
-
import { dirname as
|
|
6127
|
+
import { mkdirSync as mkdirSync12, readFileSync as readFileSync10, writeFileSync as writeFileSync11, existsSync as existsSync11 } from "fs";
|
|
6128
|
+
import { dirname as dirname11 } from "path";
|
|
4236
6129
|
var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
|
|
4237
6130
|
var MARKER_END = "<!-- @sechroom/cli:end";
|
|
4238
6131
|
function normalizeBody(s) {
|
|
@@ -4285,22 +6178,22 @@ function parseManagedBlock(content, block) {
|
|
|
4285
6178
|
return null;
|
|
4286
6179
|
}
|
|
4287
6180
|
function ensureDir2(path) {
|
|
4288
|
-
|
|
6181
|
+
mkdirSync12(dirname11(path), { recursive: true });
|
|
4289
6182
|
}
|
|
4290
6183
|
function readOr(path, fallback) {
|
|
4291
6184
|
try {
|
|
4292
|
-
return
|
|
6185
|
+
return readFileSync10(path, "utf8");
|
|
4293
6186
|
} catch {
|
|
4294
6187
|
return fallback;
|
|
4295
6188
|
}
|
|
4296
6189
|
}
|
|
4297
6190
|
function mergeMcpJson(path, snippet, dryRun) {
|
|
4298
6191
|
const incoming = JSON.parse(snippet);
|
|
4299
|
-
const existed =
|
|
6192
|
+
const existed = existsSync11(path);
|
|
4300
6193
|
let current = {};
|
|
4301
6194
|
if (existed) {
|
|
4302
6195
|
try {
|
|
4303
|
-
current = JSON.parse(
|
|
6196
|
+
current = JSON.parse(readFileSync10(path, "utf8"));
|
|
4304
6197
|
} catch {
|
|
4305
6198
|
return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
|
|
4306
6199
|
}
|
|
@@ -4308,26 +6201,26 @@ function mergeMcpJson(path, snippet, dryRun) {
|
|
|
4308
6201
|
current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
|
|
4309
6202
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
4310
6203
|
ensureDir2(path);
|
|
4311
|
-
|
|
6204
|
+
writeFileSync11(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
4312
6205
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
4313
6206
|
}
|
|
4314
6207
|
function mergeCodexToml(path, snippet, dryRun) {
|
|
4315
|
-
const existed =
|
|
6208
|
+
const existed = existsSync11(path);
|
|
4316
6209
|
let body = readOr(path, "");
|
|
4317
6210
|
body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
|
|
4318
6211
|
const trimmed = body.trim();
|
|
4319
6212
|
const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
|
|
4320
6213
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
4321
6214
|
ensureDir2(path);
|
|
4322
|
-
|
|
6215
|
+
writeFileSync11(path, next, { mode: 384 });
|
|
4323
6216
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
4324
6217
|
}
|
|
4325
6218
|
function writeInstructionBlock(path, write, dryRun) {
|
|
4326
|
-
const existed =
|
|
6219
|
+
const existed = existsSync11(path);
|
|
4327
6220
|
const next = computeBlockFile(readOr(path, ""), write);
|
|
4328
6221
|
if (dryRun) return { kind: "instruction", path, status: "dry-run" };
|
|
4329
6222
|
ensureDir2(path);
|
|
4330
|
-
|
|
6223
|
+
writeFileSync11(path, next);
|
|
4331
6224
|
return { kind: "instruction", path, status: existed ? "merged" : "created" };
|
|
4332
6225
|
}
|
|
4333
6226
|
function computeBlockFile(current, write) {
|
|
@@ -4368,7 +6261,7 @@ function applyBlock(path, write, mode, dryRun) {
|
|
|
4368
6261
|
const next = computeBlockFile(current, write);
|
|
4369
6262
|
if (!dryRun) {
|
|
4370
6263
|
ensureDir2(proposedPath);
|
|
4371
|
-
|
|
6264
|
+
writeFileSync11(proposedPath, next);
|
|
4372
6265
|
}
|
|
4373
6266
|
return {
|
|
4374
6267
|
kind: "instruction",
|
|
@@ -4498,8 +6391,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
|
|
|
4498
6391
|
}
|
|
4499
6392
|
|
|
4500
6393
|
// src/setup/skills-offer.ts
|
|
4501
|
-
import { mkdirSync as
|
|
4502
|
-
import { join as
|
|
6394
|
+
import { mkdirSync as mkdirSync13, writeFileSync as writeFileSync12 } from "fs";
|
|
6395
|
+
import { join as join14 } from "path";
|
|
4503
6396
|
|
|
4504
6397
|
// src/setup/lane-pin.ts
|
|
4505
6398
|
var CODE_LANE_PREFIX_BY_CLIENT = {
|
|
@@ -4615,8 +6508,8 @@ Found ${summary} available to you for ${surface}.
|
|
|
4615
6508
|
if (skills.length > 0) {
|
|
4616
6509
|
const written = [];
|
|
4617
6510
|
for (const s of skills) {
|
|
4618
|
-
|
|
4619
|
-
|
|
6511
|
+
mkdirSync13(join14(sDir, s.name), { recursive: true });
|
|
6512
|
+
writeFileSync12(join14(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
|
|
4620
6513
|
written.push(s.name);
|
|
4621
6514
|
}
|
|
4622
6515
|
recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
@@ -4624,11 +6517,11 @@ Found ${summary} available to you for ${surface}.
|
|
|
4624
6517
|
`);
|
|
4625
6518
|
}
|
|
4626
6519
|
if (agents.length > 0) {
|
|
4627
|
-
|
|
6520
|
+
mkdirSync13(aDir, { recursive: true });
|
|
4628
6521
|
const written = [];
|
|
4629
6522
|
for (const a of agents) {
|
|
4630
6523
|
const file = `${a.name}.md`;
|
|
4631
|
-
|
|
6524
|
+
writeFileSync12(join14(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
|
|
4632
6525
|
written.push(file);
|
|
4633
6526
|
}
|
|
4634
6527
|
recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
@@ -4963,12 +6856,12 @@ Examples:
|
|
|
4963
6856
|
});
|
|
4964
6857
|
emit(data, cmd.optsWithGlobals().json);
|
|
4965
6858
|
});
|
|
4966
|
-
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 (
|
|
6859
|
+
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) => {
|
|
4967
6860
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
4968
6861
|
const data = await runApi("Fetching namespace", async () => {
|
|
4969
6862
|
const client = await makeClient(cfg);
|
|
4970
6863
|
return client.GET("/mcp-aggregator/namespaces/{slug}", {
|
|
4971
|
-
params: { path: { slug } }
|
|
6864
|
+
params: { path: { slug: slug2 } }
|
|
4972
6865
|
});
|
|
4973
6866
|
});
|
|
4974
6867
|
emit(data, cmd.optsWithGlobals().json);
|
|
@@ -4977,11 +6870,11 @@ Examples:
|
|
|
4977
6870
|
"--client <list>",
|
|
4978
6871
|
`comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`,
|
|
4979
6872
|
DEFAULT_CLIENT_KEY
|
|
4980
|
-
).option("--dry-run", "print what would be written without writing", false).action(async (
|
|
6873
|
+
).option("--dry-run", "print what would be written without writing", false).action(async (slug2, opts, cmd) => {
|
|
4981
6874
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
4982
6875
|
const setup = await withSpinner(
|
|
4983
6876
|
"Fetching setup descriptors",
|
|
4984
|
-
() => fetchSetup(cfg,
|
|
6877
|
+
() => fetchSetup(cfg, slug2)
|
|
4985
6878
|
);
|
|
4986
6879
|
const targets = clientTargets(process.cwd());
|
|
4987
6880
|
const keys = resolveClientKeys(opts.client);
|
|
@@ -4999,25 +6892,25 @@ Examples:
|
|
|
4999
6892
|
if (!json) printActions(target, actions);
|
|
5000
6893
|
}
|
|
5001
6894
|
if (json) {
|
|
5002
|
-
emit({ namespace:
|
|
6895
|
+
emit({ namespace: slug2, dryRun: Boolean(opts.dryRun), clients: result }, true);
|
|
5003
6896
|
return;
|
|
5004
6897
|
}
|
|
5005
6898
|
process.stdout.write(
|
|
5006
6899
|
opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : `
|
|
5007
|
-
Wired to namespace '${
|
|
6900
|
+
Wired to namespace '${slug2}'. Restart your AI client (or reload MCP) to pick it up.
|
|
5008
6901
|
`
|
|
5009
6902
|
);
|
|
5010
6903
|
});
|
|
5011
6904
|
}
|
|
5012
6905
|
|
|
5013
6906
|
// src/commands/onboard.ts
|
|
5014
|
-
import { existsSync as
|
|
5015
|
-
import { basename as basename2, join as
|
|
6907
|
+
import { existsSync as existsSync13 } from "fs";
|
|
6908
|
+
import { basename as basename2, join as join16 } from "path";
|
|
5016
6909
|
|
|
5017
6910
|
// src/commands/fanout.ts
|
|
5018
6911
|
import { spawnSync } from "child_process";
|
|
5019
|
-
import { existsSync as
|
|
5020
|
-
import { isAbsolute, join as
|
|
6912
|
+
import { existsSync as existsSync12, readFileSync as readFileSync11, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
|
|
6913
|
+
import { isAbsolute, join as join15, resolve as resolve3 } from "path";
|
|
5021
6914
|
var ICON = {
|
|
5022
6915
|
refresh: "\u21BB",
|
|
5023
6916
|
bind: "+",
|
|
@@ -5025,7 +6918,7 @@ var ICON = {
|
|
|
5025
6918
|
"skip-unbound": "\u26A0"
|
|
5026
6919
|
};
|
|
5027
6920
|
function resolveChildDir(path, root) {
|
|
5028
|
-
return isAbsolute(path) ? path :
|
|
6921
|
+
return isAbsolute(path) ? path : resolve3(root, path);
|
|
5029
6922
|
}
|
|
5030
6923
|
function discoverChildren(root) {
|
|
5031
6924
|
let names;
|
|
@@ -5037,21 +6930,21 @@ function discoverChildren(root) {
|
|
|
5037
6930
|
const out = [];
|
|
5038
6931
|
for (const name of names.sort()) {
|
|
5039
6932
|
if (name.startsWith(".") || name === "node_modules") continue;
|
|
5040
|
-
const dir =
|
|
6933
|
+
const dir = join15(root, name);
|
|
5041
6934
|
try {
|
|
5042
6935
|
if (!statSync3(dir).isDirectory()) continue;
|
|
5043
6936
|
} catch {
|
|
5044
6937
|
continue;
|
|
5045
6938
|
}
|
|
5046
|
-
if (
|
|
6939
|
+
if (existsSync12(join15(dir, ".git")) || committedBindingPath(dir)) out.push(name);
|
|
5047
6940
|
}
|
|
5048
6941
|
return out;
|
|
5049
6942
|
}
|
|
5050
6943
|
function readManifest(path) {
|
|
5051
|
-
if (!
|
|
6944
|
+
if (!existsSync12(path)) return null;
|
|
5052
6945
|
let parsed;
|
|
5053
6946
|
try {
|
|
5054
|
-
parsed = JSON.parse(
|
|
6947
|
+
parsed = JSON.parse(readFileSync11(path, "utf8"));
|
|
5055
6948
|
} catch (err2) {
|
|
5056
6949
|
throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
|
|
5057
6950
|
}
|
|
@@ -5417,10 +7310,10 @@ async function chooseScope(scopeFlag, yes) {
|
|
|
5417
7310
|
}
|
|
5418
7311
|
async function planRecurseChild(entry, root, client, opts) {
|
|
5419
7312
|
const dir = resolveChildDir(entry.path, root);
|
|
5420
|
-
if (!
|
|
7313
|
+
if (!existsSync13(dir)) {
|
|
5421
7314
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
5422
7315
|
}
|
|
5423
|
-
if (
|
|
7316
|
+
if (existsSync13(join16(dir, ".sechroom.json"))) {
|
|
5424
7317
|
return {
|
|
5425
7318
|
label: entry.path,
|
|
5426
7319
|
dir,
|
|
@@ -5493,7 +7386,7 @@ This fan-out will pin the same lane in every repo:
|
|
|
5493
7386
|
async function runRecurse(cfg, g, opts) {
|
|
5494
7387
|
const { yes, dryRun, json } = opts;
|
|
5495
7388
|
const root = process.cwd();
|
|
5496
|
-
const manifestPath =
|
|
7389
|
+
const manifestPath = join16(root, ".sechroom", "repos.json");
|
|
5497
7390
|
const fromManifest = readManifest(manifestPath);
|
|
5498
7391
|
const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
|
|
5499
7392
|
const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
|
|
@@ -6026,31 +7919,31 @@ Examples:
|
|
|
6026
7919
|
|
|
6027
7920
|
// src/commands/reset.ts
|
|
6028
7921
|
import { homedir as homedir4 } from "os";
|
|
6029
|
-
import { join as
|
|
6030
|
-
import { existsSync as
|
|
7922
|
+
import { join as join17 } from "path";
|
|
7923
|
+
import { existsSync as existsSync14, readFileSync as readFileSync12, rmSync as rmSync4 } from "fs";
|
|
6031
7924
|
var SKILLS_LOCK2 = ".sechroom-skills.json";
|
|
6032
|
-
var localSkillsDir = () =>
|
|
6033
|
-
var globalSkillsDir = () =>
|
|
6034
|
-
var localAgentsDir = () =>
|
|
6035
|
-
var globalAgentsDir = () =>
|
|
7925
|
+
var localSkillsDir = () => join17(process.cwd(), ".claude", "skills");
|
|
7926
|
+
var globalSkillsDir = () => join17(homedir4(), ".claude", "skills");
|
|
7927
|
+
var localAgentsDir = () => join17(process.cwd(), ".claude", "agents");
|
|
7928
|
+
var globalAgentsDir = () => join17(homedir4(), ".claude", "agents");
|
|
6036
7929
|
function removeMaterialisedSkills(dir) {
|
|
6037
7930
|
const removed = [];
|
|
6038
|
-
const lockPath =
|
|
6039
|
-
if (!
|
|
7931
|
+
const lockPath = join17(dir, SKILLS_LOCK2);
|
|
7932
|
+
if (!existsSync14(lockPath)) return removed;
|
|
6040
7933
|
try {
|
|
6041
|
-
const lock = JSON.parse(
|
|
7934
|
+
const lock = JSON.parse(readFileSync12(lockPath, "utf8"));
|
|
6042
7935
|
for (const entry of Object.values(lock)) {
|
|
6043
7936
|
for (const name of entry.skills ?? []) {
|
|
6044
|
-
const p =
|
|
6045
|
-
if (
|
|
6046
|
-
|
|
7937
|
+
const p = join17(dir, name);
|
|
7938
|
+
if (existsSync14(p)) {
|
|
7939
|
+
rmSync4(p, { recursive: true, force: true });
|
|
6047
7940
|
removed.push(p);
|
|
6048
7941
|
}
|
|
6049
7942
|
}
|
|
6050
7943
|
}
|
|
6051
7944
|
} catch {
|
|
6052
7945
|
}
|
|
6053
|
-
|
|
7946
|
+
rmSync4(lockPath, { force: true });
|
|
6054
7947
|
removed.push(lockPath);
|
|
6055
7948
|
return removed;
|
|
6056
7949
|
}
|
|
@@ -6087,19 +7980,19 @@ function registerReset(program2) {
|
|
|
6087
7980
|
}
|
|
6088
7981
|
}
|
|
6089
7982
|
const removed = [];
|
|
6090
|
-
const stateDir =
|
|
6091
|
-
if (
|
|
6092
|
-
|
|
7983
|
+
const stateDir = join17(process.cwd(), ".sechroom");
|
|
7984
|
+
if (existsSync14(stateDir)) {
|
|
7985
|
+
rmSync4(stateDir, { recursive: true, force: true });
|
|
6093
7986
|
removed.push(stateDir);
|
|
6094
7987
|
}
|
|
6095
|
-
const legacyCfg =
|
|
6096
|
-
if (
|
|
6097
|
-
|
|
7988
|
+
const legacyCfg = join17(process.cwd(), ".sechroom.json");
|
|
7989
|
+
if (existsSync14(legacyCfg)) {
|
|
7990
|
+
rmSync4(legacyCfg, { force: true });
|
|
6098
7991
|
removed.push(legacyCfg);
|
|
6099
7992
|
}
|
|
6100
|
-
const legacySem =
|
|
6101
|
-
if (
|
|
6102
|
-
|
|
7993
|
+
const legacySem = join17(process.cwd(), ".sem");
|
|
7994
|
+
if (existsSync14(legacySem)) {
|
|
7995
|
+
rmSync4(legacySem, { force: true });
|
|
6103
7996
|
removed.push(legacySem);
|
|
6104
7997
|
}
|
|
6105
7998
|
removed.push(...removeMaterialisedSkills(localSkillsDir()));
|
|
@@ -6124,8 +8017,8 @@ function registerReset(program2) {
|
|
|
6124
8017
|
}
|
|
6125
8018
|
|
|
6126
8019
|
// src/commands/skills.ts
|
|
6127
|
-
import { existsSync as
|
|
6128
|
-
import { join as
|
|
8020
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync14, statSync as statSync4, writeFileSync as writeFileSync13 } from "fs";
|
|
8021
|
+
import { join as join18 } from "path";
|
|
6129
8022
|
function filenameFromDisposition(header) {
|
|
6130
8023
|
if (!header) return void 0;
|
|
6131
8024
|
const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header);
|
|
@@ -6133,11 +8026,11 @@ function filenameFromDisposition(header) {
|
|
|
6133
8026
|
}
|
|
6134
8027
|
function resolveOutputPath(output, serverFilename) {
|
|
6135
8028
|
const filename = serverFilename || "skills.zip";
|
|
6136
|
-
if (!output) return
|
|
6137
|
-
const looksLikeDir = output.endsWith("/") ||
|
|
8029
|
+
if (!output) return join18(process.cwd(), filename);
|
|
8030
|
+
const looksLikeDir = output.endsWith("/") || existsSync15(output) && statSync4(output).isDirectory();
|
|
6138
8031
|
if (looksLikeDir) {
|
|
6139
|
-
|
|
6140
|
-
return
|
|
8032
|
+
mkdirSync14(output, { recursive: true });
|
|
8033
|
+
return join18(output, filename);
|
|
6141
8034
|
}
|
|
6142
8035
|
return output;
|
|
6143
8036
|
}
|
|
@@ -6168,7 +8061,7 @@ async function downloadZip(label, call, output) {
|
|
|
6168
8061
|
const buf = Buffer.from(res.data);
|
|
6169
8062
|
const filename = filenameFromDisposition(res.response.headers.get("content-disposition")) ?? "skills.zip";
|
|
6170
8063
|
const path = resolveOutputPath(output, filename);
|
|
6171
|
-
|
|
8064
|
+
writeFileSync13(path, buf);
|
|
6172
8065
|
return { path, bytes: buf.length, filename };
|
|
6173
8066
|
}
|
|
6174
8067
|
function registerSkills(program2) {
|
|
@@ -6342,12 +8235,12 @@ clients select all; an unconfigured machine preserves the legacy Claude default.
|
|
|
6342
8235
|
}
|
|
6343
8236
|
|
|
6344
8237
|
// src/commands/sweep.ts
|
|
6345
|
-
import { existsSync as
|
|
6346
|
-
import { dirname as
|
|
6347
|
-
var DEFAULT_MANIFEST =
|
|
8238
|
+
import { existsSync as existsSync16 } from "fs";
|
|
8239
|
+
import { dirname as dirname12, join as join19, resolve as resolve4 } from "path";
|
|
8240
|
+
var DEFAULT_MANIFEST = join19(".sechroom", "repos.json");
|
|
6348
8241
|
function planEntry(entry, root) {
|
|
6349
8242
|
const dir = resolveChildDir(entry.path, root);
|
|
6350
|
-
if (!
|
|
8243
|
+
if (!existsSync16(dir)) {
|
|
6351
8244
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
6352
8245
|
}
|
|
6353
8246
|
if (committedBindingPath(dir)) {
|
|
@@ -6400,400 +8293,44 @@ Per repo (paths resolve relative to the manifest's root):
|
|
|
6400
8293
|
${ICON["skip-unbound"]} no workspace unbound + no workspaceId in manifest \u2192 skipped (add one, or onboard manually)
|
|
6401
8294
|
|
|
6402
8295
|
Examples:
|
|
6403
|
-
$ sechroom sweep --dry-run preview every repo's disposition, run nothing
|
|
6404
|
-
$ sechroom sweep onboard the whole tree from the root
|
|
6405
|
-
$ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
|
|
6406
|
-
).action((opts, cmd) => {
|
|
6407
|
-
const g = cmd.optsWithGlobals();
|
|
6408
|
-
const json = Boolean(g.json);
|
|
6409
|
-
const dryRun = Boolean(opts.dryRun);
|
|
6410
|
-
const manifestPath = resolve2(opts.manifest);
|
|
6411
|
-
let repos;
|
|
6412
|
-
try {
|
|
6413
|
-
repos = readManifest(manifestPath);
|
|
6414
|
-
} catch (err2) {
|
|
6415
|
-
fail(err2 instanceof Error ? err2.message : String(err2));
|
|
6416
|
-
}
|
|
6417
|
-
if (repos === null) {
|
|
6418
|
-
fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
|
|
6419
|
-
}
|
|
6420
|
-
if (repos.length === 0) {
|
|
6421
|
-
if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
|
|
6422
|
-
else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
|
|
6423
|
-
`);
|
|
6424
|
-
return;
|
|
6425
|
-
}
|
|
6426
|
-
const root = dirname10(dirname10(manifestPath));
|
|
6427
|
-
const plans = repos.map((entry) => planEntry(entry, root));
|
|
6428
|
-
if (!json) {
|
|
6429
|
-
process.stderr.write(
|
|
6430
|
-
`${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
|
|
6431
|
-
`
|
|
6432
|
-
);
|
|
6433
|
-
}
|
|
6434
|
-
const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
|
|
6435
|
-
if (json) {
|
|
6436
|
-
process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
|
|
6437
|
-
return;
|
|
6438
|
-
}
|
|
6439
|
-
summarizeFanout(results, { dryRun });
|
|
6440
|
-
});
|
|
6441
|
-
}
|
|
6442
|
-
|
|
6443
|
-
// src/commands/telemetry.ts
|
|
6444
|
-
import {
|
|
6445
|
-
existsSync as existsSync16,
|
|
6446
|
-
mkdirSync as mkdirSync13,
|
|
6447
|
-
readFileSync as readFileSync12,
|
|
6448
|
-
rmSync as rmSync4,
|
|
6449
|
-
writeFileSync as writeFileSync13
|
|
6450
|
-
} from "fs";
|
|
6451
|
-
import { dirname as dirname11, join as join18 } from "path";
|
|
6452
|
-
function registerTelemetry(program2) {
|
|
6453
|
-
const telemetry = program2.command("telemetry").description(
|
|
6454
|
-
"Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
|
|
6455
|
-
);
|
|
6456
|
-
telemetry.command("emit").description(
|
|
6457
|
-
"POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
|
|
6458
|
-
).requiredOption(
|
|
6459
|
-
"--decomposition <id>",
|
|
6460
|
-
"Decomposition id whose run this event belongs to"
|
|
6461
|
-
).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
|
|
6462
|
-
"--kind <kind>",
|
|
6463
|
-
"Event kind: raw | parsed | approval | terminal"
|
|
6464
|
-
).option(
|
|
6465
|
-
"--tokens-in <n>",
|
|
6466
|
-
"Cumulative input tokens (spend meter)",
|
|
6467
|
-
parseIntOpt
|
|
6468
|
-
).option(
|
|
6469
|
-
"--tokens-out <n>",
|
|
6470
|
-
"Cumulative output tokens (spend meter)",
|
|
6471
|
-
parseIntOpt
|
|
6472
|
-
).option(
|
|
6473
|
-
"--context-used <n>",
|
|
6474
|
-
"Context tokens currently used (occupancy meter)",
|
|
6475
|
-
parseIntOpt
|
|
6476
|
-
).option(
|
|
6477
|
-
"--context-window <n>",
|
|
6478
|
-
"Context window size (occupancy meter)",
|
|
6479
|
-
parseIntOpt
|
|
6480
|
-
).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
|
|
6481
|
-
"--verdict <v>",
|
|
6482
|
-
"Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
|
|
6483
|
-
).action(async (opts, cmd) => {
|
|
6484
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
6485
|
-
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
6486
|
-
const event = {
|
|
6487
|
-
taskId: opts.task,
|
|
6488
|
-
kind: normalizeKind(opts.kind),
|
|
6489
|
-
tokensIn: opts.tokensIn ?? null,
|
|
6490
|
-
tokensOut: opts.tokensOut ?? null,
|
|
6491
|
-
contextUsed: opts.contextUsed ?? null,
|
|
6492
|
-
contextWindow: opts.contextWindow ?? null,
|
|
6493
|
-
text: opts.text ?? null,
|
|
6494
|
-
approvalState: opts.approval ?? null,
|
|
6495
|
-
verdict: opts.verdict ?? null
|
|
6496
|
-
};
|
|
6497
|
-
let body;
|
|
6498
|
-
try {
|
|
6499
|
-
body = await postTelemetry(cfg, opts.decomposition, [event]);
|
|
6500
|
-
} catch (e) {
|
|
6501
|
-
return fail(`Telemetry emit failed: ${e.message}`);
|
|
6502
|
-
}
|
|
6503
|
-
if (json) {
|
|
6504
|
-
emit(body, true);
|
|
6505
|
-
} else {
|
|
6506
|
-
process.stderr.write(
|
|
6507
|
-
style.green("telemetry emitted") + style.dim(
|
|
6508
|
-
` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
|
|
6509
|
-
`
|
|
6510
|
-
)
|
|
6511
|
-
);
|
|
6512
|
-
}
|
|
6513
|
-
});
|
|
6514
|
-
telemetry.command("show <decompositionId>").description(
|
|
6515
|
-
"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.)"
|
|
6516
|
-
).action(async (decompositionId, _opts, cmd) => {
|
|
6517
|
-
const globals = cmd.optsWithGlobals();
|
|
6518
|
-
const cfg = resolveConfig(globals);
|
|
6519
|
-
const data = await runApi("Reading run telemetry", async () => {
|
|
6520
|
-
const client = await makeClient(cfg);
|
|
6521
|
-
return client.GET("/decompositions/{id}/run/telemetry", {
|
|
6522
|
-
params: { path: { id: decompositionId } }
|
|
6523
|
-
});
|
|
6524
|
-
});
|
|
6525
|
-
emitAction(
|
|
6526
|
-
data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
|
|
6527
|
-
data,
|
|
6528
|
-
globals.json
|
|
6529
|
-
);
|
|
6530
|
-
});
|
|
6531
|
-
telemetry.command("bind").description(
|
|
6532
|
-
"Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
|
|
6533
|
-
).requiredOption(
|
|
6534
|
-
"--decomposition <id>",
|
|
6535
|
-
"Decomposition id this session executes"
|
|
6536
|
-
).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
|
|
6537
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
6538
|
-
const dir = join18(process.cwd(), ".sechroom");
|
|
6539
|
-
mkdirSync13(dir, { recursive: true });
|
|
6540
|
-
const path = join18(dir, BINDING_FILE);
|
|
6541
|
-
const binding = {
|
|
6542
|
-
decompositionId: opts.decomposition,
|
|
6543
|
-
taskId: opts.task
|
|
6544
|
-
};
|
|
6545
|
-
writeFileSync13(path, JSON.stringify(binding, null, 2) + "\n");
|
|
6546
|
-
ensureStateDirIgnored(process.cwd());
|
|
6547
|
-
if (json) {
|
|
6548
|
-
emit({ bound: true, ...binding, path }, true);
|
|
6549
|
-
} else {
|
|
6550
|
-
process.stdout.write(
|
|
6551
|
-
style.green("telemetry bound") + style.dim(
|
|
6552
|
-
` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
|
|
6553
|
-
`
|
|
6554
|
-
)
|
|
6555
|
-
);
|
|
6556
|
-
}
|
|
6557
|
-
});
|
|
6558
|
-
telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
|
|
6559
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
6560
|
-
const path = join18(process.cwd(), ".sechroom", BINDING_FILE);
|
|
6561
|
-
const existed = existsSync16(path);
|
|
6562
|
-
if (existed) rmSync4(path);
|
|
6563
|
-
if (json) emit({ unbound: existed, path }, true);
|
|
6564
|
-
else
|
|
6565
|
-
process.stdout.write(
|
|
6566
|
-
existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
|
|
6567
|
-
);
|
|
6568
|
-
});
|
|
6569
|
-
telemetry.command("hook").description(
|
|
6570
|
-
"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."
|
|
6571
|
-
).action(async (_opts, cmd) => {
|
|
6572
|
-
try {
|
|
6573
|
-
const raw = await readStdin3();
|
|
6574
|
-
const input = parseHookInput2(raw);
|
|
6575
|
-
const cwd = input.cwd ?? process.cwd();
|
|
6576
|
-
const binding = findBinding(cwd);
|
|
6577
|
-
if (!binding) return process.exit(0);
|
|
6578
|
-
const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
|
|
6579
|
-
const events = buildHookEvents(input, usage, binding.taskId);
|
|
6580
|
-
if (events.length === 0) return process.exit(0);
|
|
6581
|
-
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
6582
|
-
await postTelemetry(cfg, binding.decompositionId, events);
|
|
6583
|
-
return process.exit(0);
|
|
6584
|
-
} catch {
|
|
6585
|
-
return process.exit(0);
|
|
6586
|
-
}
|
|
6587
|
-
});
|
|
6588
|
-
telemetry.command("install").description(
|
|
6589
|
-
"Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
|
|
6590
|
-
).option(
|
|
6591
|
-
"--scope <scope>",
|
|
6592
|
-
"global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
|
|
6593
|
-
).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
|
|
8296
|
+
$ sechroom sweep --dry-run preview every repo's disposition, run nothing
|
|
8297
|
+
$ sechroom sweep onboard the whole tree from the root
|
|
8298
|
+
$ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
|
|
8299
|
+
).action((opts, cmd) => {
|
|
6594
8300
|
const g = cmd.optsWithGlobals();
|
|
8301
|
+
const json = Boolean(g.json);
|
|
6595
8302
|
const dryRun = Boolean(opts.dryRun);
|
|
6596
|
-
const
|
|
6597
|
-
let
|
|
8303
|
+
const manifestPath = resolve4(opts.manifest);
|
|
8304
|
+
let repos;
|
|
6598
8305
|
try {
|
|
6599
|
-
|
|
8306
|
+
repos = readManifest(manifestPath);
|
|
6600
8307
|
} catch (err2) {
|
|
6601
|
-
|
|
8308
|
+
fail(err2 instanceof Error ? err2.message : String(err2));
|
|
8309
|
+
}
|
|
8310
|
+
if (repos === null) {
|
|
8311
|
+
fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
|
|
8312
|
+
}
|
|
8313
|
+
if (repos.length === 0) {
|
|
8314
|
+
if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
|
|
8315
|
+
else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
|
|
6602
8316
|
`);
|
|
6603
|
-
return
|
|
8317
|
+
return;
|
|
6604
8318
|
}
|
|
6605
|
-
const
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
cwd
|
|
6609
|
-
});
|
|
6610
|
-
const commands = {
|
|
6611
|
-
Stop: "sechroom telemetry hook",
|
|
6612
|
-
SubagentStop: "sechroom telemetry hook",
|
|
6613
|
-
Notification: "sechroom telemetry hook",
|
|
6614
|
-
PermissionDenied: "sechroom telemetry hook"
|
|
6615
|
-
};
|
|
6616
|
-
try {
|
|
6617
|
-
const multi = targets.length > 1;
|
|
6618
|
-
const results = targets.map((t) => {
|
|
6619
|
-
const r = installClaudeCommands(t.dir, commands, dryRun);
|
|
6620
|
-
process.stdout.write(
|
|
6621
|
-
`${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
|
|
6622
|
-
`
|
|
6623
|
-
);
|
|
6624
|
-
process.stdout.write(describe(r, dryRun) + "\n");
|
|
6625
|
-
return r;
|
|
6626
|
-
});
|
|
6627
|
-
if (dryRun) {
|
|
6628
|
-
process.stdout.write("\n(dry run \u2014 no files were written.)\n");
|
|
6629
|
-
} else if (results.every((r) => r.status === "current")) {
|
|
6630
|
-
process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
|
|
6631
|
-
} else {
|
|
6632
|
-
process.stdout.write(
|
|
6633
|
-
"\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
|
|
6634
|
-
);
|
|
6635
|
-
}
|
|
6636
|
-
} catch (err2) {
|
|
8319
|
+
const root = dirname12(dirname12(manifestPath));
|
|
8320
|
+
const plans = repos.map((entry) => planEntry(entry, root));
|
|
8321
|
+
if (!json) {
|
|
6637
8322
|
process.stderr.write(
|
|
6638
|
-
`
|
|
8323
|
+
`${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
|
|
6639
8324
|
`
|
|
6640
8325
|
);
|
|
6641
|
-
return process.exit(1);
|
|
6642
|
-
}
|
|
6643
|
-
warnIfSechroomNotOnPath();
|
|
6644
|
-
return process.exit(0);
|
|
6645
|
-
});
|
|
6646
|
-
}
|
|
6647
|
-
var BINDING_FILE = "telemetry.json";
|
|
6648
|
-
async function postTelemetry(cfg, decompositionId, events) {
|
|
6649
|
-
const token = await requireToken(cfg);
|
|
6650
|
-
const resp = await fetch(
|
|
6651
|
-
`${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
|
|
6652
|
-
{
|
|
6653
|
-
method: "POST",
|
|
6654
|
-
headers: {
|
|
6655
|
-
authorization: `Bearer ${token}`,
|
|
6656
|
-
tenant: cfg.tenant,
|
|
6657
|
-
"content-type": "application/json",
|
|
6658
|
-
"x-sechroom-surface": "cli"
|
|
6659
|
-
},
|
|
6660
|
-
body: JSON.stringify({ events })
|
|
6661
|
-
}
|
|
6662
|
-
);
|
|
6663
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
|
6664
|
-
return await resp.json();
|
|
6665
|
-
}
|
|
6666
|
-
function findBinding(start) {
|
|
6667
|
-
let dir = start;
|
|
6668
|
-
for (; ; ) {
|
|
6669
|
-
const path = join18(dir, ".sechroom", BINDING_FILE);
|
|
6670
|
-
if (existsSync16(path)) {
|
|
6671
|
-
try {
|
|
6672
|
-
const b = JSON.parse(
|
|
6673
|
-
readFileSync12(path, "utf8")
|
|
6674
|
-
);
|
|
6675
|
-
if (b.decompositionId && b.taskId)
|
|
6676
|
-
return { decompositionId: b.decompositionId, taskId: b.taskId };
|
|
6677
|
-
} catch {
|
|
6678
|
-
}
|
|
6679
|
-
return null;
|
|
6680
8326
|
}
|
|
6681
|
-
const
|
|
6682
|
-
if (
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
}
|
|
6686
|
-
function parseTranscript(path) {
|
|
6687
|
-
if (!existsSync16(path)) return null;
|
|
6688
|
-
let tokensIn = 0;
|
|
6689
|
-
let tokensOut = 0;
|
|
6690
|
-
let contextUsed = 0;
|
|
6691
|
-
let model = "";
|
|
6692
|
-
for (const line of readFileSync12(path, "utf8").split("\n")) {
|
|
6693
|
-
if (!line.trim()) continue;
|
|
6694
|
-
let obj;
|
|
6695
|
-
try {
|
|
6696
|
-
obj = JSON.parse(line);
|
|
6697
|
-
} catch {
|
|
6698
|
-
continue;
|
|
8327
|
+
const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
|
|
8328
|
+
if (json) {
|
|
8329
|
+
process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
|
|
8330
|
+
return;
|
|
6699
8331
|
}
|
|
6700
|
-
|
|
6701
|
-
if (!usage) continue;
|
|
6702
|
-
const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
6703
|
-
tokensIn += input;
|
|
6704
|
-
tokensOut += usage.output_tokens ?? 0;
|
|
6705
|
-
contextUsed = input;
|
|
6706
|
-
if (obj.message?.model) model = obj.message.model;
|
|
6707
|
-
}
|
|
6708
|
-
if (tokensIn === 0 && tokensOut === 0) return null;
|
|
6709
|
-
return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed) };
|
|
6710
|
-
}
|
|
6711
|
-
function windowFor(model, contextUsed = 0) {
|
|
6712
|
-
const m = model.toLowerCase();
|
|
6713
|
-
if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
|
|
6714
|
-
return contextUsed > 2e5 ? 1e6 : 2e5;
|
|
6715
|
-
}
|
|
6716
|
-
function buildHookEvents(input, usage, taskId) {
|
|
6717
|
-
const events = [];
|
|
6718
|
-
const base = (kind, over) => ({
|
|
6719
|
-
taskId,
|
|
6720
|
-
kind,
|
|
6721
|
-
tokensIn: null,
|
|
6722
|
-
tokensOut: null,
|
|
6723
|
-
contextUsed: null,
|
|
6724
|
-
contextWindow: null,
|
|
6725
|
-
text: null,
|
|
6726
|
-
approvalState: null,
|
|
6727
|
-
verdict: null,
|
|
6728
|
-
...over
|
|
8332
|
+
summarizeFanout(results, { dryRun });
|
|
6729
8333
|
});
|
|
6730
|
-
if (usage) {
|
|
6731
|
-
events.push(
|
|
6732
|
-
base("Parsed", {
|
|
6733
|
-
tokensIn: usage.tokensIn,
|
|
6734
|
-
tokensOut: usage.tokensOut,
|
|
6735
|
-
contextUsed: usage.contextUsed,
|
|
6736
|
-
contextWindow: usage.contextWindow
|
|
6737
|
-
})
|
|
6738
|
-
);
|
|
6739
|
-
}
|
|
6740
|
-
switch (input.hook_event_name) {
|
|
6741
|
-
case "PermissionDenied":
|
|
6742
|
-
events.push(
|
|
6743
|
-
base("Approval", {
|
|
6744
|
-
approvalState: "denied",
|
|
6745
|
-
text: input.tool_name ?? input.message ?? null
|
|
6746
|
-
})
|
|
6747
|
-
);
|
|
6748
|
-
break;
|
|
6749
|
-
case "Notification":
|
|
6750
|
-
if (isPermissionNotification(input))
|
|
6751
|
-
events.push(base("Approval", { text: input.message ?? null }));
|
|
6752
|
-
break;
|
|
6753
|
-
case "Stop":
|
|
6754
|
-
case "SubagentStop":
|
|
6755
|
-
events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
|
|
6756
|
-
break;
|
|
6757
|
-
}
|
|
6758
|
-
return events;
|
|
6759
|
-
}
|
|
6760
|
-
function isPermissionNotification(input) {
|
|
6761
|
-
const t = (input.notification_type ?? input.type ?? "").toLowerCase();
|
|
6762
|
-
if (t) return t.includes("permission");
|
|
6763
|
-
return (input.message ?? "").toLowerCase().includes("permission");
|
|
6764
|
-
}
|
|
6765
|
-
async function readStdin3() {
|
|
6766
|
-
if (process.stdin.isTTY) return "";
|
|
6767
|
-
const chunks = [];
|
|
6768
|
-
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
6769
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
6770
|
-
}
|
|
6771
|
-
function parseHookInput2(raw) {
|
|
6772
|
-
if (!raw.trim()) return {};
|
|
6773
|
-
try {
|
|
6774
|
-
return JSON.parse(raw);
|
|
6775
|
-
} catch {
|
|
6776
|
-
return {};
|
|
6777
|
-
}
|
|
6778
|
-
}
|
|
6779
|
-
var KINDS = {
|
|
6780
|
-
raw: "Raw",
|
|
6781
|
-
parsed: "Parsed",
|
|
6782
|
-
approval: "Approval",
|
|
6783
|
-
terminal: "Terminal"
|
|
6784
|
-
};
|
|
6785
|
-
function normalizeKind(k) {
|
|
6786
|
-
const v = KINDS[k.toLowerCase()];
|
|
6787
|
-
if (!v)
|
|
6788
|
-
fail(
|
|
6789
|
-
`Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
|
|
6790
|
-
);
|
|
6791
|
-
return v;
|
|
6792
|
-
}
|
|
6793
|
-
function parseIntOpt(v) {
|
|
6794
|
-
const n = Number.parseInt(v, 10);
|
|
6795
|
-
if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
|
|
6796
|
-
return n;
|
|
6797
8334
|
}
|
|
6798
8335
|
|
|
6799
8336
|
// src/commands/worklog.ts
|
|
@@ -7001,11 +8538,43 @@ function registerWorkBrief(program2) {
|
|
|
7001
8538
|
Examples:
|
|
7002
8539
|
$ sechroom work-brief pause mem_XXXX --reason-code operator-hold --source claude-code-chris
|
|
7003
8540
|
$ sechroom work-brief resume mem_XXXX --reason-code operator-resume --source claude-code-chris --reason "Ready to continue"
|
|
7004
|
-
$ sechroom work-brief cancel mem_XXXX --reason-code operator-stopped --source claude-code-chris --reason "Work no longer required"
|
|
8541
|
+
$ sechroom work-brief cancel mem_XXXX --reason-code operator-stopped --source claude-code-chris --reason "Work no longer required"
|
|
8542
|
+
$ sechroom work-brief park mem_XXXX --source claude-code-chris
|
|
8543
|
+
$ sechroom work-brief unpark mem_XXXX --source claude-code-chris
|
|
8544
|
+
$ sechroom work-brief supersede mem_XXXX --source claude-code-chris`
|
|
7005
8545
|
);
|
|
7006
8546
|
registerLifecycleAction(workBrief, "pause");
|
|
7007
8547
|
registerLifecycleAction(workBrief, "resume");
|
|
7008
8548
|
registerLifecycleAction(workBrief, "cancel");
|
|
8549
|
+
registerStatusAction(workBrief, "park", "status:parked");
|
|
8550
|
+
registerStatusAction(workBrief, "unpark", "status:ready_for_decomposition");
|
|
8551
|
+
registerStatusAction(workBrief, "supersede", "status:superseded");
|
|
8552
|
+
}
|
|
8553
|
+
function registerStatusAction(workBrief, action, to) {
|
|
8554
|
+
workBrief.command(`${action} <briefId>`).description(
|
|
8555
|
+
`${capitalize(action)} a work brief via its governed status transition`
|
|
8556
|
+
).requiredOption(
|
|
8557
|
+
"--source <source>",
|
|
8558
|
+
"Calling surface or lane recorded on the contribution"
|
|
8559
|
+
).action(async (briefId, opts, cmd) => {
|
|
8560
|
+
const globals = cmd.optsWithGlobals();
|
|
8561
|
+
const cfg = resolveConfig(globals);
|
|
8562
|
+
const data = await runApi(
|
|
8563
|
+
`${capitalize(action)}ing work brief`,
|
|
8564
|
+
async () => {
|
|
8565
|
+
const client = await makeClient(cfg);
|
|
8566
|
+
return client.POST("/work-briefs/{id}/status", {
|
|
8567
|
+
params: { path: { id: briefId } },
|
|
8568
|
+
body: { id: briefId, to, source: opts.source }
|
|
8569
|
+
});
|
|
8570
|
+
}
|
|
8571
|
+
);
|
|
8572
|
+
emitAction(
|
|
8573
|
+
`${action} work brief ${style.bold(briefId)} \u2192 ${data.to}`,
|
|
8574
|
+
data,
|
|
8575
|
+
globals.json
|
|
8576
|
+
);
|
|
8577
|
+
});
|
|
7009
8578
|
}
|
|
7010
8579
|
function registerLifecycleAction(workBrief, action) {
|
|
7011
8580
|
const presentParticiple = action === "pause" ? "Pausing" : action === "resume" ? "Resuming" : "Cancelling";
|
|
@@ -7043,17 +8612,24 @@ function capitalize(value) {
|
|
|
7043
8612
|
}
|
|
7044
8613
|
|
|
7045
8614
|
// src/commands/work-task.ts
|
|
8615
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
7046
8616
|
function registerWorkTask(program2) {
|
|
7047
|
-
const workTask = program2.command("work-task").description(
|
|
8617
|
+
const workTask = program2.command("work-task").description(
|
|
8618
|
+
"Read work tasks and close them out (list \xB7 card \xB7 mark-no-residue)"
|
|
8619
|
+
);
|
|
7048
8620
|
workTask.addHelpText(
|
|
7049
8621
|
"after",
|
|
7050
8622
|
`
|
|
7051
8623
|
Examples:
|
|
7052
8624
|
$ sechroom work-task list --status in-progress --lane claude-code-chris
|
|
7053
8625
|
$ sechroom work-task card mem_XXXX
|
|
7054
|
-
$ sechroom work-task mark-no-residue mem_XXXX --decomposition wlp_XXXX
|
|
8626
|
+
$ sechroom work-task mark-no-residue mem_XXXX --decomposition wlp_XXXX
|
|
8627
|
+
$ sechroom work-task residue-produce mem_XXXX --file residue.json`
|
|
7055
8628
|
);
|
|
7056
|
-
workTask.command("list").description("List work tasks, newest-first (GET /work-tasks)").option("--shape <shape>", "Filter: bare | managed").option(
|
|
8629
|
+
workTask.command("list").description("List work tasks, newest-first (GET /work-tasks)").option("--shape <shape>", "Filter: bare | managed").option(
|
|
8630
|
+
"--lane <lane>",
|
|
8631
|
+
"Filter by dispatch-lane value, e.g. claude-code-chris"
|
|
8632
|
+
).option("--status <status>", "Filter by status value, e.g. in-progress").option("--page <n>", "1-based page number", (v) => Number.parseInt(v, 10)).option(
|
|
7057
8633
|
"--page-size <n>",
|
|
7058
8634
|
"Page size (default 50, capped 200)",
|
|
7059
8635
|
(v) => Number.parseInt(v, 10)
|
|
@@ -7080,7 +8656,9 @@ Examples:
|
|
|
7080
8656
|
globals.json
|
|
7081
8657
|
);
|
|
7082
8658
|
});
|
|
7083
|
-
workTask.command("card <taskId>").description(
|
|
8659
|
+
workTask.command("card <taskId>").description(
|
|
8660
|
+
"Read one task's runnable card \u2014 its full working instructions (GET /tasks/{id}/card)"
|
|
8661
|
+
).action(async (taskId, _opts, cmd) => {
|
|
7084
8662
|
const globals = cmd.optsWithGlobals();
|
|
7085
8663
|
const cfg = resolveConfig(globals);
|
|
7086
8664
|
const data = await runApi("Reading task card", async () => {
|
|
@@ -7091,26 +8669,67 @@ Examples:
|
|
|
7091
8669
|
});
|
|
7092
8670
|
emitAction(`read card ${style.bold(taskId)}`, data, globals.json);
|
|
7093
8671
|
});
|
|
7094
|
-
workTask.command("
|
|
7095
|
-
"
|
|
8672
|
+
workTask.command("residue-produce <taskId>").description(
|
|
8673
|
+
"Produce typed residue for a work task (POST /work-tasks/produce-residue)"
|
|
7096
8674
|
).requiredOption(
|
|
7097
|
-
"--
|
|
7098
|
-
"
|
|
8675
|
+
"--file <path>",
|
|
8676
|
+
"JSON file containing a residues array; use - for stdin"
|
|
7099
8677
|
).action(async (taskId, opts, cmd) => {
|
|
8678
|
+
const raw = opts.file === "-" ? await readStdin4() : await readFile3(opts.file, "utf8");
|
|
8679
|
+
const body = parseResidueInput(raw, opts.file, taskId);
|
|
7100
8680
|
const globals = cmd.optsWithGlobals();
|
|
7101
8681
|
const cfg = resolveConfig(globals);
|
|
7102
|
-
const data = await runApi("
|
|
8682
|
+
const data = await runApi("Producing task residue", async () => {
|
|
7103
8683
|
const client = await makeClient(cfg);
|
|
7104
|
-
return client.POST("/work-tasks/
|
|
7105
|
-
body: { taskId, decompositionId: opts.decomposition }
|
|
7106
|
-
});
|
|
8684
|
+
return client.POST("/work-tasks/produce-residue", { body });
|
|
7107
8685
|
});
|
|
7108
8686
|
emitAction(
|
|
7109
|
-
`
|
|
8687
|
+
`produced ${style.bold(String(data.produced.length))} residue item(s) for ${style.bold(taskId)}`,
|
|
7110
8688
|
data,
|
|
7111
8689
|
globals.json
|
|
7112
8690
|
);
|
|
7113
8691
|
});
|
|
8692
|
+
workTask.command("mark-no-residue <taskId>").description(
|
|
8693
|
+
"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)"
|
|
8694
|
+
).requiredOption(
|
|
8695
|
+
"--decomposition <id>",
|
|
8696
|
+
"The work plan id (wlp_\u2026) the task belongs to \u2014 the marker is keyed (decompositionId, taskId)"
|
|
8697
|
+
).action(
|
|
8698
|
+
async (taskId, opts, cmd) => {
|
|
8699
|
+
const globals = cmd.optsWithGlobals();
|
|
8700
|
+
const cfg = resolveConfig(globals);
|
|
8701
|
+
const data = await runApi("Marking task no-residue", async () => {
|
|
8702
|
+
const client = await makeClient(cfg);
|
|
8703
|
+
return client.POST("/work-tasks/mark-no-residue", {
|
|
8704
|
+
body: { taskId, decompositionId: opts.decomposition }
|
|
8705
|
+
});
|
|
8706
|
+
});
|
|
8707
|
+
emitAction(
|
|
8708
|
+
`marked ${style.bold(taskId)} no-residue (minted: ${data.minted})`,
|
|
8709
|
+
data,
|
|
8710
|
+
globals.json
|
|
8711
|
+
);
|
|
8712
|
+
}
|
|
8713
|
+
);
|
|
8714
|
+
}
|
|
8715
|
+
function parseResidueInput(raw, sourceName, taskId) {
|
|
8716
|
+
let value;
|
|
8717
|
+
try {
|
|
8718
|
+
value = JSON.parse(raw);
|
|
8719
|
+
} catch (error) {
|
|
8720
|
+
throw new Error(
|
|
8721
|
+
`${sourceName} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
8722
|
+
);
|
|
8723
|
+
}
|
|
8724
|
+
const residues = Array.isArray(value) ? value : value && typeof value === "object" ? value.residues : void 0;
|
|
8725
|
+
if (!Array.isArray(residues) || residues.length === 0)
|
|
8726
|
+
throw new Error(`${sourceName} must contain a non-empty residues array`);
|
|
8727
|
+
return { taskId, residues };
|
|
8728
|
+
}
|
|
8729
|
+
async function readStdin4() {
|
|
8730
|
+
const chunks = [];
|
|
8731
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
8732
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
7114
8733
|
}
|
|
7115
8734
|
|
|
7116
8735
|
// src/index.ts
|