@sechroom/cli 2026.7.28 → 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 +2187 -604
- 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"
|
|
@@ -3572,6 +5365,9 @@ Examples:
|
|
|
3572
5365
|
$ sechroom work-plan get-plan wlp_XXXX
|
|
3573
5366
|
$ sechroom work-plan execute wlp_XXXX
|
|
3574
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
|
|
3575
5371
|
$ sechroom work-plan accept wlp_XXXX
|
|
3576
5372
|
$ sechroom work-plan reject wlp_XXXX --reason "wrong shape"`
|
|
3577
5373
|
);
|
|
@@ -3598,19 +5394,16 @@ Examples:
|
|
|
3598
5394
|
"--file <path>",
|
|
3599
5395
|
"JSON file containing { project, tasks, source? }; use - for stdin"
|
|
3600
5396
|
).action(async (briefId, opts, cmd) => {
|
|
3601
|
-
const raw = opts.file === "-" ? await
|
|
5397
|
+
const raw = opts.file === "-" ? await readStdin3() : await readFile2(opts.file, "utf8");
|
|
3602
5398
|
const body = parsePlanInput(raw, opts.file);
|
|
3603
5399
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3604
|
-
const data = await runApi(
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
});
|
|
3612
|
-
}
|
|
3613
|
-
);
|
|
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", {
|
|
5403
|
+
params: { path: { id: briefId } },
|
|
5404
|
+
body
|
|
5405
|
+
});
|
|
5406
|
+
});
|
|
3614
5407
|
emitAction(
|
|
3615
5408
|
`created ${style.bold(data.suggestionId)} from ${data.taskCount} hand-authored task(s)`,
|
|
3616
5409
|
data,
|
|
@@ -3623,7 +5416,7 @@ Examples:
|
|
|
3623
5416
|
"--file <path>",
|
|
3624
5417
|
"JSON file containing { tasks, gates? } (the AppendTasksInput shape); use - for stdin"
|
|
3625
5418
|
).action(async (decompositionId, opts, cmd) => {
|
|
3626
|
-
const raw = opts.file === "-" ? await
|
|
5419
|
+
const raw = opts.file === "-" ? await readStdin3() : await readFile2(opts.file, "utf8");
|
|
3627
5420
|
const body = parseAppendInput(raw, opts.file);
|
|
3628
5421
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3629
5422
|
const data = await runApi("Appending tasks to work plan", async () => {
|
|
@@ -3689,6 +5482,70 @@ Examples:
|
|
|
3689
5482
|
cmd.optsWithGlobals().json
|
|
3690
5483
|
);
|
|
3691
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
|
+
});
|
|
3692
5549
|
workPlan.command("accept <decompositionId>").description(
|
|
3693
5550
|
"Accept a Pending work plan \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
|
|
3694
5551
|
).action(async (decompositionId, _opts, cmd) => {
|
|
@@ -3750,7 +5607,7 @@ function parsePlanInput(raw, sourceName = "plan input") {
|
|
|
3750
5607
|
throw new Error(`${sourceName} must contain an object with a tasks array`);
|
|
3751
5608
|
return value;
|
|
3752
5609
|
}
|
|
3753
|
-
async function
|
|
5610
|
+
async function readStdin3() {
|
|
3754
5611
|
const chunks = [];
|
|
3755
5612
|
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
3756
5613
|
return Buffer.concat(chunks).toString("utf8");
|
|
@@ -4267,8 +6124,8 @@ Examples:
|
|
|
4267
6124
|
|
|
4268
6125
|
// src/setup/apply.ts
|
|
4269
6126
|
import { createHash as createHash3 } from "crypto";
|
|
4270
|
-
import { mkdirSync as
|
|
4271
|
-
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";
|
|
4272
6129
|
var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
|
|
4273
6130
|
var MARKER_END = "<!-- @sechroom/cli:end";
|
|
4274
6131
|
function normalizeBody(s) {
|
|
@@ -4321,22 +6178,22 @@ function parseManagedBlock(content, block) {
|
|
|
4321
6178
|
return null;
|
|
4322
6179
|
}
|
|
4323
6180
|
function ensureDir2(path) {
|
|
4324
|
-
|
|
6181
|
+
mkdirSync12(dirname11(path), { recursive: true });
|
|
4325
6182
|
}
|
|
4326
6183
|
function readOr(path, fallback) {
|
|
4327
6184
|
try {
|
|
4328
|
-
return
|
|
6185
|
+
return readFileSync10(path, "utf8");
|
|
4329
6186
|
} catch {
|
|
4330
6187
|
return fallback;
|
|
4331
6188
|
}
|
|
4332
6189
|
}
|
|
4333
6190
|
function mergeMcpJson(path, snippet, dryRun) {
|
|
4334
6191
|
const incoming = JSON.parse(snippet);
|
|
4335
|
-
const existed =
|
|
6192
|
+
const existed = existsSync11(path);
|
|
4336
6193
|
let current = {};
|
|
4337
6194
|
if (existed) {
|
|
4338
6195
|
try {
|
|
4339
|
-
current = JSON.parse(
|
|
6196
|
+
current = JSON.parse(readFileSync10(path, "utf8"));
|
|
4340
6197
|
} catch {
|
|
4341
6198
|
return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
|
|
4342
6199
|
}
|
|
@@ -4344,26 +6201,26 @@ function mergeMcpJson(path, snippet, dryRun) {
|
|
|
4344
6201
|
current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
|
|
4345
6202
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
4346
6203
|
ensureDir2(path);
|
|
4347
|
-
|
|
6204
|
+
writeFileSync11(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
4348
6205
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
4349
6206
|
}
|
|
4350
6207
|
function mergeCodexToml(path, snippet, dryRun) {
|
|
4351
|
-
const existed =
|
|
6208
|
+
const existed = existsSync11(path);
|
|
4352
6209
|
let body = readOr(path, "");
|
|
4353
6210
|
body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
|
|
4354
6211
|
const trimmed = body.trim();
|
|
4355
6212
|
const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
|
|
4356
6213
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
4357
6214
|
ensureDir2(path);
|
|
4358
|
-
|
|
6215
|
+
writeFileSync11(path, next, { mode: 384 });
|
|
4359
6216
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
4360
6217
|
}
|
|
4361
6218
|
function writeInstructionBlock(path, write, dryRun) {
|
|
4362
|
-
const existed =
|
|
6219
|
+
const existed = existsSync11(path);
|
|
4363
6220
|
const next = computeBlockFile(readOr(path, ""), write);
|
|
4364
6221
|
if (dryRun) return { kind: "instruction", path, status: "dry-run" };
|
|
4365
6222
|
ensureDir2(path);
|
|
4366
|
-
|
|
6223
|
+
writeFileSync11(path, next);
|
|
4367
6224
|
return { kind: "instruction", path, status: existed ? "merged" : "created" };
|
|
4368
6225
|
}
|
|
4369
6226
|
function computeBlockFile(current, write) {
|
|
@@ -4404,7 +6261,7 @@ function applyBlock(path, write, mode, dryRun) {
|
|
|
4404
6261
|
const next = computeBlockFile(current, write);
|
|
4405
6262
|
if (!dryRun) {
|
|
4406
6263
|
ensureDir2(proposedPath);
|
|
4407
|
-
|
|
6264
|
+
writeFileSync11(proposedPath, next);
|
|
4408
6265
|
}
|
|
4409
6266
|
return {
|
|
4410
6267
|
kind: "instruction",
|
|
@@ -4534,8 +6391,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
|
|
|
4534
6391
|
}
|
|
4535
6392
|
|
|
4536
6393
|
// src/setup/skills-offer.ts
|
|
4537
|
-
import { mkdirSync as
|
|
4538
|
-
import { join as
|
|
6394
|
+
import { mkdirSync as mkdirSync13, writeFileSync as writeFileSync12 } from "fs";
|
|
6395
|
+
import { join as join14 } from "path";
|
|
4539
6396
|
|
|
4540
6397
|
// src/setup/lane-pin.ts
|
|
4541
6398
|
var CODE_LANE_PREFIX_BY_CLIENT = {
|
|
@@ -4651,8 +6508,8 @@ Found ${summary} available to you for ${surface}.
|
|
|
4651
6508
|
if (skills.length > 0) {
|
|
4652
6509
|
const written = [];
|
|
4653
6510
|
for (const s of skills) {
|
|
4654
|
-
|
|
4655
|
-
|
|
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");
|
|
4656
6513
|
written.push(s.name);
|
|
4657
6514
|
}
|
|
4658
6515
|
recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
@@ -4660,11 +6517,11 @@ Found ${summary} available to you for ${surface}.
|
|
|
4660
6517
|
`);
|
|
4661
6518
|
}
|
|
4662
6519
|
if (agents.length > 0) {
|
|
4663
|
-
|
|
6520
|
+
mkdirSync13(aDir, { recursive: true });
|
|
4664
6521
|
const written = [];
|
|
4665
6522
|
for (const a of agents) {
|
|
4666
6523
|
const file = `${a.name}.md`;
|
|
4667
|
-
|
|
6524
|
+
writeFileSync12(join14(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
|
|
4668
6525
|
written.push(file);
|
|
4669
6526
|
}
|
|
4670
6527
|
recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
@@ -4999,12 +6856,12 @@ Examples:
|
|
|
4999
6856
|
});
|
|
5000
6857
|
emit(data, cmd.optsWithGlobals().json);
|
|
5001
6858
|
});
|
|
5002
|
-
namespace.command("show <slug>").description("Show a namespace's details (GET /mcp-aggregator/namespaces/{slug}). For the tool list it exposes, point an OpenAPI client at /t/{tenant}/namespaces/{slug}/api/openapi.json.").action(async (
|
|
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) => {
|
|
5003
6860
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
5004
6861
|
const data = await runApi("Fetching namespace", async () => {
|
|
5005
6862
|
const client = await makeClient(cfg);
|
|
5006
6863
|
return client.GET("/mcp-aggregator/namespaces/{slug}", {
|
|
5007
|
-
params: { path: { slug } }
|
|
6864
|
+
params: { path: { slug: slug2 } }
|
|
5008
6865
|
});
|
|
5009
6866
|
});
|
|
5010
6867
|
emit(data, cmd.optsWithGlobals().json);
|
|
@@ -5013,11 +6870,11 @@ Examples:
|
|
|
5013
6870
|
"--client <list>",
|
|
5014
6871
|
`comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`,
|
|
5015
6872
|
DEFAULT_CLIENT_KEY
|
|
5016
|
-
).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) => {
|
|
5017
6874
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
5018
6875
|
const setup = await withSpinner(
|
|
5019
6876
|
"Fetching setup descriptors",
|
|
5020
|
-
() => fetchSetup(cfg,
|
|
6877
|
+
() => fetchSetup(cfg, slug2)
|
|
5021
6878
|
);
|
|
5022
6879
|
const targets = clientTargets(process.cwd());
|
|
5023
6880
|
const keys = resolveClientKeys(opts.client);
|
|
@@ -5035,25 +6892,25 @@ Examples:
|
|
|
5035
6892
|
if (!json) printActions(target, actions);
|
|
5036
6893
|
}
|
|
5037
6894
|
if (json) {
|
|
5038
|
-
emit({ namespace:
|
|
6895
|
+
emit({ namespace: slug2, dryRun: Boolean(opts.dryRun), clients: result }, true);
|
|
5039
6896
|
return;
|
|
5040
6897
|
}
|
|
5041
6898
|
process.stdout.write(
|
|
5042
6899
|
opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : `
|
|
5043
|
-
Wired to namespace '${
|
|
6900
|
+
Wired to namespace '${slug2}'. Restart your AI client (or reload MCP) to pick it up.
|
|
5044
6901
|
`
|
|
5045
6902
|
);
|
|
5046
6903
|
});
|
|
5047
6904
|
}
|
|
5048
6905
|
|
|
5049
6906
|
// src/commands/onboard.ts
|
|
5050
|
-
import { existsSync as
|
|
5051
|
-
import { basename as basename2, join as
|
|
6907
|
+
import { existsSync as existsSync13 } from "fs";
|
|
6908
|
+
import { basename as basename2, join as join16 } from "path";
|
|
5052
6909
|
|
|
5053
6910
|
// src/commands/fanout.ts
|
|
5054
6911
|
import { spawnSync } from "child_process";
|
|
5055
|
-
import { existsSync as
|
|
5056
|
-
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";
|
|
5057
6914
|
var ICON = {
|
|
5058
6915
|
refresh: "\u21BB",
|
|
5059
6916
|
bind: "+",
|
|
@@ -5061,7 +6918,7 @@ var ICON = {
|
|
|
5061
6918
|
"skip-unbound": "\u26A0"
|
|
5062
6919
|
};
|
|
5063
6920
|
function resolveChildDir(path, root) {
|
|
5064
|
-
return isAbsolute(path) ? path :
|
|
6921
|
+
return isAbsolute(path) ? path : resolve3(root, path);
|
|
5065
6922
|
}
|
|
5066
6923
|
function discoverChildren(root) {
|
|
5067
6924
|
let names;
|
|
@@ -5073,21 +6930,21 @@ function discoverChildren(root) {
|
|
|
5073
6930
|
const out = [];
|
|
5074
6931
|
for (const name of names.sort()) {
|
|
5075
6932
|
if (name.startsWith(".") || name === "node_modules") continue;
|
|
5076
|
-
const dir =
|
|
6933
|
+
const dir = join15(root, name);
|
|
5077
6934
|
try {
|
|
5078
6935
|
if (!statSync3(dir).isDirectory()) continue;
|
|
5079
6936
|
} catch {
|
|
5080
6937
|
continue;
|
|
5081
6938
|
}
|
|
5082
|
-
if (
|
|
6939
|
+
if (existsSync12(join15(dir, ".git")) || committedBindingPath(dir)) out.push(name);
|
|
5083
6940
|
}
|
|
5084
6941
|
return out;
|
|
5085
6942
|
}
|
|
5086
6943
|
function readManifest(path) {
|
|
5087
|
-
if (!
|
|
6944
|
+
if (!existsSync12(path)) return null;
|
|
5088
6945
|
let parsed;
|
|
5089
6946
|
try {
|
|
5090
|
-
parsed = JSON.parse(
|
|
6947
|
+
parsed = JSON.parse(readFileSync11(path, "utf8"));
|
|
5091
6948
|
} catch (err2) {
|
|
5092
6949
|
throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
|
|
5093
6950
|
}
|
|
@@ -5453,10 +7310,10 @@ async function chooseScope(scopeFlag, yes) {
|
|
|
5453
7310
|
}
|
|
5454
7311
|
async function planRecurseChild(entry, root, client, opts) {
|
|
5455
7312
|
const dir = resolveChildDir(entry.path, root);
|
|
5456
|
-
if (!
|
|
7313
|
+
if (!existsSync13(dir)) {
|
|
5457
7314
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
5458
7315
|
}
|
|
5459
|
-
if (
|
|
7316
|
+
if (existsSync13(join16(dir, ".sechroom.json"))) {
|
|
5460
7317
|
return {
|
|
5461
7318
|
label: entry.path,
|
|
5462
7319
|
dir,
|
|
@@ -5529,7 +7386,7 @@ This fan-out will pin the same lane in every repo:
|
|
|
5529
7386
|
async function runRecurse(cfg, g, opts) {
|
|
5530
7387
|
const { yes, dryRun, json } = opts;
|
|
5531
7388
|
const root = process.cwd();
|
|
5532
|
-
const manifestPath =
|
|
7389
|
+
const manifestPath = join16(root, ".sechroom", "repos.json");
|
|
5533
7390
|
const fromManifest = readManifest(manifestPath);
|
|
5534
7391
|
const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
|
|
5535
7392
|
const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
|
|
@@ -6062,31 +7919,31 @@ Examples:
|
|
|
6062
7919
|
|
|
6063
7920
|
// src/commands/reset.ts
|
|
6064
7921
|
import { homedir as homedir4 } from "os";
|
|
6065
|
-
import { join as
|
|
6066
|
-
import { existsSync as
|
|
7922
|
+
import { join as join17 } from "path";
|
|
7923
|
+
import { existsSync as existsSync14, readFileSync as readFileSync12, rmSync as rmSync4 } from "fs";
|
|
6067
7924
|
var SKILLS_LOCK2 = ".sechroom-skills.json";
|
|
6068
|
-
var localSkillsDir = () =>
|
|
6069
|
-
var globalSkillsDir = () =>
|
|
6070
|
-
var localAgentsDir = () =>
|
|
6071
|
-
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");
|
|
6072
7929
|
function removeMaterialisedSkills(dir) {
|
|
6073
7930
|
const removed = [];
|
|
6074
|
-
const lockPath =
|
|
6075
|
-
if (!
|
|
7931
|
+
const lockPath = join17(dir, SKILLS_LOCK2);
|
|
7932
|
+
if (!existsSync14(lockPath)) return removed;
|
|
6076
7933
|
try {
|
|
6077
|
-
const lock = JSON.parse(
|
|
7934
|
+
const lock = JSON.parse(readFileSync12(lockPath, "utf8"));
|
|
6078
7935
|
for (const entry of Object.values(lock)) {
|
|
6079
7936
|
for (const name of entry.skills ?? []) {
|
|
6080
|
-
const p =
|
|
6081
|
-
if (
|
|
6082
|
-
|
|
7937
|
+
const p = join17(dir, name);
|
|
7938
|
+
if (existsSync14(p)) {
|
|
7939
|
+
rmSync4(p, { recursive: true, force: true });
|
|
6083
7940
|
removed.push(p);
|
|
6084
7941
|
}
|
|
6085
7942
|
}
|
|
6086
7943
|
}
|
|
6087
7944
|
} catch {
|
|
6088
7945
|
}
|
|
6089
|
-
|
|
7946
|
+
rmSync4(lockPath, { force: true });
|
|
6090
7947
|
removed.push(lockPath);
|
|
6091
7948
|
return removed;
|
|
6092
7949
|
}
|
|
@@ -6123,19 +7980,19 @@ function registerReset(program2) {
|
|
|
6123
7980
|
}
|
|
6124
7981
|
}
|
|
6125
7982
|
const removed = [];
|
|
6126
|
-
const stateDir =
|
|
6127
|
-
if (
|
|
6128
|
-
|
|
7983
|
+
const stateDir = join17(process.cwd(), ".sechroom");
|
|
7984
|
+
if (existsSync14(stateDir)) {
|
|
7985
|
+
rmSync4(stateDir, { recursive: true, force: true });
|
|
6129
7986
|
removed.push(stateDir);
|
|
6130
7987
|
}
|
|
6131
|
-
const legacyCfg =
|
|
6132
|
-
if (
|
|
6133
|
-
|
|
7988
|
+
const legacyCfg = join17(process.cwd(), ".sechroom.json");
|
|
7989
|
+
if (existsSync14(legacyCfg)) {
|
|
7990
|
+
rmSync4(legacyCfg, { force: true });
|
|
6134
7991
|
removed.push(legacyCfg);
|
|
6135
7992
|
}
|
|
6136
|
-
const legacySem =
|
|
6137
|
-
if (
|
|
6138
|
-
|
|
7993
|
+
const legacySem = join17(process.cwd(), ".sem");
|
|
7994
|
+
if (existsSync14(legacySem)) {
|
|
7995
|
+
rmSync4(legacySem, { force: true });
|
|
6139
7996
|
removed.push(legacySem);
|
|
6140
7997
|
}
|
|
6141
7998
|
removed.push(...removeMaterialisedSkills(localSkillsDir()));
|
|
@@ -6160,8 +8017,8 @@ function registerReset(program2) {
|
|
|
6160
8017
|
}
|
|
6161
8018
|
|
|
6162
8019
|
// src/commands/skills.ts
|
|
6163
|
-
import { existsSync as
|
|
6164
|
-
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";
|
|
6165
8022
|
function filenameFromDisposition(header) {
|
|
6166
8023
|
if (!header) return void 0;
|
|
6167
8024
|
const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header);
|
|
@@ -6169,11 +8026,11 @@ function filenameFromDisposition(header) {
|
|
|
6169
8026
|
}
|
|
6170
8027
|
function resolveOutputPath(output, serverFilename) {
|
|
6171
8028
|
const filename = serverFilename || "skills.zip";
|
|
6172
|
-
if (!output) return
|
|
6173
|
-
const looksLikeDir = output.endsWith("/") ||
|
|
8029
|
+
if (!output) return join18(process.cwd(), filename);
|
|
8030
|
+
const looksLikeDir = output.endsWith("/") || existsSync15(output) && statSync4(output).isDirectory();
|
|
6174
8031
|
if (looksLikeDir) {
|
|
6175
|
-
|
|
6176
|
-
return
|
|
8032
|
+
mkdirSync14(output, { recursive: true });
|
|
8033
|
+
return join18(output, filename);
|
|
6177
8034
|
}
|
|
6178
8035
|
return output;
|
|
6179
8036
|
}
|
|
@@ -6204,7 +8061,7 @@ async function downloadZip(label, call, output) {
|
|
|
6204
8061
|
const buf = Buffer.from(res.data);
|
|
6205
8062
|
const filename = filenameFromDisposition(res.response.headers.get("content-disposition")) ?? "skills.zip";
|
|
6206
8063
|
const path = resolveOutputPath(output, filename);
|
|
6207
|
-
|
|
8064
|
+
writeFileSync13(path, buf);
|
|
6208
8065
|
return { path, bytes: buf.length, filename };
|
|
6209
8066
|
}
|
|
6210
8067
|
function registerSkills(program2) {
|
|
@@ -6378,12 +8235,12 @@ clients select all; an unconfigured machine preserves the legacy Claude default.
|
|
|
6378
8235
|
}
|
|
6379
8236
|
|
|
6380
8237
|
// src/commands/sweep.ts
|
|
6381
|
-
import { existsSync as
|
|
6382
|
-
import { dirname as
|
|
6383
|
-
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");
|
|
6384
8241
|
function planEntry(entry, root) {
|
|
6385
8242
|
const dir = resolveChildDir(entry.path, root);
|
|
6386
|
-
if (!
|
|
8243
|
+
if (!existsSync16(dir)) {
|
|
6387
8244
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
6388
8245
|
}
|
|
6389
8246
|
if (committedBindingPath(dir)) {
|
|
@@ -6436,400 +8293,44 @@ Per repo (paths resolve relative to the manifest's root):
|
|
|
6436
8293
|
${ICON["skip-unbound"]} no workspace unbound + no workspaceId in manifest \u2192 skipped (add one, or onboard manually)
|
|
6437
8294
|
|
|
6438
8295
|
Examples:
|
|
6439
|
-
$ sechroom sweep --dry-run preview every repo's disposition, run nothing
|
|
6440
|
-
$ sechroom sweep onboard the whole tree from the root
|
|
6441
|
-
$ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
|
|
6442
|
-
).action((opts, cmd) => {
|
|
6443
|
-
const g = cmd.optsWithGlobals();
|
|
6444
|
-
const json = Boolean(g.json);
|
|
6445
|
-
const dryRun = Boolean(opts.dryRun);
|
|
6446
|
-
const manifestPath = resolve2(opts.manifest);
|
|
6447
|
-
let repos;
|
|
6448
|
-
try {
|
|
6449
|
-
repos = readManifest(manifestPath);
|
|
6450
|
-
} catch (err2) {
|
|
6451
|
-
fail(err2 instanceof Error ? err2.message : String(err2));
|
|
6452
|
-
}
|
|
6453
|
-
if (repos === null) {
|
|
6454
|
-
fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
|
|
6455
|
-
}
|
|
6456
|
-
if (repos.length === 0) {
|
|
6457
|
-
if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
|
|
6458
|
-
else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
|
|
6459
|
-
`);
|
|
6460
|
-
return;
|
|
6461
|
-
}
|
|
6462
|
-
const root = dirname10(dirname10(manifestPath));
|
|
6463
|
-
const plans = repos.map((entry) => planEntry(entry, root));
|
|
6464
|
-
if (!json) {
|
|
6465
|
-
process.stderr.write(
|
|
6466
|
-
`${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
|
|
6467
|
-
`
|
|
6468
|
-
);
|
|
6469
|
-
}
|
|
6470
|
-
const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
|
|
6471
|
-
if (json) {
|
|
6472
|
-
process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
|
|
6473
|
-
return;
|
|
6474
|
-
}
|
|
6475
|
-
summarizeFanout(results, { dryRun });
|
|
6476
|
-
});
|
|
6477
|
-
}
|
|
6478
|
-
|
|
6479
|
-
// src/commands/telemetry.ts
|
|
6480
|
-
import {
|
|
6481
|
-
existsSync as existsSync16,
|
|
6482
|
-
mkdirSync as mkdirSync13,
|
|
6483
|
-
readFileSync as readFileSync12,
|
|
6484
|
-
rmSync as rmSync4,
|
|
6485
|
-
writeFileSync as writeFileSync13
|
|
6486
|
-
} from "fs";
|
|
6487
|
-
import { dirname as dirname11, join as join18 } from "path";
|
|
6488
|
-
function registerTelemetry(program2) {
|
|
6489
|
-
const telemetry = program2.command("telemetry").description(
|
|
6490
|
-
"Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
|
|
6491
|
-
);
|
|
6492
|
-
telemetry.command("emit").description(
|
|
6493
|
-
"POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
|
|
6494
|
-
).requiredOption(
|
|
6495
|
-
"--decomposition <id>",
|
|
6496
|
-
"Decomposition id whose run this event belongs to"
|
|
6497
|
-
).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
|
|
6498
|
-
"--kind <kind>",
|
|
6499
|
-
"Event kind: raw | parsed | approval | terminal"
|
|
6500
|
-
).option(
|
|
6501
|
-
"--tokens-in <n>",
|
|
6502
|
-
"Cumulative input tokens (spend meter)",
|
|
6503
|
-
parseIntOpt
|
|
6504
|
-
).option(
|
|
6505
|
-
"--tokens-out <n>",
|
|
6506
|
-
"Cumulative output tokens (spend meter)",
|
|
6507
|
-
parseIntOpt
|
|
6508
|
-
).option(
|
|
6509
|
-
"--context-used <n>",
|
|
6510
|
-
"Context tokens currently used (occupancy meter)",
|
|
6511
|
-
parseIntOpt
|
|
6512
|
-
).option(
|
|
6513
|
-
"--context-window <n>",
|
|
6514
|
-
"Context window size (occupancy meter)",
|
|
6515
|
-
parseIntOpt
|
|
6516
|
-
).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
|
|
6517
|
-
"--verdict <v>",
|
|
6518
|
-
"Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
|
|
6519
|
-
).action(async (opts, cmd) => {
|
|
6520
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
6521
|
-
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
6522
|
-
const event = {
|
|
6523
|
-
taskId: opts.task,
|
|
6524
|
-
kind: normalizeKind(opts.kind),
|
|
6525
|
-
tokensIn: opts.tokensIn ?? null,
|
|
6526
|
-
tokensOut: opts.tokensOut ?? null,
|
|
6527
|
-
contextUsed: opts.contextUsed ?? null,
|
|
6528
|
-
contextWindow: opts.contextWindow ?? null,
|
|
6529
|
-
text: opts.text ?? null,
|
|
6530
|
-
approvalState: opts.approval ?? null,
|
|
6531
|
-
verdict: opts.verdict ?? null
|
|
6532
|
-
};
|
|
6533
|
-
let body;
|
|
6534
|
-
try {
|
|
6535
|
-
body = await postTelemetry(cfg, opts.decomposition, [event]);
|
|
6536
|
-
} catch (e) {
|
|
6537
|
-
return fail(`Telemetry emit failed: ${e.message}`);
|
|
6538
|
-
}
|
|
6539
|
-
if (json) {
|
|
6540
|
-
emit(body, true);
|
|
6541
|
-
} else {
|
|
6542
|
-
process.stderr.write(
|
|
6543
|
-
style.green("telemetry emitted") + style.dim(
|
|
6544
|
-
` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
|
|
6545
|
-
`
|
|
6546
|
-
)
|
|
6547
|
-
);
|
|
6548
|
-
}
|
|
6549
|
-
});
|
|
6550
|
-
telemetry.command("show <decompositionId>").description(
|
|
6551
|
-
"Read a run's telemetry \u2014 per-task meters + the raw/parsed/approval/terminal timeline (GET /decompositions/{id}/run/telemetry). Returns hasTelemetry:false, not an error, before any event is ingested \u2014 so an unstarted run and a stalled one read differently. The read side of this group; `emit` is the source side. (FR-sechroom-442 slice 1 step 4; mirrors the work_plan_run_telemetry MCP tool.)"
|
|
6552
|
-
).action(async (decompositionId, _opts, cmd) => {
|
|
6553
|
-
const globals = cmd.optsWithGlobals();
|
|
6554
|
-
const cfg = resolveConfig(globals);
|
|
6555
|
-
const data = await runApi("Reading run telemetry", async () => {
|
|
6556
|
-
const client = await makeClient(cfg);
|
|
6557
|
-
return client.GET("/decompositions/{id}/run/telemetry", {
|
|
6558
|
-
params: { path: { id: decompositionId } }
|
|
6559
|
-
});
|
|
6560
|
-
});
|
|
6561
|
-
emitAction(
|
|
6562
|
-
data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
|
|
6563
|
-
data,
|
|
6564
|
-
globals.json
|
|
6565
|
-
);
|
|
6566
|
-
});
|
|
6567
|
-
telemetry.command("bind").description(
|
|
6568
|
-
"Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
|
|
6569
|
-
).requiredOption(
|
|
6570
|
-
"--decomposition <id>",
|
|
6571
|
-
"Decomposition id this session executes"
|
|
6572
|
-
).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
|
|
6573
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
6574
|
-
const dir = join18(process.cwd(), ".sechroom");
|
|
6575
|
-
mkdirSync13(dir, { recursive: true });
|
|
6576
|
-
const path = join18(dir, BINDING_FILE);
|
|
6577
|
-
const binding = {
|
|
6578
|
-
decompositionId: opts.decomposition,
|
|
6579
|
-
taskId: opts.task
|
|
6580
|
-
};
|
|
6581
|
-
writeFileSync13(path, JSON.stringify(binding, null, 2) + "\n");
|
|
6582
|
-
ensureStateDirIgnored(process.cwd());
|
|
6583
|
-
if (json) {
|
|
6584
|
-
emit({ bound: true, ...binding, path }, true);
|
|
6585
|
-
} else {
|
|
6586
|
-
process.stdout.write(
|
|
6587
|
-
style.green("telemetry bound") + style.dim(
|
|
6588
|
-
` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
|
|
6589
|
-
`
|
|
6590
|
-
)
|
|
6591
|
-
);
|
|
6592
|
-
}
|
|
6593
|
-
});
|
|
6594
|
-
telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
|
|
6595
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
6596
|
-
const path = join18(process.cwd(), ".sechroom", BINDING_FILE);
|
|
6597
|
-
const existed = existsSync16(path);
|
|
6598
|
-
if (existed) rmSync4(path);
|
|
6599
|
-
if (json) emit({ unbound: existed, path }, true);
|
|
6600
|
-
else
|
|
6601
|
-
process.stdout.write(
|
|
6602
|
-
existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
|
|
6603
|
-
);
|
|
6604
|
-
});
|
|
6605
|
-
telemetry.command("hook").description(
|
|
6606
|
-
"Per-turn telemetry self-report for Claude Code hooks \u2014 Stop/SubagentStop \u2192 parsed + terminal, Notification/PermissionDenied \u2192 approval (reads stdin; no-op unless bound). Fail-soft."
|
|
6607
|
-
).action(async (_opts, cmd) => {
|
|
6608
|
-
try {
|
|
6609
|
-
const raw = await readStdin3();
|
|
6610
|
-
const input = parseHookInput2(raw);
|
|
6611
|
-
const cwd = input.cwd ?? process.cwd();
|
|
6612
|
-
const binding = findBinding(cwd);
|
|
6613
|
-
if (!binding) return process.exit(0);
|
|
6614
|
-
const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
|
|
6615
|
-
const events = buildHookEvents(input, usage, binding.taskId);
|
|
6616
|
-
if (events.length === 0) return process.exit(0);
|
|
6617
|
-
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
6618
|
-
await postTelemetry(cfg, binding.decompositionId, events);
|
|
6619
|
-
return process.exit(0);
|
|
6620
|
-
} catch {
|
|
6621
|
-
return process.exit(0);
|
|
6622
|
-
}
|
|
6623
|
-
});
|
|
6624
|
-
telemetry.command("install").description(
|
|
6625
|
-
"Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
|
|
6626
|
-
).option(
|
|
6627
|
-
"--scope <scope>",
|
|
6628
|
-
"global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
|
|
6629
|
-
).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
|
|
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) => {
|
|
6630
8300
|
const g = cmd.optsWithGlobals();
|
|
8301
|
+
const json = Boolean(g.json);
|
|
6631
8302
|
const dryRun = Boolean(opts.dryRun);
|
|
6632
|
-
const
|
|
6633
|
-
let
|
|
8303
|
+
const manifestPath = resolve4(opts.manifest);
|
|
8304
|
+
let repos;
|
|
6634
8305
|
try {
|
|
6635
|
-
|
|
8306
|
+
repos = readManifest(manifestPath);
|
|
6636
8307
|
} catch (err2) {
|
|
6637
|
-
|
|
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.
|
|
6638
8316
|
`);
|
|
6639
|
-
return
|
|
8317
|
+
return;
|
|
6640
8318
|
}
|
|
6641
|
-
const
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
cwd
|
|
6645
|
-
});
|
|
6646
|
-
const commands = {
|
|
6647
|
-
Stop: "sechroom telemetry hook",
|
|
6648
|
-
SubagentStop: "sechroom telemetry hook",
|
|
6649
|
-
Notification: "sechroom telemetry hook",
|
|
6650
|
-
PermissionDenied: "sechroom telemetry hook"
|
|
6651
|
-
};
|
|
6652
|
-
try {
|
|
6653
|
-
const multi = targets.length > 1;
|
|
6654
|
-
const results = targets.map((t) => {
|
|
6655
|
-
const r = installClaudeCommands(t.dir, commands, dryRun);
|
|
6656
|
-
process.stdout.write(
|
|
6657
|
-
`${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
|
|
6658
|
-
`
|
|
6659
|
-
);
|
|
6660
|
-
process.stdout.write(describe(r, dryRun) + "\n");
|
|
6661
|
-
return r;
|
|
6662
|
-
});
|
|
6663
|
-
if (dryRun) {
|
|
6664
|
-
process.stdout.write("\n(dry run \u2014 no files were written.)\n");
|
|
6665
|
-
} else if (results.every((r) => r.status === "current")) {
|
|
6666
|
-
process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
|
|
6667
|
-
} else {
|
|
6668
|
-
process.stdout.write(
|
|
6669
|
-
"\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
|
|
6670
|
-
);
|
|
6671
|
-
}
|
|
6672
|
-
} catch (err2) {
|
|
8319
|
+
const root = dirname12(dirname12(manifestPath));
|
|
8320
|
+
const plans = repos.map((entry) => planEntry(entry, root));
|
|
8321
|
+
if (!json) {
|
|
6673
8322
|
process.stderr.write(
|
|
6674
|
-
`
|
|
8323
|
+
`${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
|
|
6675
8324
|
`
|
|
6676
8325
|
);
|
|
6677
|
-
return process.exit(1);
|
|
6678
|
-
}
|
|
6679
|
-
warnIfSechroomNotOnPath();
|
|
6680
|
-
return process.exit(0);
|
|
6681
|
-
});
|
|
6682
|
-
}
|
|
6683
|
-
var BINDING_FILE = "telemetry.json";
|
|
6684
|
-
async function postTelemetry(cfg, decompositionId, events) {
|
|
6685
|
-
const token = await requireToken(cfg);
|
|
6686
|
-
const resp = await fetch(
|
|
6687
|
-
`${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
|
|
6688
|
-
{
|
|
6689
|
-
method: "POST",
|
|
6690
|
-
headers: {
|
|
6691
|
-
authorization: `Bearer ${token}`,
|
|
6692
|
-
tenant: cfg.tenant,
|
|
6693
|
-
"content-type": "application/json",
|
|
6694
|
-
"x-sechroom-surface": "cli"
|
|
6695
|
-
},
|
|
6696
|
-
body: JSON.stringify({ events })
|
|
6697
|
-
}
|
|
6698
|
-
);
|
|
6699
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
|
6700
|
-
return await resp.json();
|
|
6701
|
-
}
|
|
6702
|
-
function findBinding(start) {
|
|
6703
|
-
let dir = start;
|
|
6704
|
-
for (; ; ) {
|
|
6705
|
-
const path = join18(dir, ".sechroom", BINDING_FILE);
|
|
6706
|
-
if (existsSync16(path)) {
|
|
6707
|
-
try {
|
|
6708
|
-
const b = JSON.parse(
|
|
6709
|
-
readFileSync12(path, "utf8")
|
|
6710
|
-
);
|
|
6711
|
-
if (b.decompositionId && b.taskId)
|
|
6712
|
-
return { decompositionId: b.decompositionId, taskId: b.taskId };
|
|
6713
|
-
} catch {
|
|
6714
|
-
}
|
|
6715
|
-
return null;
|
|
6716
8326
|
}
|
|
6717
|
-
const
|
|
6718
|
-
if (
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
}
|
|
6722
|
-
function parseTranscript(path) {
|
|
6723
|
-
if (!existsSync16(path)) return null;
|
|
6724
|
-
let tokensIn = 0;
|
|
6725
|
-
let tokensOut = 0;
|
|
6726
|
-
let contextUsed = 0;
|
|
6727
|
-
let model = "";
|
|
6728
|
-
for (const line of readFileSync12(path, "utf8").split("\n")) {
|
|
6729
|
-
if (!line.trim()) continue;
|
|
6730
|
-
let obj;
|
|
6731
|
-
try {
|
|
6732
|
-
obj = JSON.parse(line);
|
|
6733
|
-
} catch {
|
|
6734
|
-
continue;
|
|
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;
|
|
6735
8331
|
}
|
|
6736
|
-
|
|
6737
|
-
if (!usage) continue;
|
|
6738
|
-
const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
6739
|
-
tokensIn += input;
|
|
6740
|
-
tokensOut += usage.output_tokens ?? 0;
|
|
6741
|
-
contextUsed = input;
|
|
6742
|
-
if (obj.message?.model) model = obj.message.model;
|
|
6743
|
-
}
|
|
6744
|
-
if (tokensIn === 0 && tokensOut === 0) return null;
|
|
6745
|
-
return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed) };
|
|
6746
|
-
}
|
|
6747
|
-
function windowFor(model, contextUsed = 0) {
|
|
6748
|
-
const m = model.toLowerCase();
|
|
6749
|
-
if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
|
|
6750
|
-
return contextUsed > 2e5 ? 1e6 : 2e5;
|
|
6751
|
-
}
|
|
6752
|
-
function buildHookEvents(input, usage, taskId) {
|
|
6753
|
-
const events = [];
|
|
6754
|
-
const base = (kind, over) => ({
|
|
6755
|
-
taskId,
|
|
6756
|
-
kind,
|
|
6757
|
-
tokensIn: null,
|
|
6758
|
-
tokensOut: null,
|
|
6759
|
-
contextUsed: null,
|
|
6760
|
-
contextWindow: null,
|
|
6761
|
-
text: null,
|
|
6762
|
-
approvalState: null,
|
|
6763
|
-
verdict: null,
|
|
6764
|
-
...over
|
|
8332
|
+
summarizeFanout(results, { dryRun });
|
|
6765
8333
|
});
|
|
6766
|
-
if (usage) {
|
|
6767
|
-
events.push(
|
|
6768
|
-
base("Parsed", {
|
|
6769
|
-
tokensIn: usage.tokensIn,
|
|
6770
|
-
tokensOut: usage.tokensOut,
|
|
6771
|
-
contextUsed: usage.contextUsed,
|
|
6772
|
-
contextWindow: usage.contextWindow
|
|
6773
|
-
})
|
|
6774
|
-
);
|
|
6775
|
-
}
|
|
6776
|
-
switch (input.hook_event_name) {
|
|
6777
|
-
case "PermissionDenied":
|
|
6778
|
-
events.push(
|
|
6779
|
-
base("Approval", {
|
|
6780
|
-
approvalState: "denied",
|
|
6781
|
-
text: input.tool_name ?? input.message ?? null
|
|
6782
|
-
})
|
|
6783
|
-
);
|
|
6784
|
-
break;
|
|
6785
|
-
case "Notification":
|
|
6786
|
-
if (isPermissionNotification(input))
|
|
6787
|
-
events.push(base("Approval", { text: input.message ?? null }));
|
|
6788
|
-
break;
|
|
6789
|
-
case "Stop":
|
|
6790
|
-
case "SubagentStop":
|
|
6791
|
-
events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
|
|
6792
|
-
break;
|
|
6793
|
-
}
|
|
6794
|
-
return events;
|
|
6795
|
-
}
|
|
6796
|
-
function isPermissionNotification(input) {
|
|
6797
|
-
const t = (input.notification_type ?? input.type ?? "").toLowerCase();
|
|
6798
|
-
if (t) return t.includes("permission");
|
|
6799
|
-
return (input.message ?? "").toLowerCase().includes("permission");
|
|
6800
|
-
}
|
|
6801
|
-
async function readStdin3() {
|
|
6802
|
-
if (process.stdin.isTTY) return "";
|
|
6803
|
-
const chunks = [];
|
|
6804
|
-
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
6805
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
6806
|
-
}
|
|
6807
|
-
function parseHookInput2(raw) {
|
|
6808
|
-
if (!raw.trim()) return {};
|
|
6809
|
-
try {
|
|
6810
|
-
return JSON.parse(raw);
|
|
6811
|
-
} catch {
|
|
6812
|
-
return {};
|
|
6813
|
-
}
|
|
6814
|
-
}
|
|
6815
|
-
var KINDS = {
|
|
6816
|
-
raw: "Raw",
|
|
6817
|
-
parsed: "Parsed",
|
|
6818
|
-
approval: "Approval",
|
|
6819
|
-
terminal: "Terminal"
|
|
6820
|
-
};
|
|
6821
|
-
function normalizeKind(k) {
|
|
6822
|
-
const v = KINDS[k.toLowerCase()];
|
|
6823
|
-
if (!v)
|
|
6824
|
-
fail(
|
|
6825
|
-
`Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
|
|
6826
|
-
);
|
|
6827
|
-
return v;
|
|
6828
|
-
}
|
|
6829
|
-
function parseIntOpt(v) {
|
|
6830
|
-
const n = Number.parseInt(v, 10);
|
|
6831
|
-
if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
|
|
6832
|
-
return n;
|
|
6833
8334
|
}
|
|
6834
8335
|
|
|
6835
8336
|
// src/commands/worklog.ts
|
|
@@ -7037,11 +8538,43 @@ function registerWorkBrief(program2) {
|
|
|
7037
8538
|
Examples:
|
|
7038
8539
|
$ sechroom work-brief pause mem_XXXX --reason-code operator-hold --source claude-code-chris
|
|
7039
8540
|
$ sechroom work-brief resume mem_XXXX --reason-code operator-resume --source claude-code-chris --reason "Ready to continue"
|
|
7040
|
-
$ sechroom work-brief cancel mem_XXXX --reason-code operator-stopped --source claude-code-chris --reason "Work no longer required"
|
|
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`
|
|
7041
8545
|
);
|
|
7042
8546
|
registerLifecycleAction(workBrief, "pause");
|
|
7043
8547
|
registerLifecycleAction(workBrief, "resume");
|
|
7044
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
|
+
});
|
|
7045
8578
|
}
|
|
7046
8579
|
function registerLifecycleAction(workBrief, action) {
|
|
7047
8580
|
const presentParticiple = action === "pause" ? "Pausing" : action === "resume" ? "Resuming" : "Cancelling";
|
|
@@ -7079,17 +8612,24 @@ function capitalize(value) {
|
|
|
7079
8612
|
}
|
|
7080
8613
|
|
|
7081
8614
|
// src/commands/work-task.ts
|
|
8615
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
7082
8616
|
function registerWorkTask(program2) {
|
|
7083
|
-
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
|
+
);
|
|
7084
8620
|
workTask.addHelpText(
|
|
7085
8621
|
"after",
|
|
7086
8622
|
`
|
|
7087
8623
|
Examples:
|
|
7088
8624
|
$ sechroom work-task list --status in-progress --lane claude-code-chris
|
|
7089
8625
|
$ sechroom work-task card mem_XXXX
|
|
7090
|
-
$ 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`
|
|
7091
8628
|
);
|
|
7092
|
-
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(
|
|
7093
8633
|
"--page-size <n>",
|
|
7094
8634
|
"Page size (default 50, capped 200)",
|
|
7095
8635
|
(v) => Number.parseInt(v, 10)
|
|
@@ -7116,7 +8656,9 @@ Examples:
|
|
|
7116
8656
|
globals.json
|
|
7117
8657
|
);
|
|
7118
8658
|
});
|
|
7119
|
-
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) => {
|
|
7120
8662
|
const globals = cmd.optsWithGlobals();
|
|
7121
8663
|
const cfg = resolveConfig(globals);
|
|
7122
8664
|
const data = await runApi("Reading task card", async () => {
|
|
@@ -7127,26 +8669,67 @@ Examples:
|
|
|
7127
8669
|
});
|
|
7128
8670
|
emitAction(`read card ${style.bold(taskId)}`, data, globals.json);
|
|
7129
8671
|
});
|
|
7130
|
-
workTask.command("
|
|
7131
|
-
"
|
|
8672
|
+
workTask.command("residue-produce <taskId>").description(
|
|
8673
|
+
"Produce typed residue for a work task (POST /work-tasks/produce-residue)"
|
|
7132
8674
|
).requiredOption(
|
|
7133
|
-
"--
|
|
7134
|
-
"
|
|
8675
|
+
"--file <path>",
|
|
8676
|
+
"JSON file containing a residues array; use - for stdin"
|
|
7135
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);
|
|
7136
8680
|
const globals = cmd.optsWithGlobals();
|
|
7137
8681
|
const cfg = resolveConfig(globals);
|
|
7138
|
-
const data = await runApi("
|
|
8682
|
+
const data = await runApi("Producing task residue", async () => {
|
|
7139
8683
|
const client = await makeClient(cfg);
|
|
7140
|
-
return client.POST("/work-tasks/
|
|
7141
|
-
body: { taskId, decompositionId: opts.decomposition }
|
|
7142
|
-
});
|
|
8684
|
+
return client.POST("/work-tasks/produce-residue", { body });
|
|
7143
8685
|
});
|
|
7144
8686
|
emitAction(
|
|
7145
|
-
`
|
|
8687
|
+
`produced ${style.bold(String(data.produced.length))} residue item(s) for ${style.bold(taskId)}`,
|
|
7146
8688
|
data,
|
|
7147
8689
|
globals.json
|
|
7148
8690
|
);
|
|
7149
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");
|
|
7150
8733
|
}
|
|
7151
8734
|
|
|
7152
8735
|
// src/index.ts
|