@rackbops/ac-agent 2.0.0-alpha.8 → 2.0.0-alpha.9
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 +158 -18
- package/data/Get-ToolchainInventory.ps1 +1397 -0
- package/data/README.md +40 -0
- package/dist/ac-agent.mjs +1870 -17
- package/package.json +3 -2
package/dist/ac-agent.mjs
CHANGED
|
@@ -43,7 +43,7 @@ function _mergeNamespaces(n, m) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
var name = "@rackbops/ac-agent";
|
|
46
|
-
var version = "2.0.0-alpha.
|
|
46
|
+
var version = "2.0.0-alpha.9";
|
|
47
47
|
var type = "module";
|
|
48
48
|
var description = "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.";
|
|
49
49
|
var bin = {
|
|
@@ -51,7 +51,8 @@ var bin = {
|
|
|
51
51
|
};
|
|
52
52
|
var files = [
|
|
53
53
|
"dist/ac-agent.mjs",
|
|
54
|
-
"README.md"
|
|
54
|
+
"README.md",
|
|
55
|
+
"data/"
|
|
55
56
|
];
|
|
56
57
|
var engines = {
|
|
57
58
|
node: ">=24"
|
|
@@ -1260,11 +1261,20 @@ function reposRootEnvOverride(env) {
|
|
|
1260
1261
|
requireAbsolute(raw, "AC_AGENT_REPOS_ROOT");
|
|
1261
1262
|
return raw;
|
|
1262
1263
|
}
|
|
1264
|
+
function claudeProjectsDirEnvOverride(env) {
|
|
1265
|
+
const raw = env?.AC_AGENT_CLAUDE_PROJECTS_DIR;
|
|
1266
|
+
if (raw === undefined || raw.trim() === "")
|
|
1267
|
+
return null;
|
|
1268
|
+
requireAbsolute(raw, "AC_AGENT_CLAUDE_PROJECTS_DIR");
|
|
1269
|
+
return raw;
|
|
1270
|
+
}
|
|
1263
1271
|
function loadConfig(dir, deps) {
|
|
1264
1272
|
const configPath = path.join(dir, "agent.toml");
|
|
1265
1273
|
const envPort = portEnvOverride(deps.env);
|
|
1266
1274
|
const envReposRoot = reposRootEnvOverride(deps.env);
|
|
1275
|
+
const envClaudeProjectsDir = claudeProjectsDirEnvOverride(deps.env);
|
|
1267
1276
|
const defaultReposRoot = () => path.join(deps.homedir(), "repos");
|
|
1277
|
+
const defaultClaudeProjectsDir = () => path.join(deps.homedir(), ".claude", "projects");
|
|
1268
1278
|
const text = deps.readFile(configPath);
|
|
1269
1279
|
if (text === null) {
|
|
1270
1280
|
return {
|
|
@@ -1273,6 +1283,7 @@ function loadConfig(dir, deps) {
|
|
|
1273
1283
|
bind: DEFAULT_BIND,
|
|
1274
1284
|
machine: deps.hostname(),
|
|
1275
1285
|
reposRoot: envReposRoot ?? defaultReposRoot(),
|
|
1286
|
+
claudeProjectsDir: envClaudeProjectsDir ?? defaultClaudeProjectsDir(),
|
|
1276
1287
|
},
|
|
1277
1288
|
path: configPath,
|
|
1278
1289
|
source: "defaults",
|
|
@@ -1285,6 +1296,10 @@ function loadConfig(dir, deps) {
|
|
|
1285
1296
|
bind: typeof raw.bind === "string" ? raw.bind : DEFAULT_BIND,
|
|
1286
1297
|
machine: typeof raw.machine === "string" ? raw.machine : deps.hostname(),
|
|
1287
1298
|
reposRoot: envReposRoot ?? (typeof raw.repos_root === "string" ? raw.repos_root : defaultReposRoot()),
|
|
1299
|
+
claudeProjectsDir: envClaudeProjectsDir ??
|
|
1300
|
+
(typeof raw.claude_projects_dir === "string"
|
|
1301
|
+
? raw.claude_projects_dir
|
|
1302
|
+
: defaultClaudeProjectsDir()),
|
|
1288
1303
|
},
|
|
1289
1304
|
path: configPath,
|
|
1290
1305
|
source: "file",
|
|
@@ -1297,6 +1312,7 @@ function writeConfig(dir, config, deps) {
|
|
|
1297
1312
|
bind: config.bind,
|
|
1298
1313
|
machine: config.machine,
|
|
1299
1314
|
repos_root: config.reposRoot,
|
|
1315
|
+
claude_projects_dir: config.claudeProjectsDir,
|
|
1300
1316
|
}));
|
|
1301
1317
|
return configPath;
|
|
1302
1318
|
}
|
|
@@ -4885,6 +4901,20 @@ function createApp(deps) {
|
|
|
4885
4901
|
deps.logEvent(`grants updated: revision ${validated.set.revision}, ${pluginCount} plugin(s)`);
|
|
4886
4902
|
return c.json({ ok: true, revision: validated.set.revision, plugins: pluginCount });
|
|
4887
4903
|
});
|
|
4904
|
+
// Bearer-gated (by the middleware above) but not a verb, exactly like PUT /v1/grants: no
|
|
4905
|
+
// capability, no plugin header -- the Agents panel's Environment section is host-level, like
|
|
4906
|
+
// Ping, so a grant to some plugin would be a fiction (#55, E4 §5 decision 19).
|
|
4907
|
+
app.get("/v1/preflight", async (c) => {
|
|
4908
|
+
if (c.req.header("X-AC-Plugin") !== undefined) {
|
|
4909
|
+
return c.json({ ok: false, error: "preflight is the console's, not a plugin's" }, 403);
|
|
4910
|
+
}
|
|
4911
|
+
const report = await deps.preflight();
|
|
4912
|
+
const okCount = report.rows.filter((r) => r.status === "ok").length;
|
|
4913
|
+
const notOkCount = report.rows.length - okCount;
|
|
4914
|
+
const perRow = report.rows.map((r) => `${r.cmd}=${r.status}`).join(", ");
|
|
4915
|
+
deps.logEvent(`preflight: ${okCount} ok, ${notOkCount} not ok (${perRow}) searchPath=${report.searchPath}`);
|
|
4916
|
+
return c.json({ ok: true, ...report });
|
|
4917
|
+
});
|
|
4888
4918
|
mountVerbs(app, deps.verbs, { grants: deps.grants, machine: deps.machine });
|
|
4889
4919
|
app.notFound((c) => c.json({ error: "no such verb" }, 404));
|
|
4890
4920
|
return app;
|
|
@@ -33745,6 +33775,132 @@ var cert = /*#__PURE__*/Object.freeze({
|
|
|
33745
33775
|
nonInternalIPv4: nonInternalIPv4
|
|
33746
33776
|
});
|
|
33747
33777
|
|
|
33778
|
+
const NONCE_PREFIX = ".ac-agent-vfs-probe-";
|
|
33779
|
+
/** Strips a single trailing separator (`path.win32.normalize` never removes the one on a root like
|
|
33780
|
+
* `C:\`, so this only ever trims an operator-typed trailing slash on a real subdirectory). */
|
|
33781
|
+
function normalizeRoot$1(p) {
|
|
33782
|
+
const normalized = path.win32.normalize(p);
|
|
33783
|
+
return normalized.length > 3 && normalized.endsWith("\\") ? normalized.slice(0, -1) : normalized;
|
|
33784
|
+
}
|
|
33785
|
+
/** Case-insensitive prefix compare on normalized absolute paths (Windows paths are case-preserving,
|
|
33786
|
+
* not case-sensitive by default -- `fsutil ... queryCaseSensitiveInfo` on #263's own SHIORI
|
|
33787
|
+
* investigation confirmed this off everywhere checked). Returns the path relative to `root` when
|
|
33788
|
+
* `dir` lies under it (possibly `""` when they're equal), or `null` when `dir` is elsewhere
|
|
33789
|
+
* entirely (an `AC_AGENT_STATE_DIR` on another drive, say) -- never guessed via `path.relative`,
|
|
33790
|
+
* which always returns *some* string (with `..` segments) regardless of actual nesting. */
|
|
33791
|
+
function relativeUnder(root, dir) {
|
|
33792
|
+
const normRoot = normalizeRoot$1(root);
|
|
33793
|
+
const normDir = path.win32.normalize(dir);
|
|
33794
|
+
const rootLower = normRoot.toLowerCase();
|
|
33795
|
+
const dirLower = normDir.toLowerCase();
|
|
33796
|
+
if (dirLower === rootLower)
|
|
33797
|
+
return "";
|
|
33798
|
+
if (dirLower.startsWith(`${rootLower}\\`))
|
|
33799
|
+
return normDir.slice(normRoot.length + 1);
|
|
33800
|
+
return null;
|
|
33801
|
+
}
|
|
33802
|
+
/** A dir can lie under either root -- the default layout puts `stateDir` under `%LOCALAPPDATA%` and
|
|
33803
|
+
* `configDir` under `%APPDATA%` (`paths.ts`), but an override could put either dir under either
|
|
33804
|
+
* root, so both roots are tried for both dirs rather than assuming the default pairing. */
|
|
33805
|
+
function findRootMatch(dir, localAppData, appData) {
|
|
33806
|
+
const localRel = relativeUnder(localAppData, dir);
|
|
33807
|
+
if (localRel !== null)
|
|
33808
|
+
return { rel: localRel, branch: "Local" };
|
|
33809
|
+
if (appData !== undefined && appData !== "") {
|
|
33810
|
+
const roamingRel = relativeUnder(appData, dir);
|
|
33811
|
+
if (roamingRel !== null)
|
|
33812
|
+
return { rel: roamingRel, branch: "Roaming" };
|
|
33813
|
+
}
|
|
33814
|
+
return null;
|
|
33815
|
+
}
|
|
33816
|
+
function detectAppDataVirtualization(deps) {
|
|
33817
|
+
if (deps.platform !== "win32")
|
|
33818
|
+
return { virtualized: false };
|
|
33819
|
+
const localAppData = deps.env.LOCALAPPDATA;
|
|
33820
|
+
if (localAppData === undefined || localAppData === "")
|
|
33821
|
+
return { virtualized: false };
|
|
33822
|
+
const appData = deps.env.APPDATA;
|
|
33823
|
+
const candidates = [];
|
|
33824
|
+
const stateMatch = findRootMatch(deps.stateDir, localAppData, appData);
|
|
33825
|
+
if (stateMatch !== null)
|
|
33826
|
+
candidates.push({ dir: deps.stateDir, ...stateMatch });
|
|
33827
|
+
const configMatch = findRootMatch(deps.configDir, localAppData, appData);
|
|
33828
|
+
if (configMatch !== null)
|
|
33829
|
+
candidates.push({ dir: deps.configDir, ...configMatch });
|
|
33830
|
+
const packagesDir = path.win32.join(localAppData, "Packages");
|
|
33831
|
+
for (const candidate of candidates) {
|
|
33832
|
+
const nonceFileName = `${NONCE_PREFIX}${deps.nonce()}`;
|
|
33833
|
+
const nonceFilePath = path.win32.join(candidate.dir, nonceFileName);
|
|
33834
|
+
try {
|
|
33835
|
+
deps.fs.writeFile(nonceFilePath, "probe");
|
|
33836
|
+
}
|
|
33837
|
+
catch {
|
|
33838
|
+
// Can't even write the probe file (permissions, a read-only fs, disk full, ...) -- this
|
|
33839
|
+
// candidate is unverifiable, not a hard failure. Without this, a write error would propagate
|
|
33840
|
+
// straight out of `status` (which, before this guard existed, never wrote anything at all) as
|
|
33841
|
+
// a raw, confusing exception instead of a graceful `virtualized: false`.
|
|
33842
|
+
continue;
|
|
33843
|
+
}
|
|
33844
|
+
try {
|
|
33845
|
+
let packageNames;
|
|
33846
|
+
try {
|
|
33847
|
+
packageNames = deps.fs.readdir(packagesDir);
|
|
33848
|
+
}
|
|
33849
|
+
catch {
|
|
33850
|
+
// No `Packages` dir at all -- not virtualized for this candidate (and, since it's the same
|
|
33851
|
+
// physical dir for every candidate, not virtualized for any of them either).
|
|
33852
|
+
continue;
|
|
33853
|
+
}
|
|
33854
|
+
for (const pkg of packageNames) {
|
|
33855
|
+
try {
|
|
33856
|
+
const vfsPath = path.win32.join(localAppData, "Packages", pkg, "LocalCache", candidate.branch, candidate.rel);
|
|
33857
|
+
const probePath = path.win32.join(vfsPath, nonceFileName);
|
|
33858
|
+
if (deps.fs.exists(probePath)) {
|
|
33859
|
+
return { virtualized: true, packageFamily: pkg, vfsPath };
|
|
33860
|
+
}
|
|
33861
|
+
}
|
|
33862
|
+
catch {
|
|
33863
|
+
// An unreadable package dir is skipped, not a hard failure.
|
|
33864
|
+
}
|
|
33865
|
+
}
|
|
33866
|
+
}
|
|
33867
|
+
finally {
|
|
33868
|
+
deps.fs.removeFile(nonceFilePath);
|
|
33869
|
+
}
|
|
33870
|
+
}
|
|
33871
|
+
return { virtualized: false };
|
|
33872
|
+
}
|
|
33873
|
+
/** The exact refusal text `installService` throws (`service/index.ts`) -- pinned by
|
|
33874
|
+
* `service.test.ts`. */
|
|
33875
|
+
function installRefusalMessage(packageFamily, vfsPath) {
|
|
33876
|
+
return `install ran inside a packaged app (${packageFamily}), which virtualizes AppData: the files landed under ${vfsPath} where the logon task cannot see them. Run ac-agent install from a normal PowerShell window, not from a session started by the Claude desktop app.`;
|
|
33877
|
+
}
|
|
33878
|
+
/** The one-line warning `status` prints to stderr and `serve` logs once at startup when the dirs it
|
|
33879
|
+
* resolved are virtualized -- neither refuses (a status/serve run from a virtualized session is
|
|
33880
|
+
* informative, or in `serve`'s case actively working within that same session; only a fresh
|
|
33881
|
+
* `install` registering a task nothing can see is refused). */
|
|
33882
|
+
function virtualizationWarningLine(packageFamily, stateDir) {
|
|
33883
|
+
return `warning: this session's AppData is virtualized (${packageFamily}); the logon task cannot see ${stateDir} -- see README, Install`;
|
|
33884
|
+
}
|
|
33885
|
+
|
|
33886
|
+
/** Shared by `installService` and `status` so both check the same two dirs the same way -- exported
|
|
33887
|
+
* for `cli.ts`'s `status` wiring, which needs the full result (not just the boolean `status` puts on
|
|
33888
|
+
* `StatusResult`) to name the offending package in its stderr warning. */
|
|
33889
|
+
function detectVirtualization(deps) {
|
|
33890
|
+
return detectAppDataVirtualization({
|
|
33891
|
+
platform: deps.osPlatform,
|
|
33892
|
+
env: deps.env ?? {},
|
|
33893
|
+
stateDir: deps.stateDir,
|
|
33894
|
+
configDir: deps.configDir,
|
|
33895
|
+
fs: {
|
|
33896
|
+
writeFile: deps.writeFile,
|
|
33897
|
+
readdir: deps.readdir,
|
|
33898
|
+
exists: deps.exists,
|
|
33899
|
+
removeFile: deps.removeFile,
|
|
33900
|
+
},
|
|
33901
|
+
nonce: deps.nonce,
|
|
33902
|
+
});
|
|
33903
|
+
}
|
|
33748
33904
|
function bundleTargetPath(stateDir) {
|
|
33749
33905
|
return path.join(stateDir, "bin", "ac-agent.mjs");
|
|
33750
33906
|
}
|
|
@@ -33771,6 +33927,14 @@ async function installService(deps) {
|
|
|
33771
33927
|
writeFile: (p, content, mode) => deps.writeFile(p, content, mode),
|
|
33772
33928
|
});
|
|
33773
33929
|
const fp = fingerprint(certPem);
|
|
33930
|
+
// #263: refuse before registering a task/unit nothing can ever see, rather than leave a broken
|
|
33931
|
+
// registration behind for `status`/`start` to fail against later. Must run after the state-dir
|
|
33932
|
+
// writes above (so a genuinely virtualized session's own writes are what the probe finds) and
|
|
33933
|
+
// before `deps.platform.install` (which is what actually registers the task/unit on Windows).
|
|
33934
|
+
const virtualization = detectVirtualization(deps);
|
|
33935
|
+
if (virtualization.virtualized) {
|
|
33936
|
+
throw new Error(installRefusalMessage(virtualization.packageFamily, virtualization.vfsPath));
|
|
33937
|
+
}
|
|
33774
33938
|
const result = await deps.platform.install({
|
|
33775
33939
|
nodePath: deps.nodePath,
|
|
33776
33940
|
bundlePath,
|
|
@@ -33802,6 +33966,7 @@ async function status(deps, now = () => new Date()) {
|
|
|
33802
33966
|
plugins: Object.keys(readGrantsResult.set.grants).length,
|
|
33803
33967
|
}
|
|
33804
33968
|
: null;
|
|
33969
|
+
const virtualization = detectVirtualization(deps);
|
|
33805
33970
|
return {
|
|
33806
33971
|
configPath: loaded.path,
|
|
33807
33972
|
configSource: loaded.source,
|
|
@@ -33813,6 +33978,7 @@ async function status(deps, now = () => new Date()) {
|
|
|
33813
33978
|
health,
|
|
33814
33979
|
reposRoot: loaded.config.reposRoot,
|
|
33815
33980
|
grants,
|
|
33981
|
+
virtualized: virtualization.virtualized,
|
|
33816
33982
|
};
|
|
33817
33983
|
}
|
|
33818
33984
|
/** Generates a one-time pairing code (#48): refuses when already paired unless `force` (which is how
|
|
@@ -33876,6 +34042,7 @@ async function uninstall(deps, options) {
|
|
|
33876
34042
|
deps.removeFile(path.join(deps.stateDir, "token.sha256"));
|
|
33877
34043
|
deps.removeFile(path.join(deps.stateDir, "pair.json"));
|
|
33878
34044
|
deps.removeFile(path.join(deps.stateDir, "grants.json"));
|
|
34045
|
+
deps.removeDir(path.join(deps.stateDir, "scans"));
|
|
33879
34046
|
if (options.purge) {
|
|
33880
34047
|
deps.removeFile(path.join(deps.configDir, "agent.toml"));
|
|
33881
34048
|
}
|
|
@@ -34485,6 +34652,7 @@ function systemdUnitPath(homedir) {
|
|
|
34485
34652
|
async function buildServiceDeps(overrides = {}) {
|
|
34486
34653
|
const os = await import('node:os');
|
|
34487
34654
|
const fs = await import('node:fs');
|
|
34655
|
+
const crypto = await import('node:crypto');
|
|
34488
34656
|
const { resolveDirs } = await Promise.resolve().then(function () { return paths; });
|
|
34489
34657
|
const { nonInternalIPv4 } = await Promise.resolve().then(function () { return cert; });
|
|
34490
34658
|
const { fileURLToPath } = await import('node:url');
|
|
@@ -34520,6 +34688,9 @@ async function buildServiceDeps(overrides = {}) {
|
|
|
34520
34688
|
// already gone
|
|
34521
34689
|
}
|
|
34522
34690
|
},
|
|
34691
|
+
removeDir: (p) => {
|
|
34692
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
34693
|
+
},
|
|
34523
34694
|
killProcess: (pid) => {
|
|
34524
34695
|
try {
|
|
34525
34696
|
process.kill(pid);
|
|
@@ -34534,13 +34705,17 @@ async function buildServiceDeps(overrides = {}) {
|
|
|
34534
34705
|
portOverride: overrides.port,
|
|
34535
34706
|
reposRootOverride: overrides.reposRoot,
|
|
34536
34707
|
env: process.env,
|
|
34708
|
+
osPlatform: process.platform,
|
|
34709
|
+
readdir: (p) => fs.readdirSync(p),
|
|
34710
|
+
exists: (p) => fs.existsSync(p),
|
|
34711
|
+
nonce: () => crypto.randomBytes(6).toString("hex"),
|
|
34537
34712
|
ips: nonInternalIPv4(os.networkInterfaces()),
|
|
34538
34713
|
nodePath: process.execPath,
|
|
34539
34714
|
user: os.userInfo().username,
|
|
34540
34715
|
platform,
|
|
34541
34716
|
print: (line) => process.stdout.write(`${line}\n`),
|
|
34542
34717
|
fetchHealth: async (healthPort, certPem, expectedFingerprint) => {
|
|
34543
|
-
const { Agent, fetch: undiciFetch } = await Promise.resolve().then(function () { return index; });
|
|
34718
|
+
const { Agent, fetch: undiciFetch } = await Promise.resolve().then(function () { return index$2; });
|
|
34544
34719
|
const agent = new Agent({
|
|
34545
34720
|
connect: {
|
|
34546
34721
|
ca: certPem,
|
|
@@ -34565,6 +34740,7 @@ async function buildServiceDeps(overrides = {}) {
|
|
|
34565
34740
|
async function realServe(overrides = {}) {
|
|
34566
34741
|
const os = await import('node:os');
|
|
34567
34742
|
const fs = await import('node:fs');
|
|
34743
|
+
const crypto = await import('node:crypto');
|
|
34568
34744
|
const { resolveDirs } = await Promise.resolve().then(function () { return paths; });
|
|
34569
34745
|
const { ensureCert, nonInternalIPv4 } = await Promise.resolve().then(function () { return cert; });
|
|
34570
34746
|
const { acquire, release } = await Promise.resolve().then(function () { return lock; });
|
|
@@ -34578,8 +34754,10 @@ async function realServe(overrides = {}) {
|
|
|
34578
34754
|
const { findExecutable } = await Promise.resolve().then(function () { return which; });
|
|
34579
34755
|
const { createJobRegistry } = await Promise.resolve().then(function () { return registry$1; });
|
|
34580
34756
|
const { createScanRegistry } = await Promise.resolve().then(function () { return registry; });
|
|
34757
|
+
const { registerScanKinds } = await Promise.resolve().then(function () { return index$1; });
|
|
34581
34758
|
const { registerScanVerbs } = await Promise.resolve().then(function () { return scan; });
|
|
34582
34759
|
const { readGrants, writeGrants, createGrantState } = await Promise.resolve().then(function () { return grants; });
|
|
34760
|
+
const { runPreflight } = await Promise.resolve().then(function () { return index; });
|
|
34583
34761
|
// `--config-dir`/`--state-dir` (E4 finding 13, #241) are what `install` bakes into the registered
|
|
34584
34762
|
// task/unit, so a `serve` launched that way never falls back to re-resolving from its own
|
|
34585
34763
|
// environment (a Task Scheduler process / systemd --user unit does not see the installing
|
|
@@ -34635,6 +34813,32 @@ async function realServe(overrides = {}) {
|
|
|
34635
34813
|
rename: (from, to) => fs.renameSync(from, to),
|
|
34636
34814
|
appendFile: (p, data) => fs.appendFileSync(p, data),
|
|
34637
34815
|
});
|
|
34816
|
+
// #263: a `serve` launched from a session whose AppData is virtualized by a packaged app still
|
|
34817
|
+
// works (everything it reads/writes goes through the same virtualized view within that session),
|
|
34818
|
+
// so this only warns, once, rather than refusing the way `install` does.
|
|
34819
|
+
const virtualizationCheck = detectAppDataVirtualization({
|
|
34820
|
+
platform: process.platform,
|
|
34821
|
+
env: process.env,
|
|
34822
|
+
stateDir: dirs.stateDir,
|
|
34823
|
+
configDir: dirs.configDir,
|
|
34824
|
+
fs: {
|
|
34825
|
+
writeFile: (p, content) => fs.writeFileSync(p, content),
|
|
34826
|
+
readdir: (p) => fs.readdirSync(p),
|
|
34827
|
+
exists: (p) => fs.existsSync(p),
|
|
34828
|
+
removeFile: (p) => {
|
|
34829
|
+
try {
|
|
34830
|
+
fs.unlinkSync(p);
|
|
34831
|
+
}
|
|
34832
|
+
catch {
|
|
34833
|
+
// already gone
|
|
34834
|
+
}
|
|
34835
|
+
},
|
|
34836
|
+
},
|
|
34837
|
+
nonce: () => crypto.randomBytes(6).toString("hex"),
|
|
34838
|
+
});
|
|
34839
|
+
if (virtualizationCheck.virtualized) {
|
|
34840
|
+
log$1.write(virtualizationWarningLine(virtualizationCheck.packageFamily, dirs.stateDir));
|
|
34841
|
+
}
|
|
34638
34842
|
const registry$3 = createVerbRegistry();
|
|
34639
34843
|
const run = createRun();
|
|
34640
34844
|
const repoFs = {
|
|
@@ -34649,12 +34853,7 @@ async function realServe(overrides = {}) {
|
|
|
34649
34853
|
logEvent: (message) => log$1.write(message),
|
|
34650
34854
|
});
|
|
34651
34855
|
registerOpenVerb(registry$3, { spawn: createSpawn(), platform: process.platform });
|
|
34652
|
-
|
|
34653
|
-
// memory-footprint, toolchain-inventory) are what register a kind into this -- this file only
|
|
34654
|
-
// freezes and mounts whatever's registered, live-only wiring like every verb group above.
|
|
34655
|
-
const scans = createScanRegistry();
|
|
34656
|
-
registerScanVerbs(registry$3, scans, { logEvent: (message) => log$1.write(message) });
|
|
34657
|
-
const which$1 = (name) => findExecutable(name, process.env, process.platform, {
|
|
34856
|
+
const whichFs = {
|
|
34658
34857
|
exists: (p) => fs.existsSync(p),
|
|
34659
34858
|
isExecutable: (p) => {
|
|
34660
34859
|
try {
|
|
@@ -34665,7 +34864,67 @@ async function realServe(overrides = {}) {
|
|
|
34665
34864
|
return false;
|
|
34666
34865
|
}
|
|
34667
34866
|
},
|
|
34867
|
+
};
|
|
34868
|
+
// The optional second (env) parameter is #55's own addition: claude.ts/gh.ts's own deps type
|
|
34869
|
+
// `which` as one-argument (a required second parameter would not be assignable to that, tsc
|
|
34870
|
+
// 5.9 TS2322), while runPreflight's deps call it with the refreshed PATH on every probe.
|
|
34871
|
+
const which$1 = (name, env = process.env) => findExecutable(name, env, process.platform, whichFs);
|
|
34872
|
+
// context-pressure and subagent-cost (#52, 52a) register here; memory-footprint and
|
|
34873
|
+
// toolchain-inventory (52b) grow this same call. registerScanKinds must run before
|
|
34874
|
+
// registerScanVerbs, which freezes the registry before mounting one verb per kind.
|
|
34875
|
+
const scans = createScanRegistry();
|
|
34876
|
+
registerScanKinds(scans, {
|
|
34877
|
+
projectsRoot: () => loaded.config.claudeProjectsDir,
|
|
34878
|
+
stateDir: dirs.stateDir,
|
|
34879
|
+
fs: {
|
|
34880
|
+
readFile: (p) => {
|
|
34881
|
+
try {
|
|
34882
|
+
return fs.readFileSync(p);
|
|
34883
|
+
}
|
|
34884
|
+
catch {
|
|
34885
|
+
return null;
|
|
34886
|
+
}
|
|
34887
|
+
},
|
|
34888
|
+
readdir: (p) => {
|
|
34889
|
+
try {
|
|
34890
|
+
return fs.readdirSync(p).map((name) => ({ name }));
|
|
34891
|
+
}
|
|
34892
|
+
catch {
|
|
34893
|
+
return [];
|
|
34894
|
+
}
|
|
34895
|
+
},
|
|
34896
|
+
isDir: (p) => {
|
|
34897
|
+
try {
|
|
34898
|
+
return fs.statSync(p).isDirectory();
|
|
34899
|
+
}
|
|
34900
|
+
catch {
|
|
34901
|
+
return false;
|
|
34902
|
+
}
|
|
34903
|
+
},
|
|
34904
|
+
stat: (p) => {
|
|
34905
|
+
try {
|
|
34906
|
+
const s = fs.statSync(p, { bigint: true });
|
|
34907
|
+
return { mtimeNs: s.mtimeNs, size: Number(s.size) };
|
|
34908
|
+
}
|
|
34909
|
+
catch {
|
|
34910
|
+
return null;
|
|
34911
|
+
}
|
|
34912
|
+
},
|
|
34913
|
+
writeFile: (p, text) => fs.writeFileSync(p, text),
|
|
34914
|
+
mkdir: (p) => fs.mkdirSync(p, { recursive: true }),
|
|
34915
|
+
rename: (from, to) => fs.renameSync(from, to),
|
|
34916
|
+
},
|
|
34917
|
+
now: Date.now,
|
|
34918
|
+
reposRoot: () => loaded.config.reposRoot,
|
|
34919
|
+
repoFs,
|
|
34920
|
+
which: which$1,
|
|
34921
|
+
homedir: os.homedir,
|
|
34922
|
+
hostname: os.hostname,
|
|
34923
|
+
platform: process.platform,
|
|
34924
|
+
env: process.env,
|
|
34925
|
+
run,
|
|
34668
34926
|
});
|
|
34927
|
+
registerScanVerbs(registry$3, scans, { logEvent: (message) => log$1.write(message) });
|
|
34669
34928
|
registerClaudeVerbs(registry$3, {
|
|
34670
34929
|
jobs: createJobRegistry(),
|
|
34671
34930
|
resolveRepo: (name) => resolveRepo(loaded.config.reposRoot, name, repoFs),
|
|
@@ -34695,6 +34954,18 @@ async function realServe(overrides = {}) {
|
|
|
34695
34954
|
logEvent: (message) => log$1.write(message),
|
|
34696
34955
|
verbs: registry$3,
|
|
34697
34956
|
grants: grants$1,
|
|
34957
|
+
preflight: () => runPreflight({
|
|
34958
|
+
platform: process.platform,
|
|
34959
|
+
env: process.env,
|
|
34960
|
+
homedir: os.homedir,
|
|
34961
|
+
which: which$1,
|
|
34962
|
+
exists: whichFs.exists,
|
|
34963
|
+
isExecutable: whichFs.isExecutable,
|
|
34964
|
+
readdir: (p) => fs.readdirSync(p),
|
|
34965
|
+
run,
|
|
34966
|
+
now: () => new Date(),
|
|
34967
|
+
machine: loaded.config.machine,
|
|
34968
|
+
}),
|
|
34698
34969
|
});
|
|
34699
34970
|
const running = await startServer({
|
|
34700
34971
|
app,
|
|
@@ -34737,6 +35008,14 @@ async function main(argv) {
|
|
|
34737
35008
|
status: async () => {
|
|
34738
35009
|
const { deps } = await buildServiceDeps();
|
|
34739
35010
|
const result = await status(deps);
|
|
35011
|
+
if (result.virtualized) {
|
|
35012
|
+
// `result.virtualized` only carries the boolean (`StatusResult`'s own shape) -- rerun the
|
|
35013
|
+
// detection once more here to get the package name this line names (#263).
|
|
35014
|
+
const detection = detectVirtualization(deps);
|
|
35015
|
+
if (detection.virtualized) {
|
|
35016
|
+
process.stderr.write(`${virtualizationWarningLine(detection.packageFamily, deps.stateDir)}\n`);
|
|
35017
|
+
}
|
|
35018
|
+
}
|
|
34740
35019
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
34741
35020
|
},
|
|
34742
35021
|
serve: realServe,
|
|
@@ -72726,7 +73005,7 @@ function requireUndici () {
|
|
|
72726
73005
|
|
|
72727
73006
|
var undiciExports = requireUndici();
|
|
72728
73007
|
|
|
72729
|
-
var index = /*#__PURE__*/_mergeNamespaces({
|
|
73008
|
+
var index$2 = /*#__PURE__*/_mergeNamespaces({
|
|
72730
73009
|
__proto__: null
|
|
72731
73010
|
}, [undiciExports]);
|
|
72732
73011
|
|
|
@@ -72789,6 +73068,7 @@ function createRun(spawnFn = spawn) {
|
|
|
72789
73068
|
let stdout = "";
|
|
72790
73069
|
let stderr = "";
|
|
72791
73070
|
let timedOut = false;
|
|
73071
|
+
let aborted = false;
|
|
72792
73072
|
let settled = false;
|
|
72793
73073
|
const child = spawnFn(cmd, args, {
|
|
72794
73074
|
cwd: opts.cwd,
|
|
@@ -72799,6 +73079,19 @@ function createRun(spawnFn = spawn) {
|
|
|
72799
73079
|
// itself is ENOENT and every credential helper is gone -- merge over process.env.
|
|
72800
73080
|
env: { ...process.env, ...opts.env },
|
|
72801
73081
|
});
|
|
73082
|
+
const onAbort = () => {
|
|
73083
|
+
aborted = true;
|
|
73084
|
+
child.kill();
|
|
73085
|
+
};
|
|
73086
|
+
// Checked once, right after launch, rather than skipping spawnFn altogether: a caller that
|
|
73087
|
+
// hands in an already-aborted signal still gets a real launch attempt, immediately killed --
|
|
73088
|
+
// never the launch-failure path (comment on RunOptions.signal above).
|
|
73089
|
+
if (opts.signal?.aborted) {
|
|
73090
|
+
onAbort();
|
|
73091
|
+
}
|
|
73092
|
+
else {
|
|
73093
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
73094
|
+
}
|
|
72802
73095
|
const timer = setTimeout(() => {
|
|
72803
73096
|
timedOut = true;
|
|
72804
73097
|
child.kill();
|
|
@@ -72809,21 +73102,31 @@ function createRun(spawnFn = spawn) {
|
|
|
72809
73102
|
child.stderr?.on("data", (chunk) => {
|
|
72810
73103
|
stderr += chunk.toString();
|
|
72811
73104
|
});
|
|
73105
|
+
const cleanup = () => {
|
|
73106
|
+
clearTimeout(timer);
|
|
73107
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
73108
|
+
};
|
|
72812
73109
|
// A launch failure (ENOENT, EACCES, ...) fires here rather than throwing synchronously --
|
|
72813
73110
|
// resolved as one error row (1.x repo_pull.py:84-89) rather than aborting the whole call.
|
|
72814
73111
|
child.on("error", (err) => {
|
|
72815
73112
|
if (settled)
|
|
72816
73113
|
return;
|
|
72817
73114
|
settled = true;
|
|
72818
|
-
|
|
73115
|
+
cleanup();
|
|
72819
73116
|
resolve({ code: 127, stdout, stderr: err.message, timedOut: false });
|
|
72820
73117
|
});
|
|
72821
73118
|
child.on("close", (code) => {
|
|
72822
73119
|
if (settled)
|
|
72823
73120
|
return;
|
|
72824
73121
|
settled = true;
|
|
72825
|
-
|
|
72826
|
-
resolve({
|
|
73122
|
+
cleanup();
|
|
73123
|
+
resolve({
|
|
73124
|
+
code: code ?? 1,
|
|
73125
|
+
stdout,
|
|
73126
|
+
stderr,
|
|
73127
|
+
timedOut,
|
|
73128
|
+
...(aborted ? { aborted: true } : {}),
|
|
73129
|
+
});
|
|
72827
73130
|
});
|
|
72828
73131
|
});
|
|
72829
73132
|
}
|
|
@@ -73909,12 +74212,24 @@ var gh = /*#__PURE__*/Object.freeze({
|
|
|
73909
74212
|
registerGhVerb: registerGhVerb
|
|
73910
74213
|
});
|
|
73911
74214
|
|
|
74215
|
+
/** Strips one pair of surrounding double quotes from a PATH entry ("C:\Program Files\Git\cmd" is
|
|
74216
|
+
* legal in a Windows PATH, and `CreateProcess` strips it before use) -- an entry left quoted
|
|
74217
|
+
* would otherwise never match a real file and read as `missing`. Harmless on POSIX, where a real
|
|
74218
|
+
* directory name wrapped in literal quotes is not a thing that happens. */
|
|
74219
|
+
function unquote(entry) {
|
|
74220
|
+
return entry.length >= 2 && entry.startsWith('"') && entry.endsWith('"')
|
|
74221
|
+
? entry.slice(1, -1)
|
|
74222
|
+
: entry;
|
|
74223
|
+
}
|
|
73912
74224
|
function findExecutable(name, env, platform, fs) {
|
|
73913
74225
|
const pathVar = env.PATH ?? env.Path ?? "";
|
|
73914
74226
|
const sep = platform === "win32" ? ";" : ":";
|
|
73915
74227
|
// A PATH entry that doesn't exist on disk is skipped by the exists() checks below, never thrown
|
|
73916
74228
|
// -- there's nothing here to throw on a plain string split, and exists() is a pure boolean.
|
|
73917
|
-
const dirs = pathVar
|
|
74229
|
+
const dirs = pathVar
|
|
74230
|
+
.split(sep)
|
|
74231
|
+
.filter((d) => d !== "")
|
|
74232
|
+
.map(unquote);
|
|
73918
74233
|
// path.win32/path.posix explicitly, never the bare path.join/path.dirname -- those pick their
|
|
73919
74234
|
// behavior from the OS this code is actually running on, not from the `platform` parameter, so
|
|
73920
74235
|
// testing "linux" logic from a Windows dev box (or vice versa) would silently join with the
|
|
@@ -73925,9 +74240,12 @@ function findExecutable(name, env, platform, fs) {
|
|
|
73925
74240
|
const exePath = join(dir, `${name}.exe`);
|
|
73926
74241
|
if (fs.exists(exePath))
|
|
73927
74242
|
return { found: true, path: exePath };
|
|
73928
|
-
|
|
73929
|
-
|
|
73930
|
-
|
|
74243
|
+
const cmdPath = join(dir, `${name}.cmd`);
|
|
74244
|
+
if (fs.exists(cmdPath))
|
|
74245
|
+
return { found: false, reason: "shim", path: cmdPath };
|
|
74246
|
+
const batPath = join(dir, `${name}.bat`);
|
|
74247
|
+
if (fs.exists(batPath))
|
|
74248
|
+
return { found: false, reason: "shim", path: batPath };
|
|
73931
74249
|
}
|
|
73932
74250
|
return { found: false, reason: "not-found" };
|
|
73933
74251
|
}
|
|
@@ -74117,6 +74435,1221 @@ var registry = /*#__PURE__*/Object.freeze({
|
|
|
74117
74435
|
createScanRegistry: createScanRegistry
|
|
74118
74436
|
});
|
|
74119
74437
|
|
|
74438
|
+
/**
|
|
74439
|
+
* Python-compatible JSON, byte for byte (#52, 52a, E4 plan item 2): #52's acceptance is a byte diff
|
|
74440
|
+
* against 1.x's own cache file, and neither `JSON.stringify`'s formatting nor JavaScript's
|
|
74441
|
+
* number model matches `json.dumps`'s. This module is the whole gap: `ensure_ascii` escaping,
|
|
74442
|
+
* `indent=2` layout, key-insertion order, integers as integers, and -- the hard part -- a per-key
|
|
74443
|
+
* set of "this one is a Python float" so its number is formatted by `repr()`, not by
|
|
74444
|
+
* `Number#toString()` (which agrees with `repr` on the digits but not on when to use scientific
|
|
74445
|
+
* notation, and never renders `85` as `85.0`).
|
|
74446
|
+
*/
|
|
74447
|
+
/** Every UTF-16 code unit outside 0x20-0x7E becomes a lowercase `\uXXXX` (DEL and lone surrogates
|
|
74448
|
+
* included), except the seven short escapes Python's encoder also special-cases; `/` is not
|
|
74449
|
+
* escaped. Astral characters are already surrogate pairs in a JS string, so iterating code UNITS
|
|
74450
|
+
* (not code points) reproduces Python's own UTF-16-based `\uXXXX` pairing for them automatically. */
|
|
74451
|
+
function escapeAscii(s) {
|
|
74452
|
+
let out = "";
|
|
74453
|
+
for (let i = 0; i < s.length; i++) {
|
|
74454
|
+
const code = s.charCodeAt(i);
|
|
74455
|
+
switch (code) {
|
|
74456
|
+
case 0x22:
|
|
74457
|
+
out += '\\"';
|
|
74458
|
+
break;
|
|
74459
|
+
case 0x5c:
|
|
74460
|
+
out += "\\\\";
|
|
74461
|
+
break;
|
|
74462
|
+
case 0x0a:
|
|
74463
|
+
out += "\\n";
|
|
74464
|
+
break;
|
|
74465
|
+
case 0x0d:
|
|
74466
|
+
out += "\\r";
|
|
74467
|
+
break;
|
|
74468
|
+
case 0x09:
|
|
74469
|
+
out += "\\t";
|
|
74470
|
+
break;
|
|
74471
|
+
case 0x08:
|
|
74472
|
+
out += "\\b";
|
|
74473
|
+
break;
|
|
74474
|
+
case 0x0c:
|
|
74475
|
+
out += "\\f";
|
|
74476
|
+
break;
|
|
74477
|
+
default:
|
|
74478
|
+
if (code >= 0x20 && code <= 0x7e) {
|
|
74479
|
+
out += s[i];
|
|
74480
|
+
}
|
|
74481
|
+
else {
|
|
74482
|
+
out += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
74483
|
+
}
|
|
74484
|
+
}
|
|
74485
|
+
}
|
|
74486
|
+
return out;
|
|
74487
|
+
}
|
|
74488
|
+
/** Python's `repr(float)`: the shortest round-trip digits (the same digits `Number#toString`
|
|
74489
|
+
* produces -- both are shortest-round-trip) laid out by Python's rule -- positional when
|
|
74490
|
+
* `-4 < decpt <= 16` (an integral value gets a trailing `.0`), otherwise scientific
|
|
74491
|
+
* `d[.ddd]e±XX` with a sign and at least two exponent digits. `decpt` is the decimal point's
|
|
74492
|
+
* position relative to the digit string (one past the first digit, e.g. digits "85" at decpt 2
|
|
74493
|
+
* is "85", at decpt 1 is "8.5"). `-0.0` is handled by sign since `String(-0)` loses the sign. */
|
|
74494
|
+
function pyFloatRepr(x) {
|
|
74495
|
+
const negative = x < 0 || Object.is(x, -0);
|
|
74496
|
+
const abs = Math.abs(x);
|
|
74497
|
+
if (abs === 0)
|
|
74498
|
+
return negative ? "-0.0" : "0.0";
|
|
74499
|
+
const exp = abs.toExponential();
|
|
74500
|
+
const match = /^(\d)(?:\.(\d+))?e([+-]\d+)$/.exec(exp);
|
|
74501
|
+
if (match === null)
|
|
74502
|
+
throw new Error(`pyFloatRepr: unexpected toExponential() output ${exp}`);
|
|
74503
|
+
const digits = match[1] + (match[2] ?? "");
|
|
74504
|
+
const e = Number(match[3]);
|
|
74505
|
+
const decpt = e + 1;
|
|
74506
|
+
let body;
|
|
74507
|
+
if (decpt > -4 && decpt <= 16) {
|
|
74508
|
+
if (decpt <= 0) {
|
|
74509
|
+
body = `0.${"0".repeat(-decpt)}${digits}`;
|
|
74510
|
+
}
|
|
74511
|
+
else if (decpt >= digits.length) {
|
|
74512
|
+
body = `${digits}${"0".repeat(decpt - digits.length)}.0`;
|
|
74513
|
+
}
|
|
74514
|
+
else {
|
|
74515
|
+
body = `${digits.slice(0, decpt)}.${digits.slice(decpt)}`;
|
|
74516
|
+
}
|
|
74517
|
+
}
|
|
74518
|
+
else {
|
|
74519
|
+
const mantissa = digits.length === 1 ? digits : `${digits[0]}.${digits.slice(1)}`;
|
|
74520
|
+
const sign = e < 0 ? "-" : "+";
|
|
74521
|
+
body = `${mantissa}e${sign}${String(Math.abs(e)).padStart(2, "0")}`;
|
|
74522
|
+
}
|
|
74523
|
+
return negative ? `-${body}` : body;
|
|
74524
|
+
}
|
|
74525
|
+
/** Adds 1 to a string of decimal digits, carrying left (e.g. "099" -> "100", "999" -> "1000"). */
|
|
74526
|
+
function incrementDecimalDigits(digits) {
|
|
74527
|
+
const chars = digits.split("");
|
|
74528
|
+
for (let i = chars.length - 1; i >= 0; i--) {
|
|
74529
|
+
if (chars[i] !== "9") {
|
|
74530
|
+
chars[i] = String(Number(chars[i]) + 1);
|
|
74531
|
+
return chars.join("");
|
|
74532
|
+
}
|
|
74533
|
+
chars[i] = "0";
|
|
74534
|
+
}
|
|
74535
|
+
return `1${chars.join("")}`;
|
|
74536
|
+
}
|
|
74537
|
+
/** Python's `round(x, ndigits)`: correctly rounded on the double's EXACT decimal expansion, ties
|
|
74538
|
+
* to even -- never `toFixed` (rounds ties away from zero) and never `Math.round(x * 10**n) / 10**n`
|
|
74539
|
+
* (both accumulates float error and still ties away from zero). `toFixed(100)` (the maximum
|
|
74540
|
+
* `Number.prototype.toFixed` allows) gives the exact expansion for every magnitude this codebase
|
|
74541
|
+
* rounds (percentages, dollar amounts, unix timestamps) -- comfortably below the ~2^52 mantissa
|
|
74542
|
+
* precision boundary where an integer part alone would already exhaust it. */
|
|
74543
|
+
function pyRound(x, ndigits) {
|
|
74544
|
+
const negative = x < 0 || Object.is(x, -0);
|
|
74545
|
+
const abs = Math.abs(x);
|
|
74546
|
+
const exact = abs.toFixed(100);
|
|
74547
|
+
const dot = exact.indexOf(".");
|
|
74548
|
+
const intPart = exact.slice(0, dot);
|
|
74549
|
+
const fracPart = exact.slice(dot + 1);
|
|
74550
|
+
if (ndigits >= fracPart.length)
|
|
74551
|
+
return negative ? -abs : abs;
|
|
74552
|
+
const keep = fracPart.slice(0, ndigits);
|
|
74553
|
+
const roundDigit = fracPart[ndigits];
|
|
74554
|
+
const rest = fracPart.slice(ndigits + 1);
|
|
74555
|
+
let roundUp;
|
|
74556
|
+
if (roundDigit === undefined || roundDigit < "5") {
|
|
74557
|
+
roundUp = false;
|
|
74558
|
+
}
|
|
74559
|
+
else if (roundDigit > "5") {
|
|
74560
|
+
roundUp = true;
|
|
74561
|
+
}
|
|
74562
|
+
else if (/[1-9]/.test(rest)) {
|
|
74563
|
+
roundUp = true;
|
|
74564
|
+
}
|
|
74565
|
+
else {
|
|
74566
|
+
// An exact tie: round to even, looking at the last digit that survives.
|
|
74567
|
+
const lastKept = ndigits > 0 ? keep[keep.length - 1] : intPart[intPart.length - 1];
|
|
74568
|
+
roundUp = Number(lastKept) % 2 === 1;
|
|
74569
|
+
}
|
|
74570
|
+
let digits = intPart + keep;
|
|
74571
|
+
if (roundUp)
|
|
74572
|
+
digits = incrementDecimalDigits(digits);
|
|
74573
|
+
const newIntLen = digits.length - keep.length;
|
|
74574
|
+
const newInt = digits.slice(0, newIntLen) || "0";
|
|
74575
|
+
const newFrac = digits.slice(newIntLen);
|
|
74576
|
+
const result = Number(ndigits > 0 ? `${newInt}.${newFrac}` : newInt);
|
|
74577
|
+
return negative ? -result : result;
|
|
74578
|
+
}
|
|
74579
|
+
/** CPython's `st_mtime`: `(double)tv_sec + (double)tv_nsec * 1e-9` (`fill_time` in `posixmodule.c`)
|
|
74580
|
+
* -- never `mtimeMs / 1000`, which rounds through milliseconds first and can disagree in the last
|
|
74581
|
+
* bit. Takes `{ sec, nsec }` split from `fs.statSync(p, { bigint: true }).mtimeNs` by the caller. */
|
|
74582
|
+
function pyMtime(stat) {
|
|
74583
|
+
return Number(stat.sec) + Number(stat.nsec) * 1e-9;
|
|
74584
|
+
}
|
|
74585
|
+
function dumpValue(value, key, opts, depth) {
|
|
74586
|
+
if (value === null)
|
|
74587
|
+
return "null";
|
|
74588
|
+
if (value === true)
|
|
74589
|
+
return "true";
|
|
74590
|
+
if (value === false)
|
|
74591
|
+
return "false";
|
|
74592
|
+
if (typeof value === "string")
|
|
74593
|
+
return `"${escapeAscii(value)}"`;
|
|
74594
|
+
if (typeof value === "number") {
|
|
74595
|
+
if (key !== undefined && opts.floatKeys.has(key))
|
|
74596
|
+
return pyFloatRepr(value);
|
|
74597
|
+
return String(value);
|
|
74598
|
+
}
|
|
74599
|
+
if (Array.isArray(value)) {
|
|
74600
|
+
if (value.length === 0)
|
|
74601
|
+
return "[]";
|
|
74602
|
+
const pad = " ".repeat(opts.indent * (depth + 1));
|
|
74603
|
+
const closePad = " ".repeat(opts.indent * depth);
|
|
74604
|
+
const items = value.map((item) => pad + dumpValue(item, undefined, opts, depth + 1));
|
|
74605
|
+
return `[\n${items.join(",\n")}\n${closePad}]`;
|
|
74606
|
+
}
|
|
74607
|
+
if (typeof value === "object") {
|
|
74608
|
+
const entries = Object.entries(value);
|
|
74609
|
+
if (entries.length === 0)
|
|
74610
|
+
return "{}";
|
|
74611
|
+
const pad = " ".repeat(opts.indent * (depth + 1));
|
|
74612
|
+
const closePad = " ".repeat(opts.indent * depth);
|
|
74613
|
+
const items = entries.map(([k, v]) => `${pad}"${escapeAscii(k)}": ${dumpValue(v, k, opts, depth + 1)}`);
|
|
74614
|
+
return `{\n${items.join(",\n")}\n${closePad}}`;
|
|
74615
|
+
}
|
|
74616
|
+
throw new Error(`pyjson.dumps: unsupported value ${JSON.stringify(value)}`);
|
|
74617
|
+
}
|
|
74618
|
+
/** `json.dumps(data, indent=2)`, byte for byte: `ensure_ascii` on, `": "` / `,\n` separators, two-
|
|
74619
|
+
* space indent, `[]`/`{}` for empties, key order = insertion order, a key in `floatKeys` formatted
|
|
74620
|
+
* by `pyFloatRepr` at any depth, everything else numeric rendered as a plain integer. */
|
|
74621
|
+
function dumps(value, opts) {
|
|
74622
|
+
return dumpValue(value, undefined, opts, 0);
|
|
74623
|
+
}
|
|
74624
|
+
/** `Path.write_text(json.dumps(...))` in Python's default text mode: `os.linesep`-terminated lines
|
|
74625
|
+
* (CRLF on Windows, LF on Linux) and no trailing newline -- `dumps`'s own `\n`s are the encoder's
|
|
74626
|
+
* structural newlines only (a `\n`/`\r` inside a string VALUE is already `\\n`/`\\r`-escaped by
|
|
74627
|
+
* `escapeAscii`, never a raw byte), so translating every `\n` is safe and exhaustive. Pure: the
|
|
74628
|
+
* caller writes the returned text through its own injected `fs.writeFile`, the same seam every
|
|
74629
|
+
* other disk write in this package goes through. */
|
|
74630
|
+
function writeCache(text) {
|
|
74631
|
+
return process.platform === "win32" ? text.replace(/\n/g, "\r\n") : text;
|
|
74632
|
+
}
|
|
74633
|
+
/** `load_cache`: `null` on a missing or corrupt file, matching either line terminator (`JSON.parse`
|
|
74634
|
+
* accepts LF or CRLF indifferently, since only whitespace separates tokens). */
|
|
74635
|
+
function parseCache(text) {
|
|
74636
|
+
if (text === null)
|
|
74637
|
+
return null;
|
|
74638
|
+
try {
|
|
74639
|
+
return JSON.parse(text);
|
|
74640
|
+
}
|
|
74641
|
+
catch {
|
|
74642
|
+
return null;
|
|
74643
|
+
}
|
|
74644
|
+
}
|
|
74645
|
+
/** `path.dirname(path.join(root, "_"))` (`repos.ts:97`'s normalisation, reused so the root a scan
|
|
74646
|
+
* reports agrees with resolveRepo's own idea of a normalised root). */
|
|
74647
|
+
function normalizeRoot(root) {
|
|
74648
|
+
return path.dirname(path.join(root, "_"));
|
|
74649
|
+
}
|
|
74650
|
+
|
|
74651
|
+
/**
|
|
74652
|
+
* Shared session/subagent transcript-scan primitives (#52, 52a, E4 plan item 1; 1.x's
|
|
74653
|
+
* `session_scan.py`): the byte-identical helpers `context-pressure.ts` and `subagent-cost.ts` both
|
|
74654
|
+
* derive from a `.claude/projects` transcript, so the two pipelines can't drift on the cross-OS
|
|
74655
|
+
* project-name rule, the timestamp parse, or the line-splitting semantics 1.x's binary file
|
|
74656
|
+
* iteration gives for free and this port has to name explicitly.
|
|
74657
|
+
*/
|
|
74658
|
+
/** The usage marker every billable transcript record carries, matched on the raw line BYTES --
|
|
74659
|
+
* both scans byte-prefilter a line (no decode, no parse) before deciding whether it is even worth
|
|
74660
|
+
* decoding, exactly as 1.x's `_USAGE_MARK in raw` does against a line read in binary mode; this is
|
|
74661
|
+
* what keeps a ~940MB cold scan from decoding and parsing lines it will discard anyway. */
|
|
74662
|
+
const USAGE_MARK = '"cache_read_input_tokens"';
|
|
74663
|
+
const USAGE_MARK_BYTES = Buffer.from(USAGE_MARK, "utf8");
|
|
74664
|
+
/** Display name for a session: the repo basename, with a worktree folded into its parent
|
|
74665
|
+
* (`R:\repos\Foo\.claude\worktrees\x` -> `Foo`). Splits on both separators by hand rather than
|
|
74666
|
+
* through `node:path`: the `cwd` in a transcript was written by whichever OS ran that session, so
|
|
74667
|
+
* a Windows path has to resolve even when this runs on Linux. */
|
|
74668
|
+
function projectName(cwd, fallback) {
|
|
74669
|
+
if (!cwd)
|
|
74670
|
+
return fallback;
|
|
74671
|
+
const parts = cwd
|
|
74672
|
+
.replace(/\\/g, "/")
|
|
74673
|
+
.split("/")
|
|
74674
|
+
.filter((p) => p !== "");
|
|
74675
|
+
const idx = parts.indexOf(".claude");
|
|
74676
|
+
if (idx > 0) {
|
|
74677
|
+
const before = parts[idx - 1];
|
|
74678
|
+
if (before !== undefined)
|
|
74679
|
+
return before;
|
|
74680
|
+
}
|
|
74681
|
+
return parts.length > 0 ? (parts[parts.length - 1] ?? fallback) : fallback;
|
|
74682
|
+
}
|
|
74683
|
+
// YYYY-MM-DDTHH:MM:SS[.f...](Z|+HH:MM|-HH:MM) -- the transcript's own grammar, not the whole of
|
|
74684
|
+
// Python's fromisoformat (which also accepts a bare offset-less string as local time, +HHMM, a
|
|
74685
|
+
// space separator, a comma fraction, or a date-only string; Claude Code writes only the Z form,
|
|
74686
|
+
// so those are rejected rather than reproduced as a machine-dependent value).
|
|
74687
|
+
const TS_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/;
|
|
74688
|
+
/** ISO-8601 record timestamp -> epoch seconds; `0` when absent/unparseable, used in preference to
|
|
74689
|
+
* the file mtime so a *resumed* month-old session doesn't enter a rolling window as new work. A
|
|
74690
|
+
* fraction longer than six digits is truncated to microseconds, as `fromisoformat` does. The
|
|
74691
|
+
* result is computed as an integer number of microseconds divided by `1e6` -- both operands exact
|
|
74692
|
+
* integers well under the float64 mantissa's 2^53 boundary for any realistic date, so the double
|
|
74693
|
+
* is the correctly rounded value Python's `datetime.timestamp()` produces, not an accumulation of
|
|
74694
|
+
* intermediate rounding. */
|
|
74695
|
+
function parseTs(value) {
|
|
74696
|
+
if (typeof value !== "string" || value === "")
|
|
74697
|
+
return 0;
|
|
74698
|
+
const match = TS_PATTERN.exec(value);
|
|
74699
|
+
if (match === null)
|
|
74700
|
+
return 0;
|
|
74701
|
+
const [, y, mo, d, h, mi, s, frac, offset] = match;
|
|
74702
|
+
const fractionDigits = (frac ?? "").slice(0, 6).padEnd(6, "0");
|
|
74703
|
+
const fractionMicros = Number(fractionDigits);
|
|
74704
|
+
let offsetMinutes = 0;
|
|
74705
|
+
if (offset !== "Z") {
|
|
74706
|
+
const sign = offset.startsWith("-") ? -1 : 1;
|
|
74707
|
+
offsetMinutes = sign * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6)));
|
|
74708
|
+
}
|
|
74709
|
+
const epochMs = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) -
|
|
74710
|
+
offsetMinutes * 60 * 1000;
|
|
74711
|
+
const totalMicros = epochMs * 1000 + fractionMicros;
|
|
74712
|
+
return totalMicros / 1e6;
|
|
74713
|
+
}
|
|
74714
|
+
/** Splits a buffer into raw line BYTES exactly as Python's binary file iteration does: on `0x0A`
|
|
74715
|
+
* alone (a `\r` stays on the line, never stripped, and IS included in the returned slice along
|
|
74716
|
+
* with the `\n`), and a final line with no trailing `\n` is still a line. Returns bytes, not
|
|
74717
|
+
* decoded text, on purpose: 1.x's own byte-prefilter (`USAGE_MARK_BYTES`/a compact-boundary
|
|
74718
|
+
* marker) runs before any decode or parse, so a line that never has to be decoded at all (the
|
|
74719
|
+
* large majority, in a ~940MB cold scan) never is -- decode only the lines that pass. */
|
|
74720
|
+
function readLines(buffer) {
|
|
74721
|
+
const lines = [];
|
|
74722
|
+
let start = 0;
|
|
74723
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
74724
|
+
if (buffer[i] === 0x0a) {
|
|
74725
|
+
lines.push(buffer.subarray(start, i + 1));
|
|
74726
|
+
start = i + 1;
|
|
74727
|
+
}
|
|
74728
|
+
}
|
|
74729
|
+
if (start < buffer.length) {
|
|
74730
|
+
lines.push(buffer.subarray(start, buffer.length));
|
|
74731
|
+
}
|
|
74732
|
+
return lines;
|
|
74733
|
+
}
|
|
74734
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
74735
|
+
/** Decodes one line's bytes to a string, or `null` on invalid UTF-8 -- the caller treats `null`
|
|
74736
|
+
* exactly like a JSON parse failure (a torn line, skipped). `fatal: true` with the default
|
|
74737
|
+
* `ignoreBOM: false` strips a leading BOM exactly as `json.loads(bytes)` does through
|
|
74738
|
+
* `utf-8-sig`, so a BOM line is parsed, not skipped. **Documented divergence**: 1.x decodes with
|
|
74739
|
+
* `surrogatepass`, so a line carrying lone-surrogate bytes parses there and is skipped here --
|
|
74740
|
+
* Claude Code never writes one. Never called with `{ stream: true }`, so reusing one decoder
|
|
74741
|
+
* instance across every line in a file carries no state between calls. */
|
|
74742
|
+
function decodeLine(raw) {
|
|
74743
|
+
try {
|
|
74744
|
+
return decoder.decode(raw);
|
|
74745
|
+
}
|
|
74746
|
+
catch {
|
|
74747
|
+
return null;
|
|
74748
|
+
}
|
|
74749
|
+
}
|
|
74750
|
+
/** Python's `int(v or 0)`: a falsy `v` (`null`/`undefined`/`0`/`""`/`false`/`NaN`) becomes `0`
|
|
74751
|
+
* first; a number truncates toward zero; `true` becomes `1`; an integer-literal string (sign,
|
|
74752
|
+
* surrounding whitespace and `_` digit separators allowed) parses, anything else throws --
|
|
74753
|
+
* 1.x's `int()` raises there too and nothing catches it, so the scan dies (the verb's own 502). */
|
|
74754
|
+
function pyIntOr0(v) {
|
|
74755
|
+
const truthy = !(v === null ||
|
|
74756
|
+
v === undefined ||
|
|
74757
|
+
v === false ||
|
|
74758
|
+
v === 0 ||
|
|
74759
|
+
v === "" ||
|
|
74760
|
+
(typeof v === "number" && Number.isNaN(v)));
|
|
74761
|
+
const value = truthy ? v : 0;
|
|
74762
|
+
if (typeof value === "number")
|
|
74763
|
+
return Math.trunc(value);
|
|
74764
|
+
if (typeof value === "boolean")
|
|
74765
|
+
return 1;
|
|
74766
|
+
if (typeof value === "string") {
|
|
74767
|
+
const trimmed = value.trim().replace(/_/g, "");
|
|
74768
|
+
if (!/^[+-]?\d+$/.test(trimmed)) {
|
|
74769
|
+
throw new Error(`invalid literal for int() with base 10: ${JSON.stringify(value)}`);
|
|
74770
|
+
}
|
|
74771
|
+
return Number.parseInt(trimmed, 10);
|
|
74772
|
+
}
|
|
74773
|
+
throw new Error(`int() argument must be a string or a number, not ${typeof value}`);
|
|
74774
|
+
}
|
|
74775
|
+
function pathSortKey(p) {
|
|
74776
|
+
// On win32, split on BOTH separators after lower-casing -- Python's `PureWindowsPath`
|
|
74777
|
+
// normalises a `/` to `\` at construction (`_str_normcase`), so a `/`-spelled root (as the
|
|
74778
|
+
// fixture tests and a `/`-configured root use) still sorts by its true components, not as one
|
|
74779
|
+
// opaque whole-string comparison. Production paths built by `path.join` on win32 already use
|
|
74780
|
+
// `\` exclusively, so this only widens what the key recognises as a separator; it never changes
|
|
74781
|
+
// the key for a path that was already all-`\`.
|
|
74782
|
+
return process.platform === "win32" ? p.toLowerCase().split(/[\\/]/) : p.split("/");
|
|
74783
|
+
}
|
|
74784
|
+
/** Python sorts `Path` objects by comparing their component lists element-wise (`<` on strings --
|
|
74785
|
+
* UTF-16 code units, identical to Python's code-point order on the BMP), a shorter list that is a
|
|
74786
|
+
* prefix of a longer one sorting first; on win32 the whole path is lower-cased first
|
|
74787
|
+
* (`PurePath._str_normcase`). Never `localeCompare`, and never just the raw path string, which
|
|
74788
|
+
* would order an "a-b" directory before "a"'s own children on some platforms and after on others. */
|
|
74789
|
+
function sortPaths(paths) {
|
|
74790
|
+
return [...paths].sort((pathA, pathB) => {
|
|
74791
|
+
const a = pathSortKey(pathA);
|
|
74792
|
+
const b = pathSortKey(pathB);
|
|
74793
|
+
const len = Math.min(a.length, b.length);
|
|
74794
|
+
for (let i = 0; i < len; i++) {
|
|
74795
|
+
const ai = a[i] ?? "";
|
|
74796
|
+
const bi = b[i] ?? "";
|
|
74797
|
+
if (ai < bi)
|
|
74798
|
+
return -1;
|
|
74799
|
+
if (ai > bi)
|
|
74800
|
+
return 1;
|
|
74801
|
+
}
|
|
74802
|
+
return a.length - b.length;
|
|
74803
|
+
});
|
|
74804
|
+
}
|
|
74805
|
+
/** `*.jsonl` (or `agent-*.jsonl`), case-insensitive on Windows and case-sensitive elsewhere, as
|
|
74806
|
+
* Python's `Path.glob` matches names. */
|
|
74807
|
+
function matchesGlobSuffix(name, suffix) {
|
|
74808
|
+
return process.platform === "win32"
|
|
74809
|
+
? name.toLowerCase().endsWith(suffix.toLowerCase())
|
|
74810
|
+
: name.endsWith(suffix);
|
|
74811
|
+
}
|
|
74812
|
+
function matchesGlobPrefixSuffix(name, prefix, suffix) {
|
|
74813
|
+
const n = process.platform === "win32" ? name.toLowerCase() : name;
|
|
74814
|
+
const p = process.platform === "win32" ? prefix.toLowerCase() : prefix;
|
|
74815
|
+
const s = process.platform === "win32" ? suffix.toLowerCase() : suffix;
|
|
74816
|
+
return n.startsWith(p) && n.endsWith(s) && n.length >= p.length + s.length;
|
|
74817
|
+
}
|
|
74818
|
+
|
|
74819
|
+
/**
|
|
74820
|
+
* Per-session Claude Code context-window occupancy (#52, 52a; 1.x `context_pressure.py`), kind
|
|
74821
|
+
* `context-pressure`, capability `agent.scan.context-pressure`. Producer only -- the reader
|
|
74822
|
+
* (`overview_summary`, the 7-day window) is E7's and pure over this payload, not ported here.
|
|
74823
|
+
*
|
|
74824
|
+
* Privacy: deserialises exactly six things -- `type`, `subtype`, `timestamp`, `cwd`,
|
|
74825
|
+
* `message.{id,model,usage}`. `message.content` is never touched.
|
|
74826
|
+
*/
|
|
74827
|
+
const COMPACT_MARK_BYTES = Buffer.from("compact_boundary", "utf8");
|
|
74828
|
+
// Measured per-model context windows (context_pressure.py:51-58) -- not inferred from the family
|
|
74829
|
+
// name, so a family rule never silently mis-sizes a model whose tier moved. Exported so
|
|
74830
|
+
// subagent-cost.test.ts can assert every windowed model is also priced (a model here but missing
|
|
74831
|
+
// from subagent-cost's PRICES contributes tokens at zero cost, understating silently).
|
|
74832
|
+
const WINDOWS = {
|
|
74833
|
+
"claude-opus-4-8": 1_000_000,
|
|
74834
|
+
"claude-opus-5": 1_000_000,
|
|
74835
|
+
"claude-fable-5": 1_000_000,
|
|
74836
|
+
"claude-sonnet-5": 1_000_000,
|
|
74837
|
+
"claude-sonnet-4-6": 200_000,
|
|
74838
|
+
"claude-haiku-4-5-20251001": 200_000,
|
|
74839
|
+
};
|
|
74840
|
+
const TIERS = [200_000, 1_000_000];
|
|
74841
|
+
function windowFor(model, occupancy) {
|
|
74842
|
+
const win = WINDOWS[model];
|
|
74843
|
+
if (win !== undefined)
|
|
74844
|
+
return { window: win, mapped: true };
|
|
74845
|
+
for (const tier of TIERS) {
|
|
74846
|
+
if (occupancy <= tier)
|
|
74847
|
+
return { window: tier, mapped: false };
|
|
74848
|
+
}
|
|
74849
|
+
return { window: TIERS[TIERS.length - 1], mapped: false };
|
|
74850
|
+
}
|
|
74851
|
+
function isPlainObject$1(v) {
|
|
74852
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
74853
|
+
}
|
|
74854
|
+
/** Peak occupancy (and compaction count) for one session log, or `null` if it holds no usage rows
|
|
74855
|
+
* at all. Byte-prefilters before any `JSON.parse` -- the scan is I/O-bound, and parsing every line
|
|
74856
|
+
* instead of the usage-bearing ones is the one thing that would make it slow. */
|
|
74857
|
+
function scanFile$1(fileBuffer, mtime, size, stem, parentName) {
|
|
74858
|
+
let peakTokens = 0;
|
|
74859
|
+
let peakPct = 0;
|
|
74860
|
+
let peakModel = "";
|
|
74861
|
+
let peakWindow = 0;
|
|
74862
|
+
let peakMapped = true;
|
|
74863
|
+
let turns = 0;
|
|
74864
|
+
let compactions = 0;
|
|
74865
|
+
let drops = 0;
|
|
74866
|
+
let lastAt = 0;
|
|
74867
|
+
let cwd = "";
|
|
74868
|
+
let prevOcc = 0;
|
|
74869
|
+
const seenIds = new Set();
|
|
74870
|
+
const unmapped = new Set();
|
|
74871
|
+
for (const raw of readLines(fileBuffer)) {
|
|
74872
|
+
const hasUsage = raw.includes(USAGE_MARK_BYTES);
|
|
74873
|
+
if (!hasUsage && !raw.includes(COMPACT_MARK_BYTES))
|
|
74874
|
+
continue;
|
|
74875
|
+
const text = decodeLine(raw);
|
|
74876
|
+
if (text === null)
|
|
74877
|
+
continue;
|
|
74878
|
+
let rec;
|
|
74879
|
+
try {
|
|
74880
|
+
rec = JSON.parse(text);
|
|
74881
|
+
}
|
|
74882
|
+
catch {
|
|
74883
|
+
continue;
|
|
74884
|
+
}
|
|
74885
|
+
if (!isPlainObject$1(rec))
|
|
74886
|
+
continue;
|
|
74887
|
+
if (rec.type === "system" && rec.subtype === "compact_boundary") {
|
|
74888
|
+
compactions++;
|
|
74889
|
+
prevOcc = 0;
|
|
74890
|
+
continue;
|
|
74891
|
+
}
|
|
74892
|
+
if (!hasUsage || rec.type !== "assistant")
|
|
74893
|
+
continue;
|
|
74894
|
+
const msg = rec.message;
|
|
74895
|
+
if (!isPlainObject$1(msg))
|
|
74896
|
+
continue;
|
|
74897
|
+
const usage = msg.usage;
|
|
74898
|
+
if (!isPlainObject$1(usage))
|
|
74899
|
+
continue;
|
|
74900
|
+
const model = String(msg.model || "");
|
|
74901
|
+
if (model === "<synthetic>")
|
|
74902
|
+
continue;
|
|
74903
|
+
const mid = msg.id;
|
|
74904
|
+
if (mid) {
|
|
74905
|
+
const midStr = String(mid);
|
|
74906
|
+
if (seenIds.has(midStr))
|
|
74907
|
+
continue;
|
|
74908
|
+
seenIds.add(midStr);
|
|
74909
|
+
}
|
|
74910
|
+
const occ = pyIntOr0(usage.input_tokens) +
|
|
74911
|
+
pyIntOr0(usage.cache_read_input_tokens) +
|
|
74912
|
+
pyIntOr0(usage.cache_creation_input_tokens);
|
|
74913
|
+
if (occ <= 0)
|
|
74914
|
+
continue;
|
|
74915
|
+
turns++;
|
|
74916
|
+
if (!cwd)
|
|
74917
|
+
cwd = String(rec.cwd || "");
|
|
74918
|
+
const ts = parseTs(rec.timestamp);
|
|
74919
|
+
if (ts > lastAt)
|
|
74920
|
+
lastAt = ts;
|
|
74921
|
+
const { window: win, mapped } = windowFor(model, occ);
|
|
74922
|
+
if (!mapped && model)
|
|
74923
|
+
unmapped.add(model);
|
|
74924
|
+
const pct = win ? (occ / win) * 100 : 0;
|
|
74925
|
+
if (pct > peakPct) {
|
|
74926
|
+
peakPct = pct;
|
|
74927
|
+
peakTokens = occ;
|
|
74928
|
+
peakModel = model;
|
|
74929
|
+
peakWindow = win;
|
|
74930
|
+
peakMapped = mapped;
|
|
74931
|
+
}
|
|
74932
|
+
if (prevOcc && prevOcc - occ >= 50_000 && occ <= prevOcc * 0.7) {
|
|
74933
|
+
drops++;
|
|
74934
|
+
}
|
|
74935
|
+
prevOcc = occ;
|
|
74936
|
+
}
|
|
74937
|
+
if (!turns)
|
|
74938
|
+
return null;
|
|
74939
|
+
return {
|
|
74940
|
+
id: stem.slice(0, 8),
|
|
74941
|
+
project: projectName(cwd, parentName),
|
|
74942
|
+
peakTokens,
|
|
74943
|
+
peakModel,
|
|
74944
|
+
peakWindow,
|
|
74945
|
+
peakPct: pyRound(peakPct, 1),
|
|
74946
|
+
mapped: peakMapped,
|
|
74947
|
+
unmapped: [...unmapped].sort(),
|
|
74948
|
+
turns,
|
|
74949
|
+
lastAt: lastAt || mtime,
|
|
74950
|
+
compactions,
|
|
74951
|
+
drops,
|
|
74952
|
+
mtime,
|
|
74953
|
+
size,
|
|
74954
|
+
};
|
|
74955
|
+
}
|
|
74956
|
+
function sessionFiles(root, fs) {
|
|
74957
|
+
if (!fs.isDir(root))
|
|
74958
|
+
return [];
|
|
74959
|
+
const found = [];
|
|
74960
|
+
for (const dirEntry of fs.readdir(root)) {
|
|
74961
|
+
const dirPath = path.join(root, dirEntry.name);
|
|
74962
|
+
if (!fs.isDir(dirPath))
|
|
74963
|
+
continue;
|
|
74964
|
+
for (const fileEntry of fs.readdir(dirPath)) {
|
|
74965
|
+
if (matchesGlobSuffix(fileEntry.name, ".jsonl")) {
|
|
74966
|
+
found.push(path.join(dirPath, fileEntry.name));
|
|
74967
|
+
}
|
|
74968
|
+
}
|
|
74969
|
+
}
|
|
74970
|
+
return sortPaths(found);
|
|
74971
|
+
}
|
|
74972
|
+
function collect$1(root, previous, signal, fs, now) {
|
|
74973
|
+
const cached = new Map();
|
|
74974
|
+
for (const row of previous?.sessions ?? []) {
|
|
74975
|
+
if (row.path)
|
|
74976
|
+
cached.set(row.path, row);
|
|
74977
|
+
}
|
|
74978
|
+
const sessions = [];
|
|
74979
|
+
const unmappedModels = new Set();
|
|
74980
|
+
let parsed = 0;
|
|
74981
|
+
for (const fp of sessionFiles(root, fs)) {
|
|
74982
|
+
if (signal.aborted)
|
|
74983
|
+
throw new Error("scan aborted");
|
|
74984
|
+
const st = fs.stat(fp);
|
|
74985
|
+
if (st === null)
|
|
74986
|
+
continue;
|
|
74987
|
+
const mtime = pyMtime({ sec: st.mtimeNs / 1000000000n, nsec: st.mtimeNs % 1000000000n });
|
|
74988
|
+
const old = cached.get(fp);
|
|
74989
|
+
if (old && old.mtime === mtime && old.size === st.size) {
|
|
74990
|
+
sessions.push(old);
|
|
74991
|
+
for (const m of old.unmapped)
|
|
74992
|
+
unmappedModels.add(m);
|
|
74993
|
+
continue;
|
|
74994
|
+
}
|
|
74995
|
+
const buffer = fs.readFile(fp);
|
|
74996
|
+
if (buffer === null) {
|
|
74997
|
+
if (old)
|
|
74998
|
+
sessions.push(old);
|
|
74999
|
+
continue;
|
|
75000
|
+
}
|
|
75001
|
+
parsed++;
|
|
75002
|
+
const parentName = path.basename(path.dirname(fp));
|
|
75003
|
+
const row = scanFile$1(buffer, mtime, st.size, path.basename(fp, ".jsonl"), parentName);
|
|
75004
|
+
if (row === null)
|
|
75005
|
+
continue;
|
|
75006
|
+
row.path = fp;
|
|
75007
|
+
sessions.push(row);
|
|
75008
|
+
for (const m of row.unmapped)
|
|
75009
|
+
unmappedModels.add(m);
|
|
75010
|
+
}
|
|
75011
|
+
return {
|
|
75012
|
+
generatedAt: now() / 1000,
|
|
75013
|
+
root: normalizeRoot(root),
|
|
75014
|
+
scanned: sessions.length,
|
|
75015
|
+
parsed,
|
|
75016
|
+
unmappedModels: [...unmappedModels].sort(),
|
|
75017
|
+
sessions,
|
|
75018
|
+
};
|
|
75019
|
+
}
|
|
75020
|
+
|
|
75021
|
+
/**
|
|
75022
|
+
* Live "Memory footprint" payload (#52, 52b; 1.x `memory_footprint.py`), kind
|
|
75023
|
+
* `memory-footprint`, capability `agent.scan.memory-footprint`. Producer only, and -- unlike
|
|
75024
|
+
* every other scan in this file -- never cached: 1.x had no producer for this at all (it ran
|
|
75025
|
+
* live in the dashboard build, "~8 stats per repo against small text files"), so the payload's
|
|
75026
|
+
* nature changes from live-in-1.x to produced-on-request here, but it stays uncached because a
|
|
75027
|
+
* cold run is kilobytes of I/O, not the ~940MB context-pressure reads (E4 plan §7.8).
|
|
75028
|
+
*
|
|
75029
|
+
* What it measures: the fixed 8-layer set Claude Code assembles into a session's preamble. Two
|
|
75030
|
+
* layers are GLOBAL -- they load into every session in every repo -- the rest are per-repo. The
|
|
75031
|
+
* repo set is 49a's `listRepos(reposRoot)` (every immediate child of `repos_root` with a `.git`
|
|
75032
|
+
* entry), not a console-configured repo list (1.x had one; the agent doesn't) -- so `repo` and
|
|
75033
|
+
* `name` are both the directory name, a disclosed change from 1.x's `owner/name` shape (E4 plan
|
|
75034
|
+
* §7.2, §7.8's "the payload shape stays" exception).
|
|
75035
|
+
*
|
|
75036
|
+
* Tokens are ESTIMATED from bytes (~4 chars/token); the payload says so (`estimated: true`) so a
|
|
75037
|
+
* consumer renders "≈". Transitive `@`-imports and `[[wikilinks]]` are NOT followed -- 1.x
|
|
75038
|
+
* doesn't either, and expanding them would measure a different thing.
|
|
75039
|
+
*
|
|
75040
|
+
* Privacy: only sizes and line counts leave this module. File CONTENT never reaches the payload.
|
|
75041
|
+
*/
|
|
75042
|
+
const BYTES_PER_TOKEN = 4;
|
|
75043
|
+
const MEMORY_MAX_LINES = 200;
|
|
75044
|
+
const MAX_REPOS = 5;
|
|
75045
|
+
// A global preamble at or above this reads as worth trimming. Global layers are paid on EVERY
|
|
75046
|
+
// session in EVERY repo, so the threshold is deliberately lower than it would be for a single
|
|
75047
|
+
// project file -- the note is about recurring cost, not absolute size.
|
|
75048
|
+
const HEAVY_TOKENS = 4000;
|
|
75049
|
+
// Layers that load into every session regardless of repo. Kept as a set rather than a flag on
|
|
75050
|
+
// each row so the split can't drift between the scan and the summary.
|
|
75051
|
+
const GLOBAL = new Set(["enterprise", "user", "user-rules"]);
|
|
75052
|
+
/** The OS-specific managed-policy location -- Windows uses Program Files, the POSIX convention is
|
|
75053
|
+
* `/etc/claude-code`. `platform`/`env` are injected (unlike 1.x's own `os.name` check) so both
|
|
75054
|
+
* branches are testable regardless of which OS the test itself runs on. */
|
|
75055
|
+
function enterprisePath(platform, env) {
|
|
75056
|
+
if (platform === "win32") {
|
|
75057
|
+
return path.win32.join(env.ProgramFiles ?? String.raw `C:\Program Files`, "ClaudeCode", "CLAUDE.md");
|
|
75058
|
+
}
|
|
75059
|
+
return path.posix.join("/etc", "claude-code", "CLAUDE.md");
|
|
75060
|
+
}
|
|
75061
|
+
/** The `~/.claude/projects` folder name for a working directory: every `:`, `\` and `/` becomes a
|
|
75062
|
+
* hyphen -- `R:\repos\foo` -> `R--repos-foo` (the colon and the backslash each contribute one; a
|
|
75063
|
+
* Windows path has to resolve even when this runs on Linux, so the replacement is textual, not
|
|
75064
|
+
* routed through `node:path`, which would treat the backslash as an ordinary character there). */
|
|
75065
|
+
function projectDirName(root) {
|
|
75066
|
+
return root.replace(/:/g, "-").replace(/\\/g, "-").replace(/\//g, "-");
|
|
75067
|
+
}
|
|
75068
|
+
/** Bytes -> approximate tokens. Deliberately crude, and labelled as such in the payload: the
|
|
75069
|
+
* question this answers is "is a layer out of budget", not an exact count. */
|
|
75070
|
+
function estimateTokens(nbytes) {
|
|
75071
|
+
return Math.floor(Math.max(0, Math.trunc(nbytes)) / BYTES_PER_TOKEN);
|
|
75072
|
+
}
|
|
75073
|
+
/** `{bytes, lines}` for one file, or `null` when it can't be read. With `maxLines`, only the
|
|
75074
|
+
* first that many lines count towards both numbers -- used for `MEMORY.md`, which Claude Code
|
|
75075
|
+
* truncates at `MEMORY_MAX_LINES`; counting the whole file would overstate a layer the session
|
|
75076
|
+
* never actually loads. Line counting matches `readLines`'s semantics exactly (0x0A-terminated,
|
|
75077
|
+
* a final unterminated line still counts). */
|
|
75078
|
+
function measureFile(fs, p, maxLines) {
|
|
75079
|
+
const buf = fs.readFile(p);
|
|
75080
|
+
if (buf === null)
|
|
75081
|
+
return null;
|
|
75082
|
+
if (maxLines === undefined) {
|
|
75083
|
+
return { bytes: buf.length, lines: readLines(buf).length };
|
|
75084
|
+
}
|
|
75085
|
+
const capped = readLines(buf).slice(0, maxLines);
|
|
75086
|
+
return { bytes: capped.reduce((sum, line) => sum + line.length, 0), lines: capped.length };
|
|
75087
|
+
}
|
|
75088
|
+
function walkForSuffix(fs, dir, suffix, found) {
|
|
75089
|
+
for (const entry of fs.readdir(dir)) {
|
|
75090
|
+
const entryPath = path.join(dir, entry.name);
|
|
75091
|
+
if (matchesGlobSuffix(entry.name, suffix))
|
|
75092
|
+
found.push(entryPath);
|
|
75093
|
+
if (fs.isDir(entryPath))
|
|
75094
|
+
walkForSuffix(fs, entryPath, suffix, found);
|
|
75095
|
+
}
|
|
75096
|
+
}
|
|
75097
|
+
/** `{bytes, lines, files}` for a rules directory (every `*.md` file under `root`, at any depth),
|
|
75098
|
+
* or `null` when `root` isn't a directory or holds no matching file -- a rules layer is either
|
|
75099
|
+
* fully present or not a row at all, never a zero-file row. */
|
|
75100
|
+
function measureGlob(fs, root, suffix) {
|
|
75101
|
+
if (!fs.isDir(root))
|
|
75102
|
+
return null;
|
|
75103
|
+
const found = [];
|
|
75104
|
+
walkForSuffix(fs, root, suffix, found);
|
|
75105
|
+
let bytes = 0;
|
|
75106
|
+
let lines = 0;
|
|
75107
|
+
let files = 0;
|
|
75108
|
+
for (const fp of sortPaths(found)) {
|
|
75109
|
+
const got = measureFile(fs, fp);
|
|
75110
|
+
if (got === null)
|
|
75111
|
+
continue;
|
|
75112
|
+
bytes += got.bytes;
|
|
75113
|
+
lines += got.lines;
|
|
75114
|
+
files++;
|
|
75115
|
+
}
|
|
75116
|
+
if (files === 0)
|
|
75117
|
+
return null;
|
|
75118
|
+
return { bytes, lines, files };
|
|
75119
|
+
}
|
|
75120
|
+
/** A payload row for one present layer, or `null` when the layer is absent. Absent layers are
|
|
75121
|
+
* dropped rather than rendered as zeroes: the payload lists what is actually loading. */
|
|
75122
|
+
function row(layer, filePath, got) {
|
|
75123
|
+
if (got === null || got.bytes <= 0)
|
|
75124
|
+
return null;
|
|
75125
|
+
const base = {
|
|
75126
|
+
layer,
|
|
75127
|
+
path: filePath,
|
|
75128
|
+
bytes: got.bytes,
|
|
75129
|
+
lines: got.lines,
|
|
75130
|
+
tokens: estimateTokens(got.bytes),
|
|
75131
|
+
scope: GLOBAL.has(layer) ? "global" : "repo",
|
|
75132
|
+
};
|
|
75133
|
+
return got.files !== undefined ? { ...base, files: got.files } : base;
|
|
75134
|
+
}
|
|
75135
|
+
function byTokensDesc(a, b) {
|
|
75136
|
+
return b.tokens - a.tokens;
|
|
75137
|
+
}
|
|
75138
|
+
/** The layers that load into every session in every repo, heaviest first. */
|
|
75139
|
+
function globalLayers(fs, home, platform, env) {
|
|
75140
|
+
const ent = enterprisePath(platform, env);
|
|
75141
|
+
const userClaude = path.join(home, ".claude", "CLAUDE.md");
|
|
75142
|
+
const userRules = path.join(home, ".claude", "rules");
|
|
75143
|
+
const rows = [
|
|
75144
|
+
row("enterprise", ent, measureFile(fs, ent)),
|
|
75145
|
+
row("user", userClaude, measureFile(fs, userClaude)),
|
|
75146
|
+
row("user-rules", userRules, measureGlob(fs, userRules, ".md")),
|
|
75147
|
+
].filter((r) => r !== null);
|
|
75148
|
+
return rows.sort(byTokensDesc);
|
|
75149
|
+
}
|
|
75150
|
+
/** The per-repo layers for one checkout, heaviest first. A `root` that doesn't exist simply
|
|
75151
|
+
* yields no rows (every `measureFile`/`measureGlob` call answers absent, not an error).
|
|
75152
|
+
* Unlike 1.x's `repo_layers(root, home)`, this takes no `home`: the auto-memory layer resolves
|
|
75153
|
+
* under the injected `projectsRoot` (52a's configured `claude_projects_dir`), never a hard-wired
|
|
75154
|
+
* `home/.claude/projects` -- see the module doc. */
|
|
75155
|
+
function repoLayers(fs, root, projectsRoot) {
|
|
75156
|
+
const mem = path.join(projectsRoot, projectDirName(root), "memory", "MEMORY.md");
|
|
75157
|
+
const projectClaude = path.join(root, "CLAUDE.md");
|
|
75158
|
+
const projectAlt = path.join(root, ".claude", "CLAUDE.md");
|
|
75159
|
+
const projectRules = path.join(root, ".claude", "rules");
|
|
75160
|
+
const projectLocal = path.join(root, "CLAUDE.local.md");
|
|
75161
|
+
const rows = [
|
|
75162
|
+
row("project", projectClaude, measureFile(fs, projectClaude)),
|
|
75163
|
+
row("project-alt", projectAlt, measureFile(fs, projectAlt)),
|
|
75164
|
+
row("project-rules", projectRules, measureGlob(fs, projectRules, ".md")),
|
|
75165
|
+
row("project-local", projectLocal, measureFile(fs, projectLocal)),
|
|
75166
|
+
// Truncated to match what Claude Code actually loads -- see MEMORY_MAX_LINES.
|
|
75167
|
+
row("auto-memory", mem, measureFile(fs, mem, MEMORY_MAX_LINES)),
|
|
75168
|
+
].filter((r) => r !== null);
|
|
75169
|
+
return rows.sort(byTokensDesc);
|
|
75170
|
+
}
|
|
75171
|
+
/** The "Memory footprint" payload. `repos` is 49a's `listRepos(reposRoot).repos` -- every
|
|
75172
|
+
* immediate child of `repos_root` with a `.git` entry -- resolved by the caller (`scans/index.ts`)
|
|
75173
|
+
* before this runs, so this module stays pure over an already-resolved repo list. Checks
|
|
75174
|
+
* `signal.aborted` between repos, throwing `Error("scan aborted")` when set -- 1.x's live,
|
|
75175
|
+
* single-request-lifetime version never had an abort path to honour. */
|
|
75176
|
+
function overviewSummary(fs, repos, home, projectsRoot, platform, env, signal) {
|
|
75177
|
+
const globRows = globalLayers(fs, home, platform, env);
|
|
75178
|
+
const globalTokens = globRows.reduce((total, r) => total + r.tokens, 0);
|
|
75179
|
+
const globalBytes = globRows.reduce((total, r) => total + r.bytes, 0);
|
|
75180
|
+
const perRepo = [];
|
|
75181
|
+
for (const repo of repos) {
|
|
75182
|
+
if (signal.aborted)
|
|
75183
|
+
throw new Error("scan aborted");
|
|
75184
|
+
const layers = repoLayers(fs, repo.path, projectsRoot);
|
|
75185
|
+
if (layers.length === 0)
|
|
75186
|
+
continue; // a repo with no memory files isn't a row
|
|
75187
|
+
perRepo.push({
|
|
75188
|
+
repo: repo.name,
|
|
75189
|
+
name: repo.name,
|
|
75190
|
+
tokens: layers.reduce((total, r) => total + r.tokens, 0),
|
|
75191
|
+
bytes: layers.reduce((total, r) => total + r.bytes, 0),
|
|
75192
|
+
layers,
|
|
75193
|
+
});
|
|
75194
|
+
}
|
|
75195
|
+
perRepo.sort((a, b) => b.tokens - a.tokens);
|
|
75196
|
+
// The actionable note names ONE layer -- the heaviest global one, since that cost is paid on
|
|
75197
|
+
// every session everywhere and is therefore the one worth trimming first. Silent below the
|
|
75198
|
+
// threshold; a heavy per-repo layer never triggers it (that cost isn't paid everywhere).
|
|
75199
|
+
const heavy = globRows.length > 0 && globalTokens >= HEAVY_TOKENS ? globRows[0] : null;
|
|
75200
|
+
return {
|
|
75201
|
+
available: true,
|
|
75202
|
+
estimated: true,
|
|
75203
|
+
bytesPerToken: BYTES_PER_TOKEN,
|
|
75204
|
+
globalTokens,
|
|
75205
|
+
globalBytes,
|
|
75206
|
+
globalLayers: globRows,
|
|
75207
|
+
repoTokens: perRepo.reduce((total, r) => total + r.tokens, 0),
|
|
75208
|
+
repos: perRepo.slice(0, MAX_REPOS),
|
|
75209
|
+
repoCount: perRepo.length,
|
|
75210
|
+
reposConsidered: repos.length,
|
|
75211
|
+
heavyLayer: heavy ? heavy.layer : null,
|
|
75212
|
+
heavyTokens: heavy ? heavy.tokens : 0,
|
|
75213
|
+
heavyPath: heavy ? heavy.path : null,
|
|
75214
|
+
memoryMaxLines: MEMORY_MAX_LINES,
|
|
75215
|
+
};
|
|
75216
|
+
}
|
|
75217
|
+
|
|
75218
|
+
/**
|
|
75219
|
+
* Per-session subagent token spend (#52, 52a; 1.x `subagent_cost.py`), kind `subagent-cost`,
|
|
75220
|
+
* capability `agent.scan.subagent-cost`. Producer only -- the reader (`overview_summary`) is E7's.
|
|
75221
|
+
*
|
|
75222
|
+
* The dollar figure is an API-equivalent, not money spent: what these tokens would have cost at
|
|
75223
|
+
* list API rates, so an unpriced model's tokens still count but its cost is excluded and the id is
|
|
75224
|
+
* surfaced in `unpricedModels`.
|
|
75225
|
+
*
|
|
75226
|
+
* Privacy: deserialises exactly six things -- `type`, `timestamp`, `cwd`, `attributionAgent`,
|
|
75227
|
+
* `message.{id,model,usage}`. `message.content` is never touched.
|
|
75228
|
+
*/
|
|
75229
|
+
const PRICES_AS_OF = "2026-07-25";
|
|
75230
|
+
const PRICES = {
|
|
75231
|
+
"claude-opus-5": { in: 5.0, out: 25.0 },
|
|
75232
|
+
"claude-opus-4-8": { in: 5.0, out: 25.0 },
|
|
75233
|
+
"claude-opus-4-7": { in: 5.0, out: 25.0 },
|
|
75234
|
+
"claude-opus-4-6": { in: 5.0, out: 25.0 },
|
|
75235
|
+
"claude-fable-5": { in: 10.0, out: 50.0 },
|
|
75236
|
+
"claude-sonnet-5": { in: 3.0, out: 15.0 },
|
|
75237
|
+
"claude-sonnet-4-6": { in: 3.0, out: 15.0 },
|
|
75238
|
+
"claude-haiku-4-5-20251001": { in: 1.0, out: 5.0 },
|
|
75239
|
+
};
|
|
75240
|
+
const CACHE_READ_MULT = 0.1;
|
|
75241
|
+
const CACHE_WRITE_5M_MULT = 1.25;
|
|
75242
|
+
const CACHE_WRITE_1H_MULT = 2.0;
|
|
75243
|
+
// A lower-cased startsWith on the FIRST attributionAgent seen -- Python's str.startswith accepts a
|
|
75244
|
+
// tuple of candidate prefixes; a match on either skips the whole file (return null/None).
|
|
75245
|
+
const SKIP_AGENT_PREFIXES = ["warmup", "acompact"];
|
|
75246
|
+
function isPlainObject(v) {
|
|
75247
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
75248
|
+
}
|
|
75249
|
+
/** USD-equivalent for one assistant turn, at list API rates. `0` for an unpriced model -- the
|
|
75250
|
+
* caller counts its tokens and surfaces the id instead of guessing. */
|
|
75251
|
+
function recordCost(model, usage) {
|
|
75252
|
+
const rate = PRICES[model];
|
|
75253
|
+
if (!rate)
|
|
75254
|
+
return 0;
|
|
75255
|
+
const creation = isPlainObject(usage.cache_creation) ? usage.cache_creation : {};
|
|
75256
|
+
let w5 = pyIntOr0(creation.ephemeral_5m_input_tokens);
|
|
75257
|
+
const w1 = pyIntOr0(creation.ephemeral_1h_input_tokens);
|
|
75258
|
+
if (!(w5 || w1))
|
|
75259
|
+
w5 = pyIntOr0(usage.cache_creation_input_tokens);
|
|
75260
|
+
const fresh = pyIntOr0(usage.input_tokens);
|
|
75261
|
+
const read = pyIntOr0(usage.cache_read_input_tokens);
|
|
75262
|
+
const out = pyIntOr0(usage.output_tokens);
|
|
75263
|
+
const dollars = fresh * rate.in +
|
|
75264
|
+
read * rate.in * CACHE_READ_MULT +
|
|
75265
|
+
w5 * rate.in * CACHE_WRITE_5M_MULT +
|
|
75266
|
+
w1 * rate.in * CACHE_WRITE_1H_MULT +
|
|
75267
|
+
out * rate.out;
|
|
75268
|
+
return dollars / 1_000_000;
|
|
75269
|
+
}
|
|
75270
|
+
/** Token totals + API-equivalent cost for one subagent transcript, or `null` when it holds no
|
|
75271
|
+
* usage rows (or is machinery rather than delegated work). */
|
|
75272
|
+
function scanFile(fileBuffer, mtime, size, stem, parentName, fullPathComponents) {
|
|
75273
|
+
let fresh = 0;
|
|
75274
|
+
let read = 0;
|
|
75275
|
+
let w5 = 0;
|
|
75276
|
+
let w1 = 0;
|
|
75277
|
+
let out = 0;
|
|
75278
|
+
let cost = 0;
|
|
75279
|
+
let turns = 0;
|
|
75280
|
+
let agent = "";
|
|
75281
|
+
let cwd = "";
|
|
75282
|
+
let lastAt = 0;
|
|
75283
|
+
const seenIds = new Set();
|
|
75284
|
+
const models = new Set();
|
|
75285
|
+
const unpriced = new Set();
|
|
75286
|
+
for (const raw of readLines(fileBuffer)) {
|
|
75287
|
+
if (!raw.includes(USAGE_MARK_BYTES))
|
|
75288
|
+
continue;
|
|
75289
|
+
const text = decodeLine(raw);
|
|
75290
|
+
if (text === null)
|
|
75291
|
+
continue;
|
|
75292
|
+
let rec;
|
|
75293
|
+
try {
|
|
75294
|
+
rec = JSON.parse(text);
|
|
75295
|
+
}
|
|
75296
|
+
catch {
|
|
75297
|
+
continue;
|
|
75298
|
+
}
|
|
75299
|
+
if (!isPlainObject(rec) || rec.type !== "assistant")
|
|
75300
|
+
continue;
|
|
75301
|
+
const msg = rec.message;
|
|
75302
|
+
if (!isPlainObject(msg))
|
|
75303
|
+
continue;
|
|
75304
|
+
const usage = msg.usage;
|
|
75305
|
+
if (!isPlainObject(usage))
|
|
75306
|
+
continue;
|
|
75307
|
+
const model = String(msg.model || "");
|
|
75308
|
+
if (model === "<synthetic>")
|
|
75309
|
+
continue;
|
|
75310
|
+
const mid = msg.id;
|
|
75311
|
+
if (mid) {
|
|
75312
|
+
const midStr = String(mid);
|
|
75313
|
+
if (seenIds.has(midStr))
|
|
75314
|
+
continue;
|
|
75315
|
+
seenIds.add(midStr);
|
|
75316
|
+
}
|
|
75317
|
+
if (!agent) {
|
|
75318
|
+
agent = String(rec.attributionAgent || "");
|
|
75319
|
+
const lower = agent.toLowerCase();
|
|
75320
|
+
if (SKIP_AGENT_PREFIXES.some((prefix) => lower.startsWith(prefix)))
|
|
75321
|
+
return null;
|
|
75322
|
+
}
|
|
75323
|
+
if (!cwd)
|
|
75324
|
+
cwd = String(rec.cwd || "");
|
|
75325
|
+
const ts = parseTs(rec.timestamp);
|
|
75326
|
+
if (ts > lastAt)
|
|
75327
|
+
lastAt = ts;
|
|
75328
|
+
const creation = isPlainObject(usage.cache_creation) ? usage.cache_creation : {};
|
|
75329
|
+
let c5 = pyIntOr0(creation.ephemeral_5m_input_tokens);
|
|
75330
|
+
const c1 = pyIntOr0(creation.ephemeral_1h_input_tokens);
|
|
75331
|
+
if (!(c5 || c1))
|
|
75332
|
+
c5 = pyIntOr0(usage.cache_creation_input_tokens);
|
|
75333
|
+
fresh += pyIntOr0(usage.input_tokens);
|
|
75334
|
+
read += pyIntOr0(usage.cache_read_input_tokens);
|
|
75335
|
+
out += pyIntOr0(usage.output_tokens);
|
|
75336
|
+
w5 += c5;
|
|
75337
|
+
w1 += c1;
|
|
75338
|
+
turns++;
|
|
75339
|
+
if (model) {
|
|
75340
|
+
models.add(model);
|
|
75341
|
+
if (!(model in PRICES))
|
|
75342
|
+
unpriced.add(model);
|
|
75343
|
+
}
|
|
75344
|
+
cost += recordCost(model, usage);
|
|
75345
|
+
}
|
|
75346
|
+
if (!turns)
|
|
75347
|
+
return null;
|
|
75348
|
+
return {
|
|
75349
|
+
id: stem.split("agent-").join("").slice(0, 8),
|
|
75350
|
+
agent: agent || "unknown",
|
|
75351
|
+
project: projectName(cwd, parentName),
|
|
75352
|
+
workflow: fullPathComponents.some((p) => p.startsWith("wf_")),
|
|
75353
|
+
inputTokens: fresh,
|
|
75354
|
+
cacheReadTokens: read,
|
|
75355
|
+
cacheWrite5mTokens: w5,
|
|
75356
|
+
cacheWrite1hTokens: w1,
|
|
75357
|
+
outputTokens: out,
|
|
75358
|
+
totalTokens: fresh + read + w5 + w1 + out,
|
|
75359
|
+
costUsd: pyRound(cost, 6),
|
|
75360
|
+
models: [...models].sort(),
|
|
75361
|
+
unpriced: [...unpriced].sort(),
|
|
75362
|
+
turns,
|
|
75363
|
+
lastAt: lastAt || mtime,
|
|
75364
|
+
mtime,
|
|
75365
|
+
size,
|
|
75366
|
+
};
|
|
75367
|
+
}
|
|
75368
|
+
/** Every entry named "agent-" + anything + ".jsonl", at any depth, under a
|
|
75369
|
+
* root, encoded-project, session, "subagents" directory (subagents at depth 3 exactly),
|
|
75370
|
+
* covering both the plain-Task layout and the nested Workflow one in a single walk. */
|
|
75371
|
+
function agentFiles(root, fs) {
|
|
75372
|
+
if (!fs.isDir(root))
|
|
75373
|
+
return [];
|
|
75374
|
+
const found = [];
|
|
75375
|
+
for (const encEntry of fs.readdir(root)) {
|
|
75376
|
+
const encPath = path.join(root, encEntry.name);
|
|
75377
|
+
if (!fs.isDir(encPath))
|
|
75378
|
+
continue;
|
|
75379
|
+
for (const sessionEntry of fs.readdir(encPath)) {
|
|
75380
|
+
const sessionPath = path.join(encPath, sessionEntry.name);
|
|
75381
|
+
if (!fs.isDir(sessionPath))
|
|
75382
|
+
continue;
|
|
75383
|
+
const subagentsPath = path.join(sessionPath, "subagents");
|
|
75384
|
+
if (!fs.isDir(subagentsPath))
|
|
75385
|
+
continue;
|
|
75386
|
+
walkForAgentFiles(subagentsPath, fs, found);
|
|
75387
|
+
}
|
|
75388
|
+
}
|
|
75389
|
+
return sortPaths(found);
|
|
75390
|
+
}
|
|
75391
|
+
function walkForAgentFiles(dir, fs, found) {
|
|
75392
|
+
for (const entry of fs.readdir(dir)) {
|
|
75393
|
+
const entryPath = path.join(dir, entry.name);
|
|
75394
|
+
if (matchesGlobPrefixSuffix(entry.name, "agent-", ".jsonl")) {
|
|
75395
|
+
found.push(entryPath);
|
|
75396
|
+
}
|
|
75397
|
+
if (fs.isDir(entryPath)) {
|
|
75398
|
+
walkForAgentFiles(entryPath, fs, found);
|
|
75399
|
+
}
|
|
75400
|
+
}
|
|
75401
|
+
}
|
|
75402
|
+
function collect(root, previous, signal, fs, now) {
|
|
75403
|
+
const cached = new Map();
|
|
75404
|
+
for (const row of previous?.agents ?? []) {
|
|
75405
|
+
if (row.path)
|
|
75406
|
+
cached.set(row.path, row);
|
|
75407
|
+
}
|
|
75408
|
+
const agents = [];
|
|
75409
|
+
const unpricedModels = new Set();
|
|
75410
|
+
let parsed = 0;
|
|
75411
|
+
for (const fp of agentFiles(root, fs)) {
|
|
75412
|
+
if (signal.aborted)
|
|
75413
|
+
throw new Error("scan aborted");
|
|
75414
|
+
const st = fs.stat(fp);
|
|
75415
|
+
if (st === null)
|
|
75416
|
+
continue;
|
|
75417
|
+
const mtime = pyMtime({ sec: st.mtimeNs / 1000000000n, nsec: st.mtimeNs % 1000000000n });
|
|
75418
|
+
const old = cached.get(fp);
|
|
75419
|
+
if (old && old.mtime === mtime && old.size === st.size) {
|
|
75420
|
+
agents.push(old);
|
|
75421
|
+
for (const m of old.unpriced)
|
|
75422
|
+
unpricedModels.add(m);
|
|
75423
|
+
continue;
|
|
75424
|
+
}
|
|
75425
|
+
const buffer = fs.readFile(fp);
|
|
75426
|
+
if (buffer === null) {
|
|
75427
|
+
if (old)
|
|
75428
|
+
agents.push(old);
|
|
75429
|
+
continue;
|
|
75430
|
+
}
|
|
75431
|
+
parsed++;
|
|
75432
|
+
const parentName = path.basename(path.dirname(fp));
|
|
75433
|
+
const relComponents = fp.split(/[\\/]/).filter((p) => p !== "");
|
|
75434
|
+
const row = scanFile(buffer, mtime, st.size, path.basename(fp, ".jsonl"), parentName, relComponents);
|
|
75435
|
+
if (row === null)
|
|
75436
|
+
continue;
|
|
75437
|
+
row.path = fp;
|
|
75438
|
+
agents.push(row);
|
|
75439
|
+
for (const m of row.unpriced)
|
|
75440
|
+
unpricedModels.add(m);
|
|
75441
|
+
}
|
|
75442
|
+
return {
|
|
75443
|
+
generatedAt: now() / 1000,
|
|
75444
|
+
root: normalizeRoot(root),
|
|
75445
|
+
scanned: agents.length,
|
|
75446
|
+
parsed,
|
|
75447
|
+
pricesAsOf: PRICES_AS_OF,
|
|
75448
|
+
unpricedModels: [...unpricedModels].sort(),
|
|
75449
|
+
agents,
|
|
75450
|
+
};
|
|
75451
|
+
}
|
|
75452
|
+
|
|
75453
|
+
var scriptText = "<#\n.SYNOPSIS\n Inventory the language toolchains installed on this machine (Windows + WSL)\n into the artifact-console store, where the dashboard shows it and the\n claude-memory-sync git log is its running history.\n\n.DESCRIPTION\n Probes a curated catalog of toolchain executables (language runtimes,\n compilers, package managers, version managers, build tools, VCS / container /\n infra CLIs) on the Windows side and inside each WSL distro, capturing each\n tool's version, official Home + GitHub links, path, and best-effort install\n source. Also dumps globally-installed packages (npm -g, pipx, uv, cargo, gem,\n go). On a real change it writes toolchain-inventory-<machine>.md into the\n artifacts store -- one file per box, since what is installed here is a\n per-machine fact and the store is shared across machines (issue #231) --\n and renders it via build.py, which lands it into the memory repo; a\n no-change run does nothing. The script never writes into its own directory\n (scratch/state go to %TEMP% / %LOCALAPPDATA%), so it is safe to keep under a\n git repo (artifacts-console/artifact-console/data) -- and, since #320, inside the\n installed package.\n\n.PARAMETER IncludeEditors\n Also probe editors / IDEs (VS Code, Neovim, Vim, Emacs, Sublime, Helix).\n Off by default. To turn it on permanently, either pass this switch or flip\n $IncludeEditorsDefault below to $true.\n\n.PARAMETER WslDistro\n WSL distro to probe. Defaults to 'Ubuntu'. Pass '' (empty) to skip WSL.\n\n.PARAMETER SourcesDir\n The artifact store root the report is written under. Derived from the console's\n config when omitted -- it used to be a hardcoded `R:\\repos\\claude-memory-sync\\\n artifacts`, correct on one machine and resolving on a second only through a\n junction (issue #231).\n\n.PARAMETER PythonExe\n The console's own python.exe, used for both the store-root lookup and the\n build.py render. Passed by sched_spec.job_argv (sys.executable) and by\n Register-InventoryTask.ps1 (resolved beside pythonw.exe) -- bare `python` on\n PATH is not necessarily the console's interpreter (issue #632), especially\n under pipx. Falls back to `python` on PATH when omitted, so a hand run still\n works.\n\n.PARAMETER OutFile\n Write the report here instead of the store (for local testing). The store\n render/land is skipped when you pass a path -- what would be rendered is the STORE's\n copy of the report, which this run did not write. An empty value is the same as\n omitting it: the report goes to the store, and that run does render.\n\n.PARAMETER SshTargets\n `alias=user@host;alias2=user@host2` -- Linux hosts to probe over key-based SSH\n alongside Windows + WSL (issue #715), one `toolchain-inventory-<alias>.md` per alias.\n Empty (the default) probes no SSH hosts. An unreachable host is isolated: its previous\n report is left untouched, every other target (local, WSL, remaining hosts) still\n publishes, and the process exits 2 so the task's Last Result shows it.\n\n.PARAMETER NoRender\n Write the report but don't render/land it into artifact-console.\n\n.PARAMETER NoSync\n Render into the docroot but don't land into the memory repo (build.py\n --no-sync); the hourly ClaudeMemorySync task still commits it later.\n\n.EXAMPLE\n .\\Get-ToolchainInventory.ps1\n.EXAMPLE\n .\\Get-ToolchainInventory.ps1 -IncludeEditors\n.EXAMPLE\n .\\Get-ToolchainInventory.ps1 -OutFile $env:TEMP\\inv.md # local test, no render\n#>\n[CmdletBinding()]\nparam(\n [switch]$IncludeEditors,\n [string]$WslDistro = 'Ubuntu',\n [string]$SourcesDir = '',\n [string]$PythonExe = '',\n [string]$OutFile,\n [string]$SshTargets = '',\n [switch]$NoRender,\n [switch]$NoSync\n)\n\n# ---------------------------------------------------------------------------\n# Editable defaults -- change these instead of the code below.\n# Flip $IncludeEditorsDefault to $true to always include editors/IDEs.\n# ---------------------------------------------------------------------------\n$IncludeEditorsDefault = $false\n\n# The report's home is the artifact-console store. On a change the script writes its\n# report there and renders it via build.py, which also lands it in the claude-memory-sync\n# repo, whose git log IS the history.\n#\n# This payload ships INSIDE the console (artifact-console/data/), so the console dir --\n# where build.py and scheduled_tasks.py live -- is simply this script's PARENT. #320 (PR\n# #330) moved the file here from the repo's PowerShell/ drawer as a pure rename (R100),\n# leaving the drawer-era `Join-Path (Split-Path $PSScriptRoot -Parent) 'artifact-console'`\n# resolving to a doubled artifact-console/artifact-console that has never existed. The\n# render guard below tests that path, so its condition became permanently TRUE and the\n# render branch unreachable: a run with something to publish wrote its report to the store,\n# printed a yellow skip and exited 0. (A no-change run returns earlier still and is not\n# affected.) So the .md kept reaching the store, but nothing rendered it into the docroot\n# and nothing recorded it for landing -- sync_memory stages only the paths build.py records,\n# never a blanket add -- so no automatic `chore(artifacts): sync Toolchain-Inventory/...`\n# commit has landed since, every later touch made by hand. (The last one is 2026-07-20, which\n# is an outer bound rather than a fingerprint: #231's per-machine rename sits between it and\n# this move.) The registrar's half of the\n# same rename was caught in #334; this was the other half, and it sat unnoticed for a month\n# because the skip message named neither the path nor which half was missing.\n#\n# The parent is right in a WHEEL too, and not by luck: data/ installs as the\n# `artifact_console_data` package while the modules install FLAT beside it, so\n# site-packages/artifact_console_data/.. is site-packages/, which holds build.py.\n#\n# The store ROOT is not spelled out here any more (issue #231). It was\n# `R:\\repos\\claude-memory-sync\\artifacts`: right on the box it was written on, and on a\n# second machine right only by way of a junction. Register-InventoryTask.ps1 passes the\n# config-derived value; a hand run asks the console for the same one.\n$ArtifactConsoleDir = Split-Path $PSScriptRoot -Parent\n$ArtifactCategory = 'Toolchain-Inventory'\n\n$ErrorActionPreference = 'Stop'\n$includeEd = $IncludeEditors.IsPresent -or $IncludeEditorsDefault\n\n# The interpreter for BOTH Python call sites below. `python` on PATH is not\n# necessarily the console's own interpreter -- under pipx it never is (#632) --\n# so a caller that knows the right one (sched_spec.job_argv, the registrar) passes\n# it explicitly. PATH is only the fallback, for a hand run with nothing supplied.\n$pyExe = if ($PythonExe) { $PythonExe } else { 'python' }\n\n# Needed before the output path is chosen, not just in the report body: this report\n# describes ONE machine, so the machine is part of its filename.\n$hostName = [System.Net.Dns]::GetHostName()\n\nif (-not $SourcesDir -and -not $OutFile) {\n $jobPaths = Join-Path $ArtifactConsoleDir 'scheduled_tasks.py'\n try {\n # Same native-exit trap as the render below: a python that RUNS and exits non-zero\n # does not throw, so without this the catch could never fire on the commonest\n # failures -- a traceback, an unconfigured store, or the Windows App Execution\n # Alias stub, which is on PATH and exits non-zero rather than being absent.\n # ConvertFrom-Json would just yield nothing, $SourcesDir would end up empty, and\n # the reason would never be printed. (A genuinely missing command is the one case\n # that always threw: CommandNotFoundException, which this catch already handled.)\n $jobPathsJson = & $pyExe $jobPaths --job-paths toolchain-inventory\n if ($LASTEXITCODE -ne 0) { throw \"scheduled_tasks.py --job-paths exited $LASTEXITCODE\" }\n $SourcesDir = ($jobPathsJson | ConvertFrom-Json).args.SourcesDir\n } catch {\n Write-Host \"! could not ask the console for the store root: $($_.Exception.Message)\" -ForegroundColor Yellow\n }\n}\n\n$ArtifactSourcesDir = $SourcesDir\n\n# This script lives in a git repo (artifacts-console) -- and, since #320, ships inside the\n# installed package, where writing would be worse still -- so it must NEVER write into its\n# own directory. Scratch (probe files) and state (last-run) go to out-of-repo temp /\n# local-appdata; the report itself goes to the artifacts store.\n$tmpDir = Join-Path ([IO.Path]::GetTempPath()) 'toolchain-inventory'\n$stateDir = Join-Path $env:LOCALAPPDATA 'toolchain-inventory'\nNew-Item -ItemType Directory -Force -Path $tmpDir, $stateDir | Out-Null\n\n# One file per machine. This report is a per-machine fact -- what is installed on THIS\n# box -- and it used to be written under a single machine-agnostic name into a git-synced\n# store shared by every machine. With the weekly task registered on two boxes, each run\n# overwrote the other's and the category became a flip-flop: every run a large diff\n# reverting the previous one, so the git log that is supposed to BE the history recorded\n# no machine's toolchain drift legibly (issue #231). Keyed by hostname, each machine owns\n# one file, diffs stay meaningful, and no coordination between boxes is needed.\n$machine = ($hostName -replace '[^A-Za-z0-9._-]', '-')\n$fileName = \"toolchain-inventory-$machine.md\"\n\n$storeDir = if ($ArtifactSourcesDir) { Join-Path $ArtifactSourcesDir $ArtifactCategory } else { '' }\n$storeAvailable = [bool]$ArtifactSourcesDir -and (Test-Path $ArtifactSourcesDir)\nif (-not $OutFile) {\n if ($storeAvailable) {\n New-Item -ItemType Directory -Force -Path $storeDir | Out-Null\n $OutFile = Join-Path $storeDir $fileName\n } else {\n $OutFile = Join-Path $stateDir $fileName\n Write-Host \"! artifacts store not found; writing locally to $OutFile\" -ForegroundColor Yellow\n }\n}\n\n# ---------------------------------------------------------------------------\n# Tool catalog. One row per tool:\n# Category | Name | Exe | Ver (arg array) | Alt (fallback arg array or $null)\n# Add a row here to track a new tool -- it is picked up on both Windows and WSL.\n# ---------------------------------------------------------------------------\n$catalog = @(\n # --- Language runtimes & compilers ---\n @{ Cat='Languages'; Name='Node.js'; Exe='node'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Deno'; Exe='deno'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Bun'; Exe='bun'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Python'; Exe='python'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Python3'; Exe='python3'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Py launcher'; Exe='py'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Go'; Exe='go'; Ver=@('version') }\n @{ Cat='Languages'; Name='Rust (rustc)';Exe='rustc'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Java'; Exe='java'; Ver=@('-version') }\n @{ Cat='Languages'; Name='javac (JDK)'; Exe='javac'; Ver=@('-version') }\n @{ Cat='Languages'; Name='.NET SDK'; Exe='dotnet'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Ruby'; Exe='ruby'; Ver=@('--version') }\n @{ Cat='Languages'; Name='PHP'; Exe='php'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Lua'; Exe='lua'; Ver=@('-v') }\n @{ Cat='Languages'; Name='LuaJIT'; Exe='luajit'; Ver=@('-v') }\n @{ Cat='Languages'; Name='Perl'; Exe='perl'; Ver=@('--version') }\n @{ Cat='Languages'; Name='GCC'; Exe='gcc'; Ver=@('--version') }\n @{ Cat='Languages'; Name='G++'; Exe='g++'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Clang'; Exe='clang'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Zig'; Exe='zig'; Ver=@('version') }\n @{ Cat='Languages'; Name='Kotlin'; Exe='kotlin'; Ver=@('-version') }\n @{ Cat='Languages'; Name='Scala'; Exe='scala'; Ver=@('-version') }\n @{ Cat='Languages'; Name='Swift'; Exe='swift'; Ver=@('--version') }\n @{ Cat='Languages'; Name='GHC (Haskell)';Exe='ghc'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Elixir'; Exe='elixir'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Julia'; Exe='julia'; Ver=@('--version') }\n @{ Cat='Languages'; Name='R'; Exe='R'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Dart'; Exe='dart'; Ver=@('--version') }\n @{ Cat='Languages'; Name='Nim'; Exe='nim'; Ver=@('--version') }\n @{ Cat='Languages'; Name='OCaml'; Exe='ocaml'; Ver=@('-version') }\n\n # --- Package managers ---\n @{ Cat='Package managers'; Name='npm'; Exe='npm'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Yarn'; Exe='yarn'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='pnpm'; Exe='pnpm'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='pip'; Exe='pip'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='pipx'; Exe='pipx'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Poetry'; Exe='poetry'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='uv'; Exe='uv'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Conda'; Exe='conda'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Cargo'; Exe='cargo'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Gem'; Exe='gem'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Bundler'; Exe='bundle'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Composer'; Exe='composer'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='Maven'; Exe='mvn'; Ver=@('-version') }\n @{ Cat='Package managers'; Name='Gradle'; Exe='gradle'; Ver=@('--version') }\n @{ Cat='Package managers'; Name='LuaRocks'; Exe='luarocks'; Ver=@('--version') }\n\n # --- Version managers ---\n @{ Cat='Version managers'; Name='nvm'; Exe='nvm'; Ver=@('--version'); Alt=@('version') }\n @{ Cat='Version managers'; Name='fnm'; Exe='fnm'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='Volta'; Exe='volta'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='pyenv'; Exe='pyenv'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='rustup'; Exe='rustup'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='rbenv'; Exe='rbenv'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='asdf'; Exe='asdf'; Ver=@('--version') }\n @{ Cat='Version managers'; Name='SDKMAN'; Exe='sdk'; Ver=@('version') }\n\n # --- Build tools & runners ---\n @{ Cat='Build tools'; Name='Make'; Exe='make'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='CMake'; Exe='cmake'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='Ninja'; Exe='ninja'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='Meson'; Exe='meson'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='Bazel'; Exe='bazel'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='just'; Exe='just'; Ver=@('--version') }\n @{ Cat='Build tools'; Name='MSBuild'; Exe='msbuild'; Ver=@('-version') }\n @{ Cat='Build tools'; Name='Ant'; Exe='ant'; Ver=@('-version') }\n\n # --- VCS, containers & infra ---\n @{ Cat='VCS & infra'; Name='Git'; Exe='git'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='GitHub CLI'; Exe='gh'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Docker'; Exe='docker'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Docker Compose'; Exe='docker-compose'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Podman'; Exe='podman'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='kubectl'; Exe='kubectl'; Ver=@('version','--client') }\n @{ Cat='VCS & infra'; Name='Helm'; Exe='helm'; Ver=@('version','--short') }\n @{ Cat='VCS & infra'; Name='Terraform'; Exe='terraform'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='OpenTofu'; Exe='tofu'; Ver=@('--version') }\n @{ Cat='VCS & infra'; Name='Ansible'; Exe='ansible'; Ver=@('--version') }\n # cloudflared -- added for #715: nitro/nucbox both front their consoles through a\n # Cloudflare Tunnel, and the epic's exit criterion names it explicitly.\n @{ Cat='VCS & infra'; Name='cloudflared'; Exe='cloudflared'; Ver=@('--version') }\n)\n\n$editorCatalog = @(\n @{ Cat='Editors'; Name='VS Code'; Exe='code'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Neovim'; Exe='nvim'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Vim'; Exe='vim'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Emacs'; Exe='emacs'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Sublime'; Exe='subl'; Ver=@('--version') }\n @{ Cat='Editors'; Name='Helix'; Exe='hx'; Ver=@('--version') }\n)\nif ($includeEd) { $catalog += $editorCatalog }\n\n# Fixed category display order.\n$catOrder = @('Languages','Package managers','Version managers','Build tools','VCS & infra','Editors')\n\n# ---------------------------------------------------------------------------\n# Reference links per tool (keyed by catalog Name): official Home + GitHub repo\n# (owner/repo; URL derived). Blank where none is canonical. Add a tool here when\n# you add it to $catalog. Rendered as the Home / GitHub columns.\n# ---------------------------------------------------------------------------\n$refLinks = @{\n # Languages\n 'Node.js' = @{ Home='https://nodejs.org'; Repo='nodejs/node' }\n 'Deno' = @{ Home='https://deno.com'; Repo='denoland/deno' }\n 'Bun' = @{ Home='https://bun.sh'; Repo='oven-sh/bun' }\n 'Python' = @{ Home='https://python.org'; Repo='python/cpython' }\n 'Python3' = @{ Home='https://python.org'; Repo='python/cpython' }\n 'Py launcher' = @{ Home='https://docs.python.org/using/windows.html'; Repo='python/cpython' }\n 'Go' = @{ Home='https://go.dev'; Repo='golang/go' }\n 'Rust (rustc)' = @{ Home='https://rust-lang.org'; Repo='rust-lang/rust' }\n 'Java' = @{ Home='https://openjdk.org'; Repo='openjdk/jdk' }\n 'javac (JDK)' = @{ Home='https://openjdk.org'; Repo='openjdk/jdk' }\n '.NET SDK' = @{ Home='https://dotnet.microsoft.com'; Repo='dotnet/sdk' }\n 'Ruby' = @{ Home='https://ruby-lang.org'; Repo='ruby/ruby' }\n 'PHP' = @{ Home='https://php.net'; Repo='php/php-src' }\n 'Lua' = @{ Home='https://lua.org'; Repo='lua/lua' }\n 'LuaJIT' = @{ Home='https://luajit.org'; Repo='LuaJIT/LuaJIT' }\n 'Perl' = @{ Home='https://perl.org'; Repo='Perl/perl5' }\n 'GCC' = @{ Home='https://gcc.gnu.org'; Repo='gcc-mirror/gcc' }\n 'G++' = @{ Home='https://gcc.gnu.org'; Repo='gcc-mirror/gcc' }\n 'Clang' = @{ Home='https://clang.llvm.org'; Repo='llvm/llvm-project' }\n 'Zig' = @{ Home='https://ziglang.org'; Repo='ziglang/zig' }\n 'Kotlin' = @{ Home='https://kotlinlang.org'; Repo='JetBrains/kotlin' }\n 'Scala' = @{ Home='https://scala-lang.org'; Repo='scala/scala' }\n 'Swift' = @{ Home='https://swift.org'; Repo='apple/swift' }\n 'GHC (Haskell)' = @{ Home='https://haskell.org'; Repo='' }\n 'Elixir' = @{ Home='https://elixir-lang.org'; Repo='elixir-lang/elixir' }\n 'Julia' = @{ Home='https://julialang.org'; Repo='JuliaLang/julia' }\n 'R' = @{ Home='https://r-project.org'; Repo='' }\n 'Dart' = @{ Home='https://dart.dev'; Repo='dart-lang/sdk' }\n 'Nim' = @{ Home='https://nim-lang.org'; Repo='nim-lang/Nim' }\n 'OCaml' = @{ Home='https://ocaml.org'; Repo='ocaml/ocaml' }\n # Package managers\n 'npm' = @{ Home='https://npmjs.com'; Repo='npm/cli' }\n 'Yarn' = @{ Home='https://yarnpkg.com'; Repo='yarnpkg/berry' }\n 'pnpm' = @{ Home='https://pnpm.io'; Repo='pnpm/pnpm' }\n 'pip' = @{ Home='https://pip.pypa.io'; Repo='pypa/pip' }\n 'pipx' = @{ Home='https://pipx.pypa.io'; Repo='pypa/pipx' }\n 'Poetry' = @{ Home='https://python-poetry.org'; Repo='python-poetry/poetry' }\n 'uv' = @{ Home='https://docs.astral.sh/uv'; Repo='astral-sh/uv' }\n 'Conda' = @{ Home='https://conda.io'; Repo='conda/conda' }\n 'Cargo' = @{ Home='https://doc.rust-lang.org/cargo'; Repo='rust-lang/cargo' }\n 'Gem' = @{ Home='https://rubygems.org'; Repo='rubygems/rubygems' }\n 'Bundler' = @{ Home='https://bundler.io'; Repo='rubygems/rubygems' }\n 'Composer' = @{ Home='https://getcomposer.org'; Repo='composer/composer' }\n 'Maven' = @{ Home='https://maven.apache.org'; Repo='apache/maven' }\n 'Gradle' = @{ Home='https://gradle.org'; Repo='gradle/gradle' }\n 'LuaRocks' = @{ Home='https://luarocks.org'; Repo='luarocks/luarocks' }\n # Version managers\n 'nvm' = @{ Home=''; Repo='nvm-sh/nvm' }\n 'fnm' = @{ Home=''; Repo='Schniz/fnm' }\n 'Volta' = @{ Home='https://volta.sh'; Repo='volta-cli/volta' }\n 'pyenv' = @{ Home=''; Repo='pyenv/pyenv' }\n 'rustup' = @{ Home='https://rustup.rs'; Repo='rust-lang/rustup' }\n 'rbenv' = @{ Home=''; Repo='rbenv/rbenv' }\n 'asdf' = @{ Home='https://asdf-vm.com'; Repo='asdf-vm/asdf' }\n 'SDKMAN' = @{ Home='https://sdkman.io'; Repo='sdkman/sdkman-cli' }\n # Build tools\n 'Make' = @{ Home='https://gnu.org/software/make'; Repo='' }\n 'CMake' = @{ Home='https://cmake.org'; Repo='Kitware/CMake' }\n 'Ninja' = @{ Home='https://ninja-build.org'; Repo='ninja-build/ninja' }\n 'Meson' = @{ Home='https://mesonbuild.com'; Repo='mesonbuild/meson' }\n 'Bazel' = @{ Home='https://bazel.build'; Repo='bazelbuild/bazel' }\n 'just' = @{ Home='https://just.systems'; Repo='casey/just' }\n 'MSBuild' = @{ Home='https://learn.microsoft.com/visualstudio/msbuild/msbuild'; Repo='dotnet/msbuild' }\n 'Ant' = @{ Home='https://ant.apache.org'; Repo='apache/ant' }\n # VCS & infra\n 'Git' = @{ Home='https://git-scm.com'; Repo='git/git' }\n 'GitHub CLI' = @{ Home='https://cli.github.com'; Repo='cli/cli' }\n 'Docker' = @{ Home='https://docker.com'; Repo='docker/cli' }\n 'Docker Compose' = @{ Home='https://docs.docker.com/compose'; Repo='docker/compose' }\n 'Podman' = @{ Home='https://podman.io'; Repo='containers/podman' }\n 'kubectl' = @{ Home='https://kubernetes.io'; Repo='kubernetes/kubernetes' }\n 'Helm' = @{ Home='https://helm.sh'; Repo='helm/helm' }\n 'Terraform' = @{ Home='https://terraform.io'; Repo='hashicorp/terraform' }\n 'OpenTofu' = @{ Home='https://opentofu.org'; Repo='opentofu/opentofu' }\n 'Ansible' = @{ Home='https://ansible.com'; Repo='ansible/ansible' }\n 'cloudflared' = @{ Home='https://developers.cloudflare.com/cloudflared'; Repo='cloudflare/cloudflared' }\n # Editors\n 'VS Code' = @{ Home='https://code.visualstudio.com'; Repo='microsoft/vscode' }\n 'Neovim' = @{ Home='https://neovim.io'; Repo='neovim/neovim' }\n 'Vim' = @{ Home='https://vim.org'; Repo='vim/vim' }\n 'Emacs' = @{ Home='https://gnu.org/software/emacs'; Repo='' }\n 'Sublime' = @{ Home='https://sublimetext.com'; Repo='' }\n 'Helix' = @{ Home='https://helix-editor.com'; Repo='helix-editor/helix' }\n}\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\nfunction Get-CleanVersion([string]$raw) {\n if (-not $raw) { return '' }\n $line = ($raw -split \"`n\" | Where-Object { $_.Trim() -ne '' } | Select-Object -First 1)\n if (-not $line) { return '' }\n $line = $line.Trim()\n $m = [regex]::Match($line, '\\d+\\.\\d+(\\.\\d+){0,3}')\n if ($m.Success) { return $m.Value }\n if ($line.Length -gt 48) { return $line.Substring(0,48) }\n return $line\n}\n\nfunction Get-WinSource([string]$path) {\n if (-not $path) { return '' }\n $p = $path.ToLower()\n if ($p -like '*\\scoop\\*') { return 'scoop' }\n if ($p -like '*\\chocolatey\\*') { return 'choco' }\n if ($p -like '*\\winget*' -or\n $p -like '*\\windowsapps\\*') { return 'winget/store' }\n if ($p -like '*\\.cargo\\*') { return 'cargo' }\n if ($p -like '*\\nvm*') { return 'nvm' }\n if ($p -like '*\\program files*') { return 'installer' }\n if ($p -like '*\\appdata\\local\\programs\\*') { return 'user-install' }\n return ''\n}\n\nfunction Get-WslSource([string]$path) {\n if (-not $path) { return '' }\n if ($path -like '*/.cargo/*') { return 'cargo' }\n if ($path -like '*/.local/*') { return 'pip/pipx' }\n if ($path -like '*/.nvm/*') { return 'nvm' }\n if ($path -like '*/snap/*') { return 'snap' }\n if ($path -like '/usr/*' -or $path -like '/bin/*') { return 'apt/system' }\n return ''\n}\n\n# Probe a single tool on the Windows side. Returns a result hashtable or $null.\nfunction Get-WinToolInfo($tool) {\n # Only real executables / scripts -- never PS aliases, functions, or cmdlets\n # (e.g. `R` is the built-in alias for Invoke-History, not the R language).\n $cmd = Get-Command $tool.Exe -CommandType Application,ExternalScript -ErrorAction SilentlyContinue |\n Select-Object -First 1\n if (-not $cmd) { return $null }\n $path = if ($cmd.Source) { $cmd.Source } else { $cmd.Name }\n $raw = ''\n try { $raw = (& $tool.Exe @($tool.Ver) 2>&1 | Out-String) } catch { $raw = '' }\n $ver = Get-CleanVersion $raw\n if (-not $ver -and $tool.Alt) {\n try { $raw = (& $tool.Exe @($tool.Alt) 2>&1 | Out-String) } catch { $raw = '' }\n $ver = Get-CleanVersion $raw\n }\n return @{ Cat=$tool.Cat; Name=$tool.Name; Version=$ver; Path=$path; Source=(Get-WinSource $path) }\n}\n\nfunction Format-HomeCell($homeUrl) {\n if (-not $homeUrl) { return '-' }\n $label = ($homeUrl -replace '^https?://','') -replace '/$',''\n return \"[$label]($homeUrl)\"\n}\nfunction Format-RepoCell($repo) {\n if (-not $repo) { return '-' }\n return \"[$repo](https://github.com/$repo)\"\n}\nfunction Format-Table-Md($rows) {\n if (-not $rows -or $rows.Count -eq 0) { return \"_none found_`n\" }\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('| Tool | Version | Home | GitHub | Source | Path |')\n [void]$sb.AppendLine('|------|---------|------|--------|--------|------|')\n foreach ($r in ($rows | Sort-Object Name)) {\n $ver = if ($r.Version) { $r.Version } else { '(present)' }\n $src = if ($r.Source) { $r.Source } else { '-' }\n $ref = $refLinks[$r.Name]\n $homeCell = Format-HomeCell $ref.Home\n $ghCell = Format-RepoCell $ref.Repo\n [void]$sb.AppendLine(\"| $($r.Name) | $ver | $homeCell | $ghCell | $src | ``$($r.Path)`` |\")\n }\n return $sb.ToString()\n}\n\nfunction Format-SideSection($rows) {\n $sb = New-Object System.Text.StringBuilder\n foreach ($cat in $catOrder) {\n $catRows = $rows | Where-Object { $_.Cat -eq $cat }\n if (-not $catRows) { continue }\n [void]$sb.AppendLine(\"### $cat`n\")\n [void]$sb.AppendLine((Format-Table-Md $catRows))\n }\n return $sb.ToString()\n}\n\n# ---------------------------------------------------------------------------\n# \"Unrecognized dev tool on PATH\" heads-up. Scans the dirs where dev package\n# managers drop user-installed CLIs (cargo/go/dotnet/npm/scoop/.local) and flags\n# any executable whose name isn't a catalogued tool or a known companion binary\n# of one. High-signal by construction: it never looks in system dirs, so a hit\n# is almost always a real tool you installed but haven't catalogued yet.\n# ---------------------------------------------------------------------------\n$knownExe = @{}\nforeach ($t in ($catalog + $editorCatalog)) { $knownExe[$t.Exe.ToLower()] = $true }\n\n# Auxiliary binaries that ship WITH a catalogued tool -- not \"new tools\".\n$companionExe = @{}\nforeach ($n in @(\n 'rustfmt','rustdoc','clippy-driver','cargo-clippy','cargo-fmt','cargo-miri',\n 'rust-gdb','rust-gdbgui','rust-lldb','rust-analyzer',\n 'gofmt','godoc','npx','pnpx','corepack','yarnpkg',\n 'uvx','uvw','rls',\n 'pydoc','2to3','idle','wheel','activate',\n 'php-cgi','phpdbg','erl','epmd','escript','iex','mix','rebar3',\n 'bundler','rdoc','ri'\n)) { $companionExe[$n] = $true }\n\nfunction Test-KnownTool([string]$name) {\n $n = $name.ToLower()\n if ($knownExe.ContainsKey($n) -or $companionExe.ContainsKey($n)) { return $true }\n # version-suffixed interpreters/tools: python3.12, pip3, ruby3.3, perl5.40, node18, php8.3\n if ($n -match '^(python|pip|ruby|perl|php|node|lua|luajit|clang|gcc|dotnet)w?[-.]?[0-9][0-9.]*$') { return $true }\n return $false\n}\n\nfunction Get-UncataloguedWin {\n $dirs = @(\n (Join-Path $env:USERPROFILE '.cargo\\bin')\n (Join-Path $env:USERPROFILE '.dotnet\\tools')\n (Join-Path $env:USERPROFILE '.local\\bin')\n (Join-Path $env:APPDATA 'npm')\n (Join-Path $env:USERPROFILE 'scoop\\shims')\n )\n if (Get-Command go -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {\n try {\n $gb = if ($env:GOBIN) { $env:GOBIN } else { Join-Path ((& go env GOPATH 2>$null).Trim()) 'bin' }\n if ($gb) { $dirs += $gb }\n } catch { Write-Verbose \"go env GOPATH failed; leaving the Go bin dir out of the scan.\" }\n }\n $seen = @{}; $out = @()\n foreach ($d in ($dirs | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique)) {\n Get-ChildItem $d -File -ErrorAction SilentlyContinue |\n Where-Object { $_.Extension -in '.exe','.cmd','.bat','.ps1' } |\n ForEach-Object {\n $base = [IO.Path]::GetFileNameWithoutExtension($_.Name)\n $bl = $base.ToLower()\n if ($base -and -not (Test-KnownTool $base) -and -not $seen.ContainsKey($bl)) {\n $seen[$bl] = $true\n $out += @{ Name = $base; Path = $_.FullName }\n }\n }\n }\n return $out\n}\n\nfunction Format-Unknown-Md($items) {\n $items = @($items)\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('### Uncatalogued on PATH')\n [void]$sb.AppendLine('')\n [void]$sb.AppendLine('_Dev-manager binaries not in the catalog. Add a `$catalog` row (+ `$refLinks`) to track one._')\n [void]$sb.AppendLine('')\n [void]$sb.AppendLine('| Tool | Path |')\n [void]$sb.AppendLine('|------|------|')\n $cap = 60; $i = 0\n foreach ($u in ($items | Sort-Object { $_.Name })) {\n if ($i -ge $cap) { break }\n [void]$sb.AppendLine(\"| $($u.Name) | ``$($u.Path)`` |\")\n $i++\n }\n if ($items.Count -gt $cap) { [void]$sb.AppendLine(\"`n_+ $($items.Count - $cap) more._\") }\n return $sb.ToString()\n}\n\n# ---------------------------------------------------------------------------\n# Host-agnostic bash probe (issue #714, TC-2). The probe script makes no\n# assumption about how it gets run -- WSL today (`wsl.exe -d <distro> -- bash\n# <file>`), SSH next (TC-3) -- so \"build the script\", \"run it via some\n# transport\" and \"parse its output\" are three separable functions, each\n# testable without wsl.exe or a registered distro.\n# ---------------------------------------------------------------------------\n\nfunction New-ToolchainProbeScript {\n <#\n Builds the probe script TEXT (LF line endings) with the tool catalog embedded as a\n quoted heredoc, so the whole probe is one self-contained file any transport can run --\n a temp file for WSL, stdin for SSH -- instead of reading a second file only the same\n host can see.\n #>\n param([Parameter(Mandatory)][array]$Catalog)\n\n $tsvLines = $(foreach ($t in $Catalog) { \"{0}`t{1}`t{2}\" -f $t.Name, $t.Exe, ($t.Ver -join ' ') })\n $tsv = ($tsvLines -join \"`n\")\n\n $probeSh = @'\n#!/usr/bin/env bash\ninterop=\"${TOOLCHAIN_PROBE_INTEROP_PREFIX:-/mnt/}\"\ndeclare -A known\nwhile IFS=$'\\t' read -r name exe verargs; do\n [ -z \"$exe\" ] && continue\n known[\"$(printf '%s' \"$exe\" | tr 'A-Z' 'a-z')\"]=1\n loc=$(command -v \"$exe\" 2>/dev/null) || continue\n case \"$loc\" in \"$interop\"*) continue;; esac # skip Windows tools seen via interop\n ver=$($exe $verargs 2>&1 | grep -m1 . | tr -d '\\r')\n printf '%s\\t%s\\t%s\\n' \"$name\" \"$ver\" \"$loc\"\ndone <<'__CATALOG__'\n__TSV__\n__CATALOG__\necho \"===GLOBALS===\"\nrun(){ local e=\"$1\"; local lbl=\"$2\"; shift 2; local p; p=$(command -v \"$e\" 2>/dev/null) || return; case \"$p\" in \"$interop\"*) return;; esac; echo \"### $lbl\"; \"$@\" 2>/dev/null; echo; }\nrun npm \"npm -g\" npm ls -g --depth=0\nrun pipx \"pipx\" pipx list --short\nrun uv \"uv tool\" uv tool list\nrun cargo \"cargo install\" cargo install --list\nrun gem \"gem (user)\" gem list --local\nif command -v go >/dev/null 2>&1; then b=\"$(go env GOBIN)\"; [ -z \"$b\" ] && b=\"$(go env GOPATH)/bin\"; if [ -d \"$b\" ]; then echo \"### go install\"; ls -1 \"$b\" 2>/dev/null; echo; fi; fi\necho \"===UNKNOWN===\"\nfor c in rustfmt rustdoc clippy-driver cargo-clippy cargo-fmt cargo-miri rust-gdb rust-gdbgui rust-lldb rust-analyzer rls gofmt godoc npx corepack uvx uvw pydoc 2to3 idle bundler rdoc ri erl epmd escript iex mix rebar3; do known[$c]=1; done\ngb=\"\"; if command -v go >/dev/null 2>&1; then gb=\"$(go env GOBIN 2>/dev/null)\"; [ -z \"$gb\" ] && gb=\"$(go env GOPATH 2>/dev/null)/bin\"; fi\nfor d in \"$HOME/.cargo/bin\" \"$gb\" \"$HOME/.local/bin\" \"$HOME/.dotnet/tools\" /usr/local/bin; do\n [ -d \"$d\" ] || continue\n for f in \"$d\"/*; do\n [ -f \"$f\" ] && [ -x \"$f\" ] || continue\n b=$(basename \"$f\"); bl=$(printf '%s' \"$b\" | tr 'A-Z' 'a-z')\n case \"$bl\" in python[0-9]*|pip[0-9]*|ruby[0-9]*|perl[0-9]*|php[0-9]*|node[0-9]*|*.ps1) continue;; esac\n [ -n \"${known[$bl]}\" ] && continue\n printf '%s\\t%s\\n' \"$b\" \"$f\"\n done\ndone | sort -u\n'@\n $probeSh = ($probeSh -replace \"`r`n\", \"`n\").Replace('__TSV__', $tsv)\n return $probeSh\n}\n\nfunction Invoke-ToolchainProbe {\n <#\n Runs probe script text through a transport and returns the raw stdout as one string.\n -Transport is a scriptblock: (ScriptText) -> raw output string. Today's only transport\n is WSL (defined next to its call site, below); TC-3 adds an SSH one with the same shape.\n #>\n param(\n [Parameter(Mandatory)][string]$ScriptText,\n [Parameter(Mandatory)][scriptblock]$Transport\n )\n return & $Transport $ScriptText\n}\n\nfunction ConvertFrom-ToolchainProbeOutput {\n <#\n Parses one probe run's raw stdout into catalogued rows, the globals text and the\n uncatalogued-tool list -- the `===GLOBALS===` / `===UNKNOWN===` sentinel split that\n used to live inline in the WSL block. Same shapes as before: Rows carry\n Cat/Name/Version/Path/Source (Source via Get-WslSource); Unknown carries Name/Path.\n #>\n param(\n [Parameter(Mandatory)][AllowEmptyString()][string]$Raw,\n [Parameter(Mandatory)][array]$Catalog\n )\n\n $rows = @()\n $unknown = @()\n\n $g = $Raw -split '===GLOBALS==='\n $toolOut = $g[0]\n $rest = if ($g.Count -gt 1) { $g[1] } else { '' }\n $u = $rest -split '===UNKNOWN==='\n $globalsText = $u[0].Trim()\n $unknownOut = if ($u.Count -gt 1) { $u[1] } else { '' }\n\n foreach ($line in ($toolOut -split \"`n\")) {\n if (-not $line.Trim()) { continue }\n $parts = $line -split \"`t\"\n if ($parts.Count -lt 3) { continue }\n $name = $parts[0].Trim()\n $ver = Get-CleanVersion $parts[1]\n $loc = $parts[2].Trim()\n $catEntry = $Catalog | Where-Object { $_.Name -eq $name } | Select-Object -First 1\n if (-not $catEntry) { continue }\n $rows += @{ Cat = $catEntry.Cat; Name = $name; Version = $ver; Path = $loc; Source = (Get-WslSource $loc) }\n }\n\n foreach ($line in ($unknownOut -split \"`n\")) {\n if (-not $line.Trim()) { continue }\n $p = $line -split \"`t\"\n if ($p.Count -lt 2) { continue }\n $unknown += @{ Name = $p[0].Trim(); Path = $p[1].Trim() }\n }\n\n return @{ Rows = @($rows); GlobalsText = $globalsText; Unknown = @($unknown) }\n}\n\n# Change detection: ignore only the volatile _Generated: line, so a no-change run does not\n# rewrite a report or create an empty commit. Top-level (not inside the dot-source guard)\n# because Publish-ToolchainReport below -- called both for real and by the pwsh-driven\n# tests, which dot-source this script -- reads it.\nfunction Remove-GeneratedLine([string]$text) {\n ($text -split \"`n\" | Where-Object { $_ -notmatch '^_Generated:' }) -join \"`n\"\n}\n\n# ---------------------------------------------------------------------------\n# SSH toolchain probing (issue #715, TC-3). Reuses the host-agnostic probe above (the\n# script text and the parser are already transport-agnostic) and adds the report-building\n# and publish steps as their own functions, so the local Windows+WSL report and each\n# remote host's report share one implementation instead of two copy-pasted blocks.\n# ---------------------------------------------------------------------------\n\nfunction ConvertTo-ToolchainSshTargets {\n <#\n `alias=user@host;alias2=user@host2` -> an array of (alias, target) pairs -- each\n pair itself a 2-element array, so `$alias, $target = $pair` destructures it.\n\n A malformed fragment is SKIPPED with a loud warning, not thrown -- config.py's own\n validation (a target must not contain ';') is the real guard against this ever\n reaching here from a registered task, but a hand run can pass -SshTargets directly,\n bypassing that validation entirely. Throwing here would take the #715 failure-isolation\n guarantee and undo it at the one entry point upstream of the whole per-target loop:\n one bad fragment would abort every OTHER configured host's probe before it even\n started, which is precisely the \"one bad target kills the fleet\" failure this script\n exists to prevent for an unreachable HOST -- a malformed fragment deserves the same\n isolation, not a worse outcome.\n #>\n param([string]$Raw)\n\n $result = @()\n if (-not $Raw) { return , $result }\n foreach ($item in ($Raw -split ';')) {\n $item = $item.Trim()\n if (-not $item) { continue }\n $parts = $item -split '=', 2\n if ($parts.Count -ne 2 -or -not $parts[0].Trim() -or -not $parts[1].Trim()) {\n Write-Host \"! malformed -SshTargets entry skipped: '$item' (expected alias=user@host)\" -ForegroundColor Yellow\n continue\n }\n $result += , @($parts[0].Trim(), $parts[1].Trim())\n }\n return , $result\n}\n\nfunction Format-GlobalsSection {\n <#\n Renders zero or more `{Label; Text}` blocks under one \"### Global packages\" heading --\n Windows has several labelled blocks (npm -g, pipx, ...); a remote/WSL side has one\n unlabelled block whose text already carries its own \"### <label>\" sub-headers (the\n probe script's own `run()` output). Empty/blank-text blocks are dropped, and the\n heading itself is omitted when nothing is left -- so a host with no global packages\n installed renders no section at all, exactly as before this was extracted.\n #>\n param([array]$Blocks)\n\n $blocks = @($Blocks | Where-Object { $_ -and $_.Text })\n if (-not $blocks.Count) { return '' }\n $sb = New-Object System.Text.StringBuilder\n [void]$sb.AppendLine('### Global packages')\n [void]$sb.AppendLine('')\n foreach ($b in $blocks) {\n if ($b.Label) {\n [void]$sb.AppendLine(\"**$($b.Label)**\")\n [void]$sb.AppendLine('')\n }\n [void]$sb.AppendLine('```')\n [void]$sb.AppendLine($b.Text)\n [void]$sb.AppendLine('```')\n [void]$sb.AppendLine('')\n }\n return $sb.ToString()\n}\n\nfunction ConvertTo-ToolchainSidecarTools($rows) {\n <#\n Rows (Cat/Name/Version/Path/Source) -> the sidecar's `tools` array, in the SAME order\n Format-SideSection/Format-Table-Md render them (walk $catOrder, then Sort-Object Name\n within each category) -- so the .json and the .md agree on tool order. `version` gets\n the same '(present)' fallback Format-Table-Md uses for a blank Version, so the field is\n never empty (a validate() requirement, #716); `source` is '' rather than omitted when\n unknown, since every tool has the key.\n #>\n $out = @()\n foreach ($cat in $catOrder) {\n $catRows = @($rows | Where-Object { $_.Cat -eq $cat } | Sort-Object Name)\n foreach ($r in $catRows) {\n $ver = if ($r.Version) { $r.Version } else { '(present)' }\n $out += [ordered]@{\n name = $r.Name\n category = $r.Cat\n version = $ver\n path = $r.Path\n source = $(if ($r.Source) { $r.Source } else { '' })\n }\n }\n }\n return , @($out)\n}\n\nfunction ConvertTo-ToolchainSidecarUnknown($items) {\n <#\n Unknown (Name/Path) -> the sidecar's `uncatalogued` array, sorted by Name. Unlike\n Format-Unknown-Md this carries every entry -- no display cap -- since the JSON is a\n data contract, not a rendered table.\n #>\n $out = @()\n foreach ($u in ($items | Sort-Object { $_.Name })) {\n $out += [ordered]@{ name = $u.Name; path = $u.Path }\n }\n return , @($out)\n}\n\nfunction New-ToolchainReport {\n <#\n Assembles ONE report's full Markdown text AND its JSON sidecar (#716, TC-4) from the\n same -Sides data: title, `_Generated:` line, an optional summary line and an optional\n `> History:` line, then one `## <Title>` section per entry in -Sides (tool table,\n global packages, uncatalogued-on-PATH) via Format-SideSection / Format-GlobalsSection\n / Format-Unknown-Md for the Markdown half, and ConvertTo-ToolchainSidecarTools /\n ConvertTo-ToolchainSidecarUnknown for the sidecar half -- both read the same\n -Sides.Rows / -Sides.Unknown, so the two documents can't drift apart from the same\n call. Each -Sides entry now also carries a `Side` key ('windows' | 'wsl:<distro>' |\n 'linux'), the sidecar's per-side identity -- distinct from `Title`, the Markdown\n heading text.\n\n Used for the LOCAL report today (Windows + WSL, two Sides entries, the summary and\n history lines populated) and for a remote host's report (#715: one Sides entry with\n Title 'Linux', no summary line, no history line) -- the difference between the two is\n entirely in what the CALLER passes, not a second code path.\n\n Returns @{ Markdown = <string>; Sidecar = <ordered hashtable> }. -HostId is the\n report-file suffix (the store's identity for the box, e.g. 'nitro' or the sanitised\n local $machine) -- the sidecar's `host`; -Hostname is the box as it reports itself\n (`hostname`); -Os is 'windows' or 'linux'; -GeneratedIso is the run's UTC timestamp,\n second precision, e.g. '2026-09-15T14:02:11Z' (the sidecar's `generated` -- distinct\n from -Now, the local-time display string the Markdown's `_Generated:` line uses).\n #>\n param(\n [Parameter(Mandatory)][string]$HostLabel,\n [Parameter(Mandatory)][string]$GeneratedOn,\n [Parameter(Mandatory)][string]$Now,\n [string]$SummaryLine = '',\n [string]$HistoryRel = '',\n [Parameter(Mandatory)][array]$Sides,\n [Parameter(Mandatory)][string]$HostId,\n [Parameter(Mandatory)][string]$Hostname,\n [Parameter(Mandatory)][ValidateSet('windows', 'linux')][string]$Os,\n [Parameter(Mandatory)][string]$GeneratedIso\n )\n\n $md = New-Object System.Text.StringBuilder\n [void]$md.AppendLine(\"# Toolchain Inventory: $HostLabel\")\n [void]$md.AppendLine('')\n [void]$md.AppendLine(\"_Generated: $Now on ${GeneratedOn}_\")\n [void]$md.AppendLine('')\n if ($SummaryLine) {\n [void]$md.AppendLine($SummaryLine)\n [void]$md.AppendLine('')\n }\n if ($HistoryRel) {\n [void]$md.AppendLine(\"> History: ``git log -- $HistoryRel`` in the claude-memory-sync repo.\")\n [void]$md.AppendLine('')\n }\n $sidecarSides = @()\n foreach ($side in $Sides) {\n [void]$md.AppendLine(\"## $($side.Title)\")\n [void]$md.AppendLine('')\n [void]$md.Append((Format-SideSection $side.Rows))\n [void]$md.Append((Format-GlobalsSection $side.GlobalsBlocks))\n if (@($side.Unknown).Count -gt 0) {\n [void]$md.Append((Format-Unknown-Md $side.Unknown))\n [void]$md.AppendLine('')\n }\n $sidecarSides += [ordered]@{\n side = $side.Side\n tools = (ConvertTo-ToolchainSidecarTools $side.Rows)\n uncatalogued = (ConvertTo-ToolchainSidecarUnknown $side.Unknown)\n }\n }\n $sidecar = [ordered]@{\n schemaVersion = 1\n host = $HostId\n hostname = $Hostname\n os = $Os\n generated = $GeneratedIso\n sides = @($sidecarSides)\n }\n return @{\n Markdown = ($md.ToString() -replace \"`r`n\", \"`n\")\n Sidecar = $sidecar\n }\n}\n\nfunction Publish-ToolchainReport {\n <#\n Change-detect, write-if-changed, and render/land ONE report -- extracted so the local\n report and each remote host's report (#715) share one implementation instead of the\n local report carrying its own copy-pasted change-detection + render tail.\n\n Ignores only the volatile `_Generated:` line when comparing (Remove-GeneratedLine), so\n a no-change run does not rewrite the file or create an empty commit. -Label is '' for\n the local report, so its two status lines stay BYTE-IDENTICAL to before #715 (#714's\n acceptance greps for one) and '<alias>: ' for a remote. -SkipRenderReason prints its\n own message instead of rendering -- the local -OutFile testing path, which writes\n somewhere other than the store and so must not render the store's (different, possibly\n absent) copy. Returns 'written', 'unchanged' or 'render-failed' (written to disk, but\n build.py exited non-zero) -- NEVER terminates the script itself, unlike the pre-#715\n local-only flow this was extracted from. A bare `exit` here would abort the enclosing\n per-target loop in Invoke-RemoteToolchainInventory via $ErrorActionPreference='Stop'\n (exit is not a catchable exception, so the surrounding try/catch would not save the\n remaining targets), defeating the whole point of #715's failure isolation for every\n target queued after the one whose render failed -- and, for the LOCAL report, would\n skip SSH probing entirely on a render failure that has nothing to do with SSH\n reachability. Callers decide what 'render-failed' means for the run as a whole: the\n main flow folds it into the final exit code (matching the pre-#715 behaviour of exiting\n 1 on a local render failure) but only AFTER SSH probing has run; Invoke-RemoteToolchainInventory\n folds it into Failed like an unreachable host, so the affected alias is visible and the\n loop still completes every other target.\n\n -Sidecar (#716, TC-4) is the JSON sidecar object New-ToolchainReport built alongside\n -Text, published from the SAME change verdict as the .md -- \"land both or neither\" --\n EXCEPT that a missing sidecar is also its own trigger: the .md alone changing (or not)\n decides whether $FileName itself is rewritten (so the sidecar's `generated` timestamp\n never forces a no-op git diff on the .md), but the sidecar is (re)written whenever it\n is either due a real change OR simply absent from disk -- covering both this feature's\n first rollout (an unchanged .md that never had a sidecar) and recovery after build.py\n rejects one as invalid and deletes it (build.py's own comment). Serialised with\n `ConvertTo-Json -Depth 6`, LF-normalised, no BOM, keys in the order\n toolchain_sidecar.py documents (that module is this contract's other half -- the\n console side that validates it). Not atomic across the two files (round-2 review,\n #716): a process kill between the .md write and the sidecar write below could leave a\n stale-but-valid sidecar the \"missing\" check can't detect, since it checks presence,\n not content. Consistent with every other write in this script (none are transactional\n either) -- accepted rather than solved here.\n #>\n param(\n [Parameter(Mandatory)][string]$FileName,\n [Parameter(Mandatory)][string]$Text,\n [Parameter(Mandatory)][object]$Sidecar,\n [string]$Rel = '',\n [string]$Label = '',\n [switch]$NoRender,\n [string]$SkipRenderReason = ''\n )\n\n $sidecarFileName = [IO.Path]::ChangeExtension($FileName, '.json')\n\n $mdChanged = $true\n if (Test-Path $FileName) {\n $old = ([IO.File]::ReadAllText($FileName)) -replace \"`r\", ''\n if ((Remove-GeneratedLine $old) -eq (Remove-GeneratedLine $Text)) { $mdChanged = $false }\n }\n # A missing sidecar is ALSO a publish trigger, independent of the .md's own diff --\n # otherwise a host whose report text happens not to change this run (the common case\n # most weeks) can never get its FIRST sidecar after this feature ships, or recover one\n # build.py rejected as invalid and (per build.py's own comment) deleted: the .md text\n # is identical either way, so a check keyed on the .md alone reports 'unchanged'\n # forever and the sidecar never lands (#716 review). This still writes ONLY the\n # sidecar when the .md itself is unchanged -- never touching $FileName -- so a\n # backfill run does not bump `_Generated:` or create a no-op git diff on the .md.\n $sidecarMissing = -not (Test-Path $sidecarFileName)\n if (-not $mdChanged -and -not $sidecarMissing) {\n Write-Host \"= ${Label}No toolchain changes since last run; nothing to publish.\" -ForegroundColor DarkGray\n return 'unchanged'\n }\n\n if ($mdChanged) {\n [IO.File]::WriteAllText($FileName, $Text, (New-Object System.Text.UTF8Encoding($false)))\n Write-Host \"= ${Label}Report written: $FileName\" -ForegroundColor Green\n } else {\n # Deliberately doesn't say \"unchanged\": this branch still returns 'written' below\n # and callers (Invoke-RemoteToolchainInventory's $written bucket, the end-of-run\n # summary) report it as such -- a message containing \"unchanged\" here would read\n # as contradicting that summary line to anyone scanning a scheduled-task log.\n Write-Host \"= ${Label}Report text unchanged; sidecar (re)written.\" -ForegroundColor DarkGray\n }\n $sidecarJson = (($Sidecar | ConvertTo-Json -Depth 6) -replace \"`r`n\", \"`n\") + \"`n\"\n [IO.File]::WriteAllText($sidecarFileName, $sidecarJson, (New-Object System.Text.UTF8Encoding($false)))\n\n if ($NoRender) {\n Write-Host \"= -NoRender: skipped console render/land.\" -ForegroundColor DarkGray\n } elseif ($SkipRenderReason) {\n Write-Host $SkipRenderReason -ForegroundColor DarkGray\n } elseif (-not $storeAvailable -or -not (Test-Path (Join-Path $ArtifactConsoleDir 'build.py'))) {\n $missing = @()\n if (-not $storeAvailable) { $missing += \"store not found: '$ArtifactSourcesDir'\" }\n if (-not (Test-Path (Join-Path $ArtifactConsoleDir 'build.py'))) {\n $missing += \"build.py not found under '$ArtifactConsoleDir'\"\n }\n Write-Host \"! $($missing -join '; '); wrote the report but skipped render.\" -ForegroundColor Yellow\n } else {\n $buildArgs = @('build.py', '--report', $Rel)\n if ($NoSync) { $buildArgs += '--no-sync' }\n Push-Location $ArtifactConsoleDir\n # Piped through Write-Host, not left as a bare native call (issue #715 follow-up):\n # Publish-ToolchainReport's own return value is CAPTURED by every caller\n # ($localResult = ...; $result = ... in Invoke-RemoteToolchainInventory), and a\n # captured function's un-redirected native-command output is swallowed into that\n # same captured collection instead of reaching the console/log -- verified directly\n # (`$x = function { & cmd /c echo hi }` yields $x = @('hi ', <return value>), and\n # nothing is printed) -- which is why build.py's own \"rendered / committed /\n # pushed\" lines stopped reaching the job log the moment this call moved from\n # top-level script code into a function whose result every caller assigns.\n # Write-Host bypasses the success-output stream entirely, so it is never captured\n # regardless of how the caller uses this function's return value.\n try { & $pyExe @buildArgs 2>&1 | ForEach-Object { Write-Host $_ } } finally { Pop-Location }\n # A native command's non-zero exit does NOT throw, even under $ErrorActionPreference =\n # 'Stop': $PSNativeCommandUseErrorActionPreference is off by default (verified $false on\n # pwsh 7.6.5). Without this check the line below announces a render that did not happen\n # and the script still exits 0 -- the same silent-success shape as the bug that buried\n # this whole branch pre-#320. Exit non-zero so the task's LastTaskResult shows it.\n if ($LASTEXITCODE -ne 0) {\n Write-Host \"! build.py exited $LASTEXITCODE; the report is written but NOT rendered: $Rel\" -ForegroundColor Red\n return 'render-failed'\n }\n Write-Host \"= Rendered to artifact-console$(if($NoSync){' (no sync)'}else{' + landed to memory repo'}): $Rel\" -ForegroundColor Green\n }\n return 'written'\n}\n\nfunction Resolve-ToolchainSshExe {\n <#\n ssh.exe resolved explicitly: the scheduled task's PATH is not the interactive one.\n Prefers the built-in OpenSSH client (present on every Windows 10/11 box since the\n optional feature shipped in-box) over whatever `ssh` a `Get-Command` search turns up,\n for the same reason Register-InventoryTask.ps1 resolves pwsh explicitly rather than\n trusting PATH.\n #>\n $sshExe = Join-Path $env:SystemRoot 'System32\\OpenSSH\\ssh.exe'\n if (Test-Path -LiteralPath $sshExe) { return $sshExe }\n $cmd = Get-Command ssh -ErrorAction SilentlyContinue\n if ($cmd) { return $cmd.Source }\n return $null\n}\n\nfunction Invoke-ToolchainSshTransport {\n <#\n Runs one SSH command, feeding -ScriptText to its stdin as EXACT bytes -- UTF-8, no BOM,\n no CR -- via a raw System.Diagnostics.Process rather than PowerShell's `|` pipeline\n operator into a native command.\n\n THE BUG THIS EXISTS TO FIX (issue #715 follow-up, caught by the real Melody run against\n real nitro/nucbox -- both `ssh exited 127: bash: line 1: $'hostname\\r': command not\n found`). The original transport was `$ScriptText | & $sshExe ...`: piping a string to a\n native command through PowerShell's own pipeline (or calling `.WriteLine()` on its\n StandardInput directly) terminates it with `Environment.NewLine` (`\\r\\n` on Windows)\n regardless of the string's own line endings -- verified directly (`od -An -c` on the\n raw bytes a real local process received). A trailing CRLF on its own does not reliably\n reproduce a FAILURE against a real local bash (a real local `bash -s` runs `hostname\\r\\n`\n fine); the exact failure mode over a real ssh channel could not be reproduced from this\n sandbox (no live target, and the orchestrator's post-merge run is the one place that\n can exercise real ssh). What IS provably true, and is what this function and its own\n tests are built around, is that writing exact, caller-specified bytes -- no CR/CRLF\n PowerShell's own string-to-native-pipe handling would otherwise add -- is unambiguously\n correct regardless of the precise mechanism behind the original failure, and a raw\n `System.Diagnostics.Process` with a direct byte-stream write is the one way to guarantee\n it. No fake `-Transport` scriptblock test can see ANY of this: it is specific to real\n native-process stdin behaviour, which is why this is now its own function, driven\n directly by a REAL local bash standing in for ssh (see test_toolchain_ssh_transport.py)\n rather than only ever exercised through a fake transport. The remote-side band-aid\n (`tr -d '\\r'`) was rejected: the SENDER has to be correct, not the receiver made\n tolerant of a bug whose exact trigger isn't even fully pinned down.\n\n -Arguments is the full, exact argv `$SshExe` runs with (`-o BatchMode=yes ...\n <target> bash -s` in production) -- passed in whole by the caller, not built here, so\n a test can point -SshExe at a real bash and -Arguments at just `@('-s')`, skipping the\n ssh-specific flags/target entirely, and still drive the SAME stdin-writing code this\n function uses for real. Throws (naming the exit code and any stderr) on a non-zero\n exit, matching the original transport's contract.\n #>\n param(\n [Parameter(Mandatory)][string]$SshExe,\n [Parameter(Mandatory)][string[]]$Arguments,\n [Parameter(Mandatory)][AllowEmptyString()][string]$ScriptText\n )\n if (-not $SshExe) { throw 'ssh.exe not found' }\n\n $psi = New-Object System.Diagnostics.ProcessStartInfo\n $psi.FileName = $SshExe\n foreach ($a in $Arguments) { [void]$psi.ArgumentList.Add($a) }\n $psi.RedirectStandardInput = $true\n $psi.RedirectStandardOutput = $true\n $psi.RedirectStandardError = $true\n # Explicit UTF-8 on the READ side too, not just the write: with neither set, .NET\n # falls back to the console's own output encoding (on this box, code page 437) for\n # decoding the child's stdout/stderr, silently mangling any non-ASCII byte a remote\n # tool's version string or path can carry -- verified directly (round-tripping\n # 'cafe unicode' through an unset-encoding child comes back corrupted). \"Byte-exact\"\n # cuts both ways: the fix is for the WRITE side's bug, but the read side gets the same\n # standard while this function is already being rewritten.\n $utf8NoBom = New-Object System.Text.UTF8Encoding($false)\n $psi.StandardOutputEncoding = $utf8NoBom\n $psi.StandardErrorEncoding = $utf8NoBom\n $psi.UseShellExecute = $false\n $psi.CreateNoWindow = $true\n\n $proc = [System.Diagnostics.Process]::Start($psi)\n try {\n # Reads started BEFORE writing stdin, and run async throughout: the child can start\n # producing output before we finish writing input, and on a large enough probe\n # script writing everything first (with nothing draining stdout/stderr) risks the\n # classic same-process pipe deadlock -- the child blocks on a full stdout/stderr\n # pipe while we block trying to finish writing a full stdin pipe.\n $stdoutTask = $proc.StandardOutput.ReadToEndAsync()\n $stderrTask = $proc.StandardError.ReadToEndAsync()\n try {\n # LF only, defensively: New-ToolchainProbeScript already emits LF-only text\n # (its own `-replace \"`r`n\", \"`n\"`), but this is the one place a CR could\n # still slip through -- a one-line literal like 'hostname' has no line ending\n # of its own to normalise, and a future caller might pass one that does.\n $normalized = $ScriptText -replace \"`r`n\", \"`n\" -replace \"`r\", \"`n\"\n $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)\n $proc.StandardInput.BaseStream.Write($bytes, 0, $bytes.Length)\n $proc.StandardInput.BaseStream.Flush()\n } catch {\n # A process that exits (or closes its own stdin) before or while we're still\n # writing -- e.g. a real ssh auth failure (\"Permission denied (publickey)\"),\n # which never even spawns bash -- throws here (\"the pipe is being closed\")\n # BEFORE the real, useful failure reason below is ever reached. Swallowing it\n # is deliberate: the process's own exit code + stderr, read next, is the\n # actual cause, and a caller told \"the pipe is being closed\" instead of\n # \"Permission denied\" or \"Connection refused\" has lost the one thing this\n # whole function exists to report accurately.\n Write-Verbose \"stdin write failed (process likely already exited): $($_.Exception.Message)\"\n } finally {\n try { $proc.StandardInput.Close() } catch { Write-Verbose \"stdin already closed.\" }\n }\n $proc.WaitForExit()\n $stdout = $stdoutTask.GetAwaiter().GetResult()\n $stderr = $stderrTask.GetAwaiter().GetResult()\n\n if ($proc.ExitCode -ne 0) {\n throw \"ssh exited $($proc.ExitCode)$(if ($stderr.Trim()) { \": $($stderr.Trim())\" })\"\n }\n return $stdout\n } finally {\n $proc.Dispose()\n }\n}\n\nfunction Invoke-RemoteToolchainInventory {\n <#\n Probes every configured SSH target, publishing one toolchain-inventory-<alias>.md per\n reachable host and leaving an unreachable host's previous report untouched (#715).\n\n -Transport is `param($target, $scriptText)` -> raw stdout as one string, and throws (or\n the caller treats a null/empty hostname response as unreachable too) when the host\n can't be reached; the production transport pipes the script over\n `ssh -o BatchMode=yes -o ConnectTimeout=8 <target> bash -s` (script on stdin, no remote\n temp file), with ssh.exe resolved explicitly since the scheduled task's PATH is not the\n interactive one. -Now is the SAME 'yyyy-MM-dd HH:mm' string the local report's\n _Generated: line uses, so every report from one run agrees. -LocalHost names where the\n SSH hop originates, for the remote _Generated: line's \"via ssh from <local>\".\n\n Per target, in order: the hostname (the transport given the one-line script\n `hostname`, so the title/`_Generated:` line carries the real name, not the alias) --\n then the host-agnostic probe script through the same transport, parsed, built into a\n single-`linux`-side report, and published under `-Label '<alias>: '`. A failure at any\n step for one target is caught, printed as `! <alias>: unreachable (<reason>)`, and does\n NOT stop the loop -- the local report and every other target still publish.\n #>\n param(\n [Parameter(Mandatory)][array]$Targets,\n [Parameter(Mandatory)][scriptblock]$Transport,\n [Parameter(Mandatory)][string]$Now,\n [Parameter(Mandatory)][string]$NowIso,\n [Parameter(Mandatory)][string]$LocalHost,\n [switch]$NoRender\n )\n\n $written = @()\n $unchanged = @()\n $failed = @()\n\n foreach ($pair in $Targets) {\n $alias, $target = $pair\n try {\n $remoteHostName = ((& $Transport $target 'hostname') | Out-String).Trim()\n if (-not $remoteHostName) { throw 'empty hostname response' }\n\n $probeScript = New-ToolchainProbeScript -Catalog $catalog\n $raw = (& $Transport $target $probeScript) | Out-String\n $parsed = ConvertFrom-ToolchainProbeOutput -Raw $raw -Catalog $catalog\n\n $side = @{\n Title = 'Linux'\n Side = 'linux'\n Rows = $parsed.Rows\n GlobalsBlocks = @(@{ Label = $null; Text = $parsed.GlobalsText })\n Unknown = $parsed.Unknown\n }\n $report = New-ToolchainReport -HostLabel \"$remoteHostName ($alias)\" `\n -GeneratedOn \"$remoteHostName via ssh from $LocalHost\" -Now $Now -Sides @($side) `\n -HostId $alias -Hostname $remoteHostName -Os 'linux' -GeneratedIso $NowIso\n\n $remoteFileName = \"toolchain-inventory-$alias.md\"\n $remoteOutPath = if ($storeAvailable) { Join-Path $storeDir $remoteFileName } else { Join-Path $stateDir $remoteFileName }\n $remoteRel = \"$ArtifactCategory/$remoteFileName\"\n\n $result = Publish-ToolchainReport -FileName $remoteOutPath -Text $report.Markdown `\n -Sidecar $report.Sidecar -Rel $remoteRel -Label \"${alias}: \" -NoRender:$NoRender\n # 'render-failed' (build.py exited non-zero) is isolated exactly like an\n # unreachable host: the report DID reach the store, but is not counted as a\n # clean success, and this alias must not stop the loop for the others.\n if ($result -eq 'written') { $written += $alias }\n elseif ($result -eq 'unchanged') { $unchanged += $alias }\n else { $failed += $alias }\n } catch {\n Write-Host \"! ${alias}: unreachable ($($_.Exception.Message))\" -ForegroundColor Yellow\n $failed += $alias\n }\n }\n\n return @{\n Written = $written\n Unchanged = $unchanged\n Failed = $failed\n ExitCode = $(if ($failed.Count -gt 0) { 2 } else { 0 })\n }\n}\n\n# Run the probing/report/render flow only when executed (pwsh -File / & script.ps1),\n# not when dot-sourced for testing (. ./Get-ToolchainInventory.ps1), which only wants\n# the functions and the catalog above defined.\nif ($MyInvocation.InvocationName -ne '.') {\n\n Write-Host \"= Toolchain inventory: probing Windows side...\" -ForegroundColor Cyan\n\n # ---------------------------------------------------------------------------\n # Windows probing\n # ---------------------------------------------------------------------------\n $winRows = @()\n foreach ($tool in $catalog) {\n $r = Get-WinToolInfo $tool\n if ($r) { $winRows += $r; Write-Host (\" + {0,-16} {1}\" -f $r.Name, $r.Version) }\n }\n\n # Windows global packages\n $winGlobals = [ordered]@{}\n function Add-WinGlobal($label, $exe, $cmdArgs) {\n if (Get-Command $exe -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {\n try {\n # stdout only -- keep error spew (e.g. a missing npm global prefix) out of the report\n $o = (& $exe @cmdArgs 2>$null | Out-String).Trim()\n if ($o) { $script:winGlobals[$label] = $o }\n } catch { Write-Verbose \"$label ($exe) failed; leaving it out of the report.\" }\n }\n }\n Add-WinGlobal 'npm -g' 'npm' @('ls','-g','--depth=0')\n Add-WinGlobal 'pipx' 'pipx' @('list','--short')\n Add-WinGlobal 'uv tool' 'uv' @('tool','list')\n Add-WinGlobal 'cargo install' 'cargo' @('install','--list')\n Add-WinGlobal 'gem (user)' 'gem' @('list','--local')\n if (Get-Command go -CommandType Application,ExternalScript -ErrorAction SilentlyContinue) {\n try {\n $gp = (& go env GOPATH 2>$null).Trim()\n $gobin = if ($env:GOBIN) { $env:GOBIN } else { Join-Path $gp 'bin' }\n if (Test-Path $gobin) {\n $bins = Get-ChildItem $gobin -File -ErrorAction SilentlyContinue |\n Select-Object -ExpandProperty Name\n if ($bins) { $winGlobals['go install'] = ($bins -join \"`n\") }\n }\n } catch { Write-Verbose \"go env GOPATH failed; leaving the 'go install' list out of the report.\" }\n }\n\n # Uncatalogued dev tools sitting on PATH (heads-up section). @() forces array\n # semantics so a single hit isn't unwrapped to a bare hashtable (whose .Count\n # would be its key count, not 1).\n $winUnknown = @(Get-UncataloguedWin)\n if ($winUnknown.Count) { Write-Host (\" ~ {0} uncatalogued on PATH\" -f $winUnknown.Count) -ForegroundColor DarkYellow }\n\n # ---------------------------------------------------------------------------\n # WSL probing. The probe logic is written to a real .sh file on disk and run\n # via `bash <file>` -- NOT passed as a `bash -lc <string>` argument, because\n # wsl.exe strips backslash escapes (\\t \\n \\r) from command-line arguments,\n # which silently breaks IFS/printf/tr. A script on disk is immune to that.\n # ---------------------------------------------------------------------------\n function ConvertTo-WslPath([string]$winPath) {\n $p = (& wsl.exe -d $WslDistro wslpath -a ($winPath -replace '\\\\','/') 2>$null)\n $p = if ($p) { \"$p\".Trim() } else { '' }\n if (-not $p) {\n $drive = $winPath.Substring(0,1).ToLower()\n $p = \"/mnt/$drive\" + ($winPath.Substring(2) -replace '\\\\','/')\n }\n return $p\n }\n\n $wslRows = @()\n $wslGlobalsText = ''\n $wslUnknown = @()\n $wslAvailable = $false\n if ($WslDistro) {\n $wslExe = Get-Command wsl.exe -ErrorAction SilentlyContinue\n if ($wslExe) {\n # Is the distro registered? (wsl -l -q emits UTF-16 unless WSL_UTF8 set)\n $env:WSL_UTF8 = '1'\n $distros = (& wsl.exe -l -q 2>$null) -split \"`n\" | ForEach-Object { $_.Trim() } | Where-Object { $_ }\n if ($distros -contains $WslDistro) {\n $wslAvailable = $true\n Write-Host \"= Probing WSL:$WslDistro side...\" -ForegroundColor Cyan\n $utf8 = New-Object System.Text.UTF8Encoding($false)\n\n $probeScript = New-ToolchainProbeScript -Catalog $catalog\n\n # WSL transport: the script is written to a real file on disk and run via\n # `bash <path>`, never passed as a `bash -lc <string>` argument -- wsl.exe\n # strips backslash escapes from arguments, per the header comment above.\n # TC-3's SSH transport will pipe `bash -s` on stdin instead.\n $wslTransport = {\n param([string]$ScriptText)\n $shPath = Join-Path $tmpDir 'probe.sh'\n [IO.File]::WriteAllText($shPath, $ScriptText, $utf8)\n $wslSh = ConvertTo-WslPath $shPath\n $raw = (& wsl.exe -d $WslDistro -- bash $wslSh 2>$null | Out-String) -replace \"`r\",''\n Remove-Item $shPath -ErrorAction SilentlyContinue\n return $raw\n }\n\n $raw = Invoke-ToolchainProbe -ScriptText $probeScript -Transport $wslTransport\n $parsed = ConvertFrom-ToolchainProbeOutput -Raw $raw -Catalog $catalog\n $wslRows = $parsed.Rows\n $wslGlobalsText = $parsed.GlobalsText\n $wslUnknown = $parsed.Unknown\n foreach ($r in $wslRows) { Write-Host (\" + {0,-16} {1}\" -f $r.Name, $r.Version) }\n if ($wslUnknown.Count) { Write-Host (\" ~ {0} uncatalogued on PATH\" -f $wslUnknown.Count) -ForegroundColor DarkYellow }\n } else {\n Write-Host \"! WSL distro '$WslDistro' not registered; skipping WSL side.\" -ForegroundColor Yellow\n }\n } else {\n Write-Host \"! wsl.exe not found; skipping WSL side.\" -ForegroundColor Yellow\n }\n }\n\n # ---------------------------------------------------------------------------\n # Build + publish the LOCAL report (Windows + WSL), via the shared functions above.\n # ---------------------------------------------------------------------------\n $now = Get-Date -Format 'yyyy-MM-dd HH:mm'\n $nowIso = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')\n\n $winGlobalsBlocks = @($winGlobals.Keys | ForEach-Object { @{ Label = $_; Text = $winGlobals[$_] } })\n $sides = @(@{ Title = 'Windows'; Side = 'windows'; Rows = $winRows; GlobalsBlocks = $winGlobalsBlocks; Unknown = $winUnknown })\n # The machine goes in the TITLE, not only the _Generated: line -- a reader who opens the\n # rendered page should not have to work out whose toolchain they are looking at, which\n # was a defect even back when only one machine wrote this file (issue #231).\n $wslCountLabel = if ($wslAvailable) { \"WSL:$WslDistro $($wslRows.Count) tools\" } else { 'WSL not probed' }\n if ($wslAvailable) {\n $sides += @{ Title = \"WSL: $WslDistro\"; Side = \"wsl:$WslDistro\"; Rows = $wslRows\n GlobalsBlocks = @(@{ Label = $null; Text = $wslGlobalsText }); Unknown = $wslUnknown }\n }\n $localReport = New-ToolchainReport -HostLabel $hostName `\n -GeneratedOn \"$hostName (editors: $(if($includeEd){'included'}else{'excluded'}))\" -Now $now `\n -SummaryLine \"**Windows:** $($winRows.Count) tools · **$wslCountLabel**\" `\n -HistoryRel \"$ArtifactCategory/$fileName\" -Sides $sides `\n -HostId $machine -Hostname $hostName -Os 'windows' -GeneratedIso $nowIso\n $mdText = $localReport.Markdown\n\n # Always record the last run time (out-of-repo state, untracked).\n [IO.File]::WriteAllText((Join-Path $stateDir 'last-run.txt'), \"$now on $hostName`n\",\n (New-Object System.Text.UTF8Encoding($false)))\n\n # -OutFile is the local-test path: the report went where the caller asked, NOT into the\n # store -- but the store's copy (named by $rel below) is a different file that may be\n # stale or absent, so rendering here would publish something this run did not write.\n # `$PSBoundParameters` is a bind-time snapshot, so it still answers \"did the caller pass\n # it\" after the store branch earlier in the script assigned $OutFile itself. Indexed\n # rather than .ContainsKey(), so an explicitly EMPTY `-OutFile ''` is falsy here exactly\n # as it is at the two `-not $OutFile` tests above: that run does write to the store, so\n # it is the one that SHOULD render.\n $localSkipReason = if ($PSBoundParameters['OutFile']) {\n '= -OutFile: wrote the report there; skipped the store render/land.'\n } else { '' }\n $localResult = Publish-ToolchainReport -FileName $OutFile -Text $mdText -Sidecar $localReport.Sidecar `\n -Rel \"$ArtifactCategory/$fileName\" -NoRender:$NoRender -SkipRenderReason $localSkipReason\n\n # ---------------------------------------------------------------------------\n # SSH toolchain probing (issue #715, TC-3). Runs regardless of whether the local\n # report changed -- a stable local box must not hide a drifting SSH host, or vice\n # versa, which is exactly why the local branch above no longer returns early.\n # ---------------------------------------------------------------------------\n $sshTargetPairs = ConvertTo-ToolchainSshTargets $SshTargets\n # Every fragment malformed (only reachable from a hand-typed -SshTargets -- the\n # scheduled/registered path always carries config.py-validated values) must not look\n # identical to \"no SSH targets configured\": that would silently probe nothing and\n # still exit 0, hiding a typo behind the one Yellow line ConvertTo-ToolchainSshTargets\n # already printed per skipped fragment. $SshTargets non-empty but $sshTargetPairs\n # empty is the tell.\n $sshTargetsAllMalformed = [bool]$SshTargets -and (@($sshTargetPairs).Count -eq 0)\n if ($sshTargetsAllMalformed) {\n Write-Host \"! -SshTargets was set but every fragment was malformed; nothing was probed.\" -ForegroundColor Red\n }\n $remote = $null\n if (@($sshTargetPairs).Count -gt 0) {\n Write-Host \"= Probing $(@($sshTargetPairs).Count) SSH host(s)...\" -ForegroundColor Cyan\n $sshExe = Resolve-ToolchainSshExe\n # Thin closure over Invoke-ToolchainSshTransport (issue #715 follow-up): the real\n # byte-exact stdin write lives there, driven directly by its own tests against a\n # real local bash; this just supplies the production argv and executable.\n $sshTransport = {\n param([string]$Target, [string]$ScriptText)\n Invoke-ToolchainSshTransport -SshExe $sshExe `\n -Arguments @('-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', $Target, 'bash', '-s') `\n -ScriptText $ScriptText\n }\n $remote = Invoke-RemoteToolchainInventory -Targets $sshTargetPairs -Transport $sshTransport `\n -Now $now -NowIso $nowIso -LocalHost $hostName -NoRender:$NoRender\n if ($remote.Written.Count -gt 0) {\n Write-Host (\"= SSH hosts written: {0}\" -f ($remote.Written -join ', ')) -ForegroundColor Green\n }\n if ($remote.Unchanged.Count -gt 0) {\n Write-Host (\"= SSH hosts unchanged: {0}\" -f ($remote.Unchanged -join ', ')) -ForegroundColor DarkGray\n }\n if ($remote.Failed.Count -gt 0) {\n Write-Host (\"! SSH hosts unreachable: {0}\" -f ($remote.Failed -join ', ')) -ForegroundColor Yellow\n }\n }\n\n Write-Host \"= Done.\" -ForegroundColor Cyan\n # A remote failure (2) outranks a local-only render failure (1, the pre-#715 behaviour\n # of exiting non-zero when build.py fails) -- both are folded in HERE, after SSH\n # probing has already run to completion, never as an early `exit` from inside\n # Publish-ToolchainReport (which would abort probing before it started).\n $exitCode = 0\n if ($localResult -eq 'render-failed') { $exitCode = 1 }\n if ($sshTargetsAllMalformed) { $exitCode = 2 }\n if ($remote -and $remote.ExitCode -ne 0) { $exitCode = $remote.ExitCode }\n if ($exitCode -ne 0) { exit $exitCode }\n}\n";
|
|
75454
|
+
|
|
75455
|
+
/**
|
|
75456
|
+
* Runs 1.x's own `Get-ToolchainInventory.ps1` through `pwsh` (#52, 52b; 1.x had no Linux
|
|
75457
|
+
* equivalent, `toolchain_sidecar.py`, `sched_spec.py:264-273`), kind `toolchain-inventory`,
|
|
75458
|
+
* capability `agent.scan.toolchain-inventory`. Shipped and run, not rewritten (`data/README.md`):
|
|
75459
|
+
* the script is 1,397 lines whose output is the byte-compare target, so a TypeScript rewrite
|
|
75460
|
+
* would have to re-derive every probe and formatting rule and still match it byte for byte.
|
|
75461
|
+
*
|
|
75462
|
+
* Windows-only, as 1.x was: on any other platform, `run` returns a skipped payload without
|
|
75463
|
+
* spawning anything. The invocation is the script's own local-test path (`-OutFile -NoRender`,
|
|
75464
|
+
* no `-SshTargets`/`-SourcesDir`/`-PythonExe`, no `-WslDistro` override beyond the script's own
|
|
75465
|
+
* default) -- it calls no Python, needs no artifacts store, renders nothing, and writes the
|
|
75466
|
+
* `.md` report plus its `.json` sidecar next to it. The child runs with `cwd` set to the scans
|
|
75467
|
+
* directory, never the agent's own cwd: the catalog probes include `pnpm --version`, and pnpm 12
|
|
75468
|
+
* rewrites `pnpm-lock.yaml` in whatever directory it runs in.
|
|
75469
|
+
*/
|
|
75470
|
+
const TIMEOUT_MS$1 = 600_000;
|
|
75471
|
+
const SCRIPT_FILENAME = "Get-ToolchainInventory.ps1";
|
|
75472
|
+
function scansDir$1(stateDir) {
|
|
75473
|
+
return path.join(stateDir, "scans");
|
|
75474
|
+
}
|
|
75475
|
+
function sha256Hex(text) {
|
|
75476
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
75477
|
+
}
|
|
75478
|
+
/** Writes the embedded script to `<state>/scans/Get-ToolchainInventory.ps1` when absent or its
|
|
75479
|
+
* bytes differ from the bundle's copy (a sha-256 compare, so the state dir always holds the
|
|
75480
|
+
* bundle's copy), via a temp name in the same directory and a rename -- so a killed agent never
|
|
75481
|
+
* leaves a half-written script. Leaves an identical file untouched (no write, no rename). */
|
|
75482
|
+
function ensureScript(fs, stateDir) {
|
|
75483
|
+
const dir = scansDir$1(stateDir);
|
|
75484
|
+
fs.mkdir(dir);
|
|
75485
|
+
const dest = path.join(dir, SCRIPT_FILENAME);
|
|
75486
|
+
const existing = fs.readFile(dest);
|
|
75487
|
+
if (existing !== null && sha256Hex(existing.toString("utf8")) === sha256Hex(scriptText)) {
|
|
75488
|
+
return dest;
|
|
75489
|
+
}
|
|
75490
|
+
const tmp = path.join(dir, `.${SCRIPT_FILENAME}.${process.pid}-${Date.now()}.tmp`);
|
|
75491
|
+
fs.writeFile(tmp, scriptText);
|
|
75492
|
+
fs.rename(tmp, dest);
|
|
75493
|
+
return dest;
|
|
75494
|
+
}
|
|
75495
|
+
/** The file-name suffix the script itself uses (`[System.Net.Dns]::GetHostName()`, matched by
|
|
75496
|
+
* `os.hostname()`) -- not decision 9's configured `machine`/`--name`, so the byte-compare's file
|
|
75497
|
+
* name always agrees with 1.x's own regardless of an operator override. */
|
|
75498
|
+
function sanitizeMachine(hostname) {
|
|
75499
|
+
return hostname.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
75500
|
+
}
|
|
75501
|
+
function lastNonEmptyLine$1(text) {
|
|
75502
|
+
const lines = text
|
|
75503
|
+
.split("\n")
|
|
75504
|
+
.map((line) => line.replace(/\r$/, ""))
|
|
75505
|
+
.filter((line) => line.length > 0);
|
|
75506
|
+
return lines.length > 0 ? lines[lines.length - 1] : "";
|
|
75507
|
+
}
|
|
75508
|
+
// The script's own three result literals (local run, empty -Label): `startsWith`, never
|
|
75509
|
+
// `includes`, so the missing-sidecar backfill line -- which also happens to contain the word
|
|
75510
|
+
// "unchanged" -- is never confused with the genuinely-unchanged line above it.
|
|
75511
|
+
const WRITTEN_LINE_PREFIXES = ["= Report written:", "= Report text unchanged; sidecar (re)written."];
|
|
75512
|
+
const UNCHANGED_LINE_PREFIX = "= No toolchain changes since last run";
|
|
75513
|
+
function classifyResult(stdout) {
|
|
75514
|
+
for (const raw of stdout.split("\n")) {
|
|
75515
|
+
const line = raw.replace(/\r$/, "");
|
|
75516
|
+
if (WRITTEN_LINE_PREFIXES.some((prefix) => line.startsWith(prefix)))
|
|
75517
|
+
return "written";
|
|
75518
|
+
if (line.startsWith(UNCHANGED_LINE_PREFIX))
|
|
75519
|
+
return "unchanged";
|
|
75520
|
+
}
|
|
75521
|
+
throw new Error("Get-ToolchainInventory.ps1: no recognized result line in stdout");
|
|
75522
|
+
}
|
|
75523
|
+
async function runToolchainInventory(deps, signal) {
|
|
75524
|
+
if (deps.platform !== "win32") {
|
|
75525
|
+
return {
|
|
75526
|
+
skipped: true,
|
|
75527
|
+
reason: "toolchain-inventory is Windows-only (1.x had no Linux equivalent)",
|
|
75528
|
+
};
|
|
75529
|
+
}
|
|
75530
|
+
const scriptFile = ensureScript(deps.fs, deps.stateDir);
|
|
75531
|
+
const machine = sanitizeMachine(deps.hostname());
|
|
75532
|
+
const fileName = `toolchain-inventory-${machine}.md`;
|
|
75533
|
+
const dir = scansDir$1(deps.stateDir);
|
|
75534
|
+
const outFile = path.join(dir, fileName);
|
|
75535
|
+
// A `shim` result (a `.cmd`/`.bat` on PATH ahead of the native binary) is impossible for pwsh
|
|
75536
|
+
// in practice but is mapped to the same error as `not-found` -- `found` is false either way.
|
|
75537
|
+
const found = deps.which("pwsh");
|
|
75538
|
+
if (!found.found)
|
|
75539
|
+
throw new Error("pwsh not found on PATH");
|
|
75540
|
+
const result = await deps.run(found.path, ["-NoProfile", "-File", scriptFile, "-OutFile", outFile, "-NoRender"], { cwd: dir, timeoutMs: TIMEOUT_MS$1, signal });
|
|
75541
|
+
// Checked in this order deliberately: when the scan's own request timeout and this signal fire
|
|
75542
|
+
// at the same instant (verbs/scan.ts aborts at the same timeoutMs this run was given), the
|
|
75543
|
+
// internal timer's `timedOut` is what gets reported here -- 49c has already answered its own
|
|
75544
|
+
// 504 to the client by then, so only this background log line ever reads which one won.
|
|
75545
|
+
if (result.timedOut) {
|
|
75546
|
+
throw new Error(`Get-ToolchainInventory.ps1 timed out after ${TIMEOUT_MS$1} ms`);
|
|
75547
|
+
}
|
|
75548
|
+
if (result.aborted) {
|
|
75549
|
+
throw new Error("scan aborted");
|
|
75550
|
+
}
|
|
75551
|
+
if (result.code !== 0) {
|
|
75552
|
+
const detail = lastNonEmptyLine$1(result.stderr) || lastNonEmptyLine$1(result.stdout);
|
|
75553
|
+
throw new Error(`Get-ToolchainInventory.ps1 exited ${result.code}: ${detail}`);
|
|
75554
|
+
}
|
|
75555
|
+
const resultKind = classifyResult(result.stdout);
|
|
75556
|
+
const markdownBuf = deps.fs.readFile(outFile);
|
|
75557
|
+
if (markdownBuf === null) {
|
|
75558
|
+
throw new Error(`Get-ToolchainInventory.ps1: could not read ${outFile}`);
|
|
75559
|
+
}
|
|
75560
|
+
const sidecarPath = outFile.replace(/\.md$/, ".json");
|
|
75561
|
+
const sidecarBuf = deps.fs.readFile(sidecarPath);
|
|
75562
|
+
if (sidecarBuf === null) {
|
|
75563
|
+
throw new Error(`Get-ToolchainInventory.ps1: could not read ${sidecarPath}`);
|
|
75564
|
+
}
|
|
75565
|
+
return {
|
|
75566
|
+
machine,
|
|
75567
|
+
fileName,
|
|
75568
|
+
markdown: markdownBuf.toString("utf8"),
|
|
75569
|
+
sidecar: JSON.parse(sidecarBuf.toString("utf8")),
|
|
75570
|
+
result: resultKind,
|
|
75571
|
+
};
|
|
75572
|
+
}
|
|
75573
|
+
|
|
75574
|
+
const CONTEXT_PRESSURE_FLOAT_KEYS = new Set(["generatedAt", "peakPct", "lastAt", "mtime"]);
|
|
75575
|
+
const SUBAGENT_COST_FLOAT_KEYS = new Set(["generatedAt", "costUsd", "lastAt", "mtime"]);
|
|
75576
|
+
const TIMEOUT_MS = 900_000;
|
|
75577
|
+
function scansDir(deps) {
|
|
75578
|
+
return path.join(deps.stateDir, "scans");
|
|
75579
|
+
}
|
|
75580
|
+
function cachePath(deps, kind) {
|
|
75581
|
+
return path.join(scansDir(deps), `${kind}.json`);
|
|
75582
|
+
}
|
|
75583
|
+
function loadPrevious(deps, kind) {
|
|
75584
|
+
const raw = deps.fs.readFile(cachePath(deps, kind));
|
|
75585
|
+
return parseCache(raw !== null ? raw.toString("utf8") : null);
|
|
75586
|
+
}
|
|
75587
|
+
/** 1.x refuses to clobber a good cache with an empty one (`main()`'s `if not data["scanned"]`
|
|
75588
|
+
* branch) -- a `scanned === 0` result is returned to the caller same as any other, just never
|
|
75589
|
+
* written. */
|
|
75590
|
+
function writeIfNonEmpty(deps, kind, result, floatKeys) {
|
|
75591
|
+
if (result.scanned === 0)
|
|
75592
|
+
return;
|
|
75593
|
+
const text = dumps(result, { indent: 2, floatKeys });
|
|
75594
|
+
deps.fs.mkdir(scansDir(deps));
|
|
75595
|
+
deps.fs.writeFile(cachePath(deps, kind), writeCache(text));
|
|
75596
|
+
}
|
|
75597
|
+
// `ctx.signal` (49c's own abort/timeout bookkeeping) is honoured; `ctx.now` is not -- it is the
|
|
75598
|
+
// verb dispatcher's real wall-clock at request-arrival, only used for its own response envelope.
|
|
75599
|
+
// `collect`'s `generatedAt` (1.x's `time.time()`) comes from `deps.now` instead, so a test can
|
|
75600
|
+
// inject a fixed or non-integral clock without a real request in flight.
|
|
75601
|
+
function registerScanKinds(scans, deps) {
|
|
75602
|
+
scans.register("context-pressure", {
|
|
75603
|
+
timeoutMs: TIMEOUT_MS,
|
|
75604
|
+
run: async (ctx) => {
|
|
75605
|
+
const root = deps.projectsRoot();
|
|
75606
|
+
if (!deps.fs.isDir(root))
|
|
75607
|
+
throw new Error(`no session-log root at ${root}`);
|
|
75608
|
+
const previous = loadPrevious(deps, "context-pressure");
|
|
75609
|
+
const result = collect$1(root, previous, ctx.signal, deps.fs, deps.now);
|
|
75610
|
+
writeIfNonEmpty(deps, "context-pressure", result, CONTEXT_PRESSURE_FLOAT_KEYS);
|
|
75611
|
+
return result;
|
|
75612
|
+
},
|
|
75613
|
+
});
|
|
75614
|
+
scans.register("subagent-cost", {
|
|
75615
|
+
timeoutMs: TIMEOUT_MS,
|
|
75616
|
+
run: async (ctx) => {
|
|
75617
|
+
const root = deps.projectsRoot();
|
|
75618
|
+
if (!deps.fs.isDir(root))
|
|
75619
|
+
throw new Error(`no session-log root at ${root}`);
|
|
75620
|
+
const previous = loadPrevious(deps, "subagent-cost");
|
|
75621
|
+
const result = collect(root, previous, ctx.signal, deps.fs, deps.now);
|
|
75622
|
+
writeIfNonEmpty(deps, "subagent-cost", result, SUBAGENT_COST_FLOAT_KEYS);
|
|
75623
|
+
return result;
|
|
75624
|
+
},
|
|
75625
|
+
});
|
|
75626
|
+
// Producer only, never cached (52b, E4 plan §7.8: a cold run is kilobytes of I/O, not the
|
|
75627
|
+
// ~940MB context-pressure reads that motivate a cache). No custom timeoutMs -- the registry's
|
|
75628
|
+
// own default is generous enough for "~8 stats per repo against small text files".
|
|
75629
|
+
scans.register("memory-footprint", {
|
|
75630
|
+
run: async (ctx) => {
|
|
75631
|
+
const { repos } = listRepos(deps.reposRoot(), deps.repoFs);
|
|
75632
|
+
return overviewSummary(deps.fs, repos, deps.homedir(), deps.projectsRoot(), deps.platform, deps.env, ctx.signal);
|
|
75633
|
+
},
|
|
75634
|
+
});
|
|
75635
|
+
scans.register("toolchain-inventory", {
|
|
75636
|
+
timeoutMs: TIMEOUT_MS$1,
|
|
75637
|
+
run: (ctx) => runToolchainInventory({
|
|
75638
|
+
platform: deps.platform,
|
|
75639
|
+
stateDir: deps.stateDir,
|
|
75640
|
+
hostname: deps.hostname,
|
|
75641
|
+
which: deps.which,
|
|
75642
|
+
run: deps.run,
|
|
75643
|
+
fs: deps.fs,
|
|
75644
|
+
}, ctx.signal),
|
|
75645
|
+
});
|
|
75646
|
+
}
|
|
75647
|
+
|
|
75648
|
+
var index$1 = /*#__PURE__*/Object.freeze({
|
|
75649
|
+
__proto__: null,
|
|
75650
|
+
registerScanKinds: registerScanKinds
|
|
75651
|
+
});
|
|
75652
|
+
|
|
74120
75653
|
const ABANDON_MULTIPLIER = 5;
|
|
74121
75654
|
/** Runs the scan and settles `inFlight`'s bookkeeping for it, independent of whatever the request
|
|
74122
75655
|
* handler itself already answered the client with (it may have already returned a `504`). Skips
|
|
@@ -74205,4 +75738,324 @@ var scan = /*#__PURE__*/Object.freeze({
|
|
|
74205
75738
|
registerScanVerbs: registerScanVerbs
|
|
74206
75739
|
});
|
|
74207
75740
|
|
|
75741
|
+
const REG_TIMEOUT_MS = 10_000;
|
|
75742
|
+
const HKLM_KEY = String.raw `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment`;
|
|
75743
|
+
const HKCU_KEY = String.raw `HKCU\Environment`;
|
|
75744
|
+
// reg.exe's own output shape: a blank line, the key line, then the value line indented four
|
|
75745
|
+
// spaces with four-space column gaps (" Path REG_EXPAND_SZ <value>"), CRLF, a trailing
|
|
75746
|
+
// blank line. The value can itself contain spaces ("C:\Program Files\Git\cmd"), so this is never
|
|
75747
|
+
// split on whitespace -- matched as one line and group 2 taken whole.
|
|
75748
|
+
const VALUE_LINE = /^\s*\S+\s+(REG_EXPAND_SZ|REG_SZ)\s+(.*)$/m;
|
|
75749
|
+
function expandVars(value, env) {
|
|
75750
|
+
const upper = new Map();
|
|
75751
|
+
for (const [name, v] of Object.entries(env)) {
|
|
75752
|
+
if (v !== undefined)
|
|
75753
|
+
upper.set(name.toUpperCase(), v);
|
|
75754
|
+
}
|
|
75755
|
+
return value.replace(/%([^%]+)%/g, (whole, name) => upper.get(name.toUpperCase()) ?? whole);
|
|
75756
|
+
}
|
|
75757
|
+
/** One `reg.exe query <key> /v Path`. A non-zero exit -- including the documented "missing value"
|
|
75758
|
+
* case (`ERROR: The system was unable to find the specified registry key or value.` on stderr) --
|
|
75759
|
+
* is treated as an empty hive, not a throw: `ok: false` lets the caller decide whether BOTH hives
|
|
75760
|
+
* failing means falling back to the process PATH entirely. */
|
|
75761
|
+
async function readHive(regExe, key, deps) {
|
|
75762
|
+
const result = await deps.run(regExe, ["query", key, "/v", "Path"], { timeoutMs: REG_TIMEOUT_MS });
|
|
75763
|
+
if (result.code !== 0)
|
|
75764
|
+
return { ok: false, value: "" };
|
|
75765
|
+
const stripped = result.stdout.replace(/\r/g, "");
|
|
75766
|
+
const match = VALUE_LINE.exec(stripped);
|
|
75767
|
+
const raw = match?.[2] ?? "";
|
|
75768
|
+
return { ok: true, value: expandVars(raw, deps.env) };
|
|
75769
|
+
}
|
|
75770
|
+
/** `{ path, source }` for the machine this process is actually on (E4 §5 decision 19). Win32: both
|
|
75771
|
+
* hives, joined machine-then-user with `;` (a missing/failed hive contributes "", which
|
|
75772
|
+
* `findExecutable` already skips as an empty PATH entry); both hives failing falls back to the
|
|
75773
|
+
* process's own PATH/Path, exactly like the non-Windows case below. Everywhere else: the process
|
|
75774
|
+
* PATH, and `reg.exe` is never invoked at all. */
|
|
75775
|
+
async function searchPath(deps) {
|
|
75776
|
+
const processFallback = () => ({
|
|
75777
|
+
path: deps.env.PATH ?? deps.env.Path ?? "",
|
|
75778
|
+
source: "process",
|
|
75779
|
+
});
|
|
75780
|
+
if (deps.platform !== "win32")
|
|
75781
|
+
return processFallback();
|
|
75782
|
+
const systemRoot = deps.env.SystemRoot ?? String.raw `C:\Windows`;
|
|
75783
|
+
const regExe = path.win32.join(systemRoot, "System32", "reg.exe");
|
|
75784
|
+
const [machine, user] = await Promise.all([
|
|
75785
|
+
readHive(regExe, HKLM_KEY, deps),
|
|
75786
|
+
readHive(regExe, HKCU_KEY, deps),
|
|
75787
|
+
]);
|
|
75788
|
+
if (!machine.ok && !user.ok)
|
|
75789
|
+
return processFallback();
|
|
75790
|
+
return { path: [machine.value, user.value].join(";"), source: "registry" };
|
|
75791
|
+
}
|
|
75792
|
+
// Where a POSIX version manager keeps the binaries it manages (1.x `preflight.py:257-262`): env
|
|
75793
|
+
// var override, default `~/<defaultHome>`, then the subpath under that root. `*` in a subpath is
|
|
75794
|
+
// globbed and sorted newest-first (nvm keeps every version side by side with no stable "default"
|
|
75795
|
+
// symlink to point at).
|
|
75796
|
+
const MANAGER_DIRS = [
|
|
75797
|
+
{ envVar: "FNM_DIR", defaultHome: ".local/share/fnm", subpath: "aliases/default/bin" },
|
|
75798
|
+
{ envVar: "NVM_DIR", defaultHome: ".nvm", subpath: "versions/node/*/bin" },
|
|
75799
|
+
{ envVar: "VOLTA_HOME", defaultHome: ".volta", subpath: "bin" },
|
|
75800
|
+
{ envVar: "ASDF_DATA_DIR", defaultHome: ".asdf", subpath: "shims" },
|
|
75801
|
+
];
|
|
75802
|
+
/** Sort key for a version directory name ("v24.18.0"): the digit runs, compared numerically so v9
|
|
75803
|
+
* sorts below v10 -- a plain string sort puts them the other way round (1.x `_version_key`). */
|
|
75804
|
+
function versionKey(name) {
|
|
75805
|
+
const digits = name.match(/\d+/g);
|
|
75806
|
+
return digits ? digits.map((d) => Number.parseInt(d, 10)) : [0];
|
|
75807
|
+
}
|
|
75808
|
+
function compareVersionsNewestFirst(a, b) {
|
|
75809
|
+
const ka = versionKey(a);
|
|
75810
|
+
const kb = versionKey(b);
|
|
75811
|
+
const len = Math.max(ka.length, kb.length);
|
|
75812
|
+
for (let i = 0; i < len; i++) {
|
|
75813
|
+
const diff = (kb[i] ?? 0) - (ka[i] ?? 0);
|
|
75814
|
+
if (diff !== 0)
|
|
75815
|
+
return diff;
|
|
75816
|
+
}
|
|
75817
|
+
return 0;
|
|
75818
|
+
}
|
|
75819
|
+
/** Every version-manager bin directory that exists on this machine, newest-version-first within a
|
|
75820
|
+
* manager (1.x `_manager_bin_dirs`, `preflight.py:272-291`). `[]` on Windows, which has its own
|
|
75821
|
+
* PATH-refresh route above and no version-manager convention of its own to search. Returns
|
|
75822
|
+
* DIRECTORIES (not the resolved executable -- `runPreflight` checks the file within each). */
|
|
75823
|
+
function managerBinDirs(deps) {
|
|
75824
|
+
if (deps.platform === "win32")
|
|
75825
|
+
return [];
|
|
75826
|
+
const dirs = [];
|
|
75827
|
+
for (const spec of MANAGER_DIRS) {
|
|
75828
|
+
const envValue = deps.env[spec.envVar];
|
|
75829
|
+
const root = envValue !== undefined && envValue !== ""
|
|
75830
|
+
? envValue
|
|
75831
|
+
: path.posix.join(deps.homedir(), spec.defaultHome);
|
|
75832
|
+
try {
|
|
75833
|
+
const starIndex = spec.subpath.split("/").indexOf("*");
|
|
75834
|
+
if (starIndex === -1) {
|
|
75835
|
+
const candidate = path.posix.join(root, spec.subpath);
|
|
75836
|
+
if (deps.exists(candidate))
|
|
75837
|
+
dirs.push(candidate);
|
|
75838
|
+
continue;
|
|
75839
|
+
}
|
|
75840
|
+
const parts = spec.subpath.split("/");
|
|
75841
|
+
const head = parts.slice(0, starIndex).join("/");
|
|
75842
|
+
const tail = parts.slice(starIndex + 1).join("/");
|
|
75843
|
+
const globRoot = path.posix.join(root, head);
|
|
75844
|
+
const entries = [...deps.readdir(globRoot)].sort(compareVersionsNewestFirst);
|
|
75845
|
+
for (const entry of entries) {
|
|
75846
|
+
const candidate = path.posix.join(globRoot, entry, tail);
|
|
75847
|
+
if (deps.exists(candidate))
|
|
75848
|
+
dirs.push(candidate);
|
|
75849
|
+
}
|
|
75850
|
+
}
|
|
75851
|
+
catch {
|
|
75852
|
+
// unreadable or missing -- simply not a candidate, never a throw (1.x's own guarantee).
|
|
75853
|
+
}
|
|
75854
|
+
}
|
|
75855
|
+
return dirs;
|
|
75856
|
+
}
|
|
75857
|
+
|
|
75858
|
+
/**
|
|
75859
|
+
* The ordered preflight table (#55): 1.x's `PREREQS` (`preflight.py:114-174`) minus the install
|
|
75860
|
+
* routes -- installing is never this agent's job, only the console's operator-facing "run this"
|
|
75861
|
+
* instruction reads that far. Order is the Agents panel's Environment section's row order.
|
|
75862
|
+
*
|
|
75863
|
+
* `why` strings are 1.x's own (`:116-157`), verbatim -- they are the *consequence* of a tool's
|
|
75864
|
+
* absence, not a restatement of its name, and this plan reuses them rather than re-deriving new
|
|
75865
|
+
* wording that could quietly drift from what the 1.x Environment card has always said.
|
|
75866
|
+
*/
|
|
75867
|
+
const PREREQ_TABLE = [
|
|
75868
|
+
{
|
|
75869
|
+
cmd: "git",
|
|
75870
|
+
label: "git",
|
|
75871
|
+
required: true,
|
|
75872
|
+
why: "every repo probe, the memory-sync commit/push, and repo discovery shell out to it; without it the console has no repo layer at all",
|
|
75873
|
+
},
|
|
75874
|
+
{
|
|
75875
|
+
cmd: "gh",
|
|
75876
|
+
label: "GitHub CLI",
|
|
75877
|
+
required: true,
|
|
75878
|
+
why: "every issue / label / PR / Actions-usage call. Presence alone is not enough -- it must also be authenticated (`gh auth login`), which the Environment card checks separately",
|
|
75879
|
+
},
|
|
75880
|
+
{
|
|
75881
|
+
cmd: "node",
|
|
75882
|
+
label: "Node.js",
|
|
75883
|
+
required: true,
|
|
75884
|
+
why: "the JS half of the test suite (vitest, plus the style.*.test.js node tests) cannot run locally without it, it builds the React shell bundle that ships in the wheel, and it is what `claude` installs through",
|
|
75885
|
+
},
|
|
75886
|
+
{
|
|
75887
|
+
cmd: "claude",
|
|
75888
|
+
label: "Claude Code CLI",
|
|
75889
|
+
required: true,
|
|
75890
|
+
why: "the spawned `claude -p` coding tasks, and the context-pressure / subagent-cost scans -- every console action that runs a headless Claude fails without it",
|
|
75891
|
+
},
|
|
75892
|
+
{
|
|
75893
|
+
cmd: "chromium",
|
|
75894
|
+
label: "PDF renderer (Chromium-family)",
|
|
75895
|
+
required: false,
|
|
75896
|
+
why: "the publish mirror's pdf/ output -- the phone / on-the-go read path; without it the HTML tree still syncs and no PDF is ever written",
|
|
75897
|
+
altCmds: ["google-chrome", "google-chrome-stable", "chromium-browser", "microsoft-edge"],
|
|
75898
|
+
winPaths: [
|
|
75899
|
+
String.raw `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
|
|
75900
|
+
String.raw `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
|
|
75901
|
+
String.raw `C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
|
75902
|
+
String.raw `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
|
|
75903
|
+
],
|
|
75904
|
+
},
|
|
75905
|
+
];
|
|
75906
|
+
|
|
75907
|
+
const VERSION_TIMEOUT_MS = 15_000;
|
|
75908
|
+
function firstNonEmptyLine(text) {
|
|
75909
|
+
const lines = text
|
|
75910
|
+
.split(/\r?\n/)
|
|
75911
|
+
.map((l) => l.trim())
|
|
75912
|
+
.filter((l) => l !== "");
|
|
75913
|
+
return lines[0] ?? "";
|
|
75914
|
+
}
|
|
75915
|
+
function lastNonEmptyLine(...texts) {
|
|
75916
|
+
const lines = texts
|
|
75917
|
+
.join("\n")
|
|
75918
|
+
.split(/\r?\n/)
|
|
75919
|
+
.map((l) => l.trim())
|
|
75920
|
+
.filter((l) => l !== "");
|
|
75921
|
+
return lines.at(-1) ?? "";
|
|
75922
|
+
}
|
|
75923
|
+
function shimDetail(entry) {
|
|
75924
|
+
if (entry.cmd === "claude")
|
|
75925
|
+
return "claude is a .cmd shim; install the native Claude Code binary";
|
|
75926
|
+
if (entry.cmd === "gh")
|
|
75927
|
+
return "gh is a .cmd shim; install the native GitHub CLI binary";
|
|
75928
|
+
return `${entry.label} is a .cmd shim; install the native binary`;
|
|
75929
|
+
}
|
|
75930
|
+
function baseOf(entry) {
|
|
75931
|
+
return { cmd: entry.cmd, label: entry.label, required: entry.required, why: entry.why };
|
|
75932
|
+
}
|
|
75933
|
+
/** The verdict once SOMETHING has been found at `foundPath` -- `onPath` distinguishes "usable as
|
|
75934
|
+
* it stands" (a winPaths hit, or found on the refreshed PATH: 1.x's `on_path=True`) from "found
|
|
75935
|
+
* only under a version manager" (`off-path` on success, never plain `ok`). `chromium` is never
|
|
75936
|
+
* executed regardless of which branch found it (E4 §5 decision 19; required: false keeps it
|
|
75937
|
+
* amber, never red, when it's missing instead). */
|
|
75938
|
+
async function verdictForFound(entry, deps, refreshedEnv, foundPath, onPath) {
|
|
75939
|
+
const base = baseOf(entry);
|
|
75940
|
+
if (entry.cmd === "chromium") {
|
|
75941
|
+
return { ...base, status: "ok", path: foundPath, version: "", detail: "" };
|
|
75942
|
+
}
|
|
75943
|
+
const versionResult = await deps.run(foundPath, ["--version"], {
|
|
75944
|
+
timeoutMs: VERSION_TIMEOUT_MS,
|
|
75945
|
+
env: refreshedEnv,
|
|
75946
|
+
});
|
|
75947
|
+
if (versionResult.timedOut) {
|
|
75948
|
+
return {
|
|
75949
|
+
...base,
|
|
75950
|
+
status: "broken",
|
|
75951
|
+
path: foundPath,
|
|
75952
|
+
version: "",
|
|
75953
|
+
detail: "timed out after 15 s",
|
|
75954
|
+
};
|
|
75955
|
+
}
|
|
75956
|
+
if (versionResult.code !== 0) {
|
|
75957
|
+
return {
|
|
75958
|
+
...base,
|
|
75959
|
+
status: "broken",
|
|
75960
|
+
path: foundPath,
|
|
75961
|
+
version: "",
|
|
75962
|
+
detail: lastNonEmptyLine(versionResult.stdout, versionResult.stderr),
|
|
75963
|
+
};
|
|
75964
|
+
}
|
|
75965
|
+
const version = firstNonEmptyLine(versionResult.stdout);
|
|
75966
|
+
if (entry.cmd === "gh") {
|
|
75967
|
+
const authResult = await deps.run(foundPath, ["auth", "status"], {
|
|
75968
|
+
timeoutMs: VERSION_TIMEOUT_MS,
|
|
75969
|
+
env: refreshedEnv,
|
|
75970
|
+
});
|
|
75971
|
+
if (authResult.code !== 0) {
|
|
75972
|
+
const stderrLine = lastNonEmptyLine(authResult.stderr);
|
|
75973
|
+
return {
|
|
75974
|
+
...base,
|
|
75975
|
+
status: "unauthenticated",
|
|
75976
|
+
path: foundPath,
|
|
75977
|
+
version,
|
|
75978
|
+
detail: stderrLine === "" ? "run gh auth login" : `run gh auth login -- ${stderrLine}`,
|
|
75979
|
+
};
|
|
75980
|
+
}
|
|
75981
|
+
}
|
|
75982
|
+
return {
|
|
75983
|
+
...base,
|
|
75984
|
+
status: onPath ? "ok" : "off-path",
|
|
75985
|
+
path: foundPath,
|
|
75986
|
+
version,
|
|
75987
|
+
detail: onPath ? "" : "found under a version manager, not on PATH",
|
|
75988
|
+
};
|
|
75989
|
+
}
|
|
75990
|
+
async function checkRowUnsafe(entry, deps, refreshedEnv) {
|
|
75991
|
+
const base = baseOf(entry);
|
|
75992
|
+
// 1. winPaths first -- an install-location hit is "usable as it stands" (1.x resolve_prereq).
|
|
75993
|
+
for (const winPath of entry.winPaths ?? []) {
|
|
75994
|
+
if (deps.exists(winPath)) {
|
|
75995
|
+
return verdictForFound(entry, deps, refreshedEnv, winPath, true);
|
|
75996
|
+
}
|
|
75997
|
+
}
|
|
75998
|
+
const names = [entry.cmd, ...(entry.altCmds ?? [])];
|
|
75999
|
+
// 2. cmd + altCmds through which() on the refreshed PATH.
|
|
76000
|
+
for (const name of names) {
|
|
76001
|
+
const found = deps.which(name, refreshedEnv);
|
|
76002
|
+
if (found.found) {
|
|
76003
|
+
return verdictForFound(entry, deps, refreshedEnv, found.path, true);
|
|
76004
|
+
}
|
|
76005
|
+
if (found.reason === "shim") {
|
|
76006
|
+
return { ...base, status: "broken", path: found.path, version: "", detail: shimDetail(entry) };
|
|
76007
|
+
}
|
|
76008
|
+
}
|
|
76009
|
+
// 3. the same names as files under a version manager's bin directory -- off-PATH, not missing.
|
|
76010
|
+
const dirs = managerBinDirs({
|
|
76011
|
+
platform: deps.platform,
|
|
76012
|
+
env: deps.env,
|
|
76013
|
+
homedir: deps.homedir,
|
|
76014
|
+
exists: deps.exists,
|
|
76015
|
+
readdir: deps.readdir,
|
|
76016
|
+
});
|
|
76017
|
+
for (const name of names) {
|
|
76018
|
+
for (const dir of dirs) {
|
|
76019
|
+
const candidate = path.posix.join(dir, name);
|
|
76020
|
+
if (deps.exists(candidate) && deps.isExecutable(candidate)) {
|
|
76021
|
+
return verdictForFound(entry, deps, refreshedEnv, candidate, false);
|
|
76022
|
+
}
|
|
76023
|
+
}
|
|
76024
|
+
}
|
|
76025
|
+
return { ...base, status: "missing", path: "", version: "", detail: "" };
|
|
76026
|
+
}
|
|
76027
|
+
/** Wraps one row's whole resolution so a throwing `which`/`run`/`exists` becomes that row's own
|
|
76028
|
+
* `broken`, never a crash that takes the other four rows down with it (1.x
|
|
76029
|
+
* `tests/test_readiness.py:263`'s "a probe that raises becomes a row, not a crash"). */
|
|
76030
|
+
async function checkRow(entry, deps, refreshedEnv) {
|
|
76031
|
+
try {
|
|
76032
|
+
return await checkRowUnsafe(entry, deps, refreshedEnv);
|
|
76033
|
+
}
|
|
76034
|
+
catch (err) {
|
|
76035
|
+
return {
|
|
76036
|
+
...baseOf(entry),
|
|
76037
|
+
status: "broken",
|
|
76038
|
+
path: "",
|
|
76039
|
+
version: "",
|
|
76040
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
76041
|
+
};
|
|
76042
|
+
}
|
|
76043
|
+
}
|
|
76044
|
+
async function runPreflight(deps) {
|
|
76045
|
+
const resolved = await searchPath({ platform: deps.platform, env: deps.env, run: deps.run });
|
|
76046
|
+
const refreshedEnv = { PATH: resolved.path };
|
|
76047
|
+
const rows = await Promise.all(PREREQ_TABLE.map((entry) => checkRow(entry, deps, refreshedEnv)));
|
|
76048
|
+
return {
|
|
76049
|
+
machine: deps.machine,
|
|
76050
|
+
checkedAt: deps.now().toISOString(),
|
|
76051
|
+
searchPath: resolved.source,
|
|
76052
|
+
rows,
|
|
76053
|
+
};
|
|
76054
|
+
}
|
|
76055
|
+
|
|
76056
|
+
var index = /*#__PURE__*/Object.freeze({
|
|
76057
|
+
__proto__: null,
|
|
76058
|
+
runPreflight: runPreflight
|
|
76059
|
+
});
|
|
76060
|
+
|
|
74208
76061
|
export { AGENT_API_VERSION, dispatch, main };
|