@sechroom/cli 2026.7.32 → 2026.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -262
- package/dist/index.js +1753 -696
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync19 } from "fs";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
7
|
// src/auth.ts
|
|
@@ -1573,7 +1573,367 @@ import {
|
|
|
1573
1573
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
1574
1574
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1575
1575
|
|
|
1576
|
+
// src/executor-run/request.ts
|
|
1577
|
+
var AuthExpiredError = class extends Error {
|
|
1578
|
+
constructor(message) {
|
|
1579
|
+
super(message);
|
|
1580
|
+
this.name = "AuthExpiredError";
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
var HttpError = class extends Error {
|
|
1584
|
+
constructor(status, method, path, body) {
|
|
1585
|
+
super(`${method} ${path} failed (${status}): ${body}`);
|
|
1586
|
+
this.status = status;
|
|
1587
|
+
this.method = method;
|
|
1588
|
+
this.path = path;
|
|
1589
|
+
this.body = body;
|
|
1590
|
+
this.name = "HttpError";
|
|
1591
|
+
}
|
|
1592
|
+
status;
|
|
1593
|
+
method;
|
|
1594
|
+
path;
|
|
1595
|
+
body;
|
|
1596
|
+
};
|
|
1597
|
+
function createAuthedRequest(cfg, deps = {}) {
|
|
1598
|
+
const getToken = deps.getToken ?? requireToken;
|
|
1599
|
+
const refresh = deps.refreshToken ?? forceRefreshToken;
|
|
1600
|
+
const doFetch = deps.fetch ?? fetch;
|
|
1601
|
+
const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
|
|
1602
|
+
...init,
|
|
1603
|
+
headers: {
|
|
1604
|
+
authorization: `Bearer ${token}`,
|
|
1605
|
+
tenant: cfg.tenant,
|
|
1606
|
+
"content-type": "application/json",
|
|
1607
|
+
"x-sechroom-surface": "cli",
|
|
1608
|
+
...init?.headers
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
return async (path, init) => {
|
|
1612
|
+
const method = init?.method ?? "GET";
|
|
1613
|
+
let token;
|
|
1614
|
+
try {
|
|
1615
|
+
token = await getToken(cfg);
|
|
1616
|
+
} catch (error) {
|
|
1617
|
+
throw new AuthExpiredError(
|
|
1618
|
+
error instanceof Error ? error.message : String(error)
|
|
1619
|
+
);
|
|
1620
|
+
}
|
|
1621
|
+
let response = await call(path, init, token);
|
|
1622
|
+
if (response.status === 401) {
|
|
1623
|
+
let fresh;
|
|
1624
|
+
try {
|
|
1625
|
+
fresh = await refresh(cfg);
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
throw new AuthExpiredError(
|
|
1628
|
+
error instanceof Error ? error.message : String(error)
|
|
1629
|
+
);
|
|
1630
|
+
}
|
|
1631
|
+
response = await call(path, init, fresh);
|
|
1632
|
+
if (response.status === 401)
|
|
1633
|
+
throw new AuthExpiredError(
|
|
1634
|
+
`${method} ${path} still 401 after token refresh \u2014 re-authenticate (\`sechroom login\`).`
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
if (!response.ok)
|
|
1638
|
+
throw new HttpError(
|
|
1639
|
+
response.status,
|
|
1640
|
+
method,
|
|
1641
|
+
path,
|
|
1642
|
+
await safeText(response)
|
|
1643
|
+
);
|
|
1644
|
+
return await response.json();
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
async function safeText(response) {
|
|
1648
|
+
try {
|
|
1649
|
+
return await response.text();
|
|
1650
|
+
} catch {
|
|
1651
|
+
return "";
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// src/executor-run/driver.ts
|
|
1656
|
+
function verdictFor(terminalStatus) {
|
|
1657
|
+
switch (terminalStatus) {
|
|
1658
|
+
case "completed":
|
|
1659
|
+
return "pass";
|
|
1660
|
+
case "needs_approval":
|
|
1661
|
+
case "cancelled":
|
|
1662
|
+
case "canceled":
|
|
1663
|
+
return "blocked";
|
|
1664
|
+
case "error":
|
|
1665
|
+
default:
|
|
1666
|
+
return "soft-fail";
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
var DEFAULT_TRANSIENT_BACKOFF = {
|
|
1670
|
+
baseMs: 2e3,
|
|
1671
|
+
maxMs: 6e4,
|
|
1672
|
+
maxConsecutive: 20
|
|
1673
|
+
};
|
|
1674
|
+
var BackoffCeilingExhaustedError = class extends Error {
|
|
1675
|
+
constructor(attempts, lastError) {
|
|
1676
|
+
super(
|
|
1677
|
+
`driven executor exiting: ${attempts} consecutive API interruptions exhausted the backoff ceiling \u2014 last error: ${String(lastError)}`
|
|
1678
|
+
);
|
|
1679
|
+
this.attempts = attempts;
|
|
1680
|
+
this.lastError = lastError;
|
|
1681
|
+
this.name = "BackoffCeilingExhaustedError";
|
|
1682
|
+
}
|
|
1683
|
+
attempts;
|
|
1684
|
+
lastError;
|
|
1685
|
+
};
|
|
1686
|
+
async function runDriverLoop(ports, options) {
|
|
1687
|
+
const summary = {
|
|
1688
|
+
processed: 0,
|
|
1689
|
+
completed: 0,
|
|
1690
|
+
abandoned: 0
|
|
1691
|
+
};
|
|
1692
|
+
const backoff = { ...DEFAULT_TRANSIENT_BACKOFF, ...options.transientBackoff };
|
|
1693
|
+
let admissionDeferred = false;
|
|
1694
|
+
let consecutiveTransient = 0;
|
|
1695
|
+
const materializationFailures = /* @__PURE__ */ new Map();
|
|
1696
|
+
while (!options.stopping()) {
|
|
1697
|
+
try {
|
|
1698
|
+
if (ports.checkAdmission) {
|
|
1699
|
+
const admission = await ports.checkAdmission();
|
|
1700
|
+
if (!admission.ok) {
|
|
1701
|
+
admissionDeferred = true;
|
|
1702
|
+
ports.log(
|
|
1703
|
+
`ADMISSION DEFERRED \u2014 not claiming: ${admission.reason ?? "usage budget exhausted"}`
|
|
1704
|
+
);
|
|
1705
|
+
await ports.waitForWake(options.pollMs);
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
if (admissionDeferred) {
|
|
1709
|
+
admissionDeferred = false;
|
|
1710
|
+
ports.log("admission recovered \u2014 resuming claims");
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
if (ports.checkRootReady) {
|
|
1714
|
+
const ready = await ports.checkRootReady();
|
|
1715
|
+
if (!ready.ok) {
|
|
1716
|
+
ports.log(
|
|
1717
|
+
`root not ready \u2014 not claiming: ${ready.reason ?? "unknown"}`
|
|
1718
|
+
);
|
|
1719
|
+
await ports.waitForWake(options.pollMs);
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
const claim = await ports.claimNext();
|
|
1724
|
+
if (consecutiveTransient > 0) {
|
|
1725
|
+
ports.log(
|
|
1726
|
+
`API reachable again after ${consecutiveTransient} interruption(s) \u2014 resuming`
|
|
1727
|
+
);
|
|
1728
|
+
consecutiveTransient = 0;
|
|
1729
|
+
}
|
|
1730
|
+
if (!claim) {
|
|
1731
|
+
await ports.waitForWake(options.pollMs);
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
summary.processed++;
|
|
1735
|
+
ports.log(`claimed ${claim.memoryId} (lease ${claim.leaseId})`);
|
|
1736
|
+
let task;
|
|
1737
|
+
try {
|
|
1738
|
+
task = await ports.loadTask(claim.memoryId);
|
|
1739
|
+
materializationFailures.delete(claim.memoryId);
|
|
1740
|
+
} catch (error) {
|
|
1741
|
+
if (error instanceof AuthExpiredError) throw error;
|
|
1742
|
+
const failures = (materializationFailures.get(claim.memoryId) ?? 0) + 1;
|
|
1743
|
+
materializationFailures.set(claim.memoryId, failures);
|
|
1744
|
+
if (failures > backoff.maxConsecutive) {
|
|
1745
|
+
ports.log(
|
|
1746
|
+
`FATAL: task ${claim.memoryId} failed materialization ${backoff.maxConsecutive} consecutive times \u2014 deregistering and exiting (loud). Its lease was never heartbeaten and expires to re-offer within \u2264120s; the task is not lost. Last error: ${String(error)}`
|
|
1747
|
+
);
|
|
1748
|
+
throw new BackoffCeilingExhaustedError(backoff.maxConsecutive, error);
|
|
1749
|
+
}
|
|
1750
|
+
throw error;
|
|
1751
|
+
}
|
|
1752
|
+
const stopHeartbeat = ports.startLeaseHeartbeat(claim);
|
|
1753
|
+
let result;
|
|
1754
|
+
try {
|
|
1755
|
+
result = await ports.runTurn(task, claim);
|
|
1756
|
+
} catch (e) {
|
|
1757
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
1758
|
+
result = { status: "crashed", reason: String(e) };
|
|
1759
|
+
} finally {
|
|
1760
|
+
stopHeartbeat();
|
|
1761
|
+
}
|
|
1762
|
+
if (result.status === "crashed" || result.status === "timeout") {
|
|
1763
|
+
summary.abandoned++;
|
|
1764
|
+
ports.log(
|
|
1765
|
+
`ABANDONED ${claim.memoryId}: ${result.status === "timeout" ? "turn timed out" : result.reason} \u2014 lease will expire and the task re-offers (work may re-run).`
|
|
1766
|
+
);
|
|
1767
|
+
} else {
|
|
1768
|
+
const verdict = verdictFor(result.packet?.terminal_status);
|
|
1769
|
+
let text2 = closeoutText(task, result);
|
|
1770
|
+
if (ports.deliver) {
|
|
1771
|
+
try {
|
|
1772
|
+
const delivery = await ports.deliver(
|
|
1773
|
+
claim,
|
|
1774
|
+
task,
|
|
1775
|
+
verdict,
|
|
1776
|
+
result.status === "completed" ? result.fileChangeCount ?? 0 : 0
|
|
1777
|
+
);
|
|
1778
|
+
ports.log(`delivery: ${delivery.note}`);
|
|
1779
|
+
text2 += `
|
|
1780
|
+
|
|
1781
|
+
Delivery: ${delivery.note}`;
|
|
1782
|
+
} catch (e) {
|
|
1783
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
1784
|
+
ports.log(
|
|
1785
|
+
`delivery threw (continuing to completion): ${String(e)}`
|
|
1786
|
+
);
|
|
1787
|
+
text2 += `
|
|
1788
|
+
|
|
1789
|
+
Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
try {
|
|
1793
|
+
const done = await ports.completeLease(
|
|
1794
|
+
claim,
|
|
1795
|
+
verdict,
|
|
1796
|
+
text2,
|
|
1797
|
+
`${task.title} \u2014 driven closeout`
|
|
1798
|
+
);
|
|
1799
|
+
summary.completed++;
|
|
1800
|
+
ports.log(
|
|
1801
|
+
`completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
|
|
1802
|
+
);
|
|
1803
|
+
} catch (e) {
|
|
1804
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
1805
|
+
summary.abandoned++;
|
|
1806
|
+
ports.log(
|
|
1807
|
+
`COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
} catch (e) {
|
|
1812
|
+
if (e instanceof BackoffCeilingExhaustedError) throw e;
|
|
1813
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
1814
|
+
consecutiveTransient++;
|
|
1815
|
+
if (consecutiveTransient > backoff.maxConsecutive) {
|
|
1816
|
+
ports.log(
|
|
1817
|
+
`FATAL: ${backoff.maxConsecutive} consecutive API interruptions exhausted the backoff ceiling \u2014 deregistering and exiting (loud). Last error: ${String(e)}`
|
|
1818
|
+
);
|
|
1819
|
+
throw new BackoffCeilingExhaustedError(backoff.maxConsecutive, e);
|
|
1820
|
+
}
|
|
1821
|
+
const delay = Math.min(
|
|
1822
|
+
backoff.baseMs * 2 ** (consecutiveTransient - 1),
|
|
1823
|
+
backoff.maxMs
|
|
1824
|
+
);
|
|
1825
|
+
ports.log(
|
|
1826
|
+
`API interruption (${consecutiveTransient}/${backoff.maxConsecutive}) \u2014 backing off ${delay}ms and continuing: ${String(e)}`
|
|
1827
|
+
);
|
|
1828
|
+
await ports.waitForWake(delay);
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
if (options.once) break;
|
|
1832
|
+
}
|
|
1833
|
+
return summary;
|
|
1834
|
+
}
|
|
1835
|
+
function closeoutText(task, result) {
|
|
1836
|
+
if (!result.packet)
|
|
1837
|
+
return `Driven codex run ended without a sechroom_closeout packet (soft-fail). Last agent message:
|
|
1838
|
+
|
|
1839
|
+
${result.lastAgentMessage || "(none)"}`;
|
|
1840
|
+
const evidence = result.packet.evidence?.length ? `
|
|
1841
|
+
|
|
1842
|
+
Evidence:
|
|
1843
|
+
${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
|
|
1844
|
+
return `${result.packet.summary}${evidence}
|
|
1845
|
+
|
|
1846
|
+
(terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
|
|
1847
|
+
}
|
|
1848
|
+
function startLeaseHeartbeat(beat, log, intervalMs = 3e4, timers = {}) {
|
|
1849
|
+
const schedule = timers.setInterval ?? setInterval;
|
|
1850
|
+
const cancel = timers.clearInterval ?? clearInterval;
|
|
1851
|
+
const timer = schedule(() => {
|
|
1852
|
+
void beat().catch(
|
|
1853
|
+
(e) => log(`lease heartbeat failed (retrying next beat): ${String(e)}`)
|
|
1854
|
+
);
|
|
1855
|
+
}, intervalMs);
|
|
1856
|
+
timer.unref?.();
|
|
1857
|
+
return () => cancel(timer);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// src/executor-run/task-context.ts
|
|
1861
|
+
async function materializeClaimedTask(request, memoryId) {
|
|
1862
|
+
const card = await request(
|
|
1863
|
+
`/tasks/${encodeURIComponent(memoryId)}/card`
|
|
1864
|
+
);
|
|
1865
|
+
const pointer = card.contextPack;
|
|
1866
|
+
let components = [];
|
|
1867
|
+
if (pointer && (!pointer.slug || !pointer.version))
|
|
1868
|
+
throw new Error(
|
|
1869
|
+
`task ${card.taskId} carried an incomplete context-pack pointer; refusing delivery`
|
|
1870
|
+
);
|
|
1871
|
+
if (pointer?.slug && pointer.version) {
|
|
1872
|
+
try {
|
|
1873
|
+
const pkg = await request(
|
|
1874
|
+
`/bundles/${encodeURIComponent(pointer.slug)}/versions/${encodeURIComponent(pointer.version)}/package`
|
|
1875
|
+
);
|
|
1876
|
+
components = validatePackage(pkg, pointer.slug, pointer.version);
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
throw new Error(
|
|
1879
|
+
`task ${card.taskId} context pack ${pointer.slug}@${pointer.version} could not be materialized before delivery: ${String(error)}`,
|
|
1880
|
+
{ cause: error }
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
return {
|
|
1885
|
+
title: card.title ?? memoryId,
|
|
1886
|
+
text: assemblePrompt(card, components)
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
function validatePackage(value, expectedSlug, expectedVersion) {
|
|
1890
|
+
if (value === null || typeof value !== "object")
|
|
1891
|
+
throw new Error("package response was not an object");
|
|
1892
|
+
const pkg = value;
|
|
1893
|
+
if (pkg.slug !== expectedSlug || pkg.version !== expectedVersion)
|
|
1894
|
+
throw new Error(
|
|
1895
|
+
`package identity mismatch (expected ${expectedSlug}@${expectedVersion}, received ${String(pkg.slug)}@${String(pkg.version)})`
|
|
1896
|
+
);
|
|
1897
|
+
if (!Array.isArray(pkg.components) || pkg.components.length === 0)
|
|
1898
|
+
throw new Error("package carried no components");
|
|
1899
|
+
for (const [index, component] of pkg.components.entries()) {
|
|
1900
|
+
if (component === null || typeof component !== "object" || typeof component.slug !== "string" || typeof component.sourceId !== "string" || !Number.isInteger(component.sourceVersion) || component.sourceVersion < 1 || typeof component.body !== "string") {
|
|
1901
|
+
throw new Error(
|
|
1902
|
+
`package component ${index + 1} lacked pinned source provenance or body`
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
return pkg.components;
|
|
1907
|
+
}
|
|
1908
|
+
function assemblePrompt(card, components) {
|
|
1909
|
+
const sections = [
|
|
1910
|
+
`# Task: ${card.title}`,
|
|
1911
|
+
`## Objective
|
|
1912
|
+
${card.task.objective}`,
|
|
1913
|
+
`## Acceptance
|
|
1914
|
+
${card.task.acceptance}`,
|
|
1915
|
+
`## Boundaries
|
|
1916
|
+
${card.task.boundaries}`,
|
|
1917
|
+
`## Closeout
|
|
1918
|
+
${card.task.closeout}`
|
|
1919
|
+
];
|
|
1920
|
+
if (components.length > 0)
|
|
1921
|
+
sections.push(
|
|
1922
|
+
`## Task context
|
|
1923
|
+
${components.map(renderComponent).join("\n\n")}`
|
|
1924
|
+
);
|
|
1925
|
+
return sections.join("\n\n");
|
|
1926
|
+
}
|
|
1927
|
+
function renderComponent(component) {
|
|
1928
|
+
return [
|
|
1929
|
+
`<!-- sechroom-task-context component=${JSON.stringify(component.slug)} id=${JSON.stringify(component.sourceId)} sourceVersion=${component.sourceVersion} -->`,
|
|
1930
|
+
`### ${component.title?.trim() || component.sourceId}`,
|
|
1931
|
+
component.body
|
|
1932
|
+
].join("\n");
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1576
1935
|
// src/commands/executor.ts
|
|
1936
|
+
import { execFileSync } from "child_process";
|
|
1577
1937
|
import { existsSync as existsSync8, mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync8 } from "fs";
|
|
1578
1938
|
import { dirname as dirname8, join as join11 } from "path";
|
|
1579
1939
|
|
|
@@ -1732,95 +2092,16 @@ function ensureSemIgnored(semPath) {
|
|
|
1732
2092
|
appendFileSync(target.path, `${sep2}${STATE_DIR_IGNORE}
|
|
1733
2093
|
`);
|
|
1734
2094
|
} else {
|
|
1735
|
-
writeFileSync4(target.path, `${STATE_DIR_IGNORE}
|
|
1736
|
-
`);
|
|
1737
|
-
}
|
|
1738
|
-
} catch {
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
// src/commands/executor-run.ts
|
|
1743
|
-
import { join as join10, resolve as resolve3 } from "path";
|
|
1744
|
-
|
|
1745
|
-
// src/executor-run/request.ts
|
|
1746
|
-
var AuthExpiredError = class extends Error {
|
|
1747
|
-
constructor(message) {
|
|
1748
|
-
super(message);
|
|
1749
|
-
this.name = "AuthExpiredError";
|
|
1750
|
-
}
|
|
1751
|
-
};
|
|
1752
|
-
var HttpError = class extends Error {
|
|
1753
|
-
constructor(status, method, path, body) {
|
|
1754
|
-
super(`${method} ${path} failed (${status}): ${body}`);
|
|
1755
|
-
this.status = status;
|
|
1756
|
-
this.method = method;
|
|
1757
|
-
this.path = path;
|
|
1758
|
-
this.body = body;
|
|
1759
|
-
this.name = "HttpError";
|
|
1760
|
-
}
|
|
1761
|
-
status;
|
|
1762
|
-
method;
|
|
1763
|
-
path;
|
|
1764
|
-
body;
|
|
1765
|
-
};
|
|
1766
|
-
function createAuthedRequest(cfg, deps = {}) {
|
|
1767
|
-
const getToken = deps.getToken ?? requireToken;
|
|
1768
|
-
const refresh = deps.refreshToken ?? forceRefreshToken;
|
|
1769
|
-
const doFetch = deps.fetch ?? fetch;
|
|
1770
|
-
const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
|
|
1771
|
-
...init,
|
|
1772
|
-
headers: {
|
|
1773
|
-
authorization: `Bearer ${token}`,
|
|
1774
|
-
tenant: cfg.tenant,
|
|
1775
|
-
"content-type": "application/json",
|
|
1776
|
-
"x-sechroom-surface": "cli",
|
|
1777
|
-
...init?.headers
|
|
1778
|
-
}
|
|
1779
|
-
});
|
|
1780
|
-
return async (path, init) => {
|
|
1781
|
-
const method = init?.method ?? "GET";
|
|
1782
|
-
let token;
|
|
1783
|
-
try {
|
|
1784
|
-
token = await getToken(cfg);
|
|
1785
|
-
} catch (error) {
|
|
1786
|
-
throw new AuthExpiredError(
|
|
1787
|
-
error instanceof Error ? error.message : String(error)
|
|
1788
|
-
);
|
|
1789
|
-
}
|
|
1790
|
-
let response = await call(path, init, token);
|
|
1791
|
-
if (response.status === 401) {
|
|
1792
|
-
let fresh;
|
|
1793
|
-
try {
|
|
1794
|
-
fresh = await refresh(cfg);
|
|
1795
|
-
} catch (error) {
|
|
1796
|
-
throw new AuthExpiredError(
|
|
1797
|
-
error instanceof Error ? error.message : String(error)
|
|
1798
|
-
);
|
|
1799
|
-
}
|
|
1800
|
-
response = await call(path, init, fresh);
|
|
1801
|
-
if (response.status === 401)
|
|
1802
|
-
throw new AuthExpiredError(
|
|
1803
|
-
`${method} ${path} still 401 after token refresh \u2014 re-authenticate (\`sechroom login\`).`
|
|
1804
|
-
);
|
|
1805
|
-
}
|
|
1806
|
-
if (!response.ok)
|
|
1807
|
-
throw new HttpError(
|
|
1808
|
-
response.status,
|
|
1809
|
-
method,
|
|
1810
|
-
path,
|
|
1811
|
-
await safeText(response)
|
|
1812
|
-
);
|
|
1813
|
-
return await response.json();
|
|
1814
|
-
};
|
|
1815
|
-
}
|
|
1816
|
-
async function safeText(response) {
|
|
1817
|
-
try {
|
|
1818
|
-
return await response.text();
|
|
2095
|
+
writeFileSync4(target.path, `${STATE_DIR_IGNORE}
|
|
2096
|
+
`);
|
|
2097
|
+
}
|
|
1819
2098
|
} catch {
|
|
1820
|
-
return "";
|
|
1821
2099
|
}
|
|
1822
2100
|
}
|
|
1823
2101
|
|
|
2102
|
+
// src/commands/executor-run.ts
|
|
2103
|
+
import { join as join10, resolve as resolve3 } from "path";
|
|
2104
|
+
|
|
1824
2105
|
// src/executor-run/usage.ts
|
|
1825
2106
|
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5 } from "fs";
|
|
1826
2107
|
import { dirname as dirname3 } from "path";
|
|
@@ -2141,7 +2422,7 @@ var SUCCESS = /* @__PURE__ */ new Set(["Claimed", "AlreadyHeld"]);
|
|
|
2141
2422
|
var CONTENTION_STATUSES = /* @__PURE__ */ new Set([409, 410]);
|
|
2142
2423
|
async function claimNextTask(deps) {
|
|
2143
2424
|
const { request, executorInstanceId } = deps;
|
|
2144
|
-
const sleep = deps.sleep ?? ((ms) => new Promise((
|
|
2425
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve9) => setTimeout(resolve9, ms)));
|
|
2145
2426
|
const idempotencyKey = deps.idempotencyKey ?? ((offer) => `executor-run:${offer.generationId}`);
|
|
2146
2427
|
const tokenVersion = deps.tokenVersion ?? 1;
|
|
2147
2428
|
const log = deps.log ?? (() => {
|
|
@@ -2471,12 +2752,12 @@ var CodexAppServer = class {
|
|
|
2471
2752
|
))
|
|
2472
2753
|
fileChangeCount += 1;
|
|
2473
2754
|
};
|
|
2474
|
-
const terminal = new Promise((
|
|
2755
|
+
const terminal = new Promise((resolve9) => {
|
|
2475
2756
|
this.onServerRequest = (msg) => {
|
|
2476
2757
|
const method = msg.method ?? "";
|
|
2477
2758
|
if (terminalMethod(method)) {
|
|
2478
2759
|
this.respond(msg.id, {});
|
|
2479
|
-
|
|
2760
|
+
resolve9("completed");
|
|
2480
2761
|
return;
|
|
2481
2762
|
}
|
|
2482
2763
|
if (method === "item/tool/call") {
|
|
@@ -2663,14 +2944,14 @@ var CodexAppServer = class {
|
|
|
2663
2944
|
if (!child || this.exited)
|
|
2664
2945
|
return Promise.reject(new Error("codex app-server is not running"));
|
|
2665
2946
|
const id = this.nextId++;
|
|
2666
|
-
return new Promise((
|
|
2947
|
+
return new Promise((resolve9, reject) => {
|
|
2667
2948
|
let timer;
|
|
2668
2949
|
const clearTimer = () => {
|
|
2669
2950
|
if (timer) clearTimeout(timer);
|
|
2670
2951
|
};
|
|
2671
2952
|
const resolvePending = (value) => {
|
|
2672
2953
|
clearTimer();
|
|
2673
|
-
|
|
2954
|
+
resolve9(value);
|
|
2674
2955
|
};
|
|
2675
2956
|
const rejectPending = (error) => {
|
|
2676
2957
|
clearTimer();
|
|
@@ -2736,10 +3017,10 @@ var CodexAppServer = class {
|
|
|
2736
3017
|
if (parsed) this.options.onRateLimits?.(parsed);
|
|
2737
3018
|
}
|
|
2738
3019
|
exitAsResult() {
|
|
2739
|
-
return new Promise((
|
|
3020
|
+
return new Promise((resolve9) => {
|
|
2740
3021
|
this.child?.once(
|
|
2741
3022
|
"exit",
|
|
2742
|
-
(code) =>
|
|
3023
|
+
(code) => resolve9(`app-server exited (${code ?? "signal"})`)
|
|
2743
3024
|
);
|
|
2744
3025
|
});
|
|
2745
3026
|
}
|
|
@@ -2765,7 +3046,7 @@ function terminalMethod(method) {
|
|
|
2765
3046
|
}
|
|
2766
3047
|
function timeout(ms) {
|
|
2767
3048
|
return new Promise(
|
|
2768
|
-
(
|
|
3049
|
+
(resolve9) => setTimeout(() => resolve9("timeout"), ms).unref?.()
|
|
2769
3050
|
);
|
|
2770
3051
|
}
|
|
2771
3052
|
function dynamicToolDefinitions() {
|
|
@@ -2805,12 +3086,12 @@ function dynamicToolDefinitions() {
|
|
|
2805
3086
|
// src/executor-run/delivery.ts
|
|
2806
3087
|
import { execFile as execFile2 } from "child_process";
|
|
2807
3088
|
function createGitRunner(rootDir) {
|
|
2808
|
-
return (bin, args) => new Promise((
|
|
3089
|
+
return (bin, args) => new Promise((resolve9) => {
|
|
2809
3090
|
execFile2(
|
|
2810
3091
|
bin,
|
|
2811
3092
|
bin === "git" ? ["-C", rootDir, ...args] : args,
|
|
2812
3093
|
{ cwd: rootDir, maxBuffer: 10 * 1024 * 1024 },
|
|
2813
|
-
(error, stdout, stderr) =>
|
|
3094
|
+
(error, stdout, stderr) => resolve9({
|
|
2814
3095
|
ok: !error,
|
|
2815
3096
|
stdout: String(stdout),
|
|
2816
3097
|
stderr: String(stderr)
|
|
@@ -2955,194 +3236,6 @@ function firstLine(text2) {
|
|
|
2955
3236
|
return text2.trim().split("\n")[0] ?? "";
|
|
2956
3237
|
}
|
|
2957
3238
|
|
|
2958
|
-
// src/executor-run/driver.ts
|
|
2959
|
-
function verdictFor(terminalStatus) {
|
|
2960
|
-
switch (terminalStatus) {
|
|
2961
|
-
case "completed":
|
|
2962
|
-
return "pass";
|
|
2963
|
-
case "needs_approval":
|
|
2964
|
-
case "cancelled":
|
|
2965
|
-
case "canceled":
|
|
2966
|
-
return "blocked";
|
|
2967
|
-
case "error":
|
|
2968
|
-
default:
|
|
2969
|
-
return "soft-fail";
|
|
2970
|
-
}
|
|
2971
|
-
}
|
|
2972
|
-
var DEFAULT_TRANSIENT_BACKOFF = {
|
|
2973
|
-
baseMs: 2e3,
|
|
2974
|
-
maxMs: 6e4,
|
|
2975
|
-
maxConsecutive: 20
|
|
2976
|
-
};
|
|
2977
|
-
var BackoffCeilingExhaustedError = class extends Error {
|
|
2978
|
-
constructor(attempts, lastError) {
|
|
2979
|
-
super(
|
|
2980
|
-
`driven executor exiting: ${attempts} consecutive API interruptions exhausted the backoff ceiling \u2014 last error: ${String(lastError)}`
|
|
2981
|
-
);
|
|
2982
|
-
this.attempts = attempts;
|
|
2983
|
-
this.lastError = lastError;
|
|
2984
|
-
this.name = "BackoffCeilingExhaustedError";
|
|
2985
|
-
}
|
|
2986
|
-
attempts;
|
|
2987
|
-
lastError;
|
|
2988
|
-
};
|
|
2989
|
-
async function runDriverLoop(ports, options) {
|
|
2990
|
-
const summary = {
|
|
2991
|
-
processed: 0,
|
|
2992
|
-
completed: 0,
|
|
2993
|
-
abandoned: 0
|
|
2994
|
-
};
|
|
2995
|
-
const backoff = { ...DEFAULT_TRANSIENT_BACKOFF, ...options.transientBackoff };
|
|
2996
|
-
let admissionDeferred = false;
|
|
2997
|
-
let consecutiveTransient = 0;
|
|
2998
|
-
while (!options.stopping()) {
|
|
2999
|
-
try {
|
|
3000
|
-
if (ports.checkAdmission) {
|
|
3001
|
-
const admission = await ports.checkAdmission();
|
|
3002
|
-
if (!admission.ok) {
|
|
3003
|
-
admissionDeferred = true;
|
|
3004
|
-
ports.log(
|
|
3005
|
-
`ADMISSION DEFERRED \u2014 not claiming: ${admission.reason ?? "usage budget exhausted"}`
|
|
3006
|
-
);
|
|
3007
|
-
await ports.waitForWake(options.pollMs);
|
|
3008
|
-
continue;
|
|
3009
|
-
}
|
|
3010
|
-
if (admissionDeferred) {
|
|
3011
|
-
admissionDeferred = false;
|
|
3012
|
-
ports.log("admission recovered \u2014 resuming claims");
|
|
3013
|
-
}
|
|
3014
|
-
}
|
|
3015
|
-
if (ports.checkRootReady) {
|
|
3016
|
-
const ready = await ports.checkRootReady();
|
|
3017
|
-
if (!ready.ok) {
|
|
3018
|
-
ports.log(
|
|
3019
|
-
`root not ready \u2014 not claiming: ${ready.reason ?? "unknown"}`
|
|
3020
|
-
);
|
|
3021
|
-
await ports.waitForWake(options.pollMs);
|
|
3022
|
-
continue;
|
|
3023
|
-
}
|
|
3024
|
-
}
|
|
3025
|
-
const claim = await ports.claimNext();
|
|
3026
|
-
if (consecutiveTransient > 0) {
|
|
3027
|
-
ports.log(
|
|
3028
|
-
`API reachable again after ${consecutiveTransient} interruption(s) \u2014 resuming`
|
|
3029
|
-
);
|
|
3030
|
-
consecutiveTransient = 0;
|
|
3031
|
-
}
|
|
3032
|
-
if (!claim) {
|
|
3033
|
-
await ports.waitForWake(options.pollMs);
|
|
3034
|
-
continue;
|
|
3035
|
-
}
|
|
3036
|
-
summary.processed++;
|
|
3037
|
-
ports.log(`claimed ${claim.memoryId} (lease ${claim.leaseId})`);
|
|
3038
|
-
const task = await ports.loadTask(claim.memoryId);
|
|
3039
|
-
const stopHeartbeat = ports.startLeaseHeartbeat(claim);
|
|
3040
|
-
let result;
|
|
3041
|
-
try {
|
|
3042
|
-
result = await ports.runTurn(task, claim);
|
|
3043
|
-
} catch (e) {
|
|
3044
|
-
if (e instanceof AuthExpiredError) throw e;
|
|
3045
|
-
result = { status: "crashed", reason: String(e) };
|
|
3046
|
-
} finally {
|
|
3047
|
-
stopHeartbeat();
|
|
3048
|
-
}
|
|
3049
|
-
if (result.status === "crashed" || result.status === "timeout") {
|
|
3050
|
-
summary.abandoned++;
|
|
3051
|
-
ports.log(
|
|
3052
|
-
`ABANDONED ${claim.memoryId}: ${result.status === "timeout" ? "turn timed out" : result.reason} \u2014 lease will expire and the task re-offers (work may re-run).`
|
|
3053
|
-
);
|
|
3054
|
-
} else {
|
|
3055
|
-
const verdict = verdictFor(result.packet?.terminal_status);
|
|
3056
|
-
let text2 = closeoutText(task, result);
|
|
3057
|
-
if (ports.deliver) {
|
|
3058
|
-
try {
|
|
3059
|
-
const delivery = await ports.deliver(
|
|
3060
|
-
claim,
|
|
3061
|
-
task,
|
|
3062
|
-
verdict,
|
|
3063
|
-
result.status === "completed" ? result.fileChangeCount ?? 0 : 0
|
|
3064
|
-
);
|
|
3065
|
-
ports.log(`delivery: ${delivery.note}`);
|
|
3066
|
-
text2 += `
|
|
3067
|
-
|
|
3068
|
-
Delivery: ${delivery.note}`;
|
|
3069
|
-
} catch (e) {
|
|
3070
|
-
if (e instanceof AuthExpiredError) throw e;
|
|
3071
|
-
ports.log(
|
|
3072
|
-
`delivery threw (continuing to completion): ${String(e)}`
|
|
3073
|
-
);
|
|
3074
|
-
text2 += `
|
|
3075
|
-
|
|
3076
|
-
Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
|
-
try {
|
|
3080
|
-
const done = await ports.completeLease(
|
|
3081
|
-
claim,
|
|
3082
|
-
verdict,
|
|
3083
|
-
text2,
|
|
3084
|
-
`${task.title} \u2014 driven closeout`
|
|
3085
|
-
);
|
|
3086
|
-
summary.completed++;
|
|
3087
|
-
ports.log(
|
|
3088
|
-
`completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
|
|
3089
|
-
);
|
|
3090
|
-
} catch (e) {
|
|
3091
|
-
if (e instanceof AuthExpiredError) throw e;
|
|
3092
|
-
summary.abandoned++;
|
|
3093
|
-
ports.log(
|
|
3094
|
-
`COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
|
|
3095
|
-
);
|
|
3096
|
-
}
|
|
3097
|
-
}
|
|
3098
|
-
} catch (e) {
|
|
3099
|
-
if (e instanceof AuthExpiredError) throw e;
|
|
3100
|
-
consecutiveTransient++;
|
|
3101
|
-
if (consecutiveTransient > backoff.maxConsecutive) {
|
|
3102
|
-
ports.log(
|
|
3103
|
-
`FATAL: ${backoff.maxConsecutive} consecutive API interruptions exhausted the backoff ceiling \u2014 deregistering and exiting (loud). Last error: ${String(e)}`
|
|
3104
|
-
);
|
|
3105
|
-
throw new BackoffCeilingExhaustedError(backoff.maxConsecutive, e);
|
|
3106
|
-
}
|
|
3107
|
-
const delay = Math.min(
|
|
3108
|
-
backoff.baseMs * 2 ** (consecutiveTransient - 1),
|
|
3109
|
-
backoff.maxMs
|
|
3110
|
-
);
|
|
3111
|
-
ports.log(
|
|
3112
|
-
`API interruption (${consecutiveTransient}/${backoff.maxConsecutive}) \u2014 backing off ${delay}ms and continuing: ${String(e)}`
|
|
3113
|
-
);
|
|
3114
|
-
await ports.waitForWake(delay);
|
|
3115
|
-
continue;
|
|
3116
|
-
}
|
|
3117
|
-
if (options.once) break;
|
|
3118
|
-
}
|
|
3119
|
-
return summary;
|
|
3120
|
-
}
|
|
3121
|
-
function closeoutText(task, result) {
|
|
3122
|
-
if (!result.packet)
|
|
3123
|
-
return `Driven codex run ended without a sechroom_closeout packet (soft-fail). Last agent message:
|
|
3124
|
-
|
|
3125
|
-
${result.lastAgentMessage || "(none)"}`;
|
|
3126
|
-
const evidence = result.packet.evidence?.length ? `
|
|
3127
|
-
|
|
3128
|
-
Evidence:
|
|
3129
|
-
${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
|
|
3130
|
-
return `${result.packet.summary}${evidence}
|
|
3131
|
-
|
|
3132
|
-
(terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
|
|
3133
|
-
}
|
|
3134
|
-
function startLeaseHeartbeat(beat, log, intervalMs = 3e4, timers = {}) {
|
|
3135
|
-
const schedule = timers.setInterval ?? setInterval;
|
|
3136
|
-
const cancel = timers.clearInterval ?? clearInterval;
|
|
3137
|
-
const timer = schedule(() => {
|
|
3138
|
-
void beat().catch(
|
|
3139
|
-
(e) => log(`lease heartbeat failed (retrying next beat): ${String(e)}`)
|
|
3140
|
-
);
|
|
3141
|
-
}, intervalMs);
|
|
3142
|
-
timer.unref?.();
|
|
3143
|
-
return () => cancel(timer);
|
|
3144
|
-
}
|
|
3145
|
-
|
|
3146
3239
|
// src/executor-run/fleet.ts
|
|
3147
3240
|
import { spawn as spawn2 } from "child_process";
|
|
3148
3241
|
import { createHash as createHash2 } from "crypto";
|
|
@@ -4616,6 +4709,7 @@ function registerTelemetry(program2) {
|
|
|
4616
4709
|
const cwd = input.cwd ?? process.cwd();
|
|
4617
4710
|
const binding = findBinding(cwd);
|
|
4618
4711
|
if (!binding) return process.exit(0);
|
|
4712
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
4619
4713
|
const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
|
|
4620
4714
|
const configRoot = resolveClaudeConfigRoot(
|
|
4621
4715
|
process.env.CLAUDE_CONFIG_DIR,
|
|
@@ -4628,7 +4722,10 @@ function registerTelemetry(program2) {
|
|
|
4628
4722
|
configRoot
|
|
4629
4723
|
);
|
|
4630
4724
|
if (events.length === 0) return process.exit(0);
|
|
4631
|
-
const
|
|
4725
|
+
const taskId = await taskIdForHook(cfg, binding);
|
|
4726
|
+
if (!taskId) {
|
|
4727
|
+
for (const event of events) delete event.taskId;
|
|
4728
|
+
}
|
|
4632
4729
|
await postTelemetry(cfg, binding.decompositionId, events);
|
|
4633
4730
|
return process.exit(0);
|
|
4634
4731
|
} catch {
|
|
@@ -4723,7 +4820,20 @@ function findBinding(start) {
|
|
|
4723
4820
|
readFileSync6(path, "utf8")
|
|
4724
4821
|
);
|
|
4725
4822
|
if (b.decompositionId && b.taskId)
|
|
4726
|
-
return {
|
|
4823
|
+
return {
|
|
4824
|
+
decompositionId: b.decompositionId,
|
|
4825
|
+
taskId: b.taskId,
|
|
4826
|
+
activeTaskCheckedAt: b.activeTaskCheckedAt,
|
|
4827
|
+
lifecycleWarning: b.lifecycleWarning,
|
|
4828
|
+
path
|
|
4829
|
+
};
|
|
4830
|
+
if (b.decompositionId && b.invalidatedTaskId && b.invalidatedReason === "terminal-task")
|
|
4831
|
+
return {
|
|
4832
|
+
decompositionId: b.decompositionId,
|
|
4833
|
+
invalidatedTaskId: b.invalidatedTaskId,
|
|
4834
|
+
invalidatedReason: b.invalidatedReason,
|
|
4835
|
+
path
|
|
4836
|
+
};
|
|
4727
4837
|
} catch {
|
|
4728
4838
|
}
|
|
4729
4839
|
return null;
|
|
@@ -4733,6 +4843,109 @@ function findBinding(start) {
|
|
|
4733
4843
|
dir = parent;
|
|
4734
4844
|
}
|
|
4735
4845
|
}
|
|
4846
|
+
var TERMINAL_TASK_STATUSES = /* @__PURE__ */ new Set([
|
|
4847
|
+
"done",
|
|
4848
|
+
"superseded",
|
|
4849
|
+
"cancelled",
|
|
4850
|
+
"abandoned-by-policy"
|
|
4851
|
+
]);
|
|
4852
|
+
var ACTIVE_TASK_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
4853
|
+
function isTerminalTaskStatus(status) {
|
|
4854
|
+
return TERMINAL_TASK_STATUSES.has(status);
|
|
4855
|
+
}
|
|
4856
|
+
async function taskIdForHook(cfg, binding) {
|
|
4857
|
+
if (!binding.taskId) return void 0;
|
|
4858
|
+
if (hasFreshActiveTaskCache(binding)) return binding.taskId;
|
|
4859
|
+
const verdict = await getTaskLifecycleVerdict(cfg, binding);
|
|
4860
|
+
if (verdict === "active") {
|
|
4861
|
+
cacheActiveBindingIfCurrent(binding);
|
|
4862
|
+
return binding.taskId;
|
|
4863
|
+
}
|
|
4864
|
+
if (verdict === "indeterminate") return binding.taskId;
|
|
4865
|
+
invalidateBindingIfCurrent(binding);
|
|
4866
|
+
return void 0;
|
|
4867
|
+
}
|
|
4868
|
+
function hasFreshActiveTaskCache(binding) {
|
|
4869
|
+
if (!binding.activeTaskCheckedAt) return false;
|
|
4870
|
+
const checkedAt = Date.parse(binding.activeTaskCheckedAt);
|
|
4871
|
+
return !Number.isNaN(checkedAt) && Date.now() - checkedAt >= 0 && Date.now() - checkedAt < ACTIVE_TASK_CACHE_TTL_MS;
|
|
4872
|
+
}
|
|
4873
|
+
async function getTaskLifecycleVerdict(cfg, binding) {
|
|
4874
|
+
try {
|
|
4875
|
+
const client = await makeClient(cfg);
|
|
4876
|
+
const result = await client.GET("/tasks/{id}/card", {
|
|
4877
|
+
params: { path: { id: binding.taskId } }
|
|
4878
|
+
});
|
|
4879
|
+
if (result.response.status === 403) {
|
|
4880
|
+
if (binding.lifecycleWarning !== "insufficient-permission") {
|
|
4881
|
+
process.stderr.write(
|
|
4882
|
+
"lifecycle check unavailable: insufficient permission \u2014 server door remains authoritative\n"
|
|
4883
|
+
);
|
|
4884
|
+
markLifecycleWarningIfCurrent(binding);
|
|
4885
|
+
}
|
|
4886
|
+
return "indeterminate";
|
|
4887
|
+
}
|
|
4888
|
+
if (!result.response.ok || !result.data) return "indeterminate";
|
|
4889
|
+
return isTerminalTaskStatus(result.data.status) ? "terminal" : "active";
|
|
4890
|
+
} catch {
|
|
4891
|
+
return "indeterminate";
|
|
4892
|
+
}
|
|
4893
|
+
}
|
|
4894
|
+
function markLifecycleWarningIfCurrent(binding) {
|
|
4895
|
+
try {
|
|
4896
|
+
const current = JSON.parse(
|
|
4897
|
+
readFileSync6(binding.path, "utf8")
|
|
4898
|
+
);
|
|
4899
|
+
if (current.decompositionId !== binding.decompositionId || current.taskId !== binding.taskId)
|
|
4900
|
+
return;
|
|
4901
|
+
const warned = {
|
|
4902
|
+
decompositionId: binding.decompositionId,
|
|
4903
|
+
taskId: binding.taskId,
|
|
4904
|
+
activeTaskCheckedAt: current.activeTaskCheckedAt,
|
|
4905
|
+
lifecycleWarning: "insufficient-permission"
|
|
4906
|
+
};
|
|
4907
|
+
writeFileSync7(binding.path, JSON.stringify(warned, null, 2) + "\n");
|
|
4908
|
+
} catch {
|
|
4909
|
+
}
|
|
4910
|
+
}
|
|
4911
|
+
function cacheActiveBindingIfCurrent(binding) {
|
|
4912
|
+
try {
|
|
4913
|
+
const current = JSON.parse(
|
|
4914
|
+
readFileSync6(binding.path, "utf8")
|
|
4915
|
+
);
|
|
4916
|
+
if (current.decompositionId !== binding.decompositionId || current.taskId !== binding.taskId)
|
|
4917
|
+
return;
|
|
4918
|
+
const cached = {
|
|
4919
|
+
decompositionId: binding.decompositionId,
|
|
4920
|
+
taskId: binding.taskId,
|
|
4921
|
+
activeTaskCheckedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4922
|
+
};
|
|
4923
|
+
writeFileSync7(binding.path, JSON.stringify(cached, null, 2) + "\n");
|
|
4924
|
+
} catch {
|
|
4925
|
+
}
|
|
4926
|
+
}
|
|
4927
|
+
function invalidateBindingIfCurrent(binding) {
|
|
4928
|
+
try {
|
|
4929
|
+
const current = JSON.parse(
|
|
4930
|
+
readFileSync6(binding.path, "utf8")
|
|
4931
|
+
);
|
|
4932
|
+
if (current.decompositionId !== binding.decompositionId || current.taskId !== binding.taskId)
|
|
4933
|
+
return;
|
|
4934
|
+
writeFileSync7(
|
|
4935
|
+
binding.path,
|
|
4936
|
+
JSON.stringify(
|
|
4937
|
+
{
|
|
4938
|
+
decompositionId: binding.decompositionId,
|
|
4939
|
+
invalidatedTaskId: binding.taskId,
|
|
4940
|
+
invalidatedReason: "terminal-task"
|
|
4941
|
+
},
|
|
4942
|
+
null,
|
|
4943
|
+
2
|
|
4944
|
+
) + "\n"
|
|
4945
|
+
);
|
|
4946
|
+
} catch {
|
|
4947
|
+
}
|
|
4948
|
+
}
|
|
4736
4949
|
function parseTranscript(path) {
|
|
4737
4950
|
if (!existsSync7(path)) return null;
|
|
4738
4951
|
let tokensIn = 0;
|
|
@@ -5158,11 +5371,16 @@ function registerExecutorRunCommand(executor) {
|
|
|
5158
5371
|
// Driver-side claim exclusion: the flag (repeatable) overlays the installed
|
|
5159
5372
|
// state (flag wins when present), else the persisted excludeTags carry through.
|
|
5160
5373
|
// Local-only — never sent in the advertisement (server schema untouched).
|
|
5161
|
-
excludeTags: opts.excludeTag ?? located.state.excludeTags
|
|
5374
|
+
excludeTags: opts.excludeTag ?? located.state.excludeTags,
|
|
5375
|
+
modelId: opts.model ? String(opts.model) : located.state.modelId,
|
|
5376
|
+
runtimeVersion: detectRuntimeVersion("codex", String(opts.codexBin)) ?? located.state.runtimeVersion
|
|
5162
5377
|
};
|
|
5163
5378
|
const heartbeatMs = Number.parseInt(String(opts.heartbeatInterval), 10) * 1e3;
|
|
5164
|
-
|
|
5165
|
-
|
|
5379
|
+
const taskLeaseTtlMs = (located.state.taskLeaseTtlSeconds ?? 120) * 1e3;
|
|
5380
|
+
if (heartbeatMs >= taskLeaseTtlMs)
|
|
5381
|
+
fail(
|
|
5382
|
+
`--heartbeat-interval must be shorter than the ${taskLeaseTtlMs / 1e3}s task lease TTL`
|
|
5383
|
+
);
|
|
5166
5384
|
const usageReserve = Number.parseFloat(String(opts.usageReserve));
|
|
5167
5385
|
if (!Number.isFinite(usageReserve) || usageReserve < 0 || usageReserve >= 100)
|
|
5168
5386
|
fail("--usage-reserve must be a percent in [0, 100)");
|
|
@@ -5231,8 +5449,8 @@ function registerExecutorRunCommand(executor) {
|
|
|
5231
5449
|
await appServer.start();
|
|
5232
5450
|
log(`codex app-server up (${String(opts.codexBin)})`);
|
|
5233
5451
|
let signalCapacityTerminal;
|
|
5234
|
-
const capacityTerminal = new Promise((
|
|
5235
|
-
signalCapacityTerminal =
|
|
5452
|
+
const capacityTerminal = new Promise((resolve9) => {
|
|
5453
|
+
signalCapacityTerminal = resolve9;
|
|
5236
5454
|
});
|
|
5237
5455
|
let stopCapacityCapture = () => {
|
|
5238
5456
|
};
|
|
@@ -5252,8 +5470,8 @@ function registerExecutorRunCommand(executor) {
|
|
|
5252
5470
|
process.once("SIGTERM", requestStop);
|
|
5253
5471
|
let wake = () => {
|
|
5254
5472
|
};
|
|
5255
|
-
const wakeSignal = () => new Promise((
|
|
5256
|
-
wake =
|
|
5473
|
+
const wakeSignal = () => new Promise((resolve9) => {
|
|
5474
|
+
wake = resolve9;
|
|
5257
5475
|
});
|
|
5258
5476
|
let connStop = async () => {
|
|
5259
5477
|
};
|
|
@@ -5313,7 +5531,7 @@ function registerExecutorRunCommand(executor) {
|
|
|
5313
5531
|
return admission;
|
|
5314
5532
|
},
|
|
5315
5533
|
claimNext: async () => await fleetInbox.waitForClaim() ?? await claimNext(request, instance.id, log, excludeTags, skipLog),
|
|
5316
|
-
loadTask: (memoryId) =>
|
|
5534
|
+
loadTask: (memoryId) => materializeClaimedTask(request, memoryId),
|
|
5317
5535
|
startLeaseHeartbeat: (claim) => startLeaseHeartbeat(
|
|
5318
5536
|
() => request(
|
|
5319
5537
|
`/me/executor-task-leases/${encodeURIComponent(claim.leaseId)}/heartbeat`,
|
|
@@ -5372,8 +5590,8 @@ function registerExecutorRunCommand(executor) {
|
|
|
5372
5590
|
),
|
|
5373
5591
|
log,
|
|
5374
5592
|
waitForWake: (ms) => Promise.race([
|
|
5375
|
-
new Promise((
|
|
5376
|
-
setTimeout(
|
|
5593
|
+
new Promise((resolve9) => {
|
|
5594
|
+
setTimeout(resolve9, ms).unref?.();
|
|
5377
5595
|
}),
|
|
5378
5596
|
wakeSignal()
|
|
5379
5597
|
])
|
|
@@ -5521,49 +5739,9 @@ async function claimNext(request, instanceId, log, excludeTags, skipLog) {
|
|
|
5521
5739
|
leaseId: claimed.leaseId,
|
|
5522
5740
|
claimToken: claimed.claimToken,
|
|
5523
5741
|
tokenVersion: claimed.tokenVersion,
|
|
5524
|
-
decompositionId: claimed.decompositionId
|
|
5525
|
-
};
|
|
5526
|
-
}
|
|
5527
|
-
async function loadTask(request, memoryId, log) {
|
|
5528
|
-
const card = await request(
|
|
5529
|
-
`/tasks/${encodeURIComponent(memoryId)}/card`
|
|
5530
|
-
);
|
|
5531
|
-
let packText = "";
|
|
5532
|
-
const pointer = card.contextPack;
|
|
5533
|
-
if (pointer?.slug && pointer.version) {
|
|
5534
|
-
try {
|
|
5535
|
-
const pkg = await request(
|
|
5536
|
-
`/bundles/${encodeURIComponent(pointer.slug)}/versions/${encodeURIComponent(pointer.version)}/package`
|
|
5537
|
-
);
|
|
5538
|
-
packText = (pkg.components ?? []).map((c) => `### ${c.title ?? "context"}
|
|
5539
|
-
${c.body}`).join("\n\n");
|
|
5540
|
-
} catch (error) {
|
|
5541
|
-
log(
|
|
5542
|
-
`warning: task ${card.taskId} context pack (${pointer.slug}@${pointer.version}) failed to resolve (${String(error)}) \u2014 running on card body only`
|
|
5543
|
-
);
|
|
5544
|
-
}
|
|
5545
|
-
}
|
|
5546
|
-
return {
|
|
5547
|
-
title: card.title ?? memoryId,
|
|
5548
|
-
text: assemblePrompt(card, packText)
|
|
5742
|
+
decompositionId: claimed.decompositionId
|
|
5549
5743
|
};
|
|
5550
5744
|
}
|
|
5551
|
-
function assemblePrompt(card, packText) {
|
|
5552
|
-
const sections = [
|
|
5553
|
-
`# Task: ${card.title}`,
|
|
5554
|
-
`## Objective
|
|
5555
|
-
${card.task.objective}`,
|
|
5556
|
-
`## Acceptance
|
|
5557
|
-
${card.task.acceptance}`,
|
|
5558
|
-
`## Boundaries
|
|
5559
|
-
${card.task.boundaries}`,
|
|
5560
|
-
`## Closeout
|
|
5561
|
-
${card.task.closeout}`
|
|
5562
|
-
];
|
|
5563
|
-
if (packText) sections.push(`## Context pack
|
|
5564
|
-
${packText}`);
|
|
5565
|
-
return sections.join("\n\n");
|
|
5566
|
-
}
|
|
5567
5745
|
function taskPrompt(task) {
|
|
5568
5746
|
return `You are a driven executor working ONE dispatched Work Layer task.
|
|
5569
5747
|
|
|
@@ -5590,6 +5768,10 @@ function executorRegistrationInput(state, deliverySubscriptionId, activationMode
|
|
|
5590
5768
|
deliverySubscriptionId,
|
|
5591
5769
|
connectorId: state.connectorId,
|
|
5592
5770
|
claimedCapabilityKeys: state.capabilityKeys,
|
|
5771
|
+
taskLeaseTtlSeconds: state.taskLeaseTtlSeconds ?? 120,
|
|
5772
|
+
modelId: state.modelId ?? null,
|
|
5773
|
+
runtimeVersion: state.runtimeVersion ?? null,
|
|
5774
|
+
effortLabel: state.effortLabel ?? null,
|
|
5593
5775
|
claimPolicy: parseClaimPolicy(state.claimPolicy),
|
|
5594
5776
|
claimTags: state.claimTags ?? [],
|
|
5595
5777
|
toolSetRef: null,
|
|
@@ -5647,6 +5829,11 @@ function registerExecutor(program2) {
|
|
|
5647
5829
|
"SignalR delivery binding name",
|
|
5648
5830
|
"executor-dispatch"
|
|
5649
5831
|
).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 600).option(
|
|
5832
|
+
"--task-lease-ttl <seconds>",
|
|
5833
|
+
"Task lease TTL (60-86400)",
|
|
5834
|
+
parseInteger,
|
|
5835
|
+
120
|
|
5836
|
+
).option("--model-id <id>", "Driver model identity").option("--effort-label <label>", "Optional driver effort label").option(
|
|
5650
5837
|
"--refresh-after <seconds>",
|
|
5651
5838
|
"Minimum age before a hook refreshes",
|
|
5652
5839
|
parseInteger,
|
|
@@ -5716,6 +5903,8 @@ function registerExecutor(program2) {
|
|
|
5716
5903
|
const sem = readSem();
|
|
5717
5904
|
const checkout = sem ? dirname8(dirname8(sem.path)) : process.cwd();
|
|
5718
5905
|
const statePath = join11(checkout, ".sechroom", EXECUTOR_STATE);
|
|
5906
|
+
const previous = existsSync8(statePath) ? JSON.parse(readFileSync7(statePath, "utf8")) : void 0;
|
|
5907
|
+
const canUpdateExisting = previous?.instanceId && previous.instanceKey === instanceKey && previous.laneId === laneId && previous.runtime === (runtime.toLowerCase() === "codex" ? "codex" : "claude-code") && previous.relayId === opts.relay && previous.connectorId === connector;
|
|
5719
5908
|
const state = {
|
|
5720
5909
|
schemaVersion: 1,
|
|
5721
5910
|
instanceKey,
|
|
@@ -5723,13 +5912,18 @@ function registerExecutor(program2) {
|
|
|
5723
5912
|
runtime: runtime.toLowerCase() === "codex" ? "codex" : "claude-code",
|
|
5724
5913
|
connectorId: connector,
|
|
5725
5914
|
capabilityKeys: capabilities ?? [],
|
|
5915
|
+
taskLeaseTtlSeconds: opts.taskLeaseTtl,
|
|
5916
|
+
modelId: opts.modelId,
|
|
5917
|
+
effortLabel: opts.effortLabel,
|
|
5726
5918
|
claimPolicy: (opts.claimPolicy ?? "open").toLowerCase() === "restricted" ? "restricted" : "open",
|
|
5727
5919
|
claimTags: opts.claimTag ?? [],
|
|
5728
5920
|
excludeTags: opts.excludeTag ?? [],
|
|
5729
5921
|
relayId: opts.relay,
|
|
5730
5922
|
subscriptionName: opts.subscriptionName,
|
|
5731
5923
|
ttlSeconds: opts.ttl,
|
|
5732
|
-
refreshAfterSeconds: opts.refreshAfter
|
|
5924
|
+
refreshAfterSeconds: opts.refreshAfter,
|
|
5925
|
+
instanceId: canUpdateExisting ? previous.instanceId : void 0,
|
|
5926
|
+
runtimeVersion: canUpdateExisting ? previous.runtimeVersion : void 0
|
|
5733
5927
|
};
|
|
5734
5928
|
if (!opts.dryRun) {
|
|
5735
5929
|
mkdirSync9(dirname8(statePath), { recursive: true });
|
|
@@ -5767,6 +5961,7 @@ function registerExecutor(program2) {
|
|
|
5767
5961
|
if (state.instanceId && age < state.refreshAfterSeconds * 1e3) return;
|
|
5768
5962
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
5769
5963
|
try {
|
|
5964
|
+
refreshRuntimeVersion(state);
|
|
5770
5965
|
await ensureExecutorInstance(cfg, { state, path });
|
|
5771
5966
|
} catch {
|
|
5772
5967
|
}
|
|
@@ -5853,7 +6048,12 @@ function registerExecutor(program2) {
|
|
|
5853
6048
|
"--activation-mode <mode>",
|
|
5854
6049
|
"attached | detached \u2014 detached marks a fleet run as a service that outlives its shell (default attached)",
|
|
5855
6050
|
"attached"
|
|
5856
|
-
).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).
|
|
6051
|
+
).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).option(
|
|
6052
|
+
"--task-lease-ttl <seconds>",
|
|
6053
|
+
"Task lease TTL (60-86400)",
|
|
6054
|
+
parseInteger,
|
|
6055
|
+
120
|
|
6056
|
+
).option("--model-id <id>", "Driver model identity").option("--effort-label <label>", "Optional driver effort label").action(async (opts, cmd) => {
|
|
5857
6057
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
5858
6058
|
const subscription = await api(
|
|
5859
6059
|
cfg,
|
|
@@ -5883,6 +6083,12 @@ function registerExecutor(program2) {
|
|
|
5883
6083
|
deliverySubscriptionId: subscription.id,
|
|
5884
6084
|
connectorId: opts.connector,
|
|
5885
6085
|
claimedCapabilityKeys: opts.capability ?? [],
|
|
6086
|
+
taskLeaseTtlSeconds: opts.taskLeaseTtl,
|
|
6087
|
+
modelId: opts.modelId ?? null,
|
|
6088
|
+
runtimeVersion: detectRuntimeVersion(
|
|
6089
|
+
String(opts.runtime).toLowerCase() === "codex" ? "codex" : "claude-code"
|
|
6090
|
+
),
|
|
6091
|
+
effortLabel: opts.effortLabel ?? null,
|
|
5886
6092
|
claimPolicy: parseClaimPolicy(opts.claimPolicy),
|
|
5887
6093
|
claimTags: opts.claimTag ?? [],
|
|
5888
6094
|
toolSetRef: opts.toolSetRef ?? null,
|
|
@@ -6017,6 +6223,31 @@ function registerExecutor(program2) {
|
|
|
6017
6223
|
);
|
|
6018
6224
|
emit(data, Boolean(cmd.optsWithGlobals().json));
|
|
6019
6225
|
});
|
|
6226
|
+
executor.command("update <id>").description(
|
|
6227
|
+
"Update executor capabilities, bounded lease TTL, and model identity in place"
|
|
6228
|
+
).option("--capability <key...>", "Capability operation keys").option(
|
|
6229
|
+
"--task-lease-ttl <seconds>",
|
|
6230
|
+
"Task lease TTL (60-86400)",
|
|
6231
|
+
parseInteger
|
|
6232
|
+
).option("--model-id <id>", "Driver model identity").option("--runtime-version <version>", "Driver runtime/version identity").option("--effort-label <label>", "Optional driver effort label").option(
|
|
6233
|
+
"--deregister-others",
|
|
6234
|
+
"Deregister the caller's other active advertisements",
|
|
6235
|
+
false
|
|
6236
|
+
).action(async (id, opts, cmd) => {
|
|
6237
|
+
const data = await updateExecutorAdvertisement(
|
|
6238
|
+
resolveConfig(cmd.optsWithGlobals()),
|
|
6239
|
+
id,
|
|
6240
|
+
{
|
|
6241
|
+
capabilityKeys: opts.capability,
|
|
6242
|
+
taskLeaseTtlSeconds: opts.taskLeaseTtl,
|
|
6243
|
+
deregisterOthers: opts.deregisterOthers,
|
|
6244
|
+
modelId: opts.modelId,
|
|
6245
|
+
runtimeVersion: opts.runtimeVersion,
|
|
6246
|
+
effortLabel: opts.effortLabel
|
|
6247
|
+
}
|
|
6248
|
+
);
|
|
6249
|
+
emit(data, Boolean(cmd.optsWithGlobals().json));
|
|
6250
|
+
});
|
|
6020
6251
|
executor.command("deregister <id>").description("Stop advertising this executor instance").action(async (id, _opts, cmd) => {
|
|
6021
6252
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
6022
6253
|
const data = await api(
|
|
@@ -6138,12 +6369,87 @@ async function deregisterInstance(cfg, id) {
|
|
|
6138
6369
|
async function ensureExecutorInstance(cfg, located, activationMode = "Attached") {
|
|
6139
6370
|
const { state, path } = located;
|
|
6140
6371
|
state.laneId ??= state.instanceKey;
|
|
6141
|
-
|
|
6372
|
+
state.taskLeaseTtlSeconds ??= 120;
|
|
6373
|
+
refreshRuntimeVersion(state);
|
|
6374
|
+
let data;
|
|
6375
|
+
if (state.instanceId) {
|
|
6376
|
+
const updated = await tryUpdateExecutorAdvertisement(
|
|
6377
|
+
cfg,
|
|
6378
|
+
state.instanceId,
|
|
6379
|
+
{
|
|
6380
|
+
capabilityKeys: state.capabilityKeys,
|
|
6381
|
+
taskLeaseTtlSeconds: state.taskLeaseTtlSeconds,
|
|
6382
|
+
deregisterOthers: false,
|
|
6383
|
+
modelId: state.modelId,
|
|
6384
|
+
runtimeVersion: state.runtimeVersion,
|
|
6385
|
+
effortLabel: state.effortLabel,
|
|
6386
|
+
laneId: state.laneId,
|
|
6387
|
+
claimPolicy: parseClaimPolicy(state.claimPolicy),
|
|
6388
|
+
claimTags: state.claimTags ?? [],
|
|
6389
|
+
parentId: state.parentId ?? null,
|
|
6390
|
+
repairRegistrationState: true
|
|
6391
|
+
}
|
|
6392
|
+
);
|
|
6393
|
+
data = updated ? await refreshExecutorInstance(cfg, state.instanceId, state.ttlSeconds) : await registerInstance(cfg, state, activationMode);
|
|
6394
|
+
} else {
|
|
6395
|
+
data = await registerInstance(cfg, state, activationMode);
|
|
6396
|
+
}
|
|
6142
6397
|
state.instanceId = data.id;
|
|
6143
6398
|
state.lastRefreshAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6144
6399
|
writeFileSync8(path, JSON.stringify(state, null, 2) + "\n");
|
|
6145
6400
|
return data;
|
|
6146
6401
|
}
|
|
6402
|
+
async function updateExecutorAdvertisement(cfg, id, input) {
|
|
6403
|
+
return await tryUpdateExecutorAdvertisement(cfg, id, input) ?? fail(`no active executor instance '${id}' is owned by the current operator`);
|
|
6404
|
+
}
|
|
6405
|
+
async function tryUpdateExecutorAdvertisement(cfg, id, input) {
|
|
6406
|
+
const token = await requireToken(cfg);
|
|
6407
|
+
const path = `/me/executor-instances/${encodeURIComponent(id)}`;
|
|
6408
|
+
const response = await fetch(`${cfg.baseUrl}${path}`, {
|
|
6409
|
+
method: "PUT",
|
|
6410
|
+
headers: {
|
|
6411
|
+
authorization: `Bearer ${token}`,
|
|
6412
|
+
tenant: cfg.tenant,
|
|
6413
|
+
"content-type": "application/json",
|
|
6414
|
+
"x-sechroom-surface": "cli"
|
|
6415
|
+
},
|
|
6416
|
+
body: JSON.stringify({
|
|
6417
|
+
claimedCapabilityKeys: input.capabilityKeys,
|
|
6418
|
+
taskLeaseTtlSeconds: input.taskLeaseTtlSeconds,
|
|
6419
|
+
deregisterOthers: input.deregisterOthers,
|
|
6420
|
+
modelId: input.modelId ?? null,
|
|
6421
|
+
runtimeVersion: input.runtimeVersion,
|
|
6422
|
+
effortLabel: input.effortLabel,
|
|
6423
|
+
laneId: input.laneId,
|
|
6424
|
+
claimPolicy: input.claimPolicy,
|
|
6425
|
+
claimTags: input.claimTags,
|
|
6426
|
+
parentId: input.parentId,
|
|
6427
|
+
repairRegistrationState: input.repairRegistrationState
|
|
6428
|
+
})
|
|
6429
|
+
});
|
|
6430
|
+
if (response.ok) return response.json();
|
|
6431
|
+
const detail = await response.text();
|
|
6432
|
+
if ((response.status === 400 || response.status === 404) && detail.includes("No active executor instance"))
|
|
6433
|
+
return void 0;
|
|
6434
|
+
return fail(`PUT ${path} failed (${response.status}): ${detail}`);
|
|
6435
|
+
}
|
|
6436
|
+
function detectRuntimeVersion(runtime, binaryOverride) {
|
|
6437
|
+
const binary = binaryOverride ?? (runtime === "codex" ? "codex" : "claude");
|
|
6438
|
+
try {
|
|
6439
|
+
return execFileSync(binary, ["--version"], {
|
|
6440
|
+
encoding: "utf8",
|
|
6441
|
+
timeout: 2e3,
|
|
6442
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
6443
|
+
}).trim() || void 0;
|
|
6444
|
+
} catch {
|
|
6445
|
+
return void 0;
|
|
6446
|
+
}
|
|
6447
|
+
}
|
|
6448
|
+
function refreshRuntimeVersion(state, binaryOverride) {
|
|
6449
|
+
const detected = detectRuntimeVersion(state.runtime, binaryOverride);
|
|
6450
|
+
if (detected) state.runtimeVersion = detected;
|
|
6451
|
+
return state.runtimeVersion;
|
|
6452
|
+
}
|
|
6147
6453
|
function readExecutorState(start = process.cwd()) {
|
|
6148
6454
|
const semPath = resolveSemPathForRead(start);
|
|
6149
6455
|
const sem = semPath ? readSem(semPath) : void 0;
|
|
@@ -6185,11 +6491,11 @@ function parseInteger(value) {
|
|
|
6185
6491
|
return parsed;
|
|
6186
6492
|
}
|
|
6187
6493
|
function holdHeartbeat(tick, intervalMs) {
|
|
6188
|
-
return new Promise((
|
|
6494
|
+
return new Promise((resolve9, reject) => {
|
|
6189
6495
|
const timer = setInterval(() => void tick().catch(reject), intervalMs);
|
|
6190
6496
|
const stop = () => {
|
|
6191
6497
|
clearInterval(timer);
|
|
6192
|
-
|
|
6498
|
+
resolve9();
|
|
6193
6499
|
};
|
|
6194
6500
|
process.once("SIGINT", stop);
|
|
6195
6501
|
process.once("SIGTERM", stop);
|
|
@@ -6201,33 +6507,26 @@ function registerChannel(program2) {
|
|
|
6201
6507
|
const channel = program2.command("channel").description(
|
|
6202
6508
|
"Receive matched substrate events over the held SignalR push leg (D-WLP-9)"
|
|
6203
6509
|
);
|
|
6204
|
-
|
|
6205
|
-
"
|
|
6206
|
-
|
|
6207
|
-
).option(
|
|
6208
|
-
"--tag <tag...>",
|
|
6209
|
-
"Deprecated: executor eligibility comes from the installed capability advertisement"
|
|
6210
|
-
).option(
|
|
6211
|
-
"--workspace <wsp...>",
|
|
6212
|
-
"Deprecated: workspace authority is resolved by the server"
|
|
6213
|
-
).option(
|
|
6214
|
-
"--executor-instance <id>",
|
|
6215
|
-
"Deprecated: the instance is read from .sechroom/executor.json"
|
|
6216
|
-
);
|
|
6217
|
-
withFilterOpts(
|
|
6218
|
-
channel.command("connect").description(
|
|
6219
|
-
"Register a SignalR subscription and stream matched events to stdout"
|
|
6220
|
-
)
|
|
6221
|
-
).action(async (opts, cmd) => {
|
|
6510
|
+
channel.command("connect").description(
|
|
6511
|
+
"Register an executor advertisement, claim exact offers, and stream tasks to stdout"
|
|
6512
|
+
).action(async (_opts, cmd) => {
|
|
6222
6513
|
const json = Boolean(cmd.optsWithGlobals().json);
|
|
6223
6514
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
6224
|
-
warnLegacyChannelOptions(opts);
|
|
6225
6515
|
const located = requireExecutorState();
|
|
6226
6516
|
const instance = await ensureExecutorInstance(cfg, located);
|
|
6227
|
-
const deliver = (
|
|
6228
|
-
|
|
6517
|
+
const deliver = createTaskClaimDelivery(
|
|
6518
|
+
cfg,
|
|
6519
|
+
(payload) => process.stdout.write(
|
|
6520
|
+
(typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
|
|
6521
|
+
)
|
|
6522
|
+
);
|
|
6523
|
+
const leaseHeartbeats = createChannelLeaseHeartbeatManager(
|
|
6524
|
+
cfg,
|
|
6525
|
+
located.state.taskLeaseTtlSeconds ?? 120
|
|
6229
6526
|
);
|
|
6230
|
-
const drain = createClaimDrain(cfg, instance.id, deliver
|
|
6527
|
+
const drain = createClaimDrain(cfg, instance.id, deliver, {
|
|
6528
|
+
onClaimed: leaseHeartbeats.onClaimed
|
|
6529
|
+
});
|
|
6231
6530
|
const conn = await openConnection(
|
|
6232
6531
|
cfg,
|
|
6233
6532
|
() => {
|
|
@@ -6238,8 +6537,7 @@ function registerChannel(program2) {
|
|
|
6238
6537
|
},
|
|
6239
6538
|
instance.id
|
|
6240
6539
|
);
|
|
6241
|
-
|
|
6242
|
-
const stopReconciliation = startOfferReconciliation(drain);
|
|
6540
|
+
const stopReconciliation = startClaimReconciliation(drain);
|
|
6243
6541
|
const stopHeartbeat = startExecutorHeartbeat(
|
|
6244
6542
|
() => refreshExecutorInstance(cfg, instance.id, located.state.ttlSeconds),
|
|
6245
6543
|
located.state.refreshAfterSeconds * 1e3
|
|
@@ -6268,36 +6566,37 @@ function registerChannel(program2) {
|
|
|
6268
6566
|
} finally {
|
|
6269
6567
|
stopReconciliation();
|
|
6270
6568
|
stopHeartbeat();
|
|
6569
|
+
leaseHeartbeats.stop();
|
|
6271
6570
|
}
|
|
6272
6571
|
});
|
|
6273
|
-
|
|
6274
|
-
channel
|
|
6275
|
-
|
|
6276
|
-
)
|
|
6277
|
-
).action(async (opts, cmd) => {
|
|
6572
|
+
channel.command("mcp").description(
|
|
6573
|
+
"Run as a Claude Code channel (local-stdio MCP server) \u2014 claim and push dispatched tasks into the session"
|
|
6574
|
+
).action(async (_opts, cmd) => {
|
|
6278
6575
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
6279
|
-
warnLegacyChannelOptions(opts);
|
|
6280
6576
|
const located = requireExecutorState();
|
|
6281
6577
|
const instance = await ensureExecutorInstance(cfg, located);
|
|
6282
6578
|
const mcp = new Server(
|
|
6283
6579
|
{ name: "sechroom", version: "0.1.0" },
|
|
6284
6580
|
{
|
|
6285
6581
|
capabilities: { experimental: { "claude/channel": {} } },
|
|
6286
|
-
instructions: '
|
|
6582
|
+
instructions: 'A WLP dispatch arrives as a <channel source="sechroom"> tag containing the complete runnable task card followed by its pinned task context. Treat it as the task-time layer on top of the session floor; do not refetch or substitute live memory bodies. Retain memory_id, lease_id, and claim_token from the metadata for holder-bound completion.'
|
|
6287
6583
|
}
|
|
6288
6584
|
);
|
|
6289
6585
|
await mcp.connect(new StdioServerTransport());
|
|
6290
|
-
const deliver = (payload) => {
|
|
6291
|
-
const { content, meta } =
|
|
6292
|
-
|
|
6586
|
+
const deliver = createTaskClaimDelivery(cfg, async (payload) => {
|
|
6587
|
+
const { content, meta } = summarizeTaskClaim(payload);
|
|
6588
|
+
await mcp.notification({
|
|
6293
6589
|
method: "notifications/claude/channel",
|
|
6294
6590
|
params: { content, meta }
|
|
6295
|
-
})
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6591
|
+
});
|
|
6592
|
+
});
|
|
6593
|
+
const leaseHeartbeats = createChannelLeaseHeartbeatManager(
|
|
6594
|
+
cfg,
|
|
6595
|
+
located.state.taskLeaseTtlSeconds ?? 120
|
|
6596
|
+
);
|
|
6597
|
+
const drain = createClaimDrain(cfg, instance.id, deliver, {
|
|
6598
|
+
onClaimed: leaseHeartbeats.onClaimed
|
|
6599
|
+
});
|
|
6301
6600
|
const conn = await openConnection(
|
|
6302
6601
|
cfg,
|
|
6303
6602
|
() => {
|
|
@@ -6308,8 +6607,7 @@ function registerChannel(program2) {
|
|
|
6308
6607
|
},
|
|
6309
6608
|
instance.id
|
|
6310
6609
|
);
|
|
6311
|
-
|
|
6312
|
-
const stopReconciliation = startOfferReconciliation(drain);
|
|
6610
|
+
const stopReconciliation = startClaimReconciliation(drain);
|
|
6313
6611
|
const stopHeartbeat = startExecutorHeartbeat(
|
|
6314
6612
|
() => refreshExecutorInstance(cfg, instance.id, located.state.ttlSeconds),
|
|
6315
6613
|
located.state.refreshAfterSeconds * 1e3
|
|
@@ -6325,31 +6623,23 @@ function registerChannel(program2) {
|
|
|
6325
6623
|
} finally {
|
|
6326
6624
|
stopReconciliation();
|
|
6327
6625
|
stopHeartbeat();
|
|
6626
|
+
leaseHeartbeats.stop();
|
|
6328
6627
|
}
|
|
6329
6628
|
});
|
|
6330
6629
|
channel.command("install").description(
|
|
6331
6630
|
"Wire `sechroom channel mcp` into the project .mcp.json as a Claude Code channel MCP server (idempotent)"
|
|
6332
|
-
).option(
|
|
6333
|
-
"--workspace <wsp...>",
|
|
6334
|
-
"Deprecated: accepted only to migrate an existing managed entry"
|
|
6335
|
-
).option(
|
|
6336
|
-
"--tag <tag...>",
|
|
6337
|
-
"Deprecated: accepted only to migrate an existing managed entry"
|
|
6338
|
-
).option(
|
|
6339
|
-
"--name <name>",
|
|
6340
|
-
"MCP server + subscription name (idempotent per name)",
|
|
6341
|
-
"sechroom-channel"
|
|
6342
6631
|
).option("--dry-run", "Print what would change; write nothing").action((opts) => {
|
|
6343
6632
|
const path = join12(process.cwd(), ".mcp.json");
|
|
6344
6633
|
const dryRun = Boolean(opts.dryRun);
|
|
6634
|
+
const name = "sechroom-channel";
|
|
6345
6635
|
const args = ["channel", "mcp"];
|
|
6346
6636
|
const entry = { command: "sechroom", args };
|
|
6347
6637
|
const config2 = readMcpConfig(path);
|
|
6348
6638
|
config2.mcpServers ??= {};
|
|
6349
|
-
const existing = config2.mcpServers[
|
|
6639
|
+
const existing = config2.mcpServers[name];
|
|
6350
6640
|
const status = JSON.stringify(existing) === JSON.stringify(entry) ? "current" : existing ? "updated" : "created";
|
|
6351
6641
|
if (status !== "current" && !dryRun) {
|
|
6352
|
-
config2.mcpServers[
|
|
6642
|
+
config2.mcpServers[name] = entry;
|
|
6353
6643
|
mkdirSync10(dirname9(path), { recursive: true });
|
|
6354
6644
|
writeFileSync9(path, JSON.stringify(config2, null, 2) + "\n");
|
|
6355
6645
|
}
|
|
@@ -6357,7 +6647,7 @@ function registerChannel(program2) {
|
|
|
6357
6647
|
process.stdout.write(`${style.green("channel")} ${path} (${verb})
|
|
6358
6648
|
`);
|
|
6359
6649
|
process.stdout.write(
|
|
6360
|
-
style.dim(` server "${
|
|
6650
|
+
style.dim(` server "${name}": sechroom ${args.join(" ")}
|
|
6361
6651
|
`)
|
|
6362
6652
|
);
|
|
6363
6653
|
if (status !== "current") {
|
|
@@ -6365,18 +6655,12 @@ function registerChannel(program2) {
|
|
|
6365
6655
|
style.dim(
|
|
6366
6656
|
`
|
|
6367
6657
|
Load it (Channels research preview) by launching your agent with:
|
|
6368
|
-
claude --dangerously-load-development-channels server:${
|
|
6658
|
+
claude --dangerously-load-development-channels server:${name}
|
|
6369
6659
|
`
|
|
6370
6660
|
)
|
|
6371
6661
|
);
|
|
6372
6662
|
}
|
|
6373
6663
|
warnIfSechroomNotOnPath();
|
|
6374
|
-
if ((opts.workspace?.length ?? 0) > 0 || (opts.tag?.length ?? 0) > 0)
|
|
6375
|
-
process.stderr.write(
|
|
6376
|
-
style.dim(
|
|
6377
|
-
"channel: --workspace/--tag are retired; the managed entry now uses the installed executor advertisement.\n"
|
|
6378
|
-
)
|
|
6379
|
-
);
|
|
6380
6664
|
});
|
|
6381
6665
|
channel.addHelpText(
|
|
6382
6666
|
"after",
|
|
@@ -6398,15 +6682,6 @@ function requireExecutorState() {
|
|
|
6398
6682
|
);
|
|
6399
6683
|
return located;
|
|
6400
6684
|
}
|
|
6401
|
-
function warnLegacyChannelOptions(opts) {
|
|
6402
|
-
if (!opts.name && (opts.workspace?.length ?? 0) === 0 && (opts.tag?.length ?? 0) === 0 && !opts.executorInstance)
|
|
6403
|
-
return;
|
|
6404
|
-
process.stderr.write(
|
|
6405
|
-
style.dim(
|
|
6406
|
-
"channel: --name, --workspace, --tag, and --executor-instance are retired; delivery, eligibility, and identity come from the installed executor advertisement.\n"
|
|
6407
|
-
)
|
|
6408
|
-
);
|
|
6409
|
-
}
|
|
6410
6685
|
function createClaimDrain(cfg, executorInstanceId, deliver, dependencies = {}) {
|
|
6411
6686
|
let active2;
|
|
6412
6687
|
const state = {};
|
|
@@ -6430,21 +6705,88 @@ function startOfferReconciliation(drain, intervalMilliseconds = 5e3, dependencie
|
|
|
6430
6705
|
}, intervalMilliseconds);
|
|
6431
6706
|
return () => cancel(timer);
|
|
6432
6707
|
}
|
|
6708
|
+
function startClaimReconciliation(drain, intervalMilliseconds = 5e3, dependencies = {}) {
|
|
6709
|
+
const onError = dependencies.onError ?? ((error) => process.stderr.write(err(`channel claim failed: ${String(error)}
|
|
6710
|
+
`)));
|
|
6711
|
+
void drain().catch(onError);
|
|
6712
|
+
return startOfferReconciliation(drain, intervalMilliseconds, {
|
|
6713
|
+
...dependencies,
|
|
6714
|
+
onError
|
|
6715
|
+
});
|
|
6716
|
+
}
|
|
6433
6717
|
function startExecutorHeartbeat(refresh, intervalMilliseconds, dependencies = {}) {
|
|
6434
6718
|
const schedule = dependencies.setInterval ?? setInterval;
|
|
6435
6719
|
const cancel = dependencies.clearInterval ?? clearInterval;
|
|
6436
|
-
const onError = dependencies.onError ?? ((error) => process.stderr.write(
|
|
6437
|
-
`)
|
|
6720
|
+
const onError = dependencies.onError ?? ((error) => process.stderr.write(
|
|
6721
|
+
err(`channel heartbeat failed: ${String(error)}
|
|
6722
|
+
`)
|
|
6723
|
+
));
|
|
6438
6724
|
const timer = schedule(() => {
|
|
6439
6725
|
void refresh().catch(onError);
|
|
6440
6726
|
}, intervalMilliseconds);
|
|
6441
6727
|
return () => cancel(timer);
|
|
6442
6728
|
}
|
|
6729
|
+
function startChannelTaskLeaseHeartbeat(cfg, claim, intervalMilliseconds = 3e4, dependencies = {}) {
|
|
6730
|
+
const leaseId = claim.lease?.id;
|
|
6731
|
+
const claimToken = claim.claimToken;
|
|
6732
|
+
if (!leaseId || !claimToken) return void 0;
|
|
6733
|
+
const request = dependencies.request ?? api;
|
|
6734
|
+
const onError = dependencies.onError ?? ((value) => process.stderr.write(
|
|
6735
|
+
err(`channel lease heartbeat failed: ${String(value)}
|
|
6736
|
+
`)
|
|
6737
|
+
));
|
|
6738
|
+
return startLeaseHeartbeat(
|
|
6739
|
+
() => request(
|
|
6740
|
+
cfg,
|
|
6741
|
+
`/me/executor-task-leases/${encodeURIComponent(leaseId)}/heartbeat`,
|
|
6742
|
+
{
|
|
6743
|
+
method: "POST",
|
|
6744
|
+
body: JSON.stringify({
|
|
6745
|
+
claimToken,
|
|
6746
|
+
tokenVersion: claim.tokenVersion ?? 1
|
|
6747
|
+
})
|
|
6748
|
+
}
|
|
6749
|
+
),
|
|
6750
|
+
onError,
|
|
6751
|
+
intervalMilliseconds,
|
|
6752
|
+
{
|
|
6753
|
+
setInterval: dependencies.setInterval,
|
|
6754
|
+
clearInterval: dependencies.clearInterval
|
|
6755
|
+
}
|
|
6756
|
+
);
|
|
6757
|
+
}
|
|
6758
|
+
function createChannelLeaseHeartbeatManager(cfg, taskLeaseTtlSeconds) {
|
|
6759
|
+
const stops = /* @__PURE__ */ new Map();
|
|
6760
|
+
const intervalMilliseconds = Math.max(
|
|
6761
|
+
1e3,
|
|
6762
|
+
Math.min(3e4, Math.floor(taskLeaseTtlSeconds * 1e3 / 4))
|
|
6763
|
+
);
|
|
6764
|
+
return {
|
|
6765
|
+
onClaimed: (claim) => {
|
|
6766
|
+
const leaseId = claim.lease?.id;
|
|
6767
|
+
if (!leaseId || stops.has(leaseId)) return;
|
|
6768
|
+
const stop = startChannelTaskLeaseHeartbeat(
|
|
6769
|
+
cfg,
|
|
6770
|
+
claim,
|
|
6771
|
+
intervalMilliseconds
|
|
6772
|
+
);
|
|
6773
|
+
if (stop) stops.set(leaseId, stop);
|
|
6774
|
+
},
|
|
6775
|
+
stop: () => {
|
|
6776
|
+
for (const stop of stops.values()) stop();
|
|
6777
|
+
stops.clear();
|
|
6778
|
+
}
|
|
6779
|
+
};
|
|
6780
|
+
}
|
|
6443
6781
|
async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {}) {
|
|
6444
6782
|
const request = dependencies.request ?? api;
|
|
6445
|
-
const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((
|
|
6783
|
+
const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve9) => setTimeout(resolve9, milliseconds)));
|
|
6446
6784
|
const idempotencyKey = dependencies.idempotencyKey ?? ((offer) => `channel:${offer.generationId}`);
|
|
6447
6785
|
const state = dependencies.state ?? {};
|
|
6786
|
+
const deliverClaim = async (claim) => {
|
|
6787
|
+
dependencies.onClaimed?.(claim);
|
|
6788
|
+
await deliver(claim);
|
|
6789
|
+
};
|
|
6448
6790
|
for (; ; ) {
|
|
6449
6791
|
if (state.pendingIdempotencyKey) {
|
|
6450
6792
|
const replay = await request(
|
|
@@ -6455,11 +6797,12 @@ async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {})
|
|
|
6455
6797
|
body: JSON.stringify({ idempotencyKey: state.pendingIdempotencyKey })
|
|
6456
6798
|
}
|
|
6457
6799
|
);
|
|
6458
|
-
state.pendingIdempotencyKey = void 0;
|
|
6459
6800
|
if (replay.outcome === "Claimed" || replay.outcome === "AlreadyHeld") {
|
|
6460
|
-
|
|
6801
|
+
await deliverClaim(replay);
|
|
6802
|
+
state.pendingIdempotencyKey = void 0;
|
|
6461
6803
|
continue;
|
|
6462
6804
|
}
|
|
6805
|
+
state.pendingIdempotencyKey = void 0;
|
|
6463
6806
|
return;
|
|
6464
6807
|
}
|
|
6465
6808
|
const offers = await request(
|
|
@@ -6479,11 +6822,13 @@ async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {})
|
|
|
6479
6822
|
body: JSON.stringify({ idempotencyKey: state.pendingIdempotencyKey })
|
|
6480
6823
|
}
|
|
6481
6824
|
);
|
|
6825
|
+
if (claim.outcome === "Claimed" || claim.outcome === "AlreadyHeld") {
|
|
6826
|
+
await deliverClaim(claim);
|
|
6827
|
+
state.pendingIdempotencyKey = void 0;
|
|
6828
|
+
continue;
|
|
6829
|
+
}
|
|
6482
6830
|
state.pendingIdempotencyKey = void 0;
|
|
6483
|
-
|
|
6484
|
-
if (claim.outcome === "Claimed" || claim.outcome === "AlreadyHeld")
|
|
6485
|
-
deliver(claim);
|
|
6486
|
-
else return;
|
|
6831
|
+
return;
|
|
6487
6832
|
}
|
|
6488
6833
|
}
|
|
6489
6834
|
function readMcpConfig(path) {
|
|
@@ -6516,9 +6861,9 @@ async function openConnection(cfg, onEvent, executorInstanceId) {
|
|
|
6516
6861
|
return conn;
|
|
6517
6862
|
}
|
|
6518
6863
|
function holdOpen(conn) {
|
|
6519
|
-
return new Promise((
|
|
6864
|
+
return new Promise((resolve9) => {
|
|
6520
6865
|
const stop = () => {
|
|
6521
|
-
void conn.stop().finally(
|
|
6866
|
+
void conn.stop().finally(resolve9);
|
|
6522
6867
|
};
|
|
6523
6868
|
process.on("SIGINT", stop);
|
|
6524
6869
|
process.on("SIGTERM", stop);
|
|
@@ -6544,18 +6889,35 @@ function parseEvent(payload) {
|
|
|
6544
6889
|
tags: Array.isArray(rawTags) ? rawTags.filter((t) => typeof t === "string") : void 0
|
|
6545
6890
|
};
|
|
6546
6891
|
}
|
|
6547
|
-
function
|
|
6548
|
-
const
|
|
6549
|
-
|
|
6892
|
+
function createTaskClaimDelivery(cfg, deliver, dependencies = {}) {
|
|
6893
|
+
const request = dependencies.request ?? api;
|
|
6894
|
+
return async (claim) => {
|
|
6895
|
+
const memoryId = claim.lease?.memoryId || claim.offer?.memoryId;
|
|
6896
|
+
if (!memoryId)
|
|
6897
|
+
throw new Error(
|
|
6898
|
+
"claimed task response carried no memory id; refusing attached-channel delivery"
|
|
6899
|
+
);
|
|
6900
|
+
const task = await materializeClaimedTask(
|
|
6901
|
+
(path, init) => request(cfg, path, init),
|
|
6902
|
+
memoryId
|
|
6903
|
+
);
|
|
6904
|
+
await deliver({ ...claim, task });
|
|
6905
|
+
};
|
|
6906
|
+
}
|
|
6907
|
+
function summarizeTaskClaim(payload) {
|
|
6908
|
+
const { task, ...claim } = payload;
|
|
6909
|
+
const parsed = parseEvent(claim);
|
|
6910
|
+
const memoryId = parsed.memoryId || claim.lease?.memoryId || claim.offer?.memoryId || "";
|
|
6911
|
+
const workspaceId = parsed.workspaceId || claim.lease?.workspaceId || claim.offer?.workspaceId || "";
|
|
6550
6912
|
const meta = {};
|
|
6551
|
-
if (eventType) meta.event_type = eventType;
|
|
6913
|
+
if (parsed.eventType) meta.event_type = parsed.eventType;
|
|
6552
6914
|
if (memoryId) meta.memory_id = memoryId;
|
|
6553
6915
|
if (workspaceId) meta.workspace_id = workspaceId;
|
|
6554
|
-
const claim = payload ?? {};
|
|
6555
6916
|
if (claim.outcome) meta.claim_outcome = claim.outcome;
|
|
6556
6917
|
if (claim.lease?.id) meta.lease_id = claim.lease.id;
|
|
6557
6918
|
if (claim.claimToken) meta.claim_token = claim.claimToken;
|
|
6558
|
-
|
|
6919
|
+
meta.context_layer = "task";
|
|
6920
|
+
return { content: task.text, meta };
|
|
6559
6921
|
}
|
|
6560
6922
|
function str(v) {
|
|
6561
6923
|
return typeof v === "string" ? v : v == null ? "" : String(v);
|
|
@@ -6644,16 +7006,16 @@ Examples:
|
|
|
6644
7006
|
}
|
|
6645
7007
|
|
|
6646
7008
|
// src/commands/checkpoint.ts
|
|
6647
|
-
import { mkdirSync as
|
|
6648
|
-
import { dirname as
|
|
7009
|
+
import { mkdirSync as mkdirSync15, writeFileSync as writeFileSync14 } from "fs";
|
|
7010
|
+
import { dirname as dirname14, join as join16 } from "path";
|
|
6649
7011
|
|
|
6650
7012
|
// src/commands/hook.ts
|
|
6651
|
-
import { createHash as
|
|
6652
|
-
import { existsSync as
|
|
6653
|
-
import { dirname as
|
|
7013
|
+
import { createHash as createHash4 } from "crypto";
|
|
7014
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync14, readFileSync as readFileSync11, statSync as statSync3, writeFileSync as writeFileSync13 } from "fs";
|
|
7015
|
+
import { dirname as dirname13, join as join15 } from "path";
|
|
6654
7016
|
|
|
6655
7017
|
// src/commands/lane-commit-hook.ts
|
|
6656
|
-
import { execFileSync } from "child_process";
|
|
7018
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
6657
7019
|
import {
|
|
6658
7020
|
chmodSync,
|
|
6659
7021
|
existsSync as existsSync10,
|
|
@@ -6717,90 +7079,427 @@ ${current.slice(shebang.length)}`;
|
|
|
6717
7079
|
${MANAGED_BLOCK}
|
|
6718
7080
|
${current}`;
|
|
6719
7081
|
}
|
|
6720
|
-
function managedBlockPattern() {
|
|
6721
|
-
return new RegExp(
|
|
6722
|
-
`${BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`
|
|
7082
|
+
function managedBlockPattern() {
|
|
7083
|
+
return new RegExp(
|
|
7084
|
+
`${BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`
|
|
7085
|
+
);
|
|
7086
|
+
}
|
|
7087
|
+
function chainWrapper(incumbentName) {
|
|
7088
|
+
return `#!/bin/sh
|
|
7089
|
+
${MANAGED_BLOCK}
|
|
7090
|
+
hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
7091
|
+
exec "$hook_dir/${incumbentName}" "$@"
|
|
7092
|
+
`;
|
|
7093
|
+
}
|
|
7094
|
+
function nextIncumbentPath(path) {
|
|
7095
|
+
const base = `${path}${INCUMBENT_SUFFIX}`;
|
|
7096
|
+
if (!existsSync10(base)) return base;
|
|
7097
|
+
for (let index = 2; ; index++) {
|
|
7098
|
+
const candidate = `${base}.${index}`;
|
|
7099
|
+
if (!existsSync10(candidate)) return candidate;
|
|
7100
|
+
}
|
|
7101
|
+
}
|
|
7102
|
+
function resolveHookPath(root, hookName) {
|
|
7103
|
+
const gitPath = execFileSync2(
|
|
7104
|
+
"git",
|
|
7105
|
+
["-C", root, "rev-parse", "--git-path", `hooks/${hookName}`],
|
|
7106
|
+
{
|
|
7107
|
+
encoding: "utf8",
|
|
7108
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
7109
|
+
}
|
|
7110
|
+
).trim();
|
|
7111
|
+
return isAbsolute(gitPath) ? gitPath : resolve4(root, gitPath);
|
|
7112
|
+
}
|
|
7113
|
+
function removeLegacyPrepareCommitMsgLeg(root) {
|
|
7114
|
+
const path = resolveHookPath(root, LEGACY_HOOK_NAME);
|
|
7115
|
+
if (!existsSync10(path)) return;
|
|
7116
|
+
const current = readFileSync9(path, "utf8");
|
|
7117
|
+
const pattern = managedBlockPattern();
|
|
7118
|
+
if (!pattern.test(current)) return;
|
|
7119
|
+
const next = current.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
7120
|
+
if (!next || next === "#!/bin/sh") {
|
|
7121
|
+
unlinkSync(path);
|
|
7122
|
+
return;
|
|
7123
|
+
}
|
|
7124
|
+
writeFileSync10(path, `${next}
|
|
7125
|
+
`, "utf8");
|
|
7126
|
+
chmodSync(path, 493);
|
|
7127
|
+
}
|
|
7128
|
+
function installLaneCommitHook(start) {
|
|
7129
|
+
try {
|
|
7130
|
+
const root = execFileSync2("git", ["-C", start, "rev-parse", "--show-toplevel"], {
|
|
7131
|
+
encoding: "utf8",
|
|
7132
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
7133
|
+
}).trim();
|
|
7134
|
+
removeLegacyPrepareCommitMsgLeg(root);
|
|
7135
|
+
const path = resolveHookPath(root, HOOK_NAME);
|
|
7136
|
+
const current = existsSync10(path) ? readFileSync9(path, "utf8") : "";
|
|
7137
|
+
let next;
|
|
7138
|
+
if (current && !isShellHook(current)) {
|
|
7139
|
+
const incumbentPath = nextIncumbentPath(path);
|
|
7140
|
+
renameSync(path, incumbentPath);
|
|
7141
|
+
chmodSync(incumbentPath, 493);
|
|
7142
|
+
next = chainWrapper(basename2(incumbentPath));
|
|
7143
|
+
} else {
|
|
7144
|
+
next = insertManagedBlock(current);
|
|
7145
|
+
}
|
|
7146
|
+
if (next !== current) {
|
|
7147
|
+
mkdirSync11(dirname10(path), { recursive: true });
|
|
7148
|
+
writeFileSync10(path, next.endsWith("\n") ? next : `${next}
|
|
7149
|
+
`, "utf8");
|
|
7150
|
+
}
|
|
7151
|
+
chmodSync(path, 493);
|
|
7152
|
+
return path;
|
|
7153
|
+
} catch {
|
|
7154
|
+
return void 0;
|
|
7155
|
+
}
|
|
7156
|
+
}
|
|
7157
|
+
|
|
7158
|
+
// src/commands/session-context.ts
|
|
7159
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
7160
|
+
import { mkdirSync as mkdirSync13, renameSync as renameSync3, rmSync as rmSync6, writeFileSync as writeFileSync12 } from "fs";
|
|
7161
|
+
import { dirname as dirname12, join as join14 } from "path";
|
|
7162
|
+
|
|
7163
|
+
// src/setup/skill-composition-materialise.ts
|
|
7164
|
+
import { createHash as createHash3, randomUUID } from "crypto";
|
|
7165
|
+
import {
|
|
7166
|
+
existsSync as existsSync11,
|
|
7167
|
+
mkdirSync as mkdirSync12,
|
|
7168
|
+
readdirSync as readdirSync2,
|
|
7169
|
+
readFileSync as readFileSync10,
|
|
7170
|
+
renameSync as renameSync2,
|
|
7171
|
+
rmdirSync,
|
|
7172
|
+
rmSync as rmSync5,
|
|
7173
|
+
statSync as statSync2,
|
|
7174
|
+
writeFileSync as writeFileSync11
|
|
7175
|
+
} from "fs";
|
|
7176
|
+
import { dirname as dirname11, isAbsolute as isAbsolute2, join as join13, relative, resolve as resolve5 } from "path";
|
|
7177
|
+
var COMPILED_SKILL_LOCK = join13(
|
|
7178
|
+
".sechroom",
|
|
7179
|
+
"compiled-skill-materialisation.json"
|
|
7180
|
+
);
|
|
7181
|
+
var GIT_EXCLUDE_BEGIN = "# sechroom compiled skills: begin";
|
|
7182
|
+
var GIT_EXCLUDE_END = "# sechroom compiled skills: end";
|
|
7183
|
+
function materialiseCompiledSkillCompositions(cwd, payload, options = {}) {
|
|
7184
|
+
const holds = [...payload.holds ?? []];
|
|
7185
|
+
if (payload.preserveExisting || holds.some((hold) => hold.skillName == null))
|
|
7186
|
+
return { items: [], holds };
|
|
7187
|
+
const destinations = resolveDestinations(cwd, options);
|
|
7188
|
+
const desired = /* @__PURE__ */ new Map();
|
|
7189
|
+
const ambiguous = /* @__PURE__ */ new Set();
|
|
7190
|
+
for (const skill of payload.skills) {
|
|
7191
|
+
if (!isSafePathSegment(skill.name)) {
|
|
7192
|
+
holds.push({
|
|
7193
|
+
code: "compiled-skill-name-invalid",
|
|
7194
|
+
reason: `Compiled skill '${skill.name}' is not a safe path segment.`,
|
|
7195
|
+
bundleSlug: skill.bundleSlug,
|
|
7196
|
+
skillName: skill.name
|
|
7197
|
+
});
|
|
7198
|
+
continue;
|
|
7199
|
+
}
|
|
7200
|
+
const body = withFinalNewline(skill.body);
|
|
7201
|
+
for (const rawTarget of skill.targets) {
|
|
7202
|
+
if (!isSupportedTarget(rawTarget)) {
|
|
7203
|
+
holds.push({
|
|
7204
|
+
code: "compiled-skill-target-unsupported",
|
|
7205
|
+
reason: `Compiled skill '${skill.name}' names unsupported target:${rawTarget}.`,
|
|
7206
|
+
bundleSlug: skill.bundleSlug,
|
|
7207
|
+
skillName: skill.name
|
|
7208
|
+
});
|
|
7209
|
+
continue;
|
|
7210
|
+
}
|
|
7211
|
+
for (const destination of destinations.filter(
|
|
7212
|
+
(candidate) => candidate.target === rawTarget
|
|
7213
|
+
)) {
|
|
7214
|
+
const key = entryKey(rawTarget, skill.name, destination.skillsRoot);
|
|
7215
|
+
if (desired.has(key) || ambiguous.has(key)) {
|
|
7216
|
+
holds.push({
|
|
7217
|
+
code: "compiled-skill-identity-ambiguous",
|
|
7218
|
+
reason: `More than one compiled composition resolved '${skill.name}' for target:${rawTarget}.`,
|
|
7219
|
+
bundleSlug: skill.bundleSlug,
|
|
7220
|
+
skillName: skill.name
|
|
7221
|
+
});
|
|
7222
|
+
desired.delete(key);
|
|
7223
|
+
ambiguous.add(key);
|
|
7224
|
+
continue;
|
|
7225
|
+
}
|
|
7226
|
+
desired.set(key, {
|
|
7227
|
+
target: rawTarget,
|
|
7228
|
+
skillsRoot: destination.skillsRoot,
|
|
7229
|
+
skill,
|
|
7230
|
+
body
|
|
7231
|
+
});
|
|
7232
|
+
}
|
|
7233
|
+
}
|
|
7234
|
+
}
|
|
7235
|
+
for (const [key, value] of desired) {
|
|
7236
|
+
if (holds.some(
|
|
7237
|
+
(hold) => hold.skillName === value.skill.name && (hold.bundleSlug == null || hold.bundleSlug === value.skill.bundleSlug)
|
|
7238
|
+
)) {
|
|
7239
|
+
desired.delete(key);
|
|
7240
|
+
}
|
|
7241
|
+
}
|
|
7242
|
+
const lockPath = join13(cwd, COMPILED_SKILL_LOCK);
|
|
7243
|
+
const previous = readLock(lockPath, destinations);
|
|
7244
|
+
const next = {
|
|
7245
|
+
version: 2,
|
|
7246
|
+
entries: { ...previous.entries }
|
|
7247
|
+
};
|
|
7248
|
+
const items = [];
|
|
7249
|
+
for (const [key, value] of desired) {
|
|
7250
|
+
const path = skillPath(value.skillsRoot, value.skill.name);
|
|
7251
|
+
const directory = dirname11(path);
|
|
7252
|
+
const prior = previous.entries[key];
|
|
7253
|
+
const existing = existsSync11(path) ? readFileSync10(path, "utf8") : void 0;
|
|
7254
|
+
const existingHash = existing === void 0 ? void 0 : sha256(existing);
|
|
7255
|
+
const owned = prior !== void 0 && prior.skillsRoot === value.skillsRoot && existing !== void 0 && (existingHash === prior.contentHash || existingHash === prior.previousContentHash);
|
|
7256
|
+
const recoverableMissing = prior !== void 0 && existing === void 0;
|
|
7257
|
+
if (existing !== void 0 && !owned) {
|
|
7258
|
+
delete next.entries[key];
|
|
7259
|
+
items.push({
|
|
7260
|
+
target: value.target,
|
|
7261
|
+
name: value.skill.name,
|
|
7262
|
+
path,
|
|
7263
|
+
status: "collision",
|
|
7264
|
+
reason: "existing SKILL.md is not owned by the compiled-skill materializer"
|
|
7265
|
+
});
|
|
7266
|
+
continue;
|
|
7267
|
+
}
|
|
7268
|
+
if (existing === void 0 && existsSync11(directory) && !recoverableMissing) {
|
|
7269
|
+
delete next.entries[key];
|
|
7270
|
+
items.push({
|
|
7271
|
+
target: value.target,
|
|
7272
|
+
name: value.skill.name,
|
|
7273
|
+
path,
|
|
7274
|
+
status: "collision",
|
|
7275
|
+
reason: "existing skill directory is not owned by the compiled-skill materializer"
|
|
7276
|
+
});
|
|
7277
|
+
continue;
|
|
7278
|
+
}
|
|
7279
|
+
const contentHash = sha256(value.body);
|
|
7280
|
+
const finalEntry = {
|
|
7281
|
+
target: value.target,
|
|
7282
|
+
name: value.skill.name,
|
|
7283
|
+
skillsRoot: value.skillsRoot,
|
|
7284
|
+
bundleSlug: value.skill.bundleSlug,
|
|
7285
|
+
bundleVersion: value.skill.bundleVersion,
|
|
7286
|
+
manifestHash: value.skill.manifestHash,
|
|
7287
|
+
contentHash
|
|
7288
|
+
};
|
|
7289
|
+
let status;
|
|
7290
|
+
if (existing === value.body) {
|
|
7291
|
+
status = "unchanged";
|
|
7292
|
+
next.entries[key] = finalEntry;
|
|
7293
|
+
writeLock(lockPath, next);
|
|
7294
|
+
} else {
|
|
7295
|
+
next.entries[key] = {
|
|
7296
|
+
...finalEntry,
|
|
7297
|
+
previousContentHash: existingHash
|
|
7298
|
+
};
|
|
7299
|
+
writeLock(lockPath, next);
|
|
7300
|
+
mkdirSync12(directory, { recursive: true });
|
|
7301
|
+
if (existingHash !== void 0 && (!existsSync11(path) || sha256(readFileSync10(path, "utf8")) !== existingHash)) {
|
|
7302
|
+
delete next.entries[key];
|
|
7303
|
+
writeLock(lockPath, next);
|
|
7304
|
+
items.push({
|
|
7305
|
+
target: value.target,
|
|
7306
|
+
name: value.skill.name,
|
|
7307
|
+
path,
|
|
7308
|
+
status: "collision",
|
|
7309
|
+
reason: "owned SKILL.md changed during reconciliation; preserved byte-identical"
|
|
7310
|
+
});
|
|
7311
|
+
continue;
|
|
7312
|
+
}
|
|
7313
|
+
writeAtomic(path, value.body);
|
|
7314
|
+
status = existing === void 0 ? "written" : "updated";
|
|
7315
|
+
next.entries[key] = finalEntry;
|
|
7316
|
+
writeLock(lockPath, next);
|
|
7317
|
+
}
|
|
7318
|
+
items.push({ target: value.target, name: value.skill.name, path, status });
|
|
7319
|
+
}
|
|
7320
|
+
for (const [key, prior] of Object.entries(previous.entries)) {
|
|
7321
|
+
if (desired.has(key)) continue;
|
|
7322
|
+
const path = skillPath(prior.skillsRoot, prior.name);
|
|
7323
|
+
const removed = removeOwnedFile(path, [
|
|
7324
|
+
prior.contentHash,
|
|
7325
|
+
prior.previousContentHash
|
|
7326
|
+
]);
|
|
7327
|
+
if (removed === "collision") {
|
|
7328
|
+
items.push({
|
|
7329
|
+
target: prior.target,
|
|
7330
|
+
name: prior.name,
|
|
7331
|
+
path,
|
|
7332
|
+
status: "collision",
|
|
7333
|
+
reason: "previously materialized SKILL.md was edited; preserved byte-identical"
|
|
7334
|
+
});
|
|
7335
|
+
} else if (removed === "removed") {
|
|
7336
|
+
removeDirectoryIfEmpty(dirname11(path));
|
|
7337
|
+
items.push({
|
|
7338
|
+
target: prior.target,
|
|
7339
|
+
name: prior.name,
|
|
7340
|
+
path,
|
|
7341
|
+
status: "removed"
|
|
7342
|
+
});
|
|
7343
|
+
}
|
|
7344
|
+
delete next.entries[key];
|
|
7345
|
+
writeLock(lockPath, next);
|
|
7346
|
+
}
|
|
7347
|
+
writeLock(lockPath, next);
|
|
7348
|
+
syncGitExcludes(cwd, next);
|
|
7349
|
+
return { items, holds };
|
|
7350
|
+
}
|
|
7351
|
+
function isSupportedTarget(value) {
|
|
7352
|
+
return value === "claude-code" || value === "gpt-codex";
|
|
7353
|
+
}
|
|
7354
|
+
function isSafePathSegment(value) {
|
|
7355
|
+
return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\") && !value.includes("\0");
|
|
7356
|
+
}
|
|
7357
|
+
function resolveDestinations(cwd, options) {
|
|
7358
|
+
const destinations = [
|
|
7359
|
+
{
|
|
7360
|
+
target: "claude-code",
|
|
7361
|
+
skillsRoot: resolve5(cwd, ".claude", "skills")
|
|
7362
|
+
}
|
|
7363
|
+
];
|
|
7364
|
+
const codexHomes = options.codexHomes ?? resolveCodexHomes({
|
|
7365
|
+
override: options.codexHomeOverride,
|
|
7366
|
+
scope: "global"
|
|
7367
|
+
});
|
|
7368
|
+
for (const home of codexHomes) {
|
|
7369
|
+
destinations.push({
|
|
7370
|
+
target: "gpt-codex",
|
|
7371
|
+
skillsRoot: resolve5(home, "skills")
|
|
7372
|
+
});
|
|
7373
|
+
}
|
|
7374
|
+
return destinations.filter(
|
|
7375
|
+
(candidate, index, all) => all.findIndex(
|
|
7376
|
+
(other) => other.target === candidate.target && other.skillsRoot === candidate.skillsRoot
|
|
7377
|
+
) === index
|
|
6723
7378
|
);
|
|
6724
7379
|
}
|
|
6725
|
-
function
|
|
6726
|
-
return
|
|
6727
|
-
${MANAGED_BLOCK}
|
|
6728
|
-
hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
6729
|
-
exec "$hook_dir/${incumbentName}" "$@"
|
|
6730
|
-
`;
|
|
7380
|
+
function entryKey(target, name, skillsRoot) {
|
|
7381
|
+
return `${target}:${sha256(skillsRoot)}:${name}`;
|
|
6731
7382
|
}
|
|
6732
|
-
function
|
|
6733
|
-
|
|
6734
|
-
if (!existsSync10(base)) return base;
|
|
6735
|
-
for (let index = 2; ; index++) {
|
|
6736
|
-
const candidate = `${base}.${index}`;
|
|
6737
|
-
if (!existsSync10(candidate)) return candidate;
|
|
6738
|
-
}
|
|
7383
|
+
function skillPath(skillsRoot, name) {
|
|
7384
|
+
return join13(skillsRoot, name, "SKILL.md");
|
|
6739
7385
|
}
|
|
6740
|
-
function
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
7386
|
+
function withFinalNewline(body) {
|
|
7387
|
+
return body.endsWith("\n") ? body : body + "\n";
|
|
7388
|
+
}
|
|
7389
|
+
function sha256(value) {
|
|
7390
|
+
return createHash3("sha256").update(value, "utf8").digest("hex");
|
|
7391
|
+
}
|
|
7392
|
+
function readLock(path, destinations) {
|
|
7393
|
+
const allowedRoots = new Set(
|
|
7394
|
+
destinations.map(
|
|
7395
|
+
(destination) => `${destination.target}\0${destination.skillsRoot}`
|
|
7396
|
+
)
|
|
7397
|
+
);
|
|
7398
|
+
try {
|
|
7399
|
+
const parsed = JSON.parse(readFileSync10(path, "utf8"));
|
|
7400
|
+
if (parsed.version === 2 && parsed.entries && typeof parsed.entries === "object") {
|
|
7401
|
+
const entries = {};
|
|
7402
|
+
for (const [key, candidate] of Object.entries(parsed.entries)) {
|
|
7403
|
+
if (!isLockEntry(candidate) || key !== entryKey(candidate.target, candidate.name, candidate.skillsRoot))
|
|
7404
|
+
continue;
|
|
7405
|
+
if (!allowedRoots.has(`${candidate.target}\0${candidate.skillsRoot}`))
|
|
7406
|
+
continue;
|
|
7407
|
+
entries[key] = candidate;
|
|
7408
|
+
}
|
|
7409
|
+
return { version: 2, entries };
|
|
6747
7410
|
}
|
|
6748
|
-
|
|
6749
|
-
|
|
7411
|
+
} catch {
|
|
7412
|
+
}
|
|
7413
|
+
return { version: 2, entries: {} };
|
|
6750
7414
|
}
|
|
6751
|
-
function
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
|
|
7415
|
+
function isLockEntry(value) {
|
|
7416
|
+
if (value === null || typeof value !== "object") return false;
|
|
7417
|
+
const candidate = value;
|
|
7418
|
+
return isSupportedTarget(String(candidate.target)) && typeof candidate.name === "string" && isSafePathSegment(candidate.name) && typeof candidate.skillsRoot === "string" && isAbsolute2(candidate.skillsRoot) && typeof candidate.bundleSlug === "string" && typeof candidate.bundleVersion === "string" && typeof candidate.manifestHash === "string" && typeof candidate.contentHash === "string" && /^[0-9a-f]{64}$/.test(candidate.contentHash) && (candidate.previousContentHash === void 0 || typeof candidate.previousContentHash === "string" && /^[0-9a-f]{64}$/.test(candidate.previousContentHash));
|
|
7419
|
+
}
|
|
7420
|
+
function writeLock(path, lock) {
|
|
7421
|
+
mkdirSync12(dirname11(path), { recursive: true });
|
|
7422
|
+
writeAtomic(path, JSON.stringify(lock, null, 2) + "\n");
|
|
7423
|
+
}
|
|
7424
|
+
function writeAtomic(path, body) {
|
|
7425
|
+
const temporary = `${path}.sechroom-${process.pid}.tmp`;
|
|
7426
|
+
writeFileSync11(temporary, body);
|
|
7427
|
+
renameSync2(temporary, path);
|
|
7428
|
+
}
|
|
7429
|
+
function removeOwnedFile(path, acceptableHashes) {
|
|
7430
|
+
const quarantine = `${path}.sechroom-retire-${process.pid}-${randomUUID()}`;
|
|
7431
|
+
try {
|
|
7432
|
+
renameSync2(path, quarantine);
|
|
7433
|
+
} catch (error) {
|
|
7434
|
+
if (error.code === "ENOENT") return "missing";
|
|
7435
|
+
throw error;
|
|
6761
7436
|
}
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
7437
|
+
const quarantinedHash = sha256(readFileSync10(quarantine, "utf8"));
|
|
7438
|
+
if (acceptableHashes.includes(quarantinedHash)) {
|
|
7439
|
+
rmSync5(quarantine);
|
|
7440
|
+
return "removed";
|
|
7441
|
+
}
|
|
7442
|
+
if (!existsSync11(path)) {
|
|
7443
|
+
renameSync2(quarantine, path);
|
|
7444
|
+
} else {
|
|
7445
|
+
renameSync2(quarantine, `${path}.sechroom-preserved-${randomUUID()}`);
|
|
7446
|
+
}
|
|
7447
|
+
return "collision";
|
|
6765
7448
|
}
|
|
6766
|
-
function
|
|
7449
|
+
function syncGitExcludes(cwd, lock) {
|
|
7450
|
+
const path = gitExcludePath(cwd);
|
|
7451
|
+
if (!path) return;
|
|
7452
|
+
let current = "";
|
|
6767
7453
|
try {
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
return
|
|
7454
|
+
current = readFileSync10(path, "utf8");
|
|
7455
|
+
} catch {
|
|
7456
|
+
}
|
|
7457
|
+
const withoutOwnedBlock = removeOwnedExcludeBlock(current);
|
|
7458
|
+
const patterns = Object.values(lock.entries).filter((entry) => entry.target === "claude-code").map((entry) => relative(cwd, skillPath(entry.skillsRoot, entry.name))).filter(
|
|
7459
|
+
(entryPath) => entryPath.length > 0 && !isAbsolute2(entryPath) && entryPath !== ".." && !entryPath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)
|
|
7460
|
+
).map((entryPath) => `/${entryPath.replaceAll("\\", "/")}`).sort();
|
|
7461
|
+
const block = patterns.length === 0 ? "" : [GIT_EXCLUDE_BEGIN, ...patterns, GIT_EXCLUDE_END, ""].join("\n");
|
|
7462
|
+
const separator = withoutOwnedBlock.length > 0 && !withoutOwnedBlock.endsWith("\n") && block ? "\n" : "";
|
|
7463
|
+
const updated = withoutOwnedBlock + separator + block;
|
|
7464
|
+
if (updated === current) return;
|
|
7465
|
+
mkdirSync12(dirname11(path), { recursive: true });
|
|
7466
|
+
writeAtomic(path, updated);
|
|
7467
|
+
}
|
|
7468
|
+
function gitExcludePath(cwd) {
|
|
7469
|
+
const dotGit = join13(cwd, ".git");
|
|
7470
|
+
try {
|
|
7471
|
+
if (statSync2(dotGit).isDirectory()) return join13(dotGit, "info", "exclude");
|
|
7472
|
+
const pointer = readFileSync10(dotGit, "utf8").trim();
|
|
7473
|
+
if (!pointer.startsWith("gitdir:")) return void 0;
|
|
7474
|
+
const raw = pointer.slice("gitdir:".length).trim();
|
|
7475
|
+
const gitDir = isAbsolute2(raw) ? raw : resolve5(cwd, raw);
|
|
7476
|
+
return join13(gitDir, "info", "exclude");
|
|
6791
7477
|
} catch {
|
|
6792
7478
|
return void 0;
|
|
6793
7479
|
}
|
|
6794
7480
|
}
|
|
7481
|
+
function removeOwnedExcludeBlock(body) {
|
|
7482
|
+
const start = body.indexOf(GIT_EXCLUDE_BEGIN);
|
|
7483
|
+
if (start < 0) return body;
|
|
7484
|
+
const endMarker = body.indexOf(GIT_EXCLUDE_END, start);
|
|
7485
|
+
if (endMarker < 0) return body;
|
|
7486
|
+
let end = endMarker + GIT_EXCLUDE_END.length;
|
|
7487
|
+
if (body.slice(end, end + 2) === "\r\n") end += 2;
|
|
7488
|
+
else if (body[end] === "\n") end += 1;
|
|
7489
|
+
return body.slice(0, start) + body.slice(end);
|
|
7490
|
+
}
|
|
7491
|
+
function removeDirectoryIfEmpty(path) {
|
|
7492
|
+
try {
|
|
7493
|
+
if (readdirSync2(path).length === 0) rmdirSync(path);
|
|
7494
|
+
} catch {
|
|
7495
|
+
}
|
|
7496
|
+
}
|
|
6795
7497
|
|
|
6796
7498
|
// src/commands/session-context.ts
|
|
6797
|
-
|
|
6798
|
-
import { mkdirSync as mkdirSync12, renameSync as renameSync2, rmSync as rmSync5, writeFileSync as writeFileSync11 } from "fs";
|
|
6799
|
-
import { dirname as dirname11, join as join13 } from "path";
|
|
6800
|
-
var DYNAMIC_AGENT_CONTEXT_FILE = join13(".sechroom", "CLAUDE.md");
|
|
7499
|
+
var DYNAMIC_AGENT_CONTEXT_FILE = join14(".sechroom", "CLAUDE.md");
|
|
6801
7500
|
function checkoutRoot(start) {
|
|
6802
7501
|
const semPath = resolveSemPathForRead(start);
|
|
6803
|
-
return semPath ?
|
|
7502
|
+
return semPath ? dirname12(dirname12(semPath)) : start;
|
|
6804
7503
|
}
|
|
6805
7504
|
function renderSessionContext(result, lane) {
|
|
6806
7505
|
if (result.status === "hold") {
|
|
@@ -6832,22 +7531,36 @@ function renderSessionContext(result, lane) {
|
|
|
6832
7531
|
}
|
|
6833
7532
|
return lines.join("\n");
|
|
6834
7533
|
}
|
|
6835
|
-
function writeSessionContext(start, lane, result) {
|
|
6836
|
-
const
|
|
7534
|
+
function writeSessionContext(start, lane, result, options = {}) {
|
|
7535
|
+
const root = checkoutRoot(start);
|
|
7536
|
+
const path = join14(root, DYNAMIC_AGENT_CONTEXT_FILE);
|
|
7537
|
+
const skills = materialiseCompiledSkillCompositions(
|
|
7538
|
+
root,
|
|
7539
|
+
result.status === "hold" ? {
|
|
7540
|
+
skills: [],
|
|
7541
|
+
holds: [{
|
|
7542
|
+
code: "session-context-hold",
|
|
7543
|
+
reason: result.reason?.trim() || "Session context resolution held without a reason.",
|
|
7544
|
+
skillName: null
|
|
7545
|
+
}],
|
|
7546
|
+
preserveExisting: true
|
|
7547
|
+
} : { skills: result.skills },
|
|
7548
|
+
options
|
|
7549
|
+
);
|
|
6837
7550
|
const context = renderSessionContext(result, lane);
|
|
6838
|
-
|
|
6839
|
-
const temporaryPath = `${path}.${process.pid}.${
|
|
7551
|
+
mkdirSync13(dirname12(path), { recursive: true });
|
|
7552
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID2()}.tmp`;
|
|
6840
7553
|
try {
|
|
6841
|
-
|
|
7554
|
+
writeFileSync12(temporaryPath, context.endsWith("\n") ? context : `${context}
|
|
6842
7555
|
`, "utf8");
|
|
6843
|
-
|
|
7556
|
+
renameSync3(temporaryPath, path);
|
|
6844
7557
|
} catch (error) {
|
|
6845
|
-
|
|
7558
|
+
rmSync6(temporaryPath, { force: true });
|
|
6846
7559
|
throw error;
|
|
6847
7560
|
}
|
|
6848
|
-
return { status: result.status, path, context };
|
|
7561
|
+
return { status: result.status, path, context, skills };
|
|
6849
7562
|
}
|
|
6850
|
-
async function materializeSessionContext(client, cfg, lane, start) {
|
|
7563
|
+
async function materializeSessionContext(client, cfg, lane, start, options = {}) {
|
|
6851
7564
|
const { data, error, response } = await client.POST("/session-context/resolve", {
|
|
6852
7565
|
body: {
|
|
6853
7566
|
laneId: lane,
|
|
@@ -6858,7 +7571,15 @@ async function materializeSessionContext(client, cfg, lane, start) {
|
|
|
6858
7571
|
const detail = error ? JSON.stringify(error) : `HTTP ${response?.status ?? "unknown"}`;
|
|
6859
7572
|
throw new Error(`session context resolution failed: ${detail}`);
|
|
6860
7573
|
}
|
|
6861
|
-
|
|
7574
|
+
assertSessionContextResult(data);
|
|
7575
|
+
return writeSessionContext(start, lane, data, options);
|
|
7576
|
+
}
|
|
7577
|
+
function assertSessionContextResult(value) {
|
|
7578
|
+
if (value === null || typeof value !== "object" || !["ready", "hold"].includes(String(value.status)) || !Array.isArray(value.members) || !Array.isArray(value.skills)) {
|
|
7579
|
+
throw new Error(
|
|
7580
|
+
"session context resolution returned an invalid payload; preserving existing compiled skills"
|
|
7581
|
+
);
|
|
7582
|
+
}
|
|
6862
7583
|
}
|
|
6863
7584
|
|
|
6864
7585
|
// src/commands/hook.ts
|
|
@@ -6885,13 +7606,13 @@ function resolveLane(flagLane, cwd) {
|
|
|
6885
7606
|
if (!base) return void 0;
|
|
6886
7607
|
return applyWorktreeLaneSuffix(base, start);
|
|
6887
7608
|
}
|
|
6888
|
-
var INTENT_FILE =
|
|
7609
|
+
var INTENT_FILE = join15(".sechroom", "continuity.json");
|
|
6889
7610
|
function resolveIntentPath(start) {
|
|
6890
7611
|
let dir = start;
|
|
6891
7612
|
for (; ; ) {
|
|
6892
|
-
const candidate =
|
|
6893
|
-
if (
|
|
6894
|
-
const parent =
|
|
7613
|
+
const candidate = join15(dir, INTENT_FILE);
|
|
7614
|
+
if (existsSync12(candidate)) return candidate;
|
|
7615
|
+
const parent = dirname13(dir);
|
|
6895
7616
|
if (parent === dir) return void 0;
|
|
6896
7617
|
dir = parent;
|
|
6897
7618
|
}
|
|
@@ -6900,7 +7621,7 @@ function readIntent(start) {
|
|
|
6900
7621
|
const path = resolveIntentPath(start);
|
|
6901
7622
|
if (!path) return void 0;
|
|
6902
7623
|
try {
|
|
6903
|
-
return JSON.parse(
|
|
7624
|
+
return JSON.parse(readFileSync11(path, "utf8"));
|
|
6904
7625
|
} catch {
|
|
6905
7626
|
return void 0;
|
|
6906
7627
|
}
|
|
@@ -6942,14 +7663,14 @@ async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScop
|
|
|
6942
7663
|
}
|
|
6943
7664
|
function ledgerPath(start) {
|
|
6944
7665
|
const intent = resolveIntentPath(start);
|
|
6945
|
-
const dir = intent ?
|
|
6946
|
-
return
|
|
7666
|
+
const dir = intent ? dirname13(intent) : join15(start, ".sechroom");
|
|
7667
|
+
return join15(dir, ".checkpoint-state.json");
|
|
6947
7668
|
}
|
|
6948
7669
|
function readLedger(start) {
|
|
6949
7670
|
try {
|
|
6950
7671
|
const p = ledgerPath(start);
|
|
6951
|
-
if (!
|
|
6952
|
-
return JSON.parse(
|
|
7672
|
+
if (!existsSync12(p)) return {};
|
|
7673
|
+
return JSON.parse(readFileSync11(p, "utf8"));
|
|
6953
7674
|
} catch {
|
|
6954
7675
|
return {};
|
|
6955
7676
|
}
|
|
@@ -6968,7 +7689,7 @@ function intentHash(i) {
|
|
|
6968
7689
|
artifacts: i.artifacts ?? [],
|
|
6969
7690
|
confidence: i.confidence ?? null
|
|
6970
7691
|
});
|
|
6971
|
-
return
|
|
7692
|
+
return createHash4("sha256").update(canonical, "utf8").digest("hex");
|
|
6972
7693
|
}
|
|
6973
7694
|
function recentlyCheckpointed(start, minutes) {
|
|
6974
7695
|
const { lastEpochMs } = readLedger(start);
|
|
@@ -6980,7 +7701,7 @@ function unchangedSinceLastPush(start, intent) {
|
|
|
6980
7701
|
const path = resolveIntentPath(start);
|
|
6981
7702
|
if (path && ledger.lastMtimeMs != null) {
|
|
6982
7703
|
try {
|
|
6983
|
-
if (
|
|
7704
|
+
if (statSync3(path).mtimeMs <= ledger.lastMtimeMs) return true;
|
|
6984
7705
|
} catch {
|
|
6985
7706
|
}
|
|
6986
7707
|
}
|
|
@@ -6992,17 +7713,17 @@ function recordPush(start, intent) {
|
|
|
6992
7713
|
const path = resolveIntentPath(start);
|
|
6993
7714
|
let mtimeMs;
|
|
6994
7715
|
try {
|
|
6995
|
-
if (path) mtimeMs =
|
|
7716
|
+
if (path) mtimeMs = statSync3(path).mtimeMs;
|
|
6996
7717
|
} catch {
|
|
6997
7718
|
mtimeMs = void 0;
|
|
6998
7719
|
}
|
|
6999
|
-
|
|
7720
|
+
mkdirSync14(dirname13(p), { recursive: true });
|
|
7000
7721
|
const ledger = {
|
|
7001
7722
|
lastEpochMs: Date.now(),
|
|
7002
7723
|
lastMtimeMs: mtimeMs,
|
|
7003
7724
|
lastHash: intentHash(intent)
|
|
7004
7725
|
};
|
|
7005
|
-
|
|
7726
|
+
writeFileSync13(p, JSON.stringify(ledger) + "\n");
|
|
7006
7727
|
} catch {
|
|
7007
7728
|
}
|
|
7008
7729
|
}
|
|
@@ -7070,18 +7791,34 @@ Fail-soft: failures exit 0 and never block; session-context refresh failures ren
|
|
|
7070
7791
|
installLaneCommitHook(cwd);
|
|
7071
7792
|
const semPath = resolveSemPathForRead(cwd);
|
|
7072
7793
|
if (semPath) ensureContinuityScaffold(semPath);
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
|
|
7078
|
-
|
|
7794
|
+
const globals = cmd.optsWithGlobals();
|
|
7795
|
+
const materialisationOptions = {
|
|
7796
|
+
codexHomeOverride: globals.codexHome
|
|
7797
|
+
};
|
|
7798
|
+
let dynamic = writeSessionContext(
|
|
7799
|
+
cwd,
|
|
7800
|
+
lane,
|
|
7801
|
+
{
|
|
7802
|
+
status: "hold",
|
|
7803
|
+
workspaceId: null,
|
|
7804
|
+
reason: "SESSION_CONTEXT_UNAVAILABLE: the session-start hook could not refresh the pinned context (transport or authentication failure).",
|
|
7805
|
+
members: [],
|
|
7806
|
+
skills: []
|
|
7807
|
+
},
|
|
7808
|
+
materialisationOptions
|
|
7809
|
+
);
|
|
7079
7810
|
let continuity = null;
|
|
7080
7811
|
try {
|
|
7081
|
-
const cfg = resolveConfig(
|
|
7812
|
+
const cfg = resolveConfig(globals, cwd);
|
|
7082
7813
|
const client = await makeClient(cfg);
|
|
7083
7814
|
try {
|
|
7084
|
-
dynamic = await materializeSessionContext(
|
|
7815
|
+
dynamic = await materializeSessionContext(
|
|
7816
|
+
client,
|
|
7817
|
+
cfg,
|
|
7818
|
+
lane,
|
|
7819
|
+
cwd,
|
|
7820
|
+
materialisationOptions
|
|
7821
|
+
);
|
|
7085
7822
|
} catch {
|
|
7086
7823
|
}
|
|
7087
7824
|
try {
|
|
@@ -7285,10 +8022,10 @@ Examples:
|
|
|
7285
8022
|
const client = await makeClient(cfg);
|
|
7286
8023
|
return client.POST("/continuity/snapshots", { body });
|
|
7287
8024
|
});
|
|
7288
|
-
const path = resolveIntentPath(cwd) ??
|
|
8025
|
+
const path = resolveIntentPath(cwd) ?? join16(cwd, INTENT_FILE);
|
|
7289
8026
|
const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
|
|
7290
|
-
|
|
7291
|
-
|
|
8027
|
+
mkdirSync15(dirname14(path), { recursive: true });
|
|
8028
|
+
writeFileSync14(path, JSON.stringify(fileBody, null, 2) + "\n");
|
|
7292
8029
|
recordPush(cwd, merged);
|
|
7293
8030
|
if (json) {
|
|
7294
8031
|
emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
|
|
@@ -7302,7 +8039,7 @@ Examples:
|
|
|
7302
8039
|
}
|
|
7303
8040
|
|
|
7304
8041
|
// src/commands/close.ts
|
|
7305
|
-
import { readFileSync as
|
|
8042
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
7306
8043
|
var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
|
|
7307
8044
|
function registerClose(program2) {
|
|
7308
8045
|
program2.command("close").description(
|
|
@@ -7343,7 +8080,7 @@ Examples:
|
|
|
7343
8080
|
);
|
|
7344
8081
|
let bodyText;
|
|
7345
8082
|
try {
|
|
7346
|
-
bodyText = opts.file ?
|
|
8083
|
+
bodyText = opts.file ? readFileSync12(opts.file, "utf8") : readFileSync12(0, "utf8");
|
|
7347
8084
|
} catch {
|
|
7348
8085
|
fail(
|
|
7349
8086
|
opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
|
|
@@ -7475,7 +8212,9 @@ Examples:
|
|
|
7475
8212
|
|
|
7476
8213
|
// src/commands/continuity.ts
|
|
7477
8214
|
function registerContinuity(program2) {
|
|
7478
|
-
const continuity = program2.command("continuity").description(
|
|
8215
|
+
const continuity = program2.command("continuity").description(
|
|
8216
|
+
"Continuity snapshots: checkpoint, resume, and recover displaced work"
|
|
8217
|
+
);
|
|
7479
8218
|
continuity.addHelpText(
|
|
7480
8219
|
"after",
|
|
7481
8220
|
`
|
|
@@ -7488,7 +8227,13 @@ Examples:
|
|
|
7488
8227
|
$ sechroom continuity snapshot-get csn_XXXX --json
|
|
7489
8228
|
$ sechroom continuity resume-me --max-artifacts 20
|
|
7490
8229
|
$ sechroom continuity changed-since --since 2026-06-01T00:00:00Z
|
|
7491
|
-
$ sechroom continuity grant csn_XXXX --grantee usr_XXXX
|
|
8230
|
+
$ sechroom continuity grant csn_XXXX --grantee usr_XXXX
|
|
8231
|
+
|
|
8232
|
+
Displaced snapshot recovery surfaces:
|
|
8233
|
+
CLI discovery: sechroom continuity snapshots --lane <laneId> --json
|
|
8234
|
+
CLI inspection: sechroom continuity snapshot-get <snapshotId> --json
|
|
8235
|
+
MCP adoption: continuity_adopt_snapshot
|
|
8236
|
+
Canonical long form: https://github.com/OcdLimited/sechroom/blob/main/frontend/apps/cli/DEVELOPMENT.md#recover-a-displaced-continuity-snapshot`
|
|
7492
8237
|
);
|
|
7493
8238
|
continuity.command("snapshot-create").description("Create a continuity snapshot (POST /continuity/snapshots)").requiredOption("--lane <laneId>", "Lane id (e.g. claude-code-chris)").requiredOption("--scope <scope>", "Snapshot scope (e.g. loop-spec)").requiredOption("--objective <text>", "Current objective").requiredOption("--state <text>", "Current state").requiredOption("--last-action <text>", "Last meaningful action").requiredOption("--next-action <text>", "Next intended action").requiredOption("--resume-instruction <text>", "Resume instruction").option("--constraint <text...>", "Active constraints (repeatable)").option("--question <text...>", "Open questions (repeatable)").option("--surface-marker <text...>", "Surface markers (repeatable)").option("--artifact <id...>", "Relevant artifact ids (repeatable)").option("--confidence <n>", "Confidence 0..1").action(async (opts, cmd) => {
|
|
7494
8239
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
@@ -7513,15 +8258,30 @@ Examples:
|
|
|
7513
8258
|
});
|
|
7514
8259
|
emitAction(`created snapshot ${style.bold(data.snapshotId)}`, data, cmd.optsWithGlobals().json);
|
|
7515
8260
|
});
|
|
7516
|
-
continuity.command("snapshot-get <id>").description(
|
|
8261
|
+
continuity.command("snapshot-get <id>").description(
|
|
8262
|
+
"Fetch one of the caller's snapshots by id; use --all for the tenant-wide admin door"
|
|
8263
|
+
).option(
|
|
8264
|
+
"--all",
|
|
8265
|
+
"Use the tenant-wide Owner/Manager endpoint (GET /continuity/snapshots/{id})",
|
|
8266
|
+
false
|
|
8267
|
+
).action(async (id, opts, cmd) => {
|
|
7517
8268
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
7518
8269
|
const data = await runApi("Fetching snapshot", async () => {
|
|
7519
8270
|
const client = await makeClient(cfg);
|
|
7520
|
-
|
|
8271
|
+
const result = await client.GET(snapshotGetPath(Boolean(opts.all)), {
|
|
8272
|
+
params: { path: { id } }
|
|
8273
|
+
});
|
|
8274
|
+
const hint = snapshotGetNotFoundHint(
|
|
8275
|
+
Boolean(opts.all),
|
|
8276
|
+
result.response.status
|
|
8277
|
+
);
|
|
8278
|
+
return hint ? { ...result, error: hint } : result;
|
|
7521
8279
|
});
|
|
7522
8280
|
emit(data, cmd.optsWithGlobals().json);
|
|
7523
8281
|
});
|
|
7524
|
-
continuity.command("snapshots").description(
|
|
8282
|
+
continuity.command("snapshots").description(
|
|
8283
|
+
"List the caller's own snapshots, including displaced recovery candidates (GET /me/continuity/snapshots)"
|
|
8284
|
+
).option("--scope <scope>", "Filter by scope").option("--lane <laneId>", "Filter by lane id").action(async (opts, cmd) => {
|
|
7525
8285
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
7526
8286
|
const data = await runApi("Listing snapshots", async () => {
|
|
7527
8287
|
const client = await makeClient(cfg);
|
|
@@ -7632,6 +8392,12 @@ Examples:
|
|
|
7632
8392
|
);
|
|
7633
8393
|
});
|
|
7634
8394
|
}
|
|
8395
|
+
function snapshotGetPath(includeAll) {
|
|
8396
|
+
return includeAll ? "/continuity/snapshots/{id}" : "/me/continuity/snapshots/{id}";
|
|
8397
|
+
}
|
|
8398
|
+
function snapshotGetNotFoundHint(includeAll, status) {
|
|
8399
|
+
return !includeAll && status === 404 ? "Snapshot not found among your own snapshots. Owners and Managers can retry with --all to use the tenant-wide door." : void 0;
|
|
8400
|
+
}
|
|
7635
8401
|
|
|
7636
8402
|
// src/commands/work-plan.ts
|
|
7637
8403
|
import { readFile as readFile2 } from "fs/promises";
|
|
@@ -8033,13 +8799,13 @@ Examples:
|
|
|
8033
8799
|
}
|
|
8034
8800
|
|
|
8035
8801
|
// src/commands/herdr.ts
|
|
8036
|
-
import { readFileSync as
|
|
8802
|
+
import { readFileSync as readFileSync13 } from "fs";
|
|
8037
8803
|
import { basename as basename3 } from "path";
|
|
8038
8804
|
|
|
8039
8805
|
// src/herdr/client.ts
|
|
8040
8806
|
import { createConnection as createConnection2 } from "net";
|
|
8041
8807
|
import { homedir as homedir5 } from "os";
|
|
8042
|
-
import { join as
|
|
8808
|
+
import { join as join17 } from "path";
|
|
8043
8809
|
var DEFAULT_HERDR_SOCKET_RELATIVE = ".config/herdr/herdr.sock";
|
|
8044
8810
|
var HerdrUnreachableError = class extends Error {
|
|
8045
8811
|
constructor(socketPath, reason) {
|
|
@@ -8064,7 +8830,7 @@ function resolveSocketPath(flag, env = process.env, home = homedir5()) {
|
|
|
8064
8830
|
if (fromFlag) return fromFlag;
|
|
8065
8831
|
const fromEnv = env.HERDR_SOCKET?.trim();
|
|
8066
8832
|
if (fromEnv) return fromEnv;
|
|
8067
|
-
return
|
|
8833
|
+
return join17(home, DEFAULT_HERDR_SOCKET_RELATIVE);
|
|
8068
8834
|
}
|
|
8069
8835
|
function expandTarget(target) {
|
|
8070
8836
|
const trimmed = target.trim();
|
|
@@ -8287,19 +9053,19 @@ function herdrStream(socketPath, subscriptions, onEvent, options = {}) {
|
|
|
8287
9053
|
});
|
|
8288
9054
|
}
|
|
8289
9055
|
function abortableSleep(milliseconds, signal) {
|
|
8290
|
-
return new Promise((
|
|
9056
|
+
return new Promise((resolve9) => {
|
|
8291
9057
|
if (signal?.aborted) {
|
|
8292
|
-
|
|
9058
|
+
resolve9();
|
|
8293
9059
|
return;
|
|
8294
9060
|
}
|
|
8295
9061
|
let timer;
|
|
8296
9062
|
const onAbort = () => {
|
|
8297
9063
|
clearTimeout(timer);
|
|
8298
|
-
|
|
9064
|
+
resolve9();
|
|
8299
9065
|
};
|
|
8300
9066
|
timer = setTimeout(() => {
|
|
8301
9067
|
signal?.removeEventListener("abort", onAbort);
|
|
8302
|
-
|
|
9068
|
+
resolve9();
|
|
8303
9069
|
}, milliseconds);
|
|
8304
9070
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
8305
9071
|
});
|
|
@@ -8322,6 +9088,7 @@ var HERDR_READ_SOURCES = [
|
|
|
8322
9088
|
];
|
|
8323
9089
|
var DEFAULT_READ_LINES = 40;
|
|
8324
9090
|
var DEFAULT_READ_SOURCE = "recent";
|
|
9091
|
+
var DEFAULT_WAIT_STATUSES = ["idle", "done"];
|
|
8325
9092
|
var TITLE_WIDTH = 48;
|
|
8326
9093
|
var STREAM_HEALTHY_MS = 3e4;
|
|
8327
9094
|
var WAIT_FOR_PANES_MAX_MS = 5e3;
|
|
@@ -8402,7 +9169,7 @@ function parseSource(value, fallback = DEFAULT_READ_SOURCE) {
|
|
|
8402
9169
|
}
|
|
8403
9170
|
return match;
|
|
8404
9171
|
}
|
|
8405
|
-
function resolveSendText(textArgs, useStdin, readStdin5 = () =>
|
|
9172
|
+
function resolveSendText(textArgs, useStdin, readStdin5 = () => readFileSync13(0, "utf8")) {
|
|
8406
9173
|
if (useStdin) {
|
|
8407
9174
|
if (textArgs.length > 0) {
|
|
8408
9175
|
throw new Error(
|
|
@@ -8550,6 +9317,176 @@ async function runSend(ports, target, text2, json) {
|
|
|
8550
9317
|
json
|
|
8551
9318
|
);
|
|
8552
9319
|
}
|
|
9320
|
+
function parseWaitStatuses(values) {
|
|
9321
|
+
const statuses = (values ?? [...DEFAULT_WAIT_STATUSES]).flatMap((value) => value.split(",")).map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0);
|
|
9322
|
+
if (statuses.length === 0) {
|
|
9323
|
+
throw new Error("--until expects at least one status");
|
|
9324
|
+
}
|
|
9325
|
+
return new Set(statuses);
|
|
9326
|
+
}
|
|
9327
|
+
function parseWaitTimeout(value) {
|
|
9328
|
+
if (value === void 0) return void 0;
|
|
9329
|
+
const seconds = Number(value);
|
|
9330
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
9331
|
+
throw new Error(
|
|
9332
|
+
`--timeout expects a positive number of seconds (got '${value}')`
|
|
9333
|
+
);
|
|
9334
|
+
}
|
|
9335
|
+
return seconds;
|
|
9336
|
+
}
|
|
9337
|
+
var HerdrWaitTimeoutError = class extends Error {
|
|
9338
|
+
constructor(timeoutSeconds, targets, statuses) {
|
|
9339
|
+
super(
|
|
9340
|
+
`herdr wait timed out after ${timeoutSeconds}s waiting for ${targets.join(", ")} to reach ${statuses.join(", ")}`
|
|
9341
|
+
);
|
|
9342
|
+
this.timeoutSeconds = timeoutSeconds;
|
|
9343
|
+
this.targets = targets;
|
|
9344
|
+
this.statuses = statuses;
|
|
9345
|
+
this.name = "HerdrWaitTimeoutError";
|
|
9346
|
+
}
|
|
9347
|
+
timeoutSeconds;
|
|
9348
|
+
targets;
|
|
9349
|
+
statuses;
|
|
9350
|
+
};
|
|
9351
|
+
function formatWaitRecord(record) {
|
|
9352
|
+
return `${record.pane_id} ${record.cwd?.trim() || "?"} [${agentLabel(record, record.pane_id)}]: ${displayStatus(record.agent_status)}`;
|
|
9353
|
+
}
|
|
9354
|
+
async function runWait(ports, targets, options) {
|
|
9355
|
+
const targetIds = [...new Set(targets.map(expandTarget))];
|
|
9356
|
+
const statuses = parseWaitStatuses(options.statuses);
|
|
9357
|
+
const timeoutSeconds = options.timeoutSeconds;
|
|
9358
|
+
const write = options.write ?? ((line) => process.stdout.write(`${line}
|
|
9359
|
+
`));
|
|
9360
|
+
const warn2 = options.warn ?? ((line) => process.stderr.write(`${line}
|
|
9361
|
+
`));
|
|
9362
|
+
const now = options.now ?? Date.now;
|
|
9363
|
+
const controller = new AbortController();
|
|
9364
|
+
let timedOut = false;
|
|
9365
|
+
let completed = false;
|
|
9366
|
+
const onExternalAbort = () => controller.abort();
|
|
9367
|
+
if (options.signal?.aborted) return;
|
|
9368
|
+
options.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
9369
|
+
const timeout2 = timeoutSeconds === void 0 ? void 0 : setTimeout(() => {
|
|
9370
|
+
timedOut = true;
|
|
9371
|
+
controller.abort();
|
|
9372
|
+
}, timeoutSeconds * 1e3);
|
|
9373
|
+
const aborted = /* @__PURE__ */ Symbol("aborted");
|
|
9374
|
+
const abortPromise = new Promise((resolve9) => {
|
|
9375
|
+
controller.signal.addEventListener("abort", () => resolve9(aborted), {
|
|
9376
|
+
once: true
|
|
9377
|
+
});
|
|
9378
|
+
});
|
|
9379
|
+
const raceAbort = (operation) => Promise.race([operation, abortPromise]);
|
|
9380
|
+
const throwIfTimedOut = () => {
|
|
9381
|
+
if (timedOut && timeoutSeconds !== void 0) {
|
|
9382
|
+
throw new HerdrWaitTimeoutError(timeoutSeconds, targetIds, [...statuses]);
|
|
9383
|
+
}
|
|
9384
|
+
};
|
|
9385
|
+
const remaining = new Set(targetIds);
|
|
9386
|
+
let seed = /* @__PURE__ */ new Map();
|
|
9387
|
+
let attempt = 0;
|
|
9388
|
+
const settle = (agent, paneId, status) => {
|
|
9389
|
+
if (!remaining.has(paneId)) return;
|
|
9390
|
+
const record = {
|
|
9391
|
+
agent: agent?.agent ?? "?",
|
|
9392
|
+
...agent,
|
|
9393
|
+
pane_id: paneId,
|
|
9394
|
+
agent_status: status
|
|
9395
|
+
};
|
|
9396
|
+
write(options.json ? JSON.stringify(record) : formatWaitRecord(record));
|
|
9397
|
+
remaining.delete(paneId);
|
|
9398
|
+
if (!options.all || remaining.size === 0) {
|
|
9399
|
+
completed = true;
|
|
9400
|
+
controller.abort();
|
|
9401
|
+
}
|
|
9402
|
+
};
|
|
9403
|
+
try {
|
|
9404
|
+
while (!controller.signal.aborted) {
|
|
9405
|
+
try {
|
|
9406
|
+
const listed = await raceAbort(
|
|
9407
|
+
ports.request("agent.list", {})
|
|
9408
|
+
);
|
|
9409
|
+
if (listed === aborted) break;
|
|
9410
|
+
const current = listed.agents ?? [];
|
|
9411
|
+
const fresh = indexAgents(current);
|
|
9412
|
+
for (const paneId of [...remaining]) {
|
|
9413
|
+
const currentAgent = fresh.get(paneId);
|
|
9414
|
+
if (currentAgent === void 0) {
|
|
9415
|
+
settle(seed.get(paneId), paneId, "exited");
|
|
9416
|
+
continue;
|
|
9417
|
+
}
|
|
9418
|
+
const status = currentAgent.agent_status?.trim().toLowerCase();
|
|
9419
|
+
if (status !== void 0 && statuses.has(status)) {
|
|
9420
|
+
settle(currentAgent, paneId, currentAgent.agent_status ?? status);
|
|
9421
|
+
}
|
|
9422
|
+
}
|
|
9423
|
+
seed = fresh;
|
|
9424
|
+
if (completed || remaining.size === 0) return;
|
|
9425
|
+
const subscriptions = buildWatchSubscriptions(
|
|
9426
|
+
current.filter((agent) => remaining.has(agent.pane_id))
|
|
9427
|
+
);
|
|
9428
|
+
let carriedTraffic = false;
|
|
9429
|
+
let relistNeeded = false;
|
|
9430
|
+
const startedAt = now();
|
|
9431
|
+
const cycle = new AbortController();
|
|
9432
|
+
const stopCycle = () => cycle.abort();
|
|
9433
|
+
controller.signal.addEventListener("abort", stopCycle, { once: true });
|
|
9434
|
+
try {
|
|
9435
|
+
const streamed = await raceAbort(
|
|
9436
|
+
ports.stream(
|
|
9437
|
+
subscriptions,
|
|
9438
|
+
(raw) => {
|
|
9439
|
+
carriedTraffic = true;
|
|
9440
|
+
const event = normalizeWatchEvent(raw);
|
|
9441
|
+
if (event === null || needsRelistFallback(event)) {
|
|
9442
|
+
relistNeeded = true;
|
|
9443
|
+
cycle.abort();
|
|
9444
|
+
return;
|
|
9445
|
+
}
|
|
9446
|
+
if (!remaining.has(event.paneId)) return;
|
|
9447
|
+
const known = seed.get(event.paneId);
|
|
9448
|
+
if (event.type === "pane.exited") {
|
|
9449
|
+
settle(known, event.paneId, "exited");
|
|
9450
|
+
return;
|
|
9451
|
+
}
|
|
9452
|
+
const status = event.to?.trim().toLowerCase();
|
|
9453
|
+
if (known && event.to) known.agent_status = event.to;
|
|
9454
|
+
if (status !== void 0 && statuses.has(status)) {
|
|
9455
|
+
settle(known, event.paneId, event.to ?? status);
|
|
9456
|
+
}
|
|
9457
|
+
},
|
|
9458
|
+
{
|
|
9459
|
+
signal: cycle.signal,
|
|
9460
|
+
onWarning: (message) => warn2(`herdr wait: ${message}`)
|
|
9461
|
+
}
|
|
9462
|
+
)
|
|
9463
|
+
);
|
|
9464
|
+
if (streamed === aborted) break;
|
|
9465
|
+
} finally {
|
|
9466
|
+
controller.signal.removeEventListener("abort", stopCycle);
|
|
9467
|
+
}
|
|
9468
|
+
if (completed) return;
|
|
9469
|
+
if (relistNeeded) continue;
|
|
9470
|
+
if (carriedTraffic || now() - startedAt >= STREAM_HEALTHY_MS) {
|
|
9471
|
+
attempt = 0;
|
|
9472
|
+
}
|
|
9473
|
+
} catch (error) {
|
|
9474
|
+
throwIfTimedOut();
|
|
9475
|
+
if (controller.signal.aborted) return;
|
|
9476
|
+
warn2(`herdr wait: ${formatFailureMessage(error)}`);
|
|
9477
|
+
}
|
|
9478
|
+
if (controller.signal.aborted) break;
|
|
9479
|
+
const delay = watchBackoffMs(attempt);
|
|
9480
|
+
attempt += 1;
|
|
9481
|
+
const slept = await raceAbort(ports.sleep(delay, controller.signal));
|
|
9482
|
+
if (slept === aborted) break;
|
|
9483
|
+
}
|
|
9484
|
+
throwIfTimedOut();
|
|
9485
|
+
} finally {
|
|
9486
|
+
if (timeout2 !== void 0) clearTimeout(timeout2);
|
|
9487
|
+
options.signal?.removeEventListener("abort", onExternalAbort);
|
|
9488
|
+
}
|
|
9489
|
+
}
|
|
8553
9490
|
async function runWatch(ports, options) {
|
|
8554
9491
|
const write = options.write ?? ((line) => process.stdout.write(`${line}
|
|
8555
9492
|
`));
|
|
@@ -8688,7 +9625,7 @@ function exitCleanlyOnBrokenPipe(stream, exit = (code) => process.exit(code), re
|
|
|
8688
9625
|
}
|
|
8689
9626
|
function registerHerdr(program2) {
|
|
8690
9627
|
const herdr = program2.command("herdr").description(
|
|
8691
|
-
"Drive local Herdr agents (herdr.dev) over its Unix-socket API \u2014 list, read, send, watch"
|
|
9628
|
+
"Drive local Herdr agents (herdr.dev) over its Unix-socket API \u2014 list, read, send, wait, watch"
|
|
8692
9629
|
);
|
|
8693
9630
|
herdr.addHelpText(
|
|
8694
9631
|
"after",
|
|
@@ -8701,6 +9638,10 @@ Examples:
|
|
|
8701
9638
|
$ sechroom herdr read w6:p1 --source visible only what's on screen
|
|
8702
9639
|
$ sechroom herdr send w6 "run the tests and report back"
|
|
8703
9640
|
$ cat brief.md | sechroom herdr send w6:p1 --stdin long dispatch from stdin
|
|
9641
|
+
$ sechroom herdr wait w3 w6 return when either pane is idle or done
|
|
9642
|
+
$ sechroom herdr wait w3 w6 --all wait until both panes have settled
|
|
9643
|
+
$ sechroom herdr wait w6 --until blocked --timeout 300
|
|
9644
|
+
$ sechroom herdr wait w6 --json machine-readable settling pane
|
|
8704
9645
|
$ sechroom herdr watch live status transitions; Ctrl-C to stop
|
|
8705
9646
|
$ sechroom herdr watch --json | jq . one JSON object per event
|
|
8706
9647
|
|
|
@@ -8731,6 +9672,40 @@ AGPL \u2014 no Herdr code is vendored here.`
|
|
|
8731
9672
|
fail(error);
|
|
8732
9673
|
}
|
|
8733
9674
|
});
|
|
9675
|
+
withSocket(
|
|
9676
|
+
herdr.command("wait <targets...>").description(
|
|
9677
|
+
"Block until any target settles (idle or done by default); --all waits for every target"
|
|
9678
|
+
).option("--all", "Wait for every target instead of the first to settle").option(
|
|
9679
|
+
"--until <status...>",
|
|
9680
|
+
`Statuses that count as settled (default ${DEFAULT_WAIT_STATUSES.join(", ")})`
|
|
9681
|
+
).option("--timeout <seconds>", "Stop waiting after this many seconds")
|
|
9682
|
+
).action(async (targets, opts, cmd) => {
|
|
9683
|
+
exitCleanlyOnBrokenPipe(process.stdout);
|
|
9684
|
+
const controller = new AbortController();
|
|
9685
|
+
const onSignal = () => controller.abort();
|
|
9686
|
+
process.once("SIGINT", onSignal);
|
|
9687
|
+
process.once("SIGTERM", onSignal);
|
|
9688
|
+
try {
|
|
9689
|
+
await runWait(portsFor(opts), targets, {
|
|
9690
|
+
all: Boolean(opts.all),
|
|
9691
|
+
json: Boolean(cmd.optsWithGlobals().json),
|
|
9692
|
+
statuses: opts.until,
|
|
9693
|
+
timeoutSeconds: parseWaitTimeout(opts.timeout),
|
|
9694
|
+
signal: controller.signal
|
|
9695
|
+
});
|
|
9696
|
+
} catch (error) {
|
|
9697
|
+
if (error instanceof HerdrWaitTimeoutError) {
|
|
9698
|
+
process.stderr.write(`error: ${error.message}
|
|
9699
|
+
`);
|
|
9700
|
+
process.exitCode = 2;
|
|
9701
|
+
return;
|
|
9702
|
+
}
|
|
9703
|
+
fail(error);
|
|
9704
|
+
} finally {
|
|
9705
|
+
process.off("SIGINT", onSignal);
|
|
9706
|
+
process.off("SIGTERM", onSignal);
|
|
9707
|
+
}
|
|
9708
|
+
});
|
|
8734
9709
|
withSocket(
|
|
8735
9710
|
herdr.command("read <target>").description(
|
|
8736
9711
|
`Print a pane's text (default ${DEFAULT_READ_LINES} lines, source ${DEFAULT_READ_SOURCE})`
|
|
@@ -8873,11 +9848,11 @@ Examples:
|
|
|
8873
9848
|
}
|
|
8874
9849
|
|
|
8875
9850
|
// src/commands/memory.ts
|
|
8876
|
-
import { readFileSync as
|
|
9851
|
+
import { readFileSync as readFileSync15 } from "fs";
|
|
8877
9852
|
|
|
8878
9853
|
// src/commands/memory-import.ts
|
|
8879
|
-
import { readdirSync as
|
|
8880
|
-
import { basename as basename4, join as
|
|
9854
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync14, realpathSync, statSync as statSync4 } from "fs";
|
|
9855
|
+
import { basename as basename4, join as join18, resolve as resolve6 } from "path";
|
|
8881
9856
|
var MARKDOWN_RE = /\.(md|markdown)$/i;
|
|
8882
9857
|
function isMarkdownPath(path) {
|
|
8883
9858
|
return MARKDOWN_RE.test(path);
|
|
@@ -8897,25 +9872,25 @@ function collectImportFiles(inputs, opts = {}) {
|
|
|
8897
9872
|
try {
|
|
8898
9873
|
key = realpathSync(path);
|
|
8899
9874
|
} catch {
|
|
8900
|
-
key =
|
|
9875
|
+
key = resolve6(path);
|
|
8901
9876
|
}
|
|
8902
9877
|
if (seen.has(key)) return;
|
|
8903
9878
|
seen.add(key);
|
|
8904
9879
|
files.push(path);
|
|
8905
9880
|
};
|
|
8906
9881
|
const walk = (dir) => {
|
|
8907
|
-
const entries =
|
|
9882
|
+
const entries = readdirSync3(dir, { withFileTypes: true }).sort(
|
|
8908
9883
|
(a, b) => a.name.localeCompare(b.name)
|
|
8909
9884
|
);
|
|
8910
9885
|
for (const entry of entries) {
|
|
8911
9886
|
if (entry.name.startsWith(".")) continue;
|
|
8912
|
-
const child =
|
|
9887
|
+
const child = join18(dir, entry.name);
|
|
8913
9888
|
let isDirectory = entry.isDirectory();
|
|
8914
9889
|
let isFile = entry.isFile();
|
|
8915
9890
|
if (entry.isSymbolicLink()) {
|
|
8916
9891
|
let target;
|
|
8917
9892
|
try {
|
|
8918
|
-
target =
|
|
9893
|
+
target = statSync4(child);
|
|
8919
9894
|
} catch {
|
|
8920
9895
|
skipped.push({ path: child, reason: "broken symlink" });
|
|
8921
9896
|
continue;
|
|
@@ -8953,7 +9928,7 @@ function collectImportFiles(inputs, opts = {}) {
|
|
|
8953
9928
|
for (const input of inputs) {
|
|
8954
9929
|
let isDirectory;
|
|
8955
9930
|
try {
|
|
8956
|
-
isDirectory =
|
|
9931
|
+
isDirectory = statSync4(input).isDirectory();
|
|
8957
9932
|
} catch {
|
|
8958
9933
|
missing.push(input);
|
|
8959
9934
|
continue;
|
|
@@ -8969,7 +9944,7 @@ function buildImportPlan(collected) {
|
|
|
8969
9944
|
for (const path of collected.files) {
|
|
8970
9945
|
let text2;
|
|
8971
9946
|
try {
|
|
8972
|
-
text2 =
|
|
9947
|
+
text2 = readFileSync14(path, "utf8");
|
|
8973
9948
|
} catch (error) {
|
|
8974
9949
|
throw new Error(
|
|
8975
9950
|
`couldn't read ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
@@ -9070,7 +10045,7 @@ function resolveCreateBody(textOpt, fileOpt) {
|
|
|
9070
10045
|
}
|
|
9071
10046
|
if (textOpt != null) return { text: textOpt, defaultTitle: null };
|
|
9072
10047
|
const fromStdin = fileOpt === "-";
|
|
9073
|
-
const text2 = fromStdin ?
|
|
10048
|
+
const text2 = fromStdin ? readFileSync15(0, "utf8") : readFileSync15(String(fileOpt), "utf8");
|
|
9074
10049
|
if (text2.trim().length === 0) {
|
|
9075
10050
|
fail(fromStdin ? "Stdin was empty." : `File is empty: ${fileOpt}`);
|
|
9076
10051
|
}
|
|
@@ -9689,16 +10664,17 @@ ${plan.rows.map(
|
|
|
9689
10664
|
}
|
|
9690
10665
|
|
|
9691
10666
|
// src/setup/apply.ts
|
|
9692
|
-
import { createHash as
|
|
9693
|
-
import { mkdirSync as
|
|
9694
|
-
import { dirname as
|
|
10667
|
+
import { createHash as createHash5 } from "crypto";
|
|
10668
|
+
import { mkdirSync as mkdirSync16, readFileSync as readFileSync16, writeFileSync as writeFileSync15, existsSync as existsSync13 } from "fs";
|
|
10669
|
+
import { dirname as dirname15 } from "path";
|
|
9695
10670
|
var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
|
|
9696
10671
|
var MARKER_END = "<!-- @sechroom/cli:end";
|
|
10672
|
+
var BOOTSTRAP_STUB_REFUSAL = "This file carries pointers only";
|
|
9697
10673
|
function normalizeBody(s) {
|
|
9698
10674
|
return s.replace(/\r\n/g, "\n").trim();
|
|
9699
10675
|
}
|
|
9700
10676
|
function bodySha256(body) {
|
|
9701
|
-
return
|
|
10677
|
+
return createHash5("sha256").update(normalizeBody(body), "utf8").digest("hex");
|
|
9702
10678
|
}
|
|
9703
10679
|
function renderBlock(write) {
|
|
9704
10680
|
const body = normalizeBody(write.body);
|
|
@@ -9744,22 +10720,22 @@ function parseManagedBlock(content, block) {
|
|
|
9744
10720
|
return null;
|
|
9745
10721
|
}
|
|
9746
10722
|
function ensureDir2(path) {
|
|
9747
|
-
|
|
10723
|
+
mkdirSync16(dirname15(path), { recursive: true });
|
|
9748
10724
|
}
|
|
9749
10725
|
function readOr(path, fallback) {
|
|
9750
10726
|
try {
|
|
9751
|
-
return
|
|
10727
|
+
return readFileSync16(path, "utf8");
|
|
9752
10728
|
} catch {
|
|
9753
10729
|
return fallback;
|
|
9754
10730
|
}
|
|
9755
10731
|
}
|
|
9756
10732
|
function mergeMcpJson(path, snippet, dryRun) {
|
|
9757
10733
|
const incoming = JSON.parse(snippet);
|
|
9758
|
-
const existed =
|
|
10734
|
+
const existed = existsSync13(path);
|
|
9759
10735
|
let current = {};
|
|
9760
10736
|
if (existed) {
|
|
9761
10737
|
try {
|
|
9762
|
-
current = JSON.parse(
|
|
10738
|
+
current = JSON.parse(readFileSync16(path, "utf8"));
|
|
9763
10739
|
} catch {
|
|
9764
10740
|
return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
|
|
9765
10741
|
}
|
|
@@ -9767,27 +10743,27 @@ function mergeMcpJson(path, snippet, dryRun) {
|
|
|
9767
10743
|
current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
|
|
9768
10744
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
9769
10745
|
ensureDir2(path);
|
|
9770
|
-
|
|
10746
|
+
writeFileSync15(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
9771
10747
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
9772
10748
|
}
|
|
9773
10749
|
function mergeCodexToml(path, snippet, dryRun) {
|
|
9774
|
-
const existed =
|
|
10750
|
+
const existed = existsSync13(path);
|
|
9775
10751
|
let body = readOr(path, "");
|
|
9776
10752
|
body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
|
|
9777
10753
|
const trimmed = body.trim();
|
|
9778
10754
|
const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
|
|
9779
10755
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
9780
10756
|
ensureDir2(path);
|
|
9781
|
-
|
|
10757
|
+
writeFileSync15(path, next, { mode: 384 });
|
|
9782
10758
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
9783
10759
|
}
|
|
9784
10760
|
function writeInstructionBlock(path, write, dryRun) {
|
|
9785
|
-
const existed =
|
|
10761
|
+
const existed = existsSync13(path);
|
|
9786
10762
|
const next = computeBlockFile(readOr(path, ""), write);
|
|
9787
|
-
if (dryRun) return { kind: "instruction", path, status: "dry-run" };
|
|
10763
|
+
if (dryRun) return { kind: "instruction", path, status: "dry-run", block: write.block };
|
|
9788
10764
|
ensureDir2(path);
|
|
9789
|
-
|
|
9790
|
-
return { kind: "instruction", path, status: existed ? "merged" : "created" };
|
|
10765
|
+
writeFileSync15(path, next);
|
|
10766
|
+
return { kind: "instruction", path, status: existed ? "merged" : "created", block: write.block };
|
|
9791
10767
|
}
|
|
9792
10768
|
function computeBlockFile(current, write) {
|
|
9793
10769
|
const rendered = renderBlock(write);
|
|
@@ -9802,7 +10778,12 @@ ${rendered}` : rendered;
|
|
|
9802
10778
|
}
|
|
9803
10779
|
function evaluateBlock(content, block, serverBody) {
|
|
9804
10780
|
const onDisk = parseManagedBlock(content, block);
|
|
9805
|
-
if (!onDisk)
|
|
10781
|
+
if (!onDisk) {
|
|
10782
|
+
const hasManagedMarker = content.includes(MARKER_BEGIN) || content.includes(MARKER_END);
|
|
10783
|
+
if (!hasManagedMarker && content.includes(BOOTSTRAP_STUB_REFUSAL))
|
|
10784
|
+
return "stub";
|
|
10785
|
+
return "absent";
|
|
10786
|
+
}
|
|
9806
10787
|
const actual = bodySha256(onDisk.body);
|
|
9807
10788
|
if (onDisk.sha256 && actual !== onDisk.sha256) return "drift";
|
|
9808
10789
|
return actual === bodySha256(serverBody) ? "current" : "stale";
|
|
@@ -9816,24 +10797,36 @@ function applyBlock(path, write, mode, dryRun) {
|
|
|
9816
10797
|
path,
|
|
9817
10798
|
status: state === "current" ? "current" : "skipped",
|
|
9818
10799
|
eval: state,
|
|
9819
|
-
|
|
10800
|
+
block: write.block,
|
|
10801
|
+
note: state === "current" ? void 0 : state === "stub" ? "dynamic chain pointer file \u2014 no static managed block to check" : `would ${state === "absent" ? "write" : "refresh"} (${state})`
|
|
10802
|
+
};
|
|
10803
|
+
}
|
|
10804
|
+
if (state === "stub") {
|
|
10805
|
+
return {
|
|
10806
|
+
kind: "instruction",
|
|
10807
|
+
path,
|
|
10808
|
+
status: "skipped",
|
|
10809
|
+
eval: "stub",
|
|
10810
|
+
block: write.block,
|
|
10811
|
+
note: "dynamic chain pointer file \u2014 managed blocks were not written"
|
|
9820
10812
|
};
|
|
9821
10813
|
}
|
|
9822
10814
|
if (state === "current") {
|
|
9823
|
-
return { kind: "instruction", path, status: "current", eval: "current" };
|
|
10815
|
+
return { kind: "instruction", path, status: "current", eval: "current", block: write.block };
|
|
9824
10816
|
}
|
|
9825
10817
|
if (state === "drift" && mode !== "force") {
|
|
9826
10818
|
const proposedPath = `${path}.proposed`;
|
|
9827
10819
|
const next = computeBlockFile(current, write);
|
|
9828
10820
|
if (!dryRun) {
|
|
9829
10821
|
ensureDir2(proposedPath);
|
|
9830
|
-
|
|
10822
|
+
writeFileSync15(proposedPath, next);
|
|
9831
10823
|
}
|
|
9832
10824
|
return {
|
|
9833
10825
|
kind: "instruction",
|
|
9834
10826
|
path,
|
|
9835
10827
|
status: "skipped",
|
|
9836
10828
|
eval: "drift",
|
|
10829
|
+
block: write.block,
|
|
9837
10830
|
proposedPath,
|
|
9838
10831
|
note: `local edits \u2014 wrote ${proposedPath} (original left untouched)`
|
|
9839
10832
|
};
|
|
@@ -9957,8 +10950,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
|
|
|
9957
10950
|
}
|
|
9958
10951
|
|
|
9959
10952
|
// src/setup/skills-offer.ts
|
|
9960
|
-
import { mkdirSync as
|
|
9961
|
-
import { join as
|
|
10953
|
+
import { mkdirSync as mkdirSync17, writeFileSync as writeFileSync16 } from "fs";
|
|
10954
|
+
import { join as join19 } from "path";
|
|
9962
10955
|
|
|
9963
10956
|
// src/setup/lane-pin.ts
|
|
9964
10957
|
var CODE_LANE_PREFIX_BY_CLIENT = {
|
|
@@ -10074,8 +11067,8 @@ Found ${summary} available to you for ${surface}.
|
|
|
10074
11067
|
if (skills.length > 0) {
|
|
10075
11068
|
const written = [];
|
|
10076
11069
|
for (const s of skills) {
|
|
10077
|
-
|
|
10078
|
-
|
|
11070
|
+
mkdirSync17(join19(sDir, s.name), { recursive: true });
|
|
11071
|
+
writeFileSync16(join19(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
|
|
10079
11072
|
written.push(s.name);
|
|
10080
11073
|
}
|
|
10081
11074
|
recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
@@ -10083,11 +11076,11 @@ Found ${summary} available to you for ${surface}.
|
|
|
10083
11076
|
`);
|
|
10084
11077
|
}
|
|
10085
11078
|
if (agents.length > 0) {
|
|
10086
|
-
|
|
11079
|
+
mkdirSync17(aDir, { recursive: true });
|
|
10087
11080
|
const written = [];
|
|
10088
11081
|
for (const a of agents) {
|
|
10089
11082
|
const file = `${a.name}.md`;
|
|
10090
|
-
|
|
11083
|
+
writeFileSync16(join19(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
|
|
10091
11084
|
written.push(file);
|
|
10092
11085
|
}
|
|
10093
11086
|
recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
@@ -10102,6 +11095,21 @@ Found ${summary} available to you for ${surface}.
|
|
|
10102
11095
|
}
|
|
10103
11096
|
|
|
10104
11097
|
// src/commands/setup.ts
|
|
11098
|
+
var CONVENTION_REGEN_CLIENTS = ["claude-code", "codex"];
|
|
11099
|
+
function buildConventionDraft(title, rawKind, rawBody) {
|
|
11100
|
+
const kind = String(rawKind).toLowerCase() === "standard" ? "standard" : "reference";
|
|
11101
|
+
const body = typeof rawBody === "string" && rawBody.trim().length > 0 ? rawBody.trim() : "_TODO: write this section, then edit the memo and re-run the regen._";
|
|
11102
|
+
return {
|
|
11103
|
+
title,
|
|
11104
|
+
kind,
|
|
11105
|
+
body,
|
|
11106
|
+
text: `# ${title}
|
|
11107
|
+
|
|
11108
|
+
${body}
|
|
11109
|
+
`,
|
|
11110
|
+
tags: ["agent-setup-bundle", "scope:sechroom", `kind:${kind}`, "archetype:document"]
|
|
11111
|
+
};
|
|
11112
|
+
}
|
|
10105
11113
|
function copyChoice(opts) {
|
|
10106
11114
|
return opts.copy === true ? "yes" : opts.copy === false ? "no" : "ask";
|
|
10107
11115
|
}
|
|
@@ -10158,27 +11166,72 @@ ${client.label} (${client.key}):
|
|
|
10158
11166
|
function resolveEvalMode(opts) {
|
|
10159
11167
|
return opts.check ? "check" : opts.force ? "force" : "apply";
|
|
10160
11168
|
}
|
|
10161
|
-
function
|
|
10162
|
-
const counts = {
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
|
|
10171
|
-
|
|
10172
|
-
|
|
10173
|
-
|
|
10174
|
-
|
|
10175
|
-
|
|
11169
|
+
function buildCheckReport(result) {
|
|
11170
|
+
const counts = {
|
|
11171
|
+
current: 0,
|
|
11172
|
+
stale: 0,
|
|
11173
|
+
drift: 0,
|
|
11174
|
+
absent: 0,
|
|
11175
|
+
stub: 0
|
|
11176
|
+
};
|
|
11177
|
+
const blocks = [];
|
|
11178
|
+
for (const { client, actions } of result) {
|
|
11179
|
+
for (const action of actions) {
|
|
11180
|
+
if (!action.eval) continue;
|
|
11181
|
+
counts[action.eval]++;
|
|
11182
|
+
blocks.push({
|
|
11183
|
+
client,
|
|
11184
|
+
path: action.path,
|
|
11185
|
+
block: action.block ?? "unknown",
|
|
11186
|
+
state: action.eval
|
|
11187
|
+
});
|
|
11188
|
+
}
|
|
11189
|
+
}
|
|
11190
|
+
return {
|
|
11191
|
+
eval: counts,
|
|
11192
|
+
wouldChange: counts.stale + counts.drift + counts.absent,
|
|
11193
|
+
blocks
|
|
11194
|
+
};
|
|
11195
|
+
}
|
|
11196
|
+
function reportCheckAndExit(result, json, refreshCommand, jsonContext = {}) {
|
|
11197
|
+
const report = buildCheckReport(result);
|
|
11198
|
+
if (json) {
|
|
11199
|
+
emit(
|
|
11200
|
+
{
|
|
11201
|
+
check: true,
|
|
11202
|
+
...jsonContext,
|
|
11203
|
+
...report,
|
|
11204
|
+
clients: result
|
|
11205
|
+
},
|
|
11206
|
+
true
|
|
11207
|
+
);
|
|
11208
|
+
} else if (report.wouldChange === 0) {
|
|
11209
|
+
if (report.eval.stub) {
|
|
11210
|
+
const files = report.eval.stub === 1 ? "file" : "files";
|
|
11211
|
+
process.stdout.write(
|
|
11212
|
+
`\u2713 ${report.eval.stub} dynamic chain pointer ${files}; no static managed blocks to check.
|
|
10176
11213
|
`
|
|
10177
|
-
|
|
10178
|
-
|
|
11214
|
+
);
|
|
11215
|
+
} else {
|
|
11216
|
+
process.stdout.write("\u2713 all instruction blocks are up to date.\n");
|
|
10179
11217
|
}
|
|
10180
|
-
|
|
11218
|
+
} else {
|
|
11219
|
+
const bits = [];
|
|
11220
|
+
if (report.eval.stale) bits.push(`${report.eval.stale} out of date`);
|
|
11221
|
+
if (report.eval.drift) bits.push(`${report.eval.drift} with local edits`);
|
|
11222
|
+
if (report.eval.absent) bits.push(`${report.eval.absent} not yet written`);
|
|
11223
|
+
process.stderr.write(
|
|
11224
|
+
`\u26A0 ${report.wouldChange} instruction block(s) would change: ${bits.join(", ")}. Run ${style.cyan(refreshCommand)}.
|
|
11225
|
+
`
|
|
11226
|
+
);
|
|
11227
|
+
}
|
|
11228
|
+
process.exit(report.wouldChange === 0 ? 0 : 1);
|
|
11229
|
+
}
|
|
11230
|
+
function summarizeEval(result, mode, json, dryRun) {
|
|
11231
|
+
if (mode === "check") {
|
|
11232
|
+
reportCheckAndExit(result, json, "--refresh");
|
|
10181
11233
|
}
|
|
11234
|
+
const { eval: counts } = buildCheckReport(result);
|
|
10182
11235
|
if (json) return;
|
|
10183
11236
|
if (!dryRun && counts.stale) {
|
|
10184
11237
|
process.stderr.write(`\u21BB refreshed ${counts.stale} section(s) the server had moved
|
|
@@ -10213,7 +11266,7 @@ async function resolveNamespaceChoice(cfg, flag) {
|
|
|
10213
11266
|
return picked === GLOBAL_NAMESPACE ? null : picked;
|
|
10214
11267
|
}
|
|
10215
11268
|
function registerInit(program2) {
|
|
10216
|
-
program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`, DEFAULT_CLIENT_KEY).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default global", "global").option("--dry-run", "print what would be written without writing", false).option("--mcp-only", "only write MCP config (skip agent files)", false).option("--agent-files-only", "only write agent instruction files (skip MCP config)", false).option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--namespace <slug>", "MCP namespace for the connection URL (interactive picker if omitted on a TTY; defaults to tenant-global)").option("--refresh", "refresh out-of-date agent-file blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite agent-file managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether agent files would change and exit (0 = current, 1 = stale/drift/absent); writes nothing", false).addHelpText(
|
|
11269
|
+
program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`, DEFAULT_CLIENT_KEY).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default global", "global").option("--dry-run", "print what would be written without writing", false).option("--mcp-only", "only write MCP config (skip agent files)", false).option("--agent-files-only", "only write agent instruction files (skip MCP config)", false).option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--namespace <slug>", "MCP namespace for the connection URL (interactive picker if omitted on a TTY; defaults to tenant-global)").option("--refresh", "refresh out-of-date agent-file blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite agent-file managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether agent files would change and exit (0 = current/stub, 1 = stale/drift/absent); writes nothing", false).addHelpText(
|
|
10217
11270
|
"after",
|
|
10218
11271
|
`
|
|
10219
11272
|
Examples:
|
|
@@ -10221,11 +11274,15 @@ Examples:
|
|
|
10221
11274
|
$ sechroom init --client all claude-code, claude-desktop, codex, cursor, antigravity
|
|
10222
11275
|
$ sechroom init --client codex cursor space-separated (comma also works)
|
|
10223
11276
|
$ sechroom init --mcp-only just the MCP config (skip agent files)
|
|
11277
|
+
$ sechroom init --agent-files-only --check CI gate: nonzero exit if agent files are out of date
|
|
10224
11278
|
$ sechroom init --dry-run --json preview the writes, change nothing`
|
|
10225
11279
|
).action(async (opts, cmd) => {
|
|
10226
11280
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
10227
11281
|
const mode = resolveEvalMode(opts);
|
|
10228
11282
|
const check = mode === "check";
|
|
11283
|
+
if (check && opts.mcpOnly) {
|
|
11284
|
+
fail("--check inspects agent files and cannot be combined with --mcp-only.");
|
|
11285
|
+
}
|
|
10229
11286
|
const namespaceSlug = await resolveNamespaceChoice(cfg, opts.namespace);
|
|
10230
11287
|
const setup = await withSpinner(
|
|
10231
11288
|
"Fetching setup descriptors",
|
|
@@ -10261,12 +11318,12 @@ Examples:
|
|
|
10261
11318
|
if (!json && !check) printActions(target, actions);
|
|
10262
11319
|
}
|
|
10263
11320
|
summarizeEval(result, mode, Boolean(json), Boolean(opts.dryRun));
|
|
10264
|
-
if (!json && !opts.dryRun && !opts.mcpOnly) {
|
|
11321
|
+
if (!json && !opts.dryRun && !opts.mcpOnly && !check) {
|
|
10265
11322
|
for (const t of claudeTargets) {
|
|
10266
11323
|
await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code", configDir: t.dir });
|
|
10267
11324
|
}
|
|
10268
11325
|
}
|
|
10269
|
-
if (!json && !opts.dryRun && !opts.mcpOnly) {
|
|
11326
|
+
if (!json && !opts.dryRun && !opts.mcpOnly && !check) {
|
|
10270
11327
|
await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
|
|
10271
11328
|
}
|
|
10272
11329
|
if (json) {
|
|
@@ -10286,12 +11343,12 @@ Next \u2014 verify: ${verify.description}
|
|
|
10286
11343
|
);
|
|
10287
11344
|
});
|
|
10288
11345
|
}
|
|
10289
|
-
function registerSetup(program2) {
|
|
11346
|
+
function registerSetup(program2, deps = {}) {
|
|
10290
11347
|
const setup = program2.command("setup").description("Granular onboarding steps (init runs these together)");
|
|
10291
11348
|
setup.command("mcp <clients...>").description(`Write only the MCP config for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--namespace <slug>", "MCP namespace for the connection URL (interactive picker if omitted on a TTY; defaults to tenant-global)").addHelpText("after", "\nExamples:\n $ sechroom setup mcp codex\n $ sechroom setup mcp claude-code codex\n $ sechroom setup mcp all").action(async (clients, opts, cmd) => {
|
|
10292
11349
|
await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false, namespace: opts.namespace });
|
|
10293
11350
|
});
|
|
10294
|
-
setup.command("agent-files <clients...>").description(`Write only the agent instruction file(s) for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").option("--refresh", "refresh out-of-date blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether anything would change and exit (0 = current, 1 = stale/drift/absent); writes nothing", false).addHelpText("after", "\nExamples:\n $ sechroom setup agent-files claude-code CLAUDE.md\n $ sechroom setup agent-files claude-code codex CLAUDE.md + AGENTS.md in one run\n $ sechroom setup agent-files all --check CI gate: nonzero exit if out of date\n $ sechroom setup agent-files claude-code --force overwrite local edits in the managed block").action(async (clients, opts, cmd) => {
|
|
11351
|
+
setup.command("agent-files <clients...>").description(`Write only the agent instruction file(s) for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").option("--refresh", "refresh out-of-date blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether anything would change and exit (0 = current/stub, 1 = stale/drift/absent); writes nothing", false).addHelpText("after", "\nExamples:\n $ sechroom setup agent-files claude-code CLAUDE.md\n $ sechroom setup agent-files claude-code codex CLAUDE.md + AGENTS.md in one run\n $ sechroom setup agent-files all --check CI gate: nonzero exit if out of date\n $ sechroom setup agent-files claude-code --force overwrite local edits in the managed block").action(async (clients, opts, cmd) => {
|
|
10295
11352
|
await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy, mode: resolveEvalMode(opts) });
|
|
10296
11353
|
});
|
|
10297
11354
|
setup.command("new-convention <title...>").description("Scaffold a workspace-conventions section: author a correctly-tagged memo (header as first body line) + regen the agent files").option("--kind <kind>", "reference | standard (orders the section; reference first)", "reference").option("--workspace <id>", "workspace to author in (default: the bound workspace)").option("--body <markdown>", "section body (default: a TODO scaffold to edit later)").option("--no-regen", "skip the agent-files regen after authoring").option("--dry-run", "print what would be authored; write nothing", false).addHelpText(
|
|
@@ -10314,29 +11371,33 @@ Examples:
|
|
|
10314
11371
|
const workspaceId = opts.workspace ?? cfg.workspaceId;
|
|
10315
11372
|
if (!workspaceId)
|
|
10316
11373
|
fail("no workspace \u2014 pass --workspace <id> or bind one (`sechroom config set --local workspaceId <id>`).");
|
|
10317
|
-
const
|
|
10318
|
-
const body = typeof opts.body === "string" && opts.body.trim().length > 0 ? opts.body.trim() : "_TODO: write this section, then edit the memo and re-run the regen._";
|
|
10319
|
-
const text2 = `# ${title}
|
|
10320
|
-
|
|
10321
|
-
${body}
|
|
10322
|
-
`;
|
|
10323
|
-
const tags = ["agent-setup-bundle", "scope:sechroom", `kind:${kind}`, "archetype:document"];
|
|
11374
|
+
const draft = buildConventionDraft(title, opts.kind, opts.body);
|
|
10324
11375
|
if (opts.dryRun) {
|
|
10325
|
-
emit(
|
|
11376
|
+
emit(
|
|
11377
|
+
{
|
|
11378
|
+
dryRun: true,
|
|
11379
|
+
workspaceId,
|
|
11380
|
+
title: draft.title,
|
|
11381
|
+
kind: draft.kind,
|
|
11382
|
+
tags: draft.tags,
|
|
11383
|
+
text: draft.text
|
|
11384
|
+
},
|
|
11385
|
+
json
|
|
11386
|
+
);
|
|
10326
11387
|
return;
|
|
10327
11388
|
}
|
|
10328
|
-
const data = await runApi("Authoring convention memo", async () => {
|
|
11389
|
+
const data = deps.authorConvention ? await deps.authorConvention({ cfg, draft, workspaceId }) : await runApi("Authoring convention memo", async () => {
|
|
10329
11390
|
const client = await makeClient(cfg);
|
|
10330
11391
|
return client.POST("/memories", {
|
|
10331
11392
|
body: {
|
|
10332
|
-
text:
|
|
10333
|
-
type: kind,
|
|
11393
|
+
text: draft.text,
|
|
11394
|
+
type: draft.kind,
|
|
10334
11395
|
content: "{}",
|
|
10335
11396
|
confidence: 1,
|
|
10336
11397
|
source: "cli-new-convention",
|
|
10337
11398
|
archetype: "Document",
|
|
10338
|
-
title,
|
|
10339
|
-
tags,
|
|
11399
|
+
title: draft.title,
|
|
11400
|
+
tags: draft.tags,
|
|
10340
11401
|
owner: { type: "Workspace", id: workspaceId }
|
|
10341
11402
|
}
|
|
10342
11403
|
});
|
|
@@ -10353,7 +11414,8 @@ ${body}
|
|
|
10353
11414
|
else process.stdout.write("Skipped regen (--no-regen). Run `sechroom setup agent-files all` to apply.\n");
|
|
10354
11415
|
return;
|
|
10355
11416
|
}
|
|
10356
|
-
|
|
11417
|
+
const regenerate = deps.regenerateConvention ?? runClients;
|
|
11418
|
+
await regenerate([...CONVENTION_REGEN_CLIENTS], cmd, {
|
|
10357
11419
|
dryRun: false,
|
|
10358
11420
|
mcp: false,
|
|
10359
11421
|
agentFiles: true,
|
|
@@ -10470,13 +11532,13 @@ Wired to namespace '${slug2}'. Restart your AI client (or reload MCP) to pick it
|
|
|
10470
11532
|
}
|
|
10471
11533
|
|
|
10472
11534
|
// src/commands/onboard.ts
|
|
10473
|
-
import { existsSync as
|
|
10474
|
-
import { basename as basename5, join as
|
|
11535
|
+
import { existsSync as existsSync15 } from "fs";
|
|
11536
|
+
import { basename as basename5, join as join21 } from "path";
|
|
10475
11537
|
|
|
10476
11538
|
// src/commands/fanout.ts
|
|
10477
11539
|
import { spawnSync } from "child_process";
|
|
10478
|
-
import { existsSync as
|
|
10479
|
-
import { isAbsolute as
|
|
11540
|
+
import { existsSync as existsSync14, readFileSync as readFileSync17, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
|
|
11541
|
+
import { isAbsolute as isAbsolute3, join as join20, resolve as resolve7 } from "path";
|
|
10480
11542
|
var ICON = {
|
|
10481
11543
|
refresh: "\u21BB",
|
|
10482
11544
|
bind: "+",
|
|
@@ -10484,33 +11546,33 @@ var ICON = {
|
|
|
10484
11546
|
"skip-unbound": "\u26A0"
|
|
10485
11547
|
};
|
|
10486
11548
|
function resolveChildDir(path, root) {
|
|
10487
|
-
return
|
|
11549
|
+
return isAbsolute3(path) ? path : resolve7(root, path);
|
|
10488
11550
|
}
|
|
10489
11551
|
function discoverChildren(root) {
|
|
10490
11552
|
let names;
|
|
10491
11553
|
try {
|
|
10492
|
-
names =
|
|
11554
|
+
names = readdirSync4(root);
|
|
10493
11555
|
} catch {
|
|
10494
11556
|
return [];
|
|
10495
11557
|
}
|
|
10496
11558
|
const out = [];
|
|
10497
11559
|
for (const name of names.sort()) {
|
|
10498
11560
|
if (name.startsWith(".") || name === "node_modules") continue;
|
|
10499
|
-
const dir =
|
|
11561
|
+
const dir = join20(root, name);
|
|
10500
11562
|
try {
|
|
10501
|
-
if (!
|
|
11563
|
+
if (!statSync5(dir).isDirectory()) continue;
|
|
10502
11564
|
} catch {
|
|
10503
11565
|
continue;
|
|
10504
11566
|
}
|
|
10505
|
-
if (
|
|
11567
|
+
if (existsSync14(join20(dir, ".git")) || committedBindingPath(dir)) out.push(name);
|
|
10506
11568
|
}
|
|
10507
11569
|
return out;
|
|
10508
11570
|
}
|
|
10509
11571
|
function readManifest(path) {
|
|
10510
|
-
if (!
|
|
11572
|
+
if (!existsSync14(path)) return null;
|
|
10511
11573
|
let parsed;
|
|
10512
11574
|
try {
|
|
10513
|
-
parsed = JSON.parse(
|
|
11575
|
+
parsed = JSON.parse(readFileSync17(path, "utf8"));
|
|
10514
11576
|
} catch (err2) {
|
|
10515
11577
|
throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
|
|
10516
11578
|
}
|
|
@@ -10876,10 +11938,10 @@ async function chooseScope(scopeFlag, yes) {
|
|
|
10876
11938
|
}
|
|
10877
11939
|
async function planRecurseChild(entry, root, client, opts) {
|
|
10878
11940
|
const dir = resolveChildDir(entry.path, root);
|
|
10879
|
-
if (!
|
|
11941
|
+
if (!existsSync15(dir)) {
|
|
10880
11942
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
10881
11943
|
}
|
|
10882
|
-
if (
|
|
11944
|
+
if (existsSync15(join21(dir, ".sechroom.json"))) {
|
|
10883
11945
|
return {
|
|
10884
11946
|
label: entry.path,
|
|
10885
11947
|
dir,
|
|
@@ -10952,7 +12014,7 @@ This fan-out will pin the same lane in every repo:
|
|
|
10952
12014
|
async function runRecurse(cfg, g, opts) {
|
|
10953
12015
|
const { yes, dryRun, json } = opts;
|
|
10954
12016
|
const root = process.cwd();
|
|
10955
|
-
const manifestPath =
|
|
12017
|
+
const manifestPath = join21(root, ".sechroom", "repos.json");
|
|
10956
12018
|
const fromManifest = readManifest(manifestPath);
|
|
10957
12019
|
const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
|
|
10958
12020
|
const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
|
|
@@ -10980,7 +12042,7 @@ async function runRecurse(cfg, g, opts) {
|
|
|
10980
12042
|
summarizeFanout(results, { dryRun });
|
|
10981
12043
|
}
|
|
10982
12044
|
function registerOnboard(program2) {
|
|
10983
|
-
program2.command("onboard").description("Guided first-run setup: configure, sign in, set timezone, detect clients, and wire this project").option("--recurse", "orchestration-root mode: onboard every child repo under this dir (auto-discovered, or from ./.sechroom/repos.json) \u2014 refreshes bound repos, prompts a workspace per new one", false).option("--lane <id>", "set the code-lane (substrate source identity) explicitly instead of inferring it; with --recurse it's used for every child repo").option("--design-lane <id>", "set the design-lane explicitly (substrate-authoring identity); with --recurse applies to every child").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default: prompt, else global").option("--local", "save the binding (tenant + base URL + workspace) to a committed .sechroom.json in this repo instead of the global config", false).option("--here", "with --local: write the binding at THIS directory even when a parent already carries one \u2014 binds a subtree (e.g. a monorepo's frontend/) to its own workspace", false).option("--workspace <id>", "bind this directory to a workspace (skips the interactive workspace pick)").option("--cli-only", "configure the CLI only \u2014 don't wire any AI client (no MCP config, no agent files)", false).option("--no-mcp", "skip the MCP server config (.mcp.json etc.); still write the agent instruction files").option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--dry-run", "walk through without writing files or changing the profile", false).option("--refresh", "re-fetch descriptors and refresh any out-of-date managed blocks (local edits preserved to .proposed)", false).option("--force", "rewrite every managed block, overwriting local edits inside the markers (content outside untouched)", false).option("--check", "report whether anything would change and exit (0 =
|
|
12045
|
+
program2.command("onboard").description("Guided first-run setup: configure, sign in, set timezone, detect clients, and wire this project").option("--recurse", "orchestration-root mode: onboard every child repo under this dir (auto-discovered, or from ./.sechroom/repos.json) \u2014 refreshes bound repos, prompts a workspace per new one", false).option("--lane <id>", "set the code-lane (substrate source identity) explicitly instead of inferring it; with --recurse it's used for every child repo").option("--design-lane <id>", "set the design-lane explicitly (substrate-authoring identity); with --recurse applies to every child").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default: prompt, else global").option("--local", "save the binding (tenant + base URL + workspace) to a committed .sechroom.json in this repo instead of the global config", false).option("--here", "with --local: write the binding at THIS directory even when a parent already carries one \u2014 binds a subtree (e.g. a monorepo's frontend/) to its own workspace", false).option("--workspace <id>", "bind this directory to a workspace (skips the interactive workspace pick)").option("--cli-only", "configure the CLI only \u2014 don't wire any AI client (no MCP config, no agent files)", false).option("--no-mcp", "skip the MCP server config (.mcp.json etc.); still write the agent instruction files").option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--dry-run", "walk through without writing files or changing the profile", false).option("--refresh", "re-fetch descriptors and refresh any out-of-date managed blocks (local edits preserved to .proposed)", false).option("--force", "rewrite every managed block, overwriting local edits inside the markers (content outside untouched)", false).option("--check", "report whether anything would change and exit (0 = current/stub, 1 = stale/drift/absent); writes nothing", false).option("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients, global config, full wire)", false).addHelpText(
|
|
10984
12046
|
"after",
|
|
10985
12047
|
`
|
|
10986
12048
|
Examples:
|
|
@@ -11002,6 +12064,9 @@ Examples:
|
|
|
11002
12064
|
const mode = opts.check ? "check" : opts.force ? "force" : "apply";
|
|
11003
12065
|
const check = mode === "check";
|
|
11004
12066
|
const yes = Boolean(opts.yes) || check;
|
|
12067
|
+
if (check && (opts.recurse || opts.cliOnly)) {
|
|
12068
|
+
fail("--check inspects this project's agent files and cannot be combined with --recurse or --cli-only.");
|
|
12069
|
+
}
|
|
11005
12070
|
if (opts.lane) process.env.SECHROOM_CODE_LANE = opts.lane;
|
|
11006
12071
|
if (opts.designLane) process.env.SECHROOM_DESIGN_LANE = opts.designLane;
|
|
11007
12072
|
if (opts.recurse) {
|
|
@@ -11069,27 +12134,19 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
|
|
|
11069
12134
|
result.push({ client: key, actions });
|
|
11070
12135
|
if (!json && !check) printActions(target, actions);
|
|
11071
12136
|
}
|
|
11072
|
-
const evalCounts = { current: 0, stale: 0, drift: 0, absent: 0 };
|
|
11073
|
-
for (const { actions } of result) for (const a of actions) if (a.eval) evalCounts[a.eval]++;
|
|
11074
|
-
const wouldChange = evalCounts.stale + evalCounts.drift + evalCounts.absent;
|
|
11075
12137
|
if (check) {
|
|
11076
|
-
|
|
11077
|
-
|
|
11078
|
-
|
|
11079
|
-
|
|
11080
|
-
|
|
11081
|
-
|
|
11082
|
-
|
|
11083
|
-
|
|
11084
|
-
|
|
11085
|
-
|
|
11086
|
-
process.stderr.write(
|
|
11087
|
-
`${warn("\u26A0")} ${wouldChange} instruction block(s) would change: ${bits.join(", ")}. Run ${style.cyan("sechroom onboard --refresh")}.
|
|
11088
|
-
`
|
|
11089
|
-
);
|
|
11090
|
-
}
|
|
11091
|
-
process.exit(wouldChange === 0 ? 0 : 1);
|
|
12138
|
+
reportCheckAndExit(
|
|
12139
|
+
result,
|
|
12140
|
+
json,
|
|
12141
|
+
"sechroom onboard --refresh",
|
|
12142
|
+
{
|
|
12143
|
+
baseUrl: cfg.baseUrl,
|
|
12144
|
+
tenant: cfg.tenant,
|
|
12145
|
+
workspaceId: cfg.workspaceId ?? null
|
|
12146
|
+
}
|
|
12147
|
+
);
|
|
11092
12148
|
}
|
|
12149
|
+
const evalCounts = buildCheckReport(result).eval;
|
|
11093
12150
|
if (!json && !dryRun) {
|
|
11094
12151
|
await ensureLanePin(cfg, { yes, dryRun, clients: keys });
|
|
11095
12152
|
}
|
|
@@ -11485,31 +12542,31 @@ Examples:
|
|
|
11485
12542
|
|
|
11486
12543
|
// src/commands/reset.ts
|
|
11487
12544
|
import { homedir as homedir6 } from "os";
|
|
11488
|
-
import { join as
|
|
11489
|
-
import { existsSync as
|
|
12545
|
+
import { join as join22 } from "path";
|
|
12546
|
+
import { existsSync as existsSync16, readFileSync as readFileSync18, rmSync as rmSync7 } from "fs";
|
|
11490
12547
|
var SKILLS_LOCK2 = ".sechroom-skills.json";
|
|
11491
|
-
var localSkillsDir = () =>
|
|
11492
|
-
var globalSkillsDir = () =>
|
|
11493
|
-
var localAgentsDir = () =>
|
|
11494
|
-
var globalAgentsDir = () =>
|
|
12548
|
+
var localSkillsDir = () => join22(process.cwd(), ".claude", "skills");
|
|
12549
|
+
var globalSkillsDir = () => join22(homedir6(), ".claude", "skills");
|
|
12550
|
+
var localAgentsDir = () => join22(process.cwd(), ".claude", "agents");
|
|
12551
|
+
var globalAgentsDir = () => join22(homedir6(), ".claude", "agents");
|
|
11495
12552
|
function removeMaterialisedSkills(dir) {
|
|
11496
12553
|
const removed = [];
|
|
11497
|
-
const lockPath =
|
|
11498
|
-
if (!
|
|
12554
|
+
const lockPath = join22(dir, SKILLS_LOCK2);
|
|
12555
|
+
if (!existsSync16(lockPath)) return removed;
|
|
11499
12556
|
try {
|
|
11500
|
-
const lock = JSON.parse(
|
|
12557
|
+
const lock = JSON.parse(readFileSync18(lockPath, "utf8"));
|
|
11501
12558
|
for (const entry of Object.values(lock)) {
|
|
11502
12559
|
for (const name of entry.skills ?? []) {
|
|
11503
|
-
const p =
|
|
11504
|
-
if (
|
|
11505
|
-
|
|
12560
|
+
const p = join22(dir, name);
|
|
12561
|
+
if (existsSync16(p)) {
|
|
12562
|
+
rmSync7(p, { recursive: true, force: true });
|
|
11506
12563
|
removed.push(p);
|
|
11507
12564
|
}
|
|
11508
12565
|
}
|
|
11509
12566
|
}
|
|
11510
12567
|
} catch {
|
|
11511
12568
|
}
|
|
11512
|
-
|
|
12569
|
+
rmSync7(lockPath, { force: true });
|
|
11513
12570
|
removed.push(lockPath);
|
|
11514
12571
|
return removed;
|
|
11515
12572
|
}
|
|
@@ -11546,19 +12603,19 @@ function registerReset(program2) {
|
|
|
11546
12603
|
}
|
|
11547
12604
|
}
|
|
11548
12605
|
const removed = [];
|
|
11549
|
-
const stateDir =
|
|
11550
|
-
if (
|
|
11551
|
-
|
|
12606
|
+
const stateDir = join22(process.cwd(), ".sechroom");
|
|
12607
|
+
if (existsSync16(stateDir)) {
|
|
12608
|
+
rmSync7(stateDir, { recursive: true, force: true });
|
|
11552
12609
|
removed.push(stateDir);
|
|
11553
12610
|
}
|
|
11554
|
-
const legacyCfg =
|
|
11555
|
-
if (
|
|
11556
|
-
|
|
12611
|
+
const legacyCfg = join22(process.cwd(), ".sechroom.json");
|
|
12612
|
+
if (existsSync16(legacyCfg)) {
|
|
12613
|
+
rmSync7(legacyCfg, { force: true });
|
|
11557
12614
|
removed.push(legacyCfg);
|
|
11558
12615
|
}
|
|
11559
|
-
const legacySem =
|
|
11560
|
-
if (
|
|
11561
|
-
|
|
12616
|
+
const legacySem = join22(process.cwd(), ".sem");
|
|
12617
|
+
if (existsSync16(legacySem)) {
|
|
12618
|
+
rmSync7(legacySem, { force: true });
|
|
11562
12619
|
removed.push(legacySem);
|
|
11563
12620
|
}
|
|
11564
12621
|
removed.push(...removeMaterialisedSkills(localSkillsDir()));
|
|
@@ -11583,8 +12640,8 @@ function registerReset(program2) {
|
|
|
11583
12640
|
}
|
|
11584
12641
|
|
|
11585
12642
|
// src/commands/skills.ts
|
|
11586
|
-
import { existsSync as
|
|
11587
|
-
import { join as
|
|
12643
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync18, statSync as statSync6, writeFileSync as writeFileSync17 } from "fs";
|
|
12644
|
+
import { join as join23 } from "path";
|
|
11588
12645
|
function filenameFromDisposition(header) {
|
|
11589
12646
|
if (!header) return void 0;
|
|
11590
12647
|
const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header);
|
|
@@ -11592,11 +12649,11 @@ function filenameFromDisposition(header) {
|
|
|
11592
12649
|
}
|
|
11593
12650
|
function resolveOutputPath(output, serverFilename) {
|
|
11594
12651
|
const filename = serverFilename || "skills.zip";
|
|
11595
|
-
if (!output) return
|
|
11596
|
-
const looksLikeDir = output.endsWith("/") ||
|
|
12652
|
+
if (!output) return join23(process.cwd(), filename);
|
|
12653
|
+
const looksLikeDir = output.endsWith("/") || existsSync17(output) && statSync6(output).isDirectory();
|
|
11597
12654
|
if (looksLikeDir) {
|
|
11598
|
-
|
|
11599
|
-
return
|
|
12655
|
+
mkdirSync18(output, { recursive: true });
|
|
12656
|
+
return join23(output, filename);
|
|
11600
12657
|
}
|
|
11601
12658
|
return output;
|
|
11602
12659
|
}
|
|
@@ -11627,7 +12684,7 @@ async function downloadZip(label, call, output) {
|
|
|
11627
12684
|
const buf = Buffer.from(res.data);
|
|
11628
12685
|
const filename = filenameFromDisposition(res.response.headers.get("content-disposition")) ?? "skills.zip";
|
|
11629
12686
|
const path = resolveOutputPath(output, filename);
|
|
11630
|
-
|
|
12687
|
+
writeFileSync17(path, buf);
|
|
11631
12688
|
return { path, bytes: buf.length, filename };
|
|
11632
12689
|
}
|
|
11633
12690
|
function registerSkills(program2) {
|
|
@@ -11801,12 +12858,12 @@ clients select all; an unconfigured machine preserves the legacy Claude default.
|
|
|
11801
12858
|
}
|
|
11802
12859
|
|
|
11803
12860
|
// src/commands/sweep.ts
|
|
11804
|
-
import { existsSync as
|
|
11805
|
-
import { dirname as
|
|
11806
|
-
var DEFAULT_MANIFEST =
|
|
12861
|
+
import { existsSync as existsSync18 } from "fs";
|
|
12862
|
+
import { dirname as dirname16, join as join24, resolve as resolve8 } from "path";
|
|
12863
|
+
var DEFAULT_MANIFEST = join24(".sechroom", "repos.json");
|
|
11807
12864
|
function planEntry(entry, root) {
|
|
11808
12865
|
const dir = resolveChildDir(entry.path, root);
|
|
11809
|
-
if (!
|
|
12866
|
+
if (!existsSync18(dir)) {
|
|
11810
12867
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
11811
12868
|
}
|
|
11812
12869
|
if (committedBindingPath(dir)) {
|
|
@@ -11866,7 +12923,7 @@ Examples:
|
|
|
11866
12923
|
const g = cmd.optsWithGlobals();
|
|
11867
12924
|
const json = Boolean(g.json);
|
|
11868
12925
|
const dryRun = Boolean(opts.dryRun);
|
|
11869
|
-
const manifestPath =
|
|
12926
|
+
const manifestPath = resolve8(opts.manifest);
|
|
11870
12927
|
let repos;
|
|
11871
12928
|
try {
|
|
11872
12929
|
repos = readManifest(manifestPath);
|
|
@@ -11882,7 +12939,7 @@ Examples:
|
|
|
11882
12939
|
`);
|
|
11883
12940
|
return;
|
|
11884
12941
|
}
|
|
11885
|
-
const root =
|
|
12942
|
+
const root = dirname16(dirname16(manifestPath));
|
|
11886
12943
|
const plans = repos.map((entry) => planEntry(entry, root));
|
|
11887
12944
|
if (!json) {
|
|
11888
12945
|
process.stderr.write(
|
|
@@ -12302,7 +13359,7 @@ async function readStdin4() {
|
|
|
12302
13359
|
function resolveVersion() {
|
|
12303
13360
|
try {
|
|
12304
13361
|
const pkg = JSON.parse(
|
|
12305
|
-
|
|
13362
|
+
readFileSync19(new URL("../package.json", import.meta.url), "utf8")
|
|
12306
13363
|
);
|
|
12307
13364
|
return pkg.version ?? "0.0.0";
|
|
12308
13365
|
} catch {
|