@sechroom/cli 2026.7.29 → 2026.7.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1088 -951
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2371,245 +2371,605 @@ function dynamicToolDefinitions() {
|
|
|
2371
2371
|
];
|
|
2372
2372
|
}
|
|
2373
2373
|
|
|
2374
|
-
// src/executor-run/
|
|
2375
|
-
import {
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
throw new Error("each fleet instance requires string 'root' and 'instanceKey'");
|
|
2386
|
-
if (keys.has(entry.instanceKey)) throw new Error(`duplicate fleet instanceKey '${entry.instanceKey}'`);
|
|
2387
|
-
keys.add(entry.instanceKey);
|
|
2388
|
-
}
|
|
2389
|
-
return parsed;
|
|
2374
|
+
// src/executor-run/delivery.ts
|
|
2375
|
+
import { execFile as execFile2 } from "child_process";
|
|
2376
|
+
function createGitRunner(rootDir) {
|
|
2377
|
+
return (bin, args) => new Promise((resolve5) => {
|
|
2378
|
+
execFile2(
|
|
2379
|
+
bin,
|
|
2380
|
+
bin === "git" ? ["-C", rootDir, ...args] : args,
|
|
2381
|
+
{ cwd: rootDir, maxBuffer: 10 * 1024 * 1024 },
|
|
2382
|
+
(error, stdout, stderr) => resolve5({ ok: !error, stdout: String(stdout), stderr: String(stderr) })
|
|
2383
|
+
);
|
|
2384
|
+
});
|
|
2390
2385
|
}
|
|
2391
|
-
function
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2386
|
+
function porcelainPaths(stdout) {
|
|
2387
|
+
return stdout.split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 3).map((line) => {
|
|
2388
|
+
const path = line.slice(3);
|
|
2389
|
+
const arrow = path.indexOf(" -> ");
|
|
2390
|
+
return arrow >= 0 ? path.slice(arrow + 4) : path;
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
async function snapshotRoot(git) {
|
|
2394
|
+
const branch = await git("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2395
|
+
const status = await git("git", ["status", "--porcelain"]);
|
|
2396
|
+
return {
|
|
2397
|
+
baseBranch: branch.ok ? branch.stdout.trim() : "HEAD",
|
|
2398
|
+
dirtyPaths: status.ok ? porcelainPaths(status.stdout) : []
|
|
2395
2399
|
};
|
|
2396
|
-
value("--lane", entry.lane);
|
|
2397
|
-
value("--model", entry.model);
|
|
2398
|
-
value("--connector", entry.connector);
|
|
2399
|
-
value("--ttl", entry.ttl);
|
|
2400
|
-
value("--poll-interval", entry.pollInterval);
|
|
2401
|
-
value("--heartbeat-interval", entry.heartbeatInterval);
|
|
2402
|
-
value("--turn-timeout", entry.turnTimeout);
|
|
2403
|
-
value("--resume-turn-timeout", entry.resumeTurnTimeout);
|
|
2404
|
-
value("--drain-timeout", entry.drainTimeout);
|
|
2405
|
-
value("--codex-bin", entry.codexBin);
|
|
2406
|
-
value("--sandbox", entry.sandbox);
|
|
2407
|
-
value("--usage-reserve", entry.usageReserve);
|
|
2408
|
-
return args;
|
|
2409
2400
|
}
|
|
2410
|
-
function
|
|
2411
|
-
const
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
const children = /* @__PURE__ */ new Map();
|
|
2415
|
-
let stopping = false;
|
|
2416
|
-
let resolveDone;
|
|
2417
|
-
const done = new Promise((resolvePromise) => {
|
|
2418
|
-
resolveDone = resolvePromise;
|
|
2419
|
-
});
|
|
2420
|
-
const status = () => log(`[fleet] ${[...states].map(([key, state]) => `${key}=${state}`).join(" ")}`);
|
|
2421
|
-
const spawnEntry = options.spawnEntry ?? ((entry, args) => {
|
|
2422
|
-
const script = process.argv[1];
|
|
2423
|
-
if (!script) throw new Error("cannot locate the sechroom CLI entrypoint");
|
|
2424
|
-
return spawn2(process.execPath, [script, ...args], {
|
|
2425
|
-
cwd: resolve(entry.root),
|
|
2426
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
2427
|
-
env: process.env
|
|
2428
|
-
});
|
|
2429
|
-
});
|
|
2430
|
-
for (const entry of config2.instances) {
|
|
2431
|
-
const child = spawnEntry(entry, entryArgs(entry));
|
|
2432
|
-
children.set(entry.instanceKey, child);
|
|
2433
|
-
states.set(entry.instanceKey, "live");
|
|
2434
|
-
const prefix = (text2) => {
|
|
2435
|
-
for (const line of text2.replace(/\n$/, "").split("\n")) log(`[${entry.instanceKey}] ${line}`);
|
|
2436
|
-
};
|
|
2437
|
-
const concrete = child;
|
|
2438
|
-
concrete.stdout?.on("data", (chunk) => prefix(String(chunk)));
|
|
2439
|
-
concrete.stderr?.on("data", (chunk) => prefix(String(chunk)));
|
|
2440
|
-
child.on("exit", (code, signal) => {
|
|
2441
|
-
states.set(entry.instanceKey, "exited");
|
|
2442
|
-
log(`[${entry.instanceKey}] exited (${signal ?? code ?? "unknown"})`);
|
|
2443
|
-
status();
|
|
2444
|
-
if ([...states.values()].every((state) => state === "exited")) resolveDone();
|
|
2445
|
-
});
|
|
2446
|
-
}
|
|
2447
|
-
status();
|
|
2401
|
+
async function checkRootReady(git, allowDirty) {
|
|
2402
|
+
const snapshot = await snapshotRoot(git);
|
|
2403
|
+
if (snapshot.dirtyPaths.length === 0 || allowDirty)
|
|
2404
|
+
return { ok: true, snapshot };
|
|
2448
2405
|
return {
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
stopping = true;
|
|
2453
|
-
for (const [key, child] of children) {
|
|
2454
|
-
if (states.get(key) !== "exited") {
|
|
2455
|
-
states.set(key, "stopping");
|
|
2456
|
-
child.kill(signal);
|
|
2457
|
-
}
|
|
2458
|
-
}
|
|
2459
|
-
status();
|
|
2460
|
-
return done;
|
|
2461
|
-
},
|
|
2462
|
-
states
|
|
2406
|
+
ok: false,
|
|
2407
|
+
snapshot,
|
|
2408
|
+
reason: `root has ${snapshot.dirtyPaths.length} uncommitted path(s) (e.g. ${snapshot.dirtyPaths[0]}) \u2014 refusing to claim; commit/clean it or pass --allow-dirty-root`
|
|
2463
2409
|
};
|
|
2464
2410
|
}
|
|
2411
|
+
async function deliverTurn(git, options) {
|
|
2412
|
+
const status = await git("git", ["status", "--porcelain"]);
|
|
2413
|
+
if (!status.ok)
|
|
2414
|
+
return { delivered: false, note: `delivery skipped \u2014 git status failed: ${status.stderr.trim()}` };
|
|
2415
|
+
const preDirty = new Set(options.snapshot.dirtyPaths);
|
|
2416
|
+
const turnPaths = porcelainPaths(status.stdout).filter((p) => !preDirty.has(p));
|
|
2417
|
+
if (turnPaths.length === 0)
|
|
2418
|
+
return { delivered: false, note: "no file changes produced by the turn \u2014 nothing to deliver" };
|
|
2419
|
+
const branch = await freeBranchName(git, `task/${slug(options.taskId)}`);
|
|
2420
|
+
const created = await git("git", ["checkout", "-b", branch]);
|
|
2421
|
+
if (!created.ok)
|
|
2422
|
+
return {
|
|
2423
|
+
delivered: false,
|
|
2424
|
+
note: `delivery FAILED \u2014 could not create branch ${branch}: ${created.stderr.trim()} (changes remain uncommitted in the root)`
|
|
2425
|
+
};
|
|
2426
|
+
const notes = [];
|
|
2427
|
+
try {
|
|
2428
|
+
const added = await git("git", ["add", "--", ...turnPaths]);
|
|
2429
|
+
if (!added.ok) return failBack(`git add failed: ${added.stderr.trim()}`);
|
|
2430
|
+
const committed = await git("git", [
|
|
2431
|
+
"commit",
|
|
2432
|
+
"-m",
|
|
2433
|
+
commitMessage(options)
|
|
2434
|
+
]);
|
|
2435
|
+
if (!committed.ok) return failBack(`git commit failed: ${committed.stderr.trim()}`);
|
|
2436
|
+
const sha = (await git("git", ["rev-parse", "--short", "HEAD"])).stdout.trim();
|
|
2437
|
+
const pushed = await git("git", ["push", "-u", "origin", branch]);
|
|
2438
|
+
if (!pushed.ok)
|
|
2439
|
+
notes.push(`push failed (${firstLine(pushed.stderr)}) \u2014 branch is local-only`);
|
|
2440
|
+
let prUrl;
|
|
2441
|
+
if (options.raisePr && pushed.ok) {
|
|
2442
|
+
const pr = await git("gh", [
|
|
2443
|
+
"pr",
|
|
2444
|
+
"create",
|
|
2445
|
+
"--head",
|
|
2446
|
+
branch,
|
|
2447
|
+
"--title",
|
|
2448
|
+
`task(${options.taskId}): ${options.title}`,
|
|
2449
|
+
"--body",
|
|
2450
|
+
prBody(options, sha)
|
|
2451
|
+
]);
|
|
2452
|
+
if (pr.ok) prUrl = firstLine(pr.stdout);
|
|
2453
|
+
else notes.push(`PR raise failed (${firstLine(pr.stderr)}) \u2014 raise manually from ${branch}`);
|
|
2454
|
+
}
|
|
2455
|
+
notes.unshift(
|
|
2456
|
+
`delivered ${turnPaths.length} path(s) to ${branch} @ ${sha}${prUrl ? ` \u2014 PR ${prUrl}` : ""}`
|
|
2457
|
+
);
|
|
2458
|
+
return { delivered: true, branch, sha, prUrl, note: notes.join("; ") };
|
|
2459
|
+
} finally {
|
|
2460
|
+
const back = await git("git", ["checkout", options.snapshot.baseBranch]);
|
|
2461
|
+
if (!back.ok)
|
|
2462
|
+
options.log(
|
|
2463
|
+
`delivery: could not return root to ${options.snapshot.baseBranch}: ${back.stderr.trim()}`
|
|
2464
|
+
);
|
|
2465
|
+
}
|
|
2466
|
+
function failBack(reason) {
|
|
2467
|
+
return { delivered: false, branch, note: `delivery FAILED \u2014 ${reason}` };
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
async function freeBranchName(git, base) {
|
|
2471
|
+
for (let i = 0; ; i++) {
|
|
2472
|
+
const candidate = i === 0 ? base : `${base}-${i + 1}`;
|
|
2473
|
+
const exists = await git("git", [
|
|
2474
|
+
"rev-parse",
|
|
2475
|
+
"--verify",
|
|
2476
|
+
"--quiet",
|
|
2477
|
+
`refs/heads/${candidate}`
|
|
2478
|
+
]);
|
|
2479
|
+
if (!exists.ok) return candidate;
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
function slug(taskId) {
|
|
2483
|
+
return taskId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2484
|
+
}
|
|
2485
|
+
function commitMessage(options) {
|
|
2486
|
+
return `task(${options.taskId}): ${options.title} [verdict:${options.verdict}]
|
|
2465
2487
|
|
|
2466
|
-
|
|
2467
|
-
import {
|
|
2468
|
-
existsSync as existsSync7,
|
|
2469
|
-
mkdirSync as mkdirSync7,
|
|
2470
|
-
readFileSync as readFileSync5,
|
|
2471
|
-
rmSync as rmSync3,
|
|
2472
|
-
writeFileSync as writeFileSync6
|
|
2473
|
-
} from "fs";
|
|
2474
|
-
import { dirname as dirname6, join as join8 } from "path";
|
|
2488
|
+
Driven-executor delivery (FR-sechroom-496): work produced by the sandboxed codex turn, landed by the driver.
|
|
2475
2489
|
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2490
|
+
Delivered-By: sechroom executor run (${options.deliveredBy})`;
|
|
2491
|
+
}
|
|
2492
|
+
function prBody(options, sha) {
|
|
2493
|
+
return `Driven-executor delivery for WLP task \`${options.taskId}\` (verdict: ${options.verdict}, commit ${sha}).
|
|
2479
2494
|
|
|
2480
|
-
|
|
2481
|
-
import { existsSync as existsSync5 } from "fs";
|
|
2482
|
-
import { homedir as homedir3 } from "os";
|
|
2483
|
-
import { dirname as dirname4, join as join6 } from "path";
|
|
2484
|
-
function claudeDesktopConfigPath(home) {
|
|
2485
|
-
switch (process.platform) {
|
|
2486
|
-
case "darwin":
|
|
2487
|
-
return join6(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
2488
|
-
case "win32":
|
|
2489
|
-
return join6(process.env.APPDATA ?? join6(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
2490
|
-
default:
|
|
2491
|
-
return join6(home, ".config", "Claude", "claude_desktop_config.json");
|
|
2492
|
-
}
|
|
2495
|
+
Work produced by a sandboxed codex turn and landed by the unsandboxed driver (FR-sechroom-496). Review against the task's acceptance before merge.`;
|
|
2493
2496
|
}
|
|
2494
|
-
function
|
|
2495
|
-
|
|
2496
|
-
const claudeDir = opts.claudeDir ?? join6(home, ".claude");
|
|
2497
|
-
const codexHome = opts.codexHome ?? join6(home, ".codex");
|
|
2498
|
-
return {
|
|
2499
|
-
"claude-code": {
|
|
2500
|
-
key: "claude-code",
|
|
2501
|
-
label: "Claude Code",
|
|
2502
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".mcp.json"), format: "json" },
|
|
2503
|
-
instruction: { surfaceKey: "claude-code", path: join6(cwd, "CLAUDE.md") }
|
|
2504
|
-
},
|
|
2505
|
-
"claude-desktop": {
|
|
2506
|
-
key: "claude-desktop",
|
|
2507
|
-
label: "Claude Desktop",
|
|
2508
|
-
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
2509
|
-
instruction: { surfaceKey: "claude-desktop", path: join6(claudeDir, "CLAUDE.md") }
|
|
2510
|
-
},
|
|
2511
|
-
codex: {
|
|
2512
|
-
key: "codex",
|
|
2513
|
-
label: "Codex CLI",
|
|
2514
|
-
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join6(codexHome, "config.toml"), format: "toml" },
|
|
2515
|
-
instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
|
|
2516
|
-
},
|
|
2517
|
-
cursor: {
|
|
2518
|
-
key: "cursor",
|
|
2519
|
-
label: "Cursor",
|
|
2520
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
2521
|
-
instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
|
|
2522
|
-
},
|
|
2523
|
-
antigravity: {
|
|
2524
|
-
key: "antigravity",
|
|
2525
|
-
label: "Google Antigravity",
|
|
2526
|
-
// FR-sechroom-247 — Antigravity reads MCP from a GLOBAL, home-relative
|
|
2527
|
-
// `~/.gemini/config/mcp_config.json` (not cwd; not affected by
|
|
2528
|
-
// CLAUDE_CONFIG_DIR / CODEX_HOME). The snippet — `serverUrl`-shaped, no
|
|
2529
|
-
// `type` — comes from the `antigravity` server surface, so we don't
|
|
2530
|
-
// hardcode it here. Instructions go in the project `AGENTS.md`
|
|
2531
|
-
// (cross-tool, shared with Codex/Cursor).
|
|
2532
|
-
mcp: { surfaceKey: "antigravity", sectionType: SectionType.McpConfig, path: join6(home, ".gemini", "config", "mcp_config.json"), format: "json" },
|
|
2533
|
-
instruction: { surfaceKey: "antigravity", path: join6(cwd, "AGENTS.md") }
|
|
2534
|
-
}
|
|
2535
|
-
};
|
|
2536
|
-
}
|
|
2537
|
-
var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor", "antigravity"];
|
|
2538
|
-
var DEFAULT_CLIENT_KEY = "claude-code";
|
|
2539
|
-
function detectInstalledClients(cwd) {
|
|
2540
|
-
const home = homedir3();
|
|
2541
|
-
const detected = [];
|
|
2542
|
-
if (resolveClaudeTargets({}).some((t) => existsSync5(t.dir))) detected.push("claude-code");
|
|
2543
|
-
if (existsSync5(dirname4(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
|
|
2544
|
-
if (resolveCodexHomes({}).some((d) => existsSync5(d))) detected.push("codex");
|
|
2545
|
-
if (existsSync5(join6(home, ".cursor")) || existsSync5(join6(cwd, ".cursor"))) detected.push("cursor");
|
|
2546
|
-
if (existsSync5(join6(home, ".gemini"))) detected.push("antigravity");
|
|
2547
|
-
return detected;
|
|
2497
|
+
function firstLine(text2) {
|
|
2498
|
+
return text2.trim().split("\n")[0] ?? "";
|
|
2548
2499
|
}
|
|
2549
2500
|
|
|
2550
|
-
// src/
|
|
2551
|
-
var
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
// `telemetry hook` verb dispatches on hook_event_name: Stop/SubagentStop → parsed (token/context) +
|
|
2557
|
-
// terminal (turn end), Notification/PermissionDenied → approval. No-op (exit 0) unless this checkout
|
|
2558
|
-
// is bound via `sechroom telemetry bind`, so it's safe to wire for every Claude install; an event a
|
|
2559
|
-
// given Claude Code version doesn't know is inert (never fires). Claude-only.
|
|
2560
|
-
Stop: "sechroom telemetry hook",
|
|
2561
|
-
SubagentStop: "sechroom telemetry hook",
|
|
2562
|
-
Notification: "sechroom telemetry hook",
|
|
2563
|
-
PermissionDenied: "sechroom telemetry hook"
|
|
2501
|
+
// src/executor-run/request.ts
|
|
2502
|
+
var AuthExpiredError = class extends Error {
|
|
2503
|
+
constructor(message) {
|
|
2504
|
+
super(message);
|
|
2505
|
+
this.name = "AuthExpiredError";
|
|
2506
|
+
}
|
|
2564
2507
|
};
|
|
2565
|
-
var
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2508
|
+
var HttpError = class extends Error {
|
|
2509
|
+
constructor(status, method, path, body) {
|
|
2510
|
+
super(`${method} ${path} failed (${status}): ${body}`);
|
|
2511
|
+
this.status = status;
|
|
2512
|
+
this.method = method;
|
|
2513
|
+
this.path = path;
|
|
2514
|
+
this.body = body;
|
|
2515
|
+
this.name = "HttpError";
|
|
2516
|
+
}
|
|
2517
|
+
status;
|
|
2518
|
+
method;
|
|
2519
|
+
path;
|
|
2520
|
+
body;
|
|
2569
2521
|
};
|
|
2570
|
-
function
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
const
|
|
2575
|
-
|
|
2522
|
+
function createAuthedRequest(cfg, deps = {}) {
|
|
2523
|
+
const getToken = deps.getToken ?? requireToken;
|
|
2524
|
+
const refresh = deps.refreshToken ?? forceRefreshToken;
|
|
2525
|
+
const doFetch = deps.fetch ?? fetch;
|
|
2526
|
+
const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
|
|
2527
|
+
...init,
|
|
2528
|
+
headers: {
|
|
2529
|
+
authorization: `Bearer ${token}`,
|
|
2530
|
+
tenant: cfg.tenant,
|
|
2531
|
+
"content-type": "application/json",
|
|
2532
|
+
"x-sechroom-surface": "cli",
|
|
2533
|
+
...init?.headers
|
|
2534
|
+
}
|
|
2535
|
+
});
|
|
2536
|
+
return async (path, init) => {
|
|
2537
|
+
const method = init?.method ?? "GET";
|
|
2538
|
+
let token;
|
|
2539
|
+
try {
|
|
2540
|
+
token = await getToken(cfg);
|
|
2541
|
+
} catch (error) {
|
|
2542
|
+
throw new AuthExpiredError(
|
|
2543
|
+
error instanceof Error ? error.message : String(error)
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
let response = await call(path, init, token);
|
|
2547
|
+
if (response.status === 401) {
|
|
2548
|
+
let fresh;
|
|
2549
|
+
try {
|
|
2550
|
+
fresh = await refresh(cfg);
|
|
2551
|
+
} catch (error) {
|
|
2552
|
+
throw new AuthExpiredError(
|
|
2553
|
+
error instanceof Error ? error.message : String(error)
|
|
2554
|
+
);
|
|
2555
|
+
}
|
|
2556
|
+
response = await call(path, init, fresh);
|
|
2557
|
+
if (response.status === 401)
|
|
2558
|
+
throw new AuthExpiredError(
|
|
2559
|
+
`${method} ${path} still 401 after token refresh \u2014 re-authenticate (\`sechroom login\`).`
|
|
2560
|
+
);
|
|
2561
|
+
}
|
|
2562
|
+
if (!response.ok)
|
|
2563
|
+
throw new HttpError(
|
|
2564
|
+
response.status,
|
|
2565
|
+
method,
|
|
2566
|
+
path,
|
|
2567
|
+
await safeText(response)
|
|
2568
|
+
);
|
|
2569
|
+
return await response.json();
|
|
2570
|
+
};
|
|
2576
2571
|
}
|
|
2577
|
-
function
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
const groups = config2.hooks[event] ??= [];
|
|
2583
|
-
groups.push({ hooks: [{ type: "command", command }] });
|
|
2584
|
-
added += 1;
|
|
2572
|
+
async function safeText(response) {
|
|
2573
|
+
try {
|
|
2574
|
+
return await response.text();
|
|
2575
|
+
} catch {
|
|
2576
|
+
return "";
|
|
2585
2577
|
}
|
|
2586
|
-
return added;
|
|
2587
2578
|
}
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2579
|
+
|
|
2580
|
+
// src/executor-run/driver.ts
|
|
2581
|
+
function verdictFor(terminalStatus) {
|
|
2582
|
+
switch (terminalStatus) {
|
|
2583
|
+
case "completed":
|
|
2584
|
+
return "pass";
|
|
2585
|
+
case "needs_approval":
|
|
2586
|
+
case "cancelled":
|
|
2587
|
+
case "canceled":
|
|
2588
|
+
return "blocked";
|
|
2589
|
+
case "error":
|
|
2590
|
+
default:
|
|
2591
|
+
return "soft-fail";
|
|
2592
|
+
}
|
|
2593
2593
|
}
|
|
2594
|
-
function
|
|
2595
|
-
const
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2594
|
+
async function runDriverLoop(ports, options) {
|
|
2595
|
+
const summary = { processed: 0, completed: 0, abandoned: 0 };
|
|
2596
|
+
let admissionDeferred = false;
|
|
2597
|
+
while (!options.stopping()) {
|
|
2598
|
+
if (ports.checkAdmission) {
|
|
2599
|
+
const admission = await ports.checkAdmission();
|
|
2600
|
+
if (!admission.ok) {
|
|
2601
|
+
admissionDeferred = true;
|
|
2602
|
+
ports.log(
|
|
2603
|
+
`ADMISSION DEFERRED \u2014 not claiming: ${admission.reason ?? "usage budget exhausted"}`
|
|
2604
|
+
);
|
|
2605
|
+
await ports.waitForWake(options.pollMs);
|
|
2606
|
+
continue;
|
|
2607
|
+
}
|
|
2608
|
+
if (admissionDeferred) {
|
|
2609
|
+
admissionDeferred = false;
|
|
2610
|
+
ports.log("admission recovered \u2014 resuming claims");
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
if (ports.checkRootReady) {
|
|
2614
|
+
const ready = await ports.checkRootReady();
|
|
2615
|
+
if (!ready.ok) {
|
|
2616
|
+
ports.log(`root not ready \u2014 not claiming: ${ready.reason ?? "unknown"}`);
|
|
2617
|
+
await ports.waitForWake(options.pollMs);
|
|
2618
|
+
continue;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
const claim = await ports.claimNext();
|
|
2622
|
+
if (!claim) {
|
|
2623
|
+
await ports.waitForWake(options.pollMs);
|
|
2624
|
+
continue;
|
|
2625
|
+
}
|
|
2626
|
+
summary.processed++;
|
|
2627
|
+
ports.log(`claimed ${claim.memoryId} (lease ${claim.leaseId})`);
|
|
2628
|
+
const task = await ports.loadTask(claim.memoryId);
|
|
2629
|
+
const stopHeartbeat = ports.startLeaseHeartbeat(claim);
|
|
2630
|
+
let result;
|
|
2631
|
+
try {
|
|
2632
|
+
result = await ports.runTurn(task, claim);
|
|
2633
|
+
} catch (e) {
|
|
2634
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
2635
|
+
result = { status: "crashed", reason: String(e) };
|
|
2636
|
+
} finally {
|
|
2637
|
+
stopHeartbeat();
|
|
2638
|
+
}
|
|
2639
|
+
if (result.status === "crashed" || result.status === "timeout") {
|
|
2640
|
+
summary.abandoned++;
|
|
2641
|
+
ports.log(
|
|
2642
|
+
`ABANDONED ${claim.memoryId}: ${result.status === "timeout" ? "turn timed out" : result.reason} \u2014 lease will expire and the task re-offers (work may re-run).`
|
|
2643
|
+
);
|
|
2644
|
+
} else {
|
|
2645
|
+
const verdict = verdictFor(result.packet?.terminal_status);
|
|
2646
|
+
let text2 = closeoutText(task, result);
|
|
2647
|
+
if (ports.deliver) {
|
|
2648
|
+
try {
|
|
2649
|
+
const delivery = await ports.deliver(claim, task, verdict);
|
|
2650
|
+
ports.log(`delivery: ${delivery.note}`);
|
|
2651
|
+
text2 += `
|
|
2652
|
+
|
|
2653
|
+
Delivery: ${delivery.note}`;
|
|
2654
|
+
} catch (e) {
|
|
2655
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
2656
|
+
ports.log(`delivery threw (continuing to completion): ${String(e)}`);
|
|
2657
|
+
text2 += `
|
|
2658
|
+
|
|
2659
|
+
Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
try {
|
|
2663
|
+
const done = await ports.completeLease(
|
|
2664
|
+
claim,
|
|
2665
|
+
verdict,
|
|
2666
|
+
text2,
|
|
2667
|
+
`${task.title} \u2014 driven closeout`
|
|
2668
|
+
);
|
|
2669
|
+
summary.completed++;
|
|
2670
|
+
ports.log(
|
|
2671
|
+
`completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
|
|
2672
|
+
);
|
|
2673
|
+
} catch (e) {
|
|
2674
|
+
if (e instanceof AuthExpiredError) throw e;
|
|
2675
|
+
summary.abandoned++;
|
|
2676
|
+
ports.log(
|
|
2677
|
+
`COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
|
|
2678
|
+
);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
if (options.once) break;
|
|
2602
2682
|
}
|
|
2603
|
-
return
|
|
2683
|
+
return summary;
|
|
2604
2684
|
}
|
|
2605
|
-
function
|
|
2606
|
-
|
|
2685
|
+
function closeoutText(task, result) {
|
|
2686
|
+
if (!result.packet)
|
|
2687
|
+
return `Driven codex run ended without a sechroom_closeout packet (soft-fail). Last agent message:
|
|
2688
|
+
|
|
2689
|
+
${result.lastAgentMessage || "(none)"}`;
|
|
2690
|
+
const evidence = result.packet.evidence?.length ? `
|
|
2691
|
+
|
|
2692
|
+
Evidence:
|
|
2693
|
+
${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
|
|
2694
|
+
return `${result.packet.summary}${evidence}
|
|
2695
|
+
|
|
2696
|
+
(terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
|
|
2607
2697
|
}
|
|
2608
|
-
function
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2698
|
+
function startLeaseHeartbeat(beat, log, intervalMs = 3e4, timers = {}) {
|
|
2699
|
+
const schedule = timers.setInterval ?? setInterval;
|
|
2700
|
+
const cancel = timers.clearInterval ?? clearInterval;
|
|
2701
|
+
const timer = schedule(() => {
|
|
2702
|
+
void beat().catch(
|
|
2703
|
+
(e) => log(`lease heartbeat failed (retrying next beat): ${String(e)}`)
|
|
2704
|
+
);
|
|
2705
|
+
}, intervalMs);
|
|
2706
|
+
timer.unref?.();
|
|
2707
|
+
return () => cancel(timer);
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// src/executor-run/fleet.ts
|
|
2711
|
+
import { spawn as spawn2 } from "child_process";
|
|
2712
|
+
import { readFile } from "fs/promises";
|
|
2713
|
+
import { resolve } from "path";
|
|
2714
|
+
async function readFleetConfig(path) {
|
|
2715
|
+
const parsed = JSON.parse(
|
|
2716
|
+
await readFile(resolve(path), "utf8")
|
|
2717
|
+
);
|
|
2718
|
+
if (!Array.isArray(parsed.instances) || parsed.instances.length === 0)
|
|
2719
|
+
throw new Error("fleet config must contain a non-empty 'instances' array");
|
|
2720
|
+
const keys = /* @__PURE__ */ new Set();
|
|
2721
|
+
for (const entry of parsed.instances) {
|
|
2722
|
+
if (!entry || typeof entry.root !== "string" || typeof entry.instanceKey !== "string")
|
|
2723
|
+
throw new Error(
|
|
2724
|
+
"each fleet instance requires string 'root' and 'instanceKey'"
|
|
2725
|
+
);
|
|
2726
|
+
if (keys.has(entry.instanceKey))
|
|
2727
|
+
throw new Error(`duplicate fleet instanceKey '${entry.instanceKey}'`);
|
|
2728
|
+
keys.add(entry.instanceKey);
|
|
2729
|
+
}
|
|
2730
|
+
if (parsed.node !== void 0) {
|
|
2731
|
+
const node = parsed.node;
|
|
2732
|
+
if (!node || typeof node.instanceKey !== "string" || typeof node.connector !== "string")
|
|
2733
|
+
throw new Error(
|
|
2734
|
+
"fleet 'node' requires string 'instanceKey' and 'connector'"
|
|
2735
|
+
);
|
|
2736
|
+
}
|
|
2737
|
+
return parsed;
|
|
2738
|
+
}
|
|
2739
|
+
function entryArgs(entry, parentId) {
|
|
2740
|
+
const args = [
|
|
2741
|
+
"executor",
|
|
2742
|
+
"run",
|
|
2743
|
+
"--root",
|
|
2744
|
+
resolve(entry.root),
|
|
2745
|
+
"--instance-key",
|
|
2746
|
+
entry.instanceKey
|
|
2747
|
+
];
|
|
2748
|
+
const value = (flag, v) => {
|
|
2749
|
+
if (v !== void 0) args.push(flag, String(v));
|
|
2750
|
+
};
|
|
2751
|
+
value("--parent-id", parentId);
|
|
2752
|
+
value("--lane", entry.lane);
|
|
2753
|
+
value("--model", entry.model);
|
|
2754
|
+
value("--connector", entry.connector);
|
|
2755
|
+
value("--ttl", entry.ttl);
|
|
2756
|
+
value("--poll-interval", entry.pollInterval);
|
|
2757
|
+
value("--heartbeat-interval", entry.heartbeatInterval);
|
|
2758
|
+
value("--turn-timeout", entry.turnTimeout);
|
|
2759
|
+
value("--resume-turn-timeout", entry.resumeTurnTimeout);
|
|
2760
|
+
value("--drain-timeout", entry.drainTimeout);
|
|
2761
|
+
value("--codex-bin", entry.codexBin);
|
|
2762
|
+
value("--sandbox", entry.sandbox);
|
|
2763
|
+
value("--usage-reserve", entry.usageReserve);
|
|
2764
|
+
return args;
|
|
2765
|
+
}
|
|
2766
|
+
function superviseFleet(config2, options = {}) {
|
|
2767
|
+
const log = options.log ?? ((line) => process.stderr.write(`${line}
|
|
2768
|
+
`));
|
|
2769
|
+
const states = /* @__PURE__ */ new Map();
|
|
2770
|
+
const children = /* @__PURE__ */ new Map();
|
|
2771
|
+
let stopping = false;
|
|
2772
|
+
let resolveDone;
|
|
2773
|
+
const done = new Promise((resolvePromise) => {
|
|
2774
|
+
resolveDone = resolvePromise;
|
|
2775
|
+
});
|
|
2776
|
+
const status = () => log(
|
|
2777
|
+
`[fleet] ${[...states].map(([key, state]) => `${key}=${state}`).join(" ")}`
|
|
2778
|
+
);
|
|
2779
|
+
const spawnEntry = options.spawnEntry ?? ((entry, args) => {
|
|
2780
|
+
const script = process.argv[1];
|
|
2781
|
+
if (!script) throw new Error("cannot locate the sechroom CLI entrypoint");
|
|
2782
|
+
return spawn2(process.execPath, [script, ...args], {
|
|
2783
|
+
cwd: resolve(entry.root),
|
|
2784
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2785
|
+
env: process.env
|
|
2786
|
+
});
|
|
2787
|
+
});
|
|
2788
|
+
for (const entry of config2.instances) {
|
|
2789
|
+
const child = spawnEntry(entry, entryArgs(entry, options.parentId));
|
|
2790
|
+
children.set(entry.instanceKey, child);
|
|
2791
|
+
states.set(entry.instanceKey, "live");
|
|
2792
|
+
const prefix = (text2) => {
|
|
2793
|
+
for (const line of text2.replace(/\n$/, "").split("\n"))
|
|
2794
|
+
log(`[${entry.instanceKey}] ${line}`);
|
|
2795
|
+
};
|
|
2796
|
+
const concrete = child;
|
|
2797
|
+
concrete.stdout?.on("data", (chunk) => prefix(String(chunk)));
|
|
2798
|
+
concrete.stderr?.on("data", (chunk) => prefix(String(chunk)));
|
|
2799
|
+
child.on("exit", (code, signal) => {
|
|
2800
|
+
states.set(entry.instanceKey, "exited");
|
|
2801
|
+
log(`[${entry.instanceKey}] exited (${signal ?? code ?? "unknown"})`);
|
|
2802
|
+
status();
|
|
2803
|
+
if ([...states.values()].every((state) => state === "exited"))
|
|
2804
|
+
resolveDone();
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
status();
|
|
2808
|
+
return {
|
|
2809
|
+
done,
|
|
2810
|
+
shutdown(signal = "SIGINT") {
|
|
2811
|
+
if (stopping) return done;
|
|
2812
|
+
stopping = true;
|
|
2813
|
+
for (const [key, child] of children) {
|
|
2814
|
+
if (states.get(key) !== "exited") {
|
|
2815
|
+
states.set(key, "stopping");
|
|
2816
|
+
child.kill(signal);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
status();
|
|
2820
|
+
return done;
|
|
2821
|
+
},
|
|
2822
|
+
states
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// src/commands/telemetry.ts
|
|
2827
|
+
import {
|
|
2828
|
+
existsSync as existsSync7,
|
|
2829
|
+
mkdirSync as mkdirSync7,
|
|
2830
|
+
readFileSync as readFileSync5,
|
|
2831
|
+
rmSync as rmSync3,
|
|
2832
|
+
writeFileSync as writeFileSync6
|
|
2833
|
+
} from "fs";
|
|
2834
|
+
import { dirname as dirname6, join as join8 } from "path";
|
|
2835
|
+
|
|
2836
|
+
// src/commands/hook-install.ts
|
|
2837
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
|
|
2838
|
+
import { delimiter, dirname as dirname5, join as join7 } from "path";
|
|
2839
|
+
|
|
2840
|
+
// src/setup/clients.ts
|
|
2841
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2842
|
+
import { homedir as homedir3 } from "os";
|
|
2843
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
2844
|
+
function claudeDesktopConfigPath(home) {
|
|
2845
|
+
switch (process.platform) {
|
|
2846
|
+
case "darwin":
|
|
2847
|
+
return join6(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
2848
|
+
case "win32":
|
|
2849
|
+
return join6(process.env.APPDATA ?? join6(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
2850
|
+
default:
|
|
2851
|
+
return join6(home, ".config", "Claude", "claude_desktop_config.json");
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
function clientTargets(cwd, opts = {}) {
|
|
2855
|
+
const home = homedir3();
|
|
2856
|
+
const claudeDir = opts.claudeDir ?? join6(home, ".claude");
|
|
2857
|
+
const codexHome = opts.codexHome ?? join6(home, ".codex");
|
|
2858
|
+
return {
|
|
2859
|
+
"claude-code": {
|
|
2860
|
+
key: "claude-code",
|
|
2861
|
+
label: "Claude Code",
|
|
2862
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".mcp.json"), format: "json" },
|
|
2863
|
+
instruction: { surfaceKey: "claude-code", path: join6(cwd, "CLAUDE.md") }
|
|
2864
|
+
},
|
|
2865
|
+
"claude-desktop": {
|
|
2866
|
+
key: "claude-desktop",
|
|
2867
|
+
label: "Claude Desktop",
|
|
2868
|
+
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
2869
|
+
instruction: { surfaceKey: "claude-desktop", path: join6(claudeDir, "CLAUDE.md") }
|
|
2870
|
+
},
|
|
2871
|
+
codex: {
|
|
2872
|
+
key: "codex",
|
|
2873
|
+
label: "Codex CLI",
|
|
2874
|
+
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join6(codexHome, "config.toml"), format: "toml" },
|
|
2875
|
+
instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
|
|
2876
|
+
},
|
|
2877
|
+
cursor: {
|
|
2878
|
+
key: "cursor",
|
|
2879
|
+
label: "Cursor",
|
|
2880
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
2881
|
+
instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
|
|
2882
|
+
},
|
|
2883
|
+
antigravity: {
|
|
2884
|
+
key: "antigravity",
|
|
2885
|
+
label: "Google Antigravity",
|
|
2886
|
+
// FR-sechroom-247 — Antigravity reads MCP from a GLOBAL, home-relative
|
|
2887
|
+
// `~/.gemini/config/mcp_config.json` (not cwd; not affected by
|
|
2888
|
+
// CLAUDE_CONFIG_DIR / CODEX_HOME). The snippet — `serverUrl`-shaped, no
|
|
2889
|
+
// `type` — comes from the `antigravity` server surface, so we don't
|
|
2890
|
+
// hardcode it here. Instructions go in the project `AGENTS.md`
|
|
2891
|
+
// (cross-tool, shared with Codex/Cursor).
|
|
2892
|
+
mcp: { surfaceKey: "antigravity", sectionType: SectionType.McpConfig, path: join6(home, ".gemini", "config", "mcp_config.json"), format: "json" },
|
|
2893
|
+
instruction: { surfaceKey: "antigravity", path: join6(cwd, "AGENTS.md") }
|
|
2894
|
+
}
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor", "antigravity"];
|
|
2898
|
+
var DEFAULT_CLIENT_KEY = "claude-code";
|
|
2899
|
+
function detectInstalledClients(cwd) {
|
|
2900
|
+
const home = homedir3();
|
|
2901
|
+
const detected = [];
|
|
2902
|
+
if (resolveClaudeTargets({}).some((t) => existsSync5(t.dir))) detected.push("claude-code");
|
|
2903
|
+
if (existsSync5(dirname4(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
|
|
2904
|
+
if (resolveCodexHomes({}).some((d) => existsSync5(d))) detected.push("codex");
|
|
2905
|
+
if (existsSync5(join6(home, ".cursor")) || existsSync5(join6(cwd, ".cursor"))) detected.push("cursor");
|
|
2906
|
+
if (existsSync5(join6(home, ".gemini"))) detected.push("antigravity");
|
|
2907
|
+
return detected;
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
// src/commands/hook-install.ts
|
|
2911
|
+
var CLAUDE_HOOK_COMMANDS = {
|
|
2912
|
+
SessionStart: "sechroom hook session-start",
|
|
2913
|
+
PreCompact: "sechroom hook pre-compact",
|
|
2914
|
+
SessionEnd: "sechroom hook session-end",
|
|
2915
|
+
// WLP telemetry tap (D-WLP-10 + FR-352 Tier 1) — per-turn executor self-report. The one
|
|
2916
|
+
// `telemetry hook` verb dispatches on hook_event_name: Stop/SubagentStop → parsed (token/context) +
|
|
2917
|
+
// terminal (turn end), Notification/PermissionDenied → approval. No-op (exit 0) unless this checkout
|
|
2918
|
+
// is bound via `sechroom telemetry bind`, so it's safe to wire for every Claude install; an event a
|
|
2919
|
+
// given Claude Code version doesn't know is inert (never fires). Claude-only.
|
|
2920
|
+
Stop: "sechroom telemetry hook",
|
|
2921
|
+
SubagentStop: "sechroom telemetry hook",
|
|
2922
|
+
Notification: "sechroom telemetry hook",
|
|
2923
|
+
PermissionDenied: "sechroom telemetry hook"
|
|
2924
|
+
};
|
|
2925
|
+
var CODEX_HOOK_COMMANDS = {
|
|
2926
|
+
SessionStart: "sechroom hook session-start",
|
|
2927
|
+
PreCompact: "sechroom hook pre-compact",
|
|
2928
|
+
Stop: "sechroom hook session-end --debounce-minutes 10"
|
|
2929
|
+
};
|
|
2930
|
+
function hookCommandsForSurface(surface) {
|
|
2931
|
+
return surface === "claude" ? CLAUDE_HOOK_COMMANDS : CODEX_HOOK_COMMANDS;
|
|
2932
|
+
}
|
|
2933
|
+
function hasHookCommand(config2, event, command) {
|
|
2934
|
+
const groups = config2.hooks?.[event] ?? [];
|
|
2935
|
+
return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
|
|
2936
|
+
}
|
|
2937
|
+
function mergeHooks(config2, commands) {
|
|
2938
|
+
config2.hooks ??= {};
|
|
2939
|
+
let added = 0;
|
|
2940
|
+
for (const [event, command] of Object.entries(commands)) {
|
|
2941
|
+
if (hasHookCommand(config2, event, command)) continue;
|
|
2942
|
+
const groups = config2.hooks[event] ??= [];
|
|
2943
|
+
groups.push({ hooks: [{ type: "command", command }] });
|
|
2944
|
+
added += 1;
|
|
2945
|
+
}
|
|
2946
|
+
return added;
|
|
2947
|
+
}
|
|
2948
|
+
function readJsonConfig2(path) {
|
|
2949
|
+
if (!existsSync6(path)) return {};
|
|
2950
|
+
const raw = readFileSync4(path, "utf8");
|
|
2951
|
+
if (!raw.trim()) return {};
|
|
2952
|
+
return JSON.parse(raw);
|
|
2953
|
+
}
|
|
2954
|
+
function installHooksJson(path, commands, dryRun) {
|
|
2955
|
+
const existed = existsSync6(path) && readFileSync4(path, "utf8").trim().length > 0;
|
|
2956
|
+
const config2 = readJsonConfig2(path);
|
|
2957
|
+
const added = mergeHooks(config2, commands);
|
|
2958
|
+
if (added === 0 && existed) return { path, status: "current" };
|
|
2959
|
+
if (!dryRun) {
|
|
2960
|
+
mkdirSync6(dirname5(path), { recursive: true });
|
|
2961
|
+
writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
|
|
2962
|
+
}
|
|
2963
|
+
return { path, status: existed ? "merged" : "created" };
|
|
2964
|
+
}
|
|
2965
|
+
function installClaudeCommands(claudeDir, commands, dryRun) {
|
|
2966
|
+
return installHooksJson(join7(claudeDir, "settings.json"), commands, dryRun);
|
|
2967
|
+
}
|
|
2968
|
+
function installCodexCommands(codexHome, commands, dryRun) {
|
|
2969
|
+
return [
|
|
2970
|
+
installHooksJson(join7(codexHome, "hooks.json"), commands, dryRun),
|
|
2971
|
+
installCodexFeatureFlag(join7(codexHome, "config.toml"), dryRun)
|
|
2972
|
+
];
|
|
2613
2973
|
}
|
|
2614
2974
|
function ensureCodexFeaturesHooks(content) {
|
|
2615
2975
|
const lines = content.split("\n");
|
|
@@ -2632,764 +2992,447 @@ function ensureCodexFeaturesHooks(content) {
|
|
|
2632
2992
|
return { next: lines.join("\n"), changed: true };
|
|
2633
2993
|
}
|
|
2634
2994
|
function installCodexFeatureFlag(path, dryRun) {
|
|
2635
|
-
const existed = existsSync6(path);
|
|
2636
|
-
const content = existed ? readFileSync4(path, "utf8") : "";
|
|
2637
|
-
const { next, changed } = ensureCodexFeaturesHooks(content);
|
|
2638
|
-
if (!changed) return { path, status: "current" };
|
|
2639
|
-
if (!dryRun) {
|
|
2640
|
-
mkdirSync6(dirname5(path), { recursive: true });
|
|
2641
|
-
writeFileSync5(path, next);
|
|
2642
|
-
}
|
|
2643
|
-
return { path, status: existed ? "merged" : "created" };
|
|
2644
|
-
}
|
|
2645
|
-
function resolveSurfaces(surface, cwd) {
|
|
2646
|
-
if (surface === "claude") return ["claude"];
|
|
2647
|
-
if (surface === "codex") return ["codex"];
|
|
2648
|
-
if (surface === "both") return ["claude", "codex"];
|
|
2649
|
-
if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
|
|
2650
|
-
const surfaces = detectHookSurfaces(cwd);
|
|
2651
|
-
return surfaces.length > 0 ? surfaces : ["claude", "codex"];
|
|
2652
|
-
}
|
|
2653
|
-
function describe(result, dryRun) {
|
|
2654
|
-
if (result.status === "current") return ` \u2713 ${result.path} (already configured)`;
|
|
2655
|
-
const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
|
|
2656
|
-
return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
|
|
2657
|
-
}
|
|
2658
|
-
var HOOK_SURFACE_LABEL = {
|
|
2659
|
-
claude: "Claude Code",
|
|
2660
|
-
codex: "Codex"
|
|
2661
|
-
};
|
|
2662
|
-
function installHookSurfaces(surfaces, opts) {
|
|
2663
|
-
const out = [];
|
|
2664
|
-
for (const surface of surfaces) {
|
|
2665
|
-
if (surface === "claude") {
|
|
2666
|
-
const path = join7(opts.claudeDir, "settings.json");
|
|
2667
|
-
out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
|
|
2668
|
-
} else {
|
|
2669
|
-
const hooksJson = installHooksJson(join7(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
|
|
2670
|
-
const featureFlag = installCodexFeatureFlag(join7(opts.codexHome, "config.toml"), opts.dryRun);
|
|
2671
|
-
out.push({ surface, results: [hooksJson, featureFlag] });
|
|
2672
|
-
}
|
|
2673
|
-
}
|
|
2674
|
-
return out;
|
|
2675
|
-
}
|
|
2676
|
-
function detectHookSurfaces(cwd) {
|
|
2677
|
-
const detected = detectInstalledClients(cwd);
|
|
2678
|
-
const surfaces = [];
|
|
2679
|
-
if (detected.includes("claude-code")) surfaces.push("claude");
|
|
2680
|
-
if (detected.includes("codex")) surfaces.push("codex");
|
|
2681
|
-
return surfaces;
|
|
2682
|
-
}
|
|
2683
|
-
function isSechroomOnPath() {
|
|
2684
|
-
const pathEnv = process.env.PATH ?? "";
|
|
2685
|
-
if (!pathEnv) return false;
|
|
2686
|
-
const names = process.platform === "win32" ? ["sechroom.cmd", "sechroom.exe", "sechroom.bat", "sechroom"] : ["sechroom"];
|
|
2687
|
-
for (const dir of pathEnv.split(delimiter)) {
|
|
2688
|
-
if (!dir) continue;
|
|
2689
|
-
for (const name of names) {
|
|
2690
|
-
if (existsSync6(join7(dir, name))) return true;
|
|
2691
|
-
}
|
|
2692
|
-
}
|
|
2693
|
-
return false;
|
|
2694
|
-
}
|
|
2695
|
-
function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
|
|
2696
|
-
if (isSechroomOnPath()) return false;
|
|
2697
|
-
write(
|
|
2698
|
-
"\n\u26A0 `sechroom` isn't on your PATH. The hooks run a bare `sechroom hook \u2026` command\n when your agent fires them, so a non-global install (npx / local) will fail at\n that point. Install globally so the command resolves:\n npm i -g @sechroom/cli\n"
|
|
2699
|
-
);
|
|
2700
|
-
return true;
|
|
2701
|
-
}
|
|
2702
|
-
|
|
2703
|
-
// src/commands/telemetry.ts
|
|
2704
|
-
function registerTelemetry(program2) {
|
|
2705
|
-
const telemetry = program2.command("telemetry").description(
|
|
2706
|
-
"Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
|
|
2707
|
-
);
|
|
2708
|
-
telemetry.command("emit").description(
|
|
2709
|
-
"POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
|
|
2710
|
-
).requiredOption(
|
|
2711
|
-
"--decomposition <id>",
|
|
2712
|
-
"Decomposition id whose run this event belongs to"
|
|
2713
|
-
).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
|
|
2714
|
-
"--kind <kind>",
|
|
2715
|
-
"Event kind: raw | parsed | approval | terminal"
|
|
2716
|
-
).option(
|
|
2717
|
-
"--tokens-in <n>",
|
|
2718
|
-
"Cumulative input tokens (spend meter)",
|
|
2719
|
-
parseIntOpt
|
|
2720
|
-
).option(
|
|
2721
|
-
"--tokens-out <n>",
|
|
2722
|
-
"Cumulative output tokens (spend meter)",
|
|
2723
|
-
parseIntOpt
|
|
2724
|
-
).option(
|
|
2725
|
-
"--context-used <n>",
|
|
2726
|
-
"Context tokens currently used (occupancy meter)",
|
|
2727
|
-
parseIntOpt
|
|
2728
|
-
).option(
|
|
2729
|
-
"--context-window <n>",
|
|
2730
|
-
"Context window size (occupancy meter)",
|
|
2731
|
-
parseIntOpt
|
|
2732
|
-
).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
|
|
2733
|
-
"--verdict <v>",
|
|
2734
|
-
"Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
|
|
2735
|
-
).action(async (opts, cmd) => {
|
|
2736
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
2737
|
-
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2738
|
-
const event = {
|
|
2739
|
-
taskId: opts.task,
|
|
2740
|
-
kind: normalizeKind(opts.kind),
|
|
2741
|
-
tokensIn: opts.tokensIn ?? null,
|
|
2742
|
-
tokensOut: opts.tokensOut ?? null,
|
|
2743
|
-
contextUsed: opts.contextUsed ?? null,
|
|
2744
|
-
contextWindow: opts.contextWindow ?? null,
|
|
2745
|
-
text: opts.text ?? null,
|
|
2746
|
-
approvalState: opts.approval ?? null,
|
|
2747
|
-
verdict: opts.verdict ?? null
|
|
2748
|
-
};
|
|
2749
|
-
let body;
|
|
2750
|
-
try {
|
|
2751
|
-
body = await postTelemetry(cfg, opts.decomposition, [event]);
|
|
2752
|
-
} catch (e) {
|
|
2753
|
-
return fail(`Telemetry emit failed: ${e.message}`);
|
|
2754
|
-
}
|
|
2755
|
-
if (json) {
|
|
2756
|
-
emit(body, true);
|
|
2757
|
-
} else {
|
|
2758
|
-
process.stderr.write(
|
|
2759
|
-
style.green("telemetry emitted") + style.dim(
|
|
2760
|
-
` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
|
|
2761
|
-
`
|
|
2762
|
-
)
|
|
2763
|
-
);
|
|
2764
|
-
}
|
|
2765
|
-
});
|
|
2766
|
-
telemetry.command("show <decompositionId>").description(
|
|
2767
|
-
"Read a run's telemetry \u2014 per-task meters + the raw/parsed/approval/terminal timeline (GET /decompositions/{id}/run/telemetry). Returns hasTelemetry:false, not an error, before any event is ingested \u2014 so an unstarted run and a stalled one read differently. The read side of this group; `emit` is the source side. (FR-sechroom-442 slice 1 step 4; mirrors the work_plan_run_telemetry MCP tool.)"
|
|
2768
|
-
).action(async (decompositionId, _opts, cmd) => {
|
|
2769
|
-
const globals = cmd.optsWithGlobals();
|
|
2770
|
-
const cfg = resolveConfig(globals);
|
|
2771
|
-
const data = await runApi("Reading run telemetry", async () => {
|
|
2772
|
-
const client = await makeClient(cfg);
|
|
2773
|
-
return client.GET("/decompositions/{id}/run/telemetry", {
|
|
2774
|
-
params: { path: { id: decompositionId } }
|
|
2775
|
-
});
|
|
2776
|
-
});
|
|
2777
|
-
emitAction(
|
|
2778
|
-
data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
|
|
2779
|
-
data,
|
|
2780
|
-
globals.json
|
|
2781
|
-
);
|
|
2782
|
-
});
|
|
2783
|
-
telemetry.command("bind").description(
|
|
2784
|
-
"Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
|
|
2785
|
-
).requiredOption(
|
|
2786
|
-
"--decomposition <id>",
|
|
2787
|
-
"Decomposition id this session executes"
|
|
2788
|
-
).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
|
|
2789
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
2790
|
-
const dir = join8(process.cwd(), ".sechroom");
|
|
2791
|
-
mkdirSync7(dir, { recursive: true });
|
|
2792
|
-
const path = join8(dir, BINDING_FILE);
|
|
2793
|
-
const binding = {
|
|
2794
|
-
decompositionId: opts.decomposition,
|
|
2795
|
-
taskId: opts.task
|
|
2796
|
-
};
|
|
2797
|
-
writeFileSync6(path, JSON.stringify(binding, null, 2) + "\n");
|
|
2798
|
-
ensureStateDirIgnored(process.cwd());
|
|
2799
|
-
if (json) {
|
|
2800
|
-
emit({ bound: true, ...binding, path }, true);
|
|
2801
|
-
} else {
|
|
2802
|
-
process.stdout.write(
|
|
2803
|
-
style.green("telemetry bound") + style.dim(
|
|
2804
|
-
` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
|
|
2805
|
-
`
|
|
2806
|
-
)
|
|
2807
|
-
);
|
|
2808
|
-
}
|
|
2809
|
-
});
|
|
2810
|
-
telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
|
|
2811
|
-
const json = Boolean(cmd.optsWithGlobals().json);
|
|
2812
|
-
const path = join8(process.cwd(), ".sechroom", BINDING_FILE);
|
|
2813
|
-
const existed = existsSync7(path);
|
|
2814
|
-
if (existed) rmSync3(path);
|
|
2815
|
-
if (json) emit({ unbound: existed, path }, true);
|
|
2816
|
-
else
|
|
2817
|
-
process.stdout.write(
|
|
2818
|
-
existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
|
|
2819
|
-
);
|
|
2820
|
-
});
|
|
2821
|
-
telemetry.command("hook").description(
|
|
2822
|
-
"Per-turn telemetry self-report for Claude Code hooks \u2014 Stop/SubagentStop \u2192 parsed + terminal, Notification/PermissionDenied \u2192 approval (reads stdin; no-op unless bound). Fail-soft."
|
|
2823
|
-
).action(async (_opts, cmd) => {
|
|
2824
|
-
try {
|
|
2825
|
-
const raw = await readStdin();
|
|
2826
|
-
const input = parseHookInput(raw);
|
|
2827
|
-
const cwd = input.cwd ?? process.cwd();
|
|
2828
|
-
const binding = findBinding(cwd);
|
|
2829
|
-
if (!binding) return process.exit(0);
|
|
2830
|
-
const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
|
|
2831
|
-
const events = buildHookEvents(input, usage, binding.taskId);
|
|
2832
|
-
if (events.length === 0) return process.exit(0);
|
|
2833
|
-
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2834
|
-
await postTelemetry(cfg, binding.decompositionId, events);
|
|
2835
|
-
return process.exit(0);
|
|
2836
|
-
} catch {
|
|
2837
|
-
return process.exit(0);
|
|
2838
|
-
}
|
|
2839
|
-
});
|
|
2840
|
-
telemetry.command("install").description(
|
|
2841
|
-
"Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
|
|
2842
|
-
).option(
|
|
2843
|
-
"--scope <scope>",
|
|
2844
|
-
"global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
|
|
2845
|
-
).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
|
|
2846
|
-
const g = cmd.optsWithGlobals();
|
|
2847
|
-
const dryRun = Boolean(opts.dryRun);
|
|
2848
|
-
const cwd = process.cwd();
|
|
2849
|
-
let scope;
|
|
2850
|
-
try {
|
|
2851
|
-
scope = opts.local ? "project" : resolveScope(opts.scope);
|
|
2852
|
-
} catch (err2) {
|
|
2853
|
-
process.stderr.write(`${err2.message}
|
|
2854
|
-
`);
|
|
2855
|
-
return process.exit(2);
|
|
2856
|
-
}
|
|
2857
|
-
const targets = resolveClaudeTargets({
|
|
2858
|
-
override: g.claudeConfigDir,
|
|
2859
|
-
scope,
|
|
2860
|
-
cwd
|
|
2861
|
-
});
|
|
2862
|
-
const commands = {
|
|
2863
|
-
Stop: "sechroom telemetry hook",
|
|
2864
|
-
SubagentStop: "sechroom telemetry hook",
|
|
2865
|
-
Notification: "sechroom telemetry hook",
|
|
2866
|
-
PermissionDenied: "sechroom telemetry hook"
|
|
2867
|
-
};
|
|
2868
|
-
try {
|
|
2869
|
-
const multi = targets.length > 1;
|
|
2870
|
-
const results = targets.map((t) => {
|
|
2871
|
-
const r = installClaudeCommands(t.dir, commands, dryRun);
|
|
2872
|
-
process.stdout.write(
|
|
2873
|
-
`${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
|
|
2874
|
-
`
|
|
2875
|
-
);
|
|
2876
|
-
process.stdout.write(describe(r, dryRun) + "\n");
|
|
2877
|
-
return r;
|
|
2878
|
-
});
|
|
2879
|
-
if (dryRun) {
|
|
2880
|
-
process.stdout.write("\n(dry run \u2014 no files were written.)\n");
|
|
2881
|
-
} else if (results.every((r) => r.status === "current")) {
|
|
2882
|
-
process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
|
|
2883
|
-
} else {
|
|
2884
|
-
process.stdout.write(
|
|
2885
|
-
"\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
|
|
2886
|
-
);
|
|
2887
|
-
}
|
|
2888
|
-
} catch (err2) {
|
|
2889
|
-
process.stderr.write(
|
|
2890
|
-
`telemetry install failed: ${err2.message}
|
|
2891
|
-
`
|
|
2892
|
-
);
|
|
2893
|
-
return process.exit(1);
|
|
2894
|
-
}
|
|
2895
|
-
warnIfSechroomNotOnPath();
|
|
2896
|
-
return process.exit(0);
|
|
2897
|
-
});
|
|
2898
|
-
}
|
|
2899
|
-
var BINDING_FILE = "telemetry.json";
|
|
2900
|
-
async function postTelemetry(cfg, decompositionId, events) {
|
|
2901
|
-
const token = await requireToken(cfg);
|
|
2902
|
-
const resp = await fetch(
|
|
2903
|
-
`${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
|
|
2904
|
-
{
|
|
2905
|
-
method: "POST",
|
|
2906
|
-
headers: {
|
|
2907
|
-
authorization: `Bearer ${token}`,
|
|
2908
|
-
tenant: cfg.tenant,
|
|
2909
|
-
"content-type": "application/json",
|
|
2910
|
-
"x-sechroom-surface": "cli"
|
|
2911
|
-
},
|
|
2912
|
-
body: JSON.stringify({ events })
|
|
2913
|
-
}
|
|
2914
|
-
);
|
|
2915
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
|
2916
|
-
return await resp.json();
|
|
2917
|
-
}
|
|
2918
|
-
function findBinding(start) {
|
|
2919
|
-
let dir = start;
|
|
2920
|
-
for (; ; ) {
|
|
2921
|
-
const path = join8(dir, ".sechroom", BINDING_FILE);
|
|
2922
|
-
if (existsSync7(path)) {
|
|
2923
|
-
try {
|
|
2924
|
-
const b = JSON.parse(
|
|
2925
|
-
readFileSync5(path, "utf8")
|
|
2926
|
-
);
|
|
2927
|
-
if (b.decompositionId && b.taskId)
|
|
2928
|
-
return { decompositionId: b.decompositionId, taskId: b.taskId };
|
|
2929
|
-
} catch {
|
|
2930
|
-
}
|
|
2931
|
-
return null;
|
|
2932
|
-
}
|
|
2933
|
-
const parent = dirname6(dir);
|
|
2934
|
-
if (parent === dir) return null;
|
|
2935
|
-
dir = parent;
|
|
2936
|
-
}
|
|
2937
|
-
}
|
|
2938
|
-
function parseTranscript(path) {
|
|
2939
|
-
if (!existsSync7(path)) return null;
|
|
2940
|
-
let tokensIn = 0;
|
|
2941
|
-
let tokensOut = 0;
|
|
2942
|
-
let contextUsed = 0;
|
|
2943
|
-
let model = "";
|
|
2944
|
-
for (const line of readFileSync5(path, "utf8").split("\n")) {
|
|
2945
|
-
if (!line.trim()) continue;
|
|
2946
|
-
let obj;
|
|
2947
|
-
try {
|
|
2948
|
-
obj = JSON.parse(line);
|
|
2949
|
-
} catch {
|
|
2950
|
-
continue;
|
|
2951
|
-
}
|
|
2952
|
-
const usage = obj.type === "assistant" ? obj.message?.usage : void 0;
|
|
2953
|
-
if (!usage) continue;
|
|
2954
|
-
const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
2955
|
-
tokensIn += input;
|
|
2956
|
-
tokensOut += usage.output_tokens ?? 0;
|
|
2957
|
-
contextUsed = input;
|
|
2958
|
-
if (obj.message?.model) model = obj.message.model;
|
|
2959
|
-
}
|
|
2960
|
-
if (tokensIn === 0 && tokensOut === 0) return null;
|
|
2961
|
-
return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed), modelId: model || null };
|
|
2962
|
-
}
|
|
2963
|
-
function windowFor(model, contextUsed = 0) {
|
|
2964
|
-
const m = model.toLowerCase();
|
|
2965
|
-
if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
|
|
2966
|
-
return contextUsed > 2e5 ? 1e6 : 2e5;
|
|
2967
|
-
}
|
|
2968
|
-
function buildHookEvents(input, usage, taskId) {
|
|
2969
|
-
const events = [];
|
|
2970
|
-
const base = (kind, over) => ({
|
|
2971
|
-
taskId,
|
|
2972
|
-
kind,
|
|
2973
|
-
tokensIn: null,
|
|
2974
|
-
tokensOut: null,
|
|
2975
|
-
contextUsed: null,
|
|
2976
|
-
contextWindow: null,
|
|
2977
|
-
text: null,
|
|
2978
|
-
approvalState: null,
|
|
2979
|
-
verdict: null,
|
|
2980
|
-
modelId: null,
|
|
2981
|
-
...over
|
|
2982
|
-
});
|
|
2983
|
-
if (usage) {
|
|
2984
|
-
events.push(
|
|
2985
|
-
base("Parsed", {
|
|
2986
|
-
tokensIn: usage.tokensIn,
|
|
2987
|
-
tokensOut: usage.tokensOut,
|
|
2988
|
-
contextUsed: usage.contextUsed,
|
|
2989
|
-
contextWindow: usage.contextWindow,
|
|
2990
|
-
modelId: usage.modelId
|
|
2991
|
-
})
|
|
2992
|
-
);
|
|
2993
|
-
}
|
|
2994
|
-
switch (input.hook_event_name) {
|
|
2995
|
-
case "PermissionDenied":
|
|
2996
|
-
events.push(
|
|
2997
|
-
base("Approval", {
|
|
2998
|
-
approvalState: "denied",
|
|
2999
|
-
text: input.tool_name ?? input.message ?? null
|
|
3000
|
-
})
|
|
3001
|
-
);
|
|
3002
|
-
break;
|
|
3003
|
-
case "Notification":
|
|
3004
|
-
if (isPermissionNotification(input))
|
|
3005
|
-
events.push(base("Approval", { text: input.message ?? null }));
|
|
3006
|
-
break;
|
|
3007
|
-
case "Stop":
|
|
3008
|
-
case "SubagentStop":
|
|
3009
|
-
events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
|
|
3010
|
-
break;
|
|
2995
|
+
const existed = existsSync6(path);
|
|
2996
|
+
const content = existed ? readFileSync4(path, "utf8") : "";
|
|
2997
|
+
const { next, changed } = ensureCodexFeaturesHooks(content);
|
|
2998
|
+
if (!changed) return { path, status: "current" };
|
|
2999
|
+
if (!dryRun) {
|
|
3000
|
+
mkdirSync6(dirname5(path), { recursive: true });
|
|
3001
|
+
writeFileSync5(path, next);
|
|
3011
3002
|
}
|
|
3012
|
-
return
|
|
3003
|
+
return { path, status: existed ? "merged" : "created" };
|
|
3013
3004
|
}
|
|
3014
|
-
function
|
|
3015
|
-
|
|
3016
|
-
if (
|
|
3017
|
-
|
|
3005
|
+
function resolveSurfaces(surface, cwd) {
|
|
3006
|
+
if (surface === "claude") return ["claude"];
|
|
3007
|
+
if (surface === "codex") return ["codex"];
|
|
3008
|
+
if (surface === "both") return ["claude", "codex"];
|
|
3009
|
+
if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
|
|
3010
|
+
const surfaces = detectHookSurfaces(cwd);
|
|
3011
|
+
return surfaces.length > 0 ? surfaces : ["claude", "codex"];
|
|
3018
3012
|
}
|
|
3019
|
-
|
|
3020
|
-
if (
|
|
3021
|
-
const
|
|
3022
|
-
|
|
3023
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
3013
|
+
function describe(result, dryRun) {
|
|
3014
|
+
if (result.status === "current") return ` \u2713 ${result.path} (already configured)`;
|
|
3015
|
+
const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
|
|
3016
|
+
return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
|
|
3024
3017
|
}
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3018
|
+
var HOOK_SURFACE_LABEL = {
|
|
3019
|
+
claude: "Claude Code",
|
|
3020
|
+
codex: "Codex"
|
|
3021
|
+
};
|
|
3022
|
+
function installHookSurfaces(surfaces, opts) {
|
|
3023
|
+
const out = [];
|
|
3024
|
+
for (const surface of surfaces) {
|
|
3025
|
+
if (surface === "claude") {
|
|
3026
|
+
const path = join7(opts.claudeDir, "settings.json");
|
|
3027
|
+
out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
|
|
3028
|
+
} else {
|
|
3029
|
+
const hooksJson = installHooksJson(join7(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
|
|
3030
|
+
const featureFlag = installCodexFeatureFlag(join7(opts.codexHome, "config.toml"), opts.dryRun);
|
|
3031
|
+
out.push({ surface, results: [hooksJson, featureFlag] });
|
|
3032
|
+
}
|
|
3031
3033
|
}
|
|
3034
|
+
return out;
|
|
3032
3035
|
}
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
function normalizeKind(k) {
|
|
3040
|
-
const v = KINDS[k.toLowerCase()];
|
|
3041
|
-
if (!v)
|
|
3042
|
-
fail(
|
|
3043
|
-
`Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
|
|
3044
|
-
);
|
|
3045
|
-
return v;
|
|
3036
|
+
function detectHookSurfaces(cwd) {
|
|
3037
|
+
const detected = detectInstalledClients(cwd);
|
|
3038
|
+
const surfaces = [];
|
|
3039
|
+
if (detected.includes("claude-code")) surfaces.push("claude");
|
|
3040
|
+
if (detected.includes("codex")) surfaces.push("codex");
|
|
3041
|
+
return surfaces;
|
|
3046
3042
|
}
|
|
3047
|
-
function
|
|
3048
|
-
const
|
|
3049
|
-
if (
|
|
3050
|
-
|
|
3043
|
+
function isSechroomOnPath() {
|
|
3044
|
+
const pathEnv = process.env.PATH ?? "";
|
|
3045
|
+
if (!pathEnv) return false;
|
|
3046
|
+
const names = process.platform === "win32" ? ["sechroom.cmd", "sechroom.exe", "sechroom.bat", "sechroom"] : ["sechroom"];
|
|
3047
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
3048
|
+
if (!dir) continue;
|
|
3049
|
+
for (const name of names) {
|
|
3050
|
+
if (existsSync6(join7(dir, name))) return true;
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
return false;
|
|
3054
|
+
}
|
|
3055
|
+
function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
|
|
3056
|
+
if (isSechroomOnPath()) return false;
|
|
3057
|
+
write(
|
|
3058
|
+
"\n\u26A0 `sechroom` isn't on your PATH. The hooks run a bare `sechroom hook \u2026` command\n when your agent fires them, so a non-global install (npx / local) will fail at\n that point. Install globally so the command resolves:\n npm i -g @sechroom/cli\n"
|
|
3059
|
+
);
|
|
3060
|
+
return true;
|
|
3051
3061
|
}
|
|
3052
3062
|
|
|
3053
|
-
// src/
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
+
// src/commands/telemetry.ts
|
|
3064
|
+
function registerTelemetry(program2) {
|
|
3065
|
+
const telemetry = program2.command("telemetry").description(
|
|
3066
|
+
"Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
|
|
3067
|
+
);
|
|
3068
|
+
telemetry.command("emit").description(
|
|
3069
|
+
"POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
|
|
3070
|
+
).requiredOption(
|
|
3071
|
+
"--decomposition <id>",
|
|
3072
|
+
"Decomposition id whose run this event belongs to"
|
|
3073
|
+
).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
|
|
3074
|
+
"--kind <kind>",
|
|
3075
|
+
"Event kind: raw | parsed | approval | terminal"
|
|
3076
|
+
).option(
|
|
3077
|
+
"--tokens-in <n>",
|
|
3078
|
+
"Cumulative input tokens (spend meter)",
|
|
3079
|
+
parseIntOpt
|
|
3080
|
+
).option(
|
|
3081
|
+
"--tokens-out <n>",
|
|
3082
|
+
"Cumulative output tokens (spend meter)",
|
|
3083
|
+
parseIntOpt
|
|
3084
|
+
).option(
|
|
3085
|
+
"--context-used <n>",
|
|
3086
|
+
"Context tokens currently used (occupancy meter)",
|
|
3087
|
+
parseIntOpt
|
|
3088
|
+
).option(
|
|
3089
|
+
"--context-window <n>",
|
|
3090
|
+
"Context window size (occupancy meter)",
|
|
3091
|
+
parseIntOpt
|
|
3092
|
+
).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
|
|
3093
|
+
"--verdict <v>",
|
|
3094
|
+
"Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
|
|
3095
|
+
).action(async (opts, cmd) => {
|
|
3096
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
3097
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3098
|
+
const event = {
|
|
3099
|
+
taskId: opts.task,
|
|
3100
|
+
kind: normalizeKind(opts.kind),
|
|
3101
|
+
tokensIn: opts.tokensIn ?? null,
|
|
3102
|
+
tokensOut: opts.tokensOut ?? null,
|
|
3103
|
+
contextUsed: opts.contextUsed ?? null,
|
|
3104
|
+
contextWindow: opts.contextWindow ?? null,
|
|
3105
|
+
text: opts.text ?? null,
|
|
3106
|
+
approvalState: opts.approval ?? null,
|
|
3107
|
+
verdict: opts.verdict ?? null
|
|
3108
|
+
};
|
|
3109
|
+
let body;
|
|
3110
|
+
try {
|
|
3111
|
+
body = await postTelemetry(cfg, opts.decomposition, [event]);
|
|
3112
|
+
} catch (e) {
|
|
3113
|
+
return fail(`Telemetry emit failed: ${e.message}`);
|
|
3114
|
+
}
|
|
3115
|
+
if (json) {
|
|
3116
|
+
emit(body, true);
|
|
3117
|
+
} else {
|
|
3118
|
+
process.stderr.write(
|
|
3119
|
+
style.green("telemetry emitted") + style.dim(
|
|
3120
|
+
` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
|
|
3121
|
+
`
|
|
3122
|
+
)
|
|
3123
|
+
);
|
|
3124
|
+
}
|
|
3063
3125
|
});
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
const
|
|
3068
|
-
const
|
|
3069
|
-
|
|
3126
|
+
telemetry.command("show <decompositionId>").description(
|
|
3127
|
+
"Read a run's telemetry \u2014 per-task meters + the raw/parsed/approval/terminal timeline (GET /decompositions/{id}/run/telemetry). Returns hasTelemetry:false, not an error, before any event is ingested \u2014 so an unstarted run and a stalled one read differently. The read side of this group; `emit` is the source side. (FR-sechroom-442 slice 1 step 4; mirrors the work_plan_run_telemetry MCP tool.)"
|
|
3128
|
+
).action(async (decompositionId, _opts, cmd) => {
|
|
3129
|
+
const globals = cmd.optsWithGlobals();
|
|
3130
|
+
const cfg = resolveConfig(globals);
|
|
3131
|
+
const data = await runApi("Reading run telemetry", async () => {
|
|
3132
|
+
const client = await makeClient(cfg);
|
|
3133
|
+
return client.GET("/decompositions/{id}/run/telemetry", {
|
|
3134
|
+
params: { path: { id: decompositionId } }
|
|
3135
|
+
});
|
|
3136
|
+
});
|
|
3137
|
+
emitAction(
|
|
3138
|
+
data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
|
|
3139
|
+
data,
|
|
3140
|
+
globals.json
|
|
3141
|
+
);
|
|
3070
3142
|
});
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
}
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
}
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
if (turnPaths.length === 0)
|
|
3097
|
-
return { delivered: false, note: "no file changes produced by the turn \u2014 nothing to deliver" };
|
|
3098
|
-
const branch = await freeBranchName(git, `task/${slug(options.taskId)}`);
|
|
3099
|
-
const created = await git("git", ["checkout", "-b", branch]);
|
|
3100
|
-
if (!created.ok)
|
|
3101
|
-
return {
|
|
3102
|
-
delivered: false,
|
|
3103
|
-
note: `delivery FAILED \u2014 could not create branch ${branch}: ${created.stderr.trim()} (changes remain uncommitted in the root)`
|
|
3104
|
-
};
|
|
3105
|
-
const notes = [];
|
|
3106
|
-
try {
|
|
3107
|
-
const added = await git("git", ["add", "--", ...turnPaths]);
|
|
3108
|
-
if (!added.ok) return failBack(`git add failed: ${added.stderr.trim()}`);
|
|
3109
|
-
const committed = await git("git", [
|
|
3110
|
-
"commit",
|
|
3111
|
-
"-m",
|
|
3112
|
-
commitMessage(options)
|
|
3113
|
-
]);
|
|
3114
|
-
if (!committed.ok) return failBack(`git commit failed: ${committed.stderr.trim()}`);
|
|
3115
|
-
const sha = (await git("git", ["rev-parse", "--short", "HEAD"])).stdout.trim();
|
|
3116
|
-
const pushed = await git("git", ["push", "-u", "origin", branch]);
|
|
3117
|
-
if (!pushed.ok)
|
|
3118
|
-
notes.push(`push failed (${firstLine(pushed.stderr)}) \u2014 branch is local-only`);
|
|
3119
|
-
let prUrl;
|
|
3120
|
-
if (options.raisePr && pushed.ok) {
|
|
3121
|
-
const pr = await git("gh", [
|
|
3122
|
-
"pr",
|
|
3123
|
-
"create",
|
|
3124
|
-
"--head",
|
|
3125
|
-
branch,
|
|
3126
|
-
"--title",
|
|
3127
|
-
`task(${options.taskId}): ${options.title}`,
|
|
3128
|
-
"--body",
|
|
3129
|
-
prBody(options, sha)
|
|
3130
|
-
]);
|
|
3131
|
-
if (pr.ok) prUrl = firstLine(pr.stdout);
|
|
3132
|
-
else notes.push(`PR raise failed (${firstLine(pr.stderr)}) \u2014 raise manually from ${branch}`);
|
|
3143
|
+
telemetry.command("bind").description(
|
|
3144
|
+
"Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
|
|
3145
|
+
).requiredOption(
|
|
3146
|
+
"--decomposition <id>",
|
|
3147
|
+
"Decomposition id this session executes"
|
|
3148
|
+
).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
|
|
3149
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
3150
|
+
const dir = join8(process.cwd(), ".sechroom");
|
|
3151
|
+
mkdirSync7(dir, { recursive: true });
|
|
3152
|
+
const path = join8(dir, BINDING_FILE);
|
|
3153
|
+
const binding = {
|
|
3154
|
+
decompositionId: opts.decomposition,
|
|
3155
|
+
taskId: opts.task
|
|
3156
|
+
};
|
|
3157
|
+
writeFileSync6(path, JSON.stringify(binding, null, 2) + "\n");
|
|
3158
|
+
ensureStateDirIgnored(process.cwd());
|
|
3159
|
+
if (json) {
|
|
3160
|
+
emit({ bound: true, ...binding, path }, true);
|
|
3161
|
+
} else {
|
|
3162
|
+
process.stdout.write(
|
|
3163
|
+
style.green("telemetry bound") + style.dim(
|
|
3164
|
+
` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
|
|
3165
|
+
`
|
|
3166
|
+
)
|
|
3167
|
+
);
|
|
3133
3168
|
}
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
);
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
if (
|
|
3141
|
-
|
|
3142
|
-
|
|
3169
|
+
});
|
|
3170
|
+
telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
|
|
3171
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
3172
|
+
const path = join8(process.cwd(), ".sechroom", BINDING_FILE);
|
|
3173
|
+
const existed = existsSync7(path);
|
|
3174
|
+
if (existed) rmSync3(path);
|
|
3175
|
+
if (json) emit({ unbound: existed, path }, true);
|
|
3176
|
+
else
|
|
3177
|
+
process.stdout.write(
|
|
3178
|
+
existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
|
|
3143
3179
|
);
|
|
3144
|
-
}
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
}
|
|
3161
|
-
|
|
3162
|
-
return taskId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3163
|
-
}
|
|
3164
|
-
function commitMessage(options) {
|
|
3165
|
-
return `task(${options.taskId}): ${options.title} [verdict:${options.verdict}]
|
|
3166
|
-
|
|
3167
|
-
Driven-executor delivery (FR-sechroom-496): work produced by the sandboxed codex turn, landed by the driver.
|
|
3168
|
-
|
|
3169
|
-
Delivered-By: sechroom executor run (${options.deliveredBy})`;
|
|
3170
|
-
}
|
|
3171
|
-
function prBody(options, sha) {
|
|
3172
|
-
return `Driven-executor delivery for WLP task \`${options.taskId}\` (verdict: ${options.verdict}, commit ${sha}).
|
|
3173
|
-
|
|
3174
|
-
Work produced by a sandboxed codex turn and landed by the unsandboxed driver (FR-sechroom-496). Review against the task's acceptance before merge.`;
|
|
3175
|
-
}
|
|
3176
|
-
function firstLine(text2) {
|
|
3177
|
-
return text2.trim().split("\n")[0] ?? "";
|
|
3178
|
-
}
|
|
3179
|
-
|
|
3180
|
-
// src/executor-run/request.ts
|
|
3181
|
-
var AuthExpiredError = class extends Error {
|
|
3182
|
-
constructor(message) {
|
|
3183
|
-
super(message);
|
|
3184
|
-
this.name = "AuthExpiredError";
|
|
3185
|
-
}
|
|
3186
|
-
};
|
|
3187
|
-
var HttpError = class extends Error {
|
|
3188
|
-
constructor(status, method, path, body) {
|
|
3189
|
-
super(`${method} ${path} failed (${status}): ${body}`);
|
|
3190
|
-
this.status = status;
|
|
3191
|
-
this.method = method;
|
|
3192
|
-
this.path = path;
|
|
3193
|
-
this.body = body;
|
|
3194
|
-
this.name = "HttpError";
|
|
3195
|
-
}
|
|
3196
|
-
status;
|
|
3197
|
-
method;
|
|
3198
|
-
path;
|
|
3199
|
-
body;
|
|
3200
|
-
};
|
|
3201
|
-
function createAuthedRequest(cfg, deps = {}) {
|
|
3202
|
-
const getToken = deps.getToken ?? requireToken;
|
|
3203
|
-
const refresh = deps.refreshToken ?? forceRefreshToken;
|
|
3204
|
-
const doFetch = deps.fetch ?? fetch;
|
|
3205
|
-
const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
|
|
3206
|
-
...init,
|
|
3207
|
-
headers: {
|
|
3208
|
-
authorization: `Bearer ${token}`,
|
|
3209
|
-
tenant: cfg.tenant,
|
|
3210
|
-
"content-type": "application/json",
|
|
3211
|
-
"x-sechroom-surface": "cli",
|
|
3212
|
-
...init?.headers
|
|
3180
|
+
});
|
|
3181
|
+
telemetry.command("hook").description(
|
|
3182
|
+
"Per-turn telemetry self-report for Claude Code hooks \u2014 Stop/SubagentStop \u2192 parsed + terminal, Notification/PermissionDenied \u2192 approval (reads stdin; no-op unless bound). Fail-soft."
|
|
3183
|
+
).action(async (_opts, cmd) => {
|
|
3184
|
+
try {
|
|
3185
|
+
const raw = await readStdin();
|
|
3186
|
+
const input = parseHookInput(raw);
|
|
3187
|
+
const cwd = input.cwd ?? process.cwd();
|
|
3188
|
+
const binding = findBinding(cwd);
|
|
3189
|
+
if (!binding) return process.exit(0);
|
|
3190
|
+
const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
|
|
3191
|
+
const events = buildHookEvents(input, usage, binding.taskId);
|
|
3192
|
+
if (events.length === 0) return process.exit(0);
|
|
3193
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3194
|
+
await postTelemetry(cfg, binding.decompositionId, events);
|
|
3195
|
+
return process.exit(0);
|
|
3196
|
+
} catch {
|
|
3197
|
+
return process.exit(0);
|
|
3213
3198
|
}
|
|
3214
3199
|
});
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3200
|
+
telemetry.command("install").description(
|
|
3201
|
+
"Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
|
|
3202
|
+
).option(
|
|
3203
|
+
"--scope <scope>",
|
|
3204
|
+
"global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
|
|
3205
|
+
).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
|
|
3206
|
+
const g = cmd.optsWithGlobals();
|
|
3207
|
+
const dryRun = Boolean(opts.dryRun);
|
|
3208
|
+
const cwd = process.cwd();
|
|
3209
|
+
let scope;
|
|
3218
3210
|
try {
|
|
3219
|
-
|
|
3220
|
-
} catch (
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
);
|
|
3211
|
+
scope = opts.local ? "project" : resolveScope(opts.scope);
|
|
3212
|
+
} catch (err2) {
|
|
3213
|
+
process.stderr.write(`${err2.message}
|
|
3214
|
+
`);
|
|
3215
|
+
return process.exit(2);
|
|
3224
3216
|
}
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3217
|
+
const targets = resolveClaudeTargets({
|
|
3218
|
+
override: g.claudeConfigDir,
|
|
3219
|
+
scope,
|
|
3220
|
+
cwd
|
|
3221
|
+
});
|
|
3222
|
+
const commands = {
|
|
3223
|
+
Stop: "sechroom telemetry hook",
|
|
3224
|
+
SubagentStop: "sechroom telemetry hook",
|
|
3225
|
+
Notification: "sechroom telemetry hook",
|
|
3226
|
+
PermissionDenied: "sechroom telemetry hook"
|
|
3227
|
+
};
|
|
3228
|
+
try {
|
|
3229
|
+
const multi = targets.length > 1;
|
|
3230
|
+
const results = targets.map((t) => {
|
|
3231
|
+
const r = installClaudeCommands(t.dir, commands, dryRun);
|
|
3232
|
+
process.stdout.write(
|
|
3233
|
+
`${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
|
|
3234
|
+
`
|
|
3233
3235
|
);
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3236
|
+
process.stdout.write(describe(r, dryRun) + "\n");
|
|
3237
|
+
return r;
|
|
3238
|
+
});
|
|
3239
|
+
if (dryRun) {
|
|
3240
|
+
process.stdout.write("\n(dry run \u2014 no files were written.)\n");
|
|
3241
|
+
} else if (results.every((r) => r.status === "current")) {
|
|
3242
|
+
process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
|
|
3243
|
+
} else {
|
|
3244
|
+
process.stdout.write(
|
|
3245
|
+
"\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
|
|
3239
3246
|
);
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
path,
|
|
3246
|
-
await safeText(response)
|
|
3247
|
+
}
|
|
3248
|
+
} catch (err2) {
|
|
3249
|
+
process.stderr.write(
|
|
3250
|
+
`telemetry install failed: ${err2.message}
|
|
3251
|
+
`
|
|
3247
3252
|
);
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
return await response.text();
|
|
3254
|
-
} catch {
|
|
3255
|
-
return "";
|
|
3256
|
-
}
|
|
3257
|
-
}
|
|
3258
|
-
|
|
3259
|
-
// src/executor-run/driver.ts
|
|
3260
|
-
function verdictFor(terminalStatus) {
|
|
3261
|
-
switch (terminalStatus) {
|
|
3262
|
-
case "completed":
|
|
3263
|
-
return "pass";
|
|
3264
|
-
case "needs_approval":
|
|
3265
|
-
case "cancelled":
|
|
3266
|
-
case "canceled":
|
|
3267
|
-
return "blocked";
|
|
3268
|
-
case "error":
|
|
3269
|
-
default:
|
|
3270
|
-
return "soft-fail";
|
|
3271
|
-
}
|
|
3253
|
+
return process.exit(1);
|
|
3254
|
+
}
|
|
3255
|
+
warnIfSechroomNotOnPath();
|
|
3256
|
+
return process.exit(0);
|
|
3257
|
+
});
|
|
3272
3258
|
}
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
}
|
|
3287
|
-
if (admissionDeferred) {
|
|
3288
|
-
admissionDeferred = false;
|
|
3289
|
-
ports.log("admission recovered \u2014 resuming claims");
|
|
3290
|
-
}
|
|
3259
|
+
var BINDING_FILE = "telemetry.json";
|
|
3260
|
+
async function postTelemetry(cfg, decompositionId, events) {
|
|
3261
|
+
const token = await requireToken(cfg);
|
|
3262
|
+
const resp = await fetch(
|
|
3263
|
+
`${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
|
|
3264
|
+
{
|
|
3265
|
+
method: "POST",
|
|
3266
|
+
headers: {
|
|
3267
|
+
authorization: `Bearer ${token}`,
|
|
3268
|
+
tenant: cfg.tenant,
|
|
3269
|
+
"content-type": "application/json",
|
|
3270
|
+
"x-sechroom-surface": "cli"
|
|
3271
|
+
},
|
|
3272
|
+
body: JSON.stringify({ events })
|
|
3291
3273
|
}
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3274
|
+
);
|
|
3275
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
|
3276
|
+
return await resp.json();
|
|
3277
|
+
}
|
|
3278
|
+
function findBinding(start) {
|
|
3279
|
+
let dir = start;
|
|
3280
|
+
for (; ; ) {
|
|
3281
|
+
const path = join8(dir, ".sechroom", BINDING_FILE);
|
|
3282
|
+
if (existsSync7(path)) {
|
|
3283
|
+
try {
|
|
3284
|
+
const b = JSON.parse(
|
|
3285
|
+
readFileSync5(path, "utf8")
|
|
3286
|
+
);
|
|
3287
|
+
if (b.decompositionId && b.taskId)
|
|
3288
|
+
return { decompositionId: b.decompositionId, taskId: b.taskId };
|
|
3289
|
+
} catch {
|
|
3298
3290
|
}
|
|
3291
|
+
return null;
|
|
3299
3292
|
}
|
|
3300
|
-
const
|
|
3301
|
-
if (
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3293
|
+
const parent = dirname6(dir);
|
|
3294
|
+
if (parent === dir) return null;
|
|
3295
|
+
dir = parent;
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
function parseTranscript(path) {
|
|
3299
|
+
if (!existsSync7(path)) return null;
|
|
3300
|
+
let tokensIn = 0;
|
|
3301
|
+
let tokensOut = 0;
|
|
3302
|
+
let contextUsed = 0;
|
|
3303
|
+
let model = "";
|
|
3304
|
+
for (const line of readFileSync5(path, "utf8").split("\n")) {
|
|
3305
|
+
if (!line.trim()) continue;
|
|
3306
|
+
let obj;
|
|
3310
3307
|
try {
|
|
3311
|
-
|
|
3312
|
-
} catch
|
|
3313
|
-
|
|
3314
|
-
result = { status: "crashed", reason: String(e) };
|
|
3315
|
-
} finally {
|
|
3316
|
-
stopHeartbeat();
|
|
3308
|
+
obj = JSON.parse(line);
|
|
3309
|
+
} catch {
|
|
3310
|
+
continue;
|
|
3317
3311
|
}
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3312
|
+
const usage = obj.type === "assistant" ? obj.message?.usage : void 0;
|
|
3313
|
+
if (!usage) continue;
|
|
3314
|
+
const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
3315
|
+
tokensIn += input;
|
|
3316
|
+
tokensOut += usage.output_tokens ?? 0;
|
|
3317
|
+
contextUsed = input;
|
|
3318
|
+
if (obj.message?.model) model = obj.message.model;
|
|
3319
|
+
}
|
|
3320
|
+
if (tokensIn === 0 && tokensOut === 0) return null;
|
|
3321
|
+
return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed), modelId: model || null };
|
|
3322
|
+
}
|
|
3323
|
+
function windowFor(model, contextUsed = 0) {
|
|
3324
|
+
const m = model.toLowerCase();
|
|
3325
|
+
if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
|
|
3326
|
+
return contextUsed > 2e5 ? 1e6 : 2e5;
|
|
3327
|
+
}
|
|
3328
|
+
function buildHookEvents(input, usage, taskId) {
|
|
3329
|
+
const events = [];
|
|
3330
|
+
const base = (kind, over) => ({
|
|
3331
|
+
taskId,
|
|
3332
|
+
kind,
|
|
3333
|
+
tokensIn: null,
|
|
3334
|
+
tokensOut: null,
|
|
3335
|
+
contextUsed: null,
|
|
3336
|
+
contextWindow: null,
|
|
3337
|
+
text: null,
|
|
3338
|
+
approvalState: null,
|
|
3339
|
+
verdict: null,
|
|
3340
|
+
modelId: null,
|
|
3341
|
+
...over
|
|
3342
|
+
});
|
|
3343
|
+
if (usage) {
|
|
3344
|
+
events.push(
|
|
3345
|
+
base("Parsed", {
|
|
3346
|
+
tokensIn: usage.tokensIn,
|
|
3347
|
+
tokensOut: usage.tokensOut,
|
|
3348
|
+
contextUsed: usage.contextUsed,
|
|
3349
|
+
contextWindow: usage.contextWindow,
|
|
3350
|
+
modelId: usage.modelId
|
|
3351
|
+
})
|
|
3352
|
+
);
|
|
3353
|
+
}
|
|
3354
|
+
switch (input.hook_event_name) {
|
|
3355
|
+
case "PermissionDenied":
|
|
3356
|
+
events.push(
|
|
3357
|
+
base("Approval", {
|
|
3358
|
+
approvalState: "denied",
|
|
3359
|
+
text: input.tool_name ?? input.message ?? null
|
|
3360
|
+
})
|
|
3322
3361
|
);
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
Delivery: ${delivery.note}`;
|
|
3333
|
-
} catch (e) {
|
|
3334
|
-
if (e instanceof AuthExpiredError) throw e;
|
|
3335
|
-
ports.log(`delivery threw (continuing to completion): ${String(e)}`);
|
|
3336
|
-
text2 += `
|
|
3337
|
-
|
|
3338
|
-
Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
|
|
3339
|
-
}
|
|
3340
|
-
}
|
|
3341
|
-
try {
|
|
3342
|
-
const done = await ports.completeLease(
|
|
3343
|
-
claim,
|
|
3344
|
-
verdict,
|
|
3345
|
-
text2,
|
|
3346
|
-
`${task.title} \u2014 driven closeout`
|
|
3347
|
-
);
|
|
3348
|
-
summary.completed++;
|
|
3349
|
-
ports.log(
|
|
3350
|
-
`completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
|
|
3351
|
-
);
|
|
3352
|
-
} catch (e) {
|
|
3353
|
-
if (e instanceof AuthExpiredError) throw e;
|
|
3354
|
-
summary.abandoned++;
|
|
3355
|
-
ports.log(
|
|
3356
|
-
`COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
|
|
3357
|
-
);
|
|
3358
|
-
}
|
|
3359
|
-
}
|
|
3360
|
-
if (options.once) break;
|
|
3362
|
+
break;
|
|
3363
|
+
case "Notification":
|
|
3364
|
+
if (isPermissionNotification(input))
|
|
3365
|
+
events.push(base("Approval", { text: input.message ?? null }));
|
|
3366
|
+
break;
|
|
3367
|
+
case "Stop":
|
|
3368
|
+
case "SubagentStop":
|
|
3369
|
+
events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
|
|
3370
|
+
break;
|
|
3361
3371
|
}
|
|
3362
|
-
return
|
|
3372
|
+
return events;
|
|
3363
3373
|
}
|
|
3364
|
-
function
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
${result.lastAgentMessage || "(none)"}`;
|
|
3369
|
-
const evidence = result.packet.evidence?.length ? `
|
|
3370
|
-
|
|
3371
|
-
Evidence:
|
|
3372
|
-
${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
|
|
3373
|
-
return `${result.packet.summary}${evidence}
|
|
3374
|
-
|
|
3375
|
-
(terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
|
|
3374
|
+
function isPermissionNotification(input) {
|
|
3375
|
+
const t = (input.notification_type ?? input.type ?? "").toLowerCase();
|
|
3376
|
+
if (t) return t.includes("permission");
|
|
3377
|
+
return (input.message ?? "").toLowerCase().includes("permission");
|
|
3376
3378
|
}
|
|
3377
|
-
function
|
|
3378
|
-
|
|
3379
|
-
const
|
|
3380
|
-
const
|
|
3381
|
-
|
|
3382
|
-
|
|
3379
|
+
async function readStdin() {
|
|
3380
|
+
if (process.stdin.isTTY) return "";
|
|
3381
|
+
const chunks = [];
|
|
3382
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
3383
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3384
|
+
}
|
|
3385
|
+
function parseHookInput(raw) {
|
|
3386
|
+
if (!raw.trim()) return {};
|
|
3387
|
+
try {
|
|
3388
|
+
return JSON.parse(raw);
|
|
3389
|
+
} catch {
|
|
3390
|
+
return {};
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
var KINDS = {
|
|
3394
|
+
raw: "Raw",
|
|
3395
|
+
parsed: "Parsed",
|
|
3396
|
+
approval: "Approval",
|
|
3397
|
+
terminal: "Terminal"
|
|
3398
|
+
};
|
|
3399
|
+
function normalizeKind(k) {
|
|
3400
|
+
const v = KINDS[k.toLowerCase()];
|
|
3401
|
+
if (!v)
|
|
3402
|
+
fail(
|
|
3403
|
+
`Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
|
|
3383
3404
|
);
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3405
|
+
return v;
|
|
3406
|
+
}
|
|
3407
|
+
function parseIntOpt(v) {
|
|
3408
|
+
const n = Number.parseInt(v, 10);
|
|
3409
|
+
if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
|
|
3410
|
+
return n;
|
|
3387
3411
|
}
|
|
3388
3412
|
|
|
3389
3413
|
// src/commands/executor-run.ts
|
|
3390
3414
|
function registerExecutorRunCommand(executor) {
|
|
3391
|
-
executor.command("fleet").description(
|
|
3392
|
-
|
|
3415
|
+
executor.command("fleet").description(
|
|
3416
|
+
"Run multiple isolated driven codex executors from one config file"
|
|
3417
|
+
).requiredOption("--config <file>", "JSON fleet config").action(async (opts, cmd) => {
|
|
3418
|
+
const config2 = await readFleetConfig(String(opts.config));
|
|
3419
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3420
|
+
const log = (line) => process.stderr.write(style.dim(`[fleet] ${line}
|
|
3421
|
+
`));
|
|
3422
|
+
let nodeId;
|
|
3423
|
+
let stopNodeHeartbeat;
|
|
3424
|
+
if (config2.node) {
|
|
3425
|
+
const node = await registerFleetNode(cfg, config2.node);
|
|
3426
|
+
nodeId = node.id;
|
|
3427
|
+
const nodeTtl = config2.node.ttl ?? 120;
|
|
3428
|
+
log(`node registered \u2014 ${node.id} (${config2.node.instanceKey})`);
|
|
3429
|
+
stopNodeHeartbeat = startExecutorHeartbeat(
|
|
3430
|
+
() => refreshExecutorInstance(cfg, node.id, nodeTtl),
|
|
3431
|
+
Math.max(10, Math.floor(nodeTtl * 0.66)) * 1e3,
|
|
3432
|
+
{ onError: (e) => log(`node refresh failed: ${String(e)}`) }
|
|
3433
|
+
);
|
|
3434
|
+
}
|
|
3435
|
+
const fleet = superviseFleet(config2, { parentId: nodeId });
|
|
3393
3436
|
const stop = () => void fleet.shutdown("SIGINT");
|
|
3394
3437
|
process.once("SIGINT", stop);
|
|
3395
3438
|
process.once("SIGTERM", stop);
|
|
@@ -3398,6 +3441,17 @@ function registerExecutorRunCommand(executor) {
|
|
|
3398
3441
|
} finally {
|
|
3399
3442
|
process.off("SIGINT", stop);
|
|
3400
3443
|
process.off("SIGTERM", stop);
|
|
3444
|
+
stopNodeHeartbeat?.();
|
|
3445
|
+
if (nodeId) {
|
|
3446
|
+
try {
|
|
3447
|
+
await deregisterInstance(cfg, nodeId);
|
|
3448
|
+
log(`node deregistered \u2014 ${nodeId}`);
|
|
3449
|
+
} catch (e) {
|
|
3450
|
+
log(
|
|
3451
|
+
`node deregister failed (${String(e)}) \u2014 advertisement will expire by TTL`
|
|
3452
|
+
);
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3401
3455
|
}
|
|
3402
3456
|
});
|
|
3403
3457
|
executor.command("run").description(
|
|
@@ -3419,6 +3473,9 @@ function registerExecutorRunCommand(executor) {
|
|
|
3419
3473
|
).option(
|
|
3420
3474
|
"--connector <id>",
|
|
3421
3475
|
"Approved connector id (defaults to installed executor.json)"
|
|
3476
|
+
).option(
|
|
3477
|
+
"--parent-id <id>",
|
|
3478
|
+
"Fleet node this child enrolls under (D-WLP-55 containment; set by the fleet supervisor)"
|
|
3422
3479
|
).option("--ttl <seconds>", "Advertisement TTL (30-600)").option("--once", "Process a single task, then exit", false).option(
|
|
3423
3480
|
"--no-deliver",
|
|
3424
3481
|
"Skip driver-side delivery (branch/commit/push of the turn's changes)"
|
|
@@ -3426,11 +3483,7 @@ function registerExecutorRunCommand(executor) {
|
|
|
3426
3483
|
"--allow-dirty-root",
|
|
3427
3484
|
"Claim even when the root has uncommitted changes (they are fenced out of the delivery commit)",
|
|
3428
3485
|
false
|
|
3429
|
-
).option("--no-pr", "Deliver without raising a PR (branch + push only)").option("--poll-interval <seconds>", "Offer reconciliation interval", "5").option(
|
|
3430
|
-
"--heartbeat-interval <seconds>",
|
|
3431
|
-
"Lease heartbeat cadence",
|
|
3432
|
-
"30"
|
|
3433
|
-
).option("--turn-timeout <seconds>", "Fresh turn timeout", "300").option("--resume-turn-timeout <seconds>", "Resumed turn timeout", "1200").option(
|
|
3486
|
+
).option("--no-pr", "Deliver without raising a PR (branch + push only)").option("--poll-interval <seconds>", "Offer reconciliation interval", "5").option("--heartbeat-interval <seconds>", "Lease heartbeat cadence", "30").option("--turn-timeout <seconds>", "Fresh turn timeout", "300").option("--resume-turn-timeout <seconds>", "Resumed turn timeout", "1200").option(
|
|
3434
3487
|
"--drain-timeout <seconds>",
|
|
3435
3488
|
"On shutdown, seconds to let an in-flight turn finish before interrupting",
|
|
3436
3489
|
"30"
|
|
@@ -3440,7 +3493,9 @@ function registerExecutorRunCommand(executor) {
|
|
|
3440
3493
|
"2"
|
|
3441
3494
|
).action(async (opts, cmd) => {
|
|
3442
3495
|
if (String(opts.runtime).toLowerCase() !== "codex")
|
|
3443
|
-
fail(
|
|
3496
|
+
fail(
|
|
3497
|
+
"executor run drives runtime codex only (claude-code stays attached)"
|
|
3498
|
+
);
|
|
3444
3499
|
const located = readExecutorState();
|
|
3445
3500
|
if (!located)
|
|
3446
3501
|
fail(
|
|
@@ -3455,6 +3510,7 @@ function registerExecutorRunCommand(executor) {
|
|
|
3455
3510
|
instanceKey: opts.instanceKey ? String(opts.instanceKey) : located.state.instanceKey,
|
|
3456
3511
|
laneId: opts.lane ? String(opts.lane) : located.state.laneId,
|
|
3457
3512
|
connectorId: opts.connector ? String(opts.connector) : located.state.connectorId,
|
|
3513
|
+
parentId: opts.parentId ? String(opts.parentId) : located.state.parentId,
|
|
3458
3514
|
ttlSeconds: opts.ttl ? Number.parseInt(String(opts.ttl), 10) : located.state.ttlSeconds
|
|
3459
3515
|
};
|
|
3460
3516
|
const heartbeatMs = Number.parseInt(String(opts.heartbeatInterval), 10) * 1e3;
|
|
@@ -3650,14 +3706,20 @@ function registerExecutorRunCommand(executor) {
|
|
|
3650
3706
|
);
|
|
3651
3707
|
log("deregistered");
|
|
3652
3708
|
} catch (e) {
|
|
3653
|
-
log(
|
|
3709
|
+
log(
|
|
3710
|
+
`deregister failed (${String(e)}) \u2014 advertisement will expire by TTL`
|
|
3711
|
+
);
|
|
3654
3712
|
}
|
|
3655
3713
|
appServer.stop();
|
|
3656
3714
|
}
|
|
3657
3715
|
});
|
|
3658
3716
|
}
|
|
3659
3717
|
async function claimNext(request, instanceId, log) {
|
|
3660
|
-
const claimed = await claimNextTask({
|
|
3718
|
+
const claimed = await claimNextTask({
|
|
3719
|
+
request,
|
|
3720
|
+
executorInstanceId: instanceId,
|
|
3721
|
+
log
|
|
3722
|
+
});
|
|
3661
3723
|
if (!claimed) return void 0;
|
|
3662
3724
|
return {
|
|
3663
3725
|
memoryId: claimed.memoryId,
|
|
@@ -3686,7 +3748,10 @@ ${c.body}`).join("\n\n");
|
|
|
3686
3748
|
);
|
|
3687
3749
|
}
|
|
3688
3750
|
}
|
|
3689
|
-
return {
|
|
3751
|
+
return {
|
|
3752
|
+
title: card.title ?? memoryId,
|
|
3753
|
+
text: assemblePrompt(card, packText)
|
|
3754
|
+
};
|
|
3690
3755
|
}
|
|
3691
3756
|
function assemblePrompt(card, packText) {
|
|
3692
3757
|
const sections = [
|
|
@@ -3730,7 +3795,10 @@ function executorRegistrationInput(state, deliverySubscriptionId) {
|
|
|
3730
3795
|
deliverySubscriptionId,
|
|
3731
3796
|
connectorId: state.connectorId,
|
|
3732
3797
|
claimedCapabilityKeys: state.capabilityKeys,
|
|
3798
|
+
claimPolicy: parseClaimPolicy(state.claimPolicy),
|
|
3799
|
+
claimTags: state.claimTags ?? [],
|
|
3733
3800
|
toolSetRef: null,
|
|
3801
|
+
parentId: state.parentId ?? null,
|
|
3734
3802
|
ttlSeconds: state.ttlSeconds
|
|
3735
3803
|
};
|
|
3736
3804
|
}
|
|
@@ -3765,6 +3833,13 @@ function registerExecutor(program2) {
|
|
|
3765
3833
|
).option("--runtime <kind>", "claude-code | codex").option("--surface <surface>", "claude | codex").option(
|
|
3766
3834
|
"--capability <key...>",
|
|
3767
3835
|
"Capability operation keys claimed by this instance"
|
|
3836
|
+
).option(
|
|
3837
|
+
"--claim-policy <policy>",
|
|
3838
|
+
"open | restricted (restricted only claims preferred/own-lane/allow-listed-tag work)",
|
|
3839
|
+
"open"
|
|
3840
|
+
).option(
|
|
3841
|
+
"--claim-tag <tag...>",
|
|
3842
|
+
"Task tag this instance accepts under --claim-policy restricted"
|
|
3768
3843
|
).option(
|
|
3769
3844
|
"--relay <id>",
|
|
3770
3845
|
"Relay identity shared by sibling instances",
|
|
@@ -3835,6 +3910,7 @@ function registerExecutor(program2) {
|
|
|
3835
3910
|
if (!opts.yes && !canPrompt())
|
|
3836
3911
|
fail("non-interactive executor install requires --yes");
|
|
3837
3912
|
parseRuntimeKind(runtime);
|
|
3913
|
+
parseClaimPolicy(opts.claimPolicy);
|
|
3838
3914
|
if (!["claude", "codex"].includes(surface))
|
|
3839
3915
|
fail("surface must be claude or codex");
|
|
3840
3916
|
if (opts.refreshAfter >= opts.ttl)
|
|
@@ -3849,6 +3925,8 @@ function registerExecutor(program2) {
|
|
|
3849
3925
|
runtime: runtime.toLowerCase() === "codex" ? "codex" : "claude-code",
|
|
3850
3926
|
connectorId: connector,
|
|
3851
3927
|
capabilityKeys: capabilities ?? [],
|
|
3928
|
+
claimPolicy: (opts.claimPolicy ?? "open").toLowerCase() === "restricted" ? "restricted" : "open",
|
|
3929
|
+
claimTags: opts.claimTag ?? [],
|
|
3852
3930
|
relayId: opts.relay,
|
|
3853
3931
|
subscriptionName: opts.subscriptionName,
|
|
3854
3932
|
ttlSeconds: opts.ttl,
|
|
@@ -3962,7 +4040,17 @@ function registerExecutor(program2) {
|
|
|
3962
4040
|
).option(
|
|
3963
4041
|
"--capability <key...>",
|
|
3964
4042
|
"Capability operation keys claimed by this instance"
|
|
3965
|
-
).option(
|
|
4043
|
+
).option(
|
|
4044
|
+
"--claim-policy <policy>",
|
|
4045
|
+
"open | restricted (restricted only claims preferred/own-lane/allow-listed-tag work)",
|
|
4046
|
+
"open"
|
|
4047
|
+
).option(
|
|
4048
|
+
"--claim-tag <tag...>",
|
|
4049
|
+
"Task tag this instance accepts under --claim-policy restricted"
|
|
4050
|
+
).option("--tool-set-ref <ref>", "Optional governed tool-set reference").option(
|
|
4051
|
+
"--parent-id <id>",
|
|
4052
|
+
"Fleet node this instance enrolls under (D-WLP-55 containment; sets the roster parentId)"
|
|
4053
|
+
).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).action(async (opts, cmd) => {
|
|
3966
4054
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3967
4055
|
const subscription = await api(
|
|
3968
4056
|
cfg,
|
|
@@ -3992,7 +4080,10 @@ function registerExecutor(program2) {
|
|
|
3992
4080
|
deliverySubscriptionId: subscription.id,
|
|
3993
4081
|
connectorId: opts.connector,
|
|
3994
4082
|
claimedCapabilityKeys: opts.capability ?? [],
|
|
4083
|
+
claimPolicy: parseClaimPolicy(opts.claimPolicy),
|
|
4084
|
+
claimTags: opts.claimTag ?? [],
|
|
3995
4085
|
toolSetRef: opts.toolSetRef ?? null,
|
|
4086
|
+
parentId: opts.parentId ?? null,
|
|
3996
4087
|
ttlSeconds: opts.ttl
|
|
3997
4088
|
})
|
|
3998
4089
|
}
|
|
@@ -4058,6 +4149,17 @@ function parseRuntimeKind(value) {
|
|
|
4058
4149
|
return fail("runtime must be claude-code or codex");
|
|
4059
4150
|
}
|
|
4060
4151
|
}
|
|
4152
|
+
function parseClaimPolicy(value) {
|
|
4153
|
+
switch ((value ?? "open").trim().toLowerCase()) {
|
|
4154
|
+
case "":
|
|
4155
|
+
case "open":
|
|
4156
|
+
return "Open";
|
|
4157
|
+
case "restricted":
|
|
4158
|
+
return "Restricted";
|
|
4159
|
+
default:
|
|
4160
|
+
return fail("claim-policy must be open or restricted");
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4061
4163
|
function parseTransport(value) {
|
|
4062
4164
|
switch (value.trim().toLowerCase()) {
|
|
4063
4165
|
case "push":
|
|
@@ -4092,6 +4194,41 @@ async function registerInstance(cfg, state) {
|
|
|
4092
4194
|
body: JSON.stringify(executorRegistrationInput(state, subscription.id))
|
|
4093
4195
|
});
|
|
4094
4196
|
}
|
|
4197
|
+
async function registerFleetNode(cfg, node) {
|
|
4198
|
+
const subscriptionName = node.subscriptionName ?? "executor-dispatch";
|
|
4199
|
+
const subscription = await api(
|
|
4200
|
+
cfg,
|
|
4201
|
+
"/me/delivery-subscriptions/signalr",
|
|
4202
|
+
{
|
|
4203
|
+
method: "POST",
|
|
4204
|
+
body: JSON.stringify(executorSubscriptionInput(subscriptionName))
|
|
4205
|
+
}
|
|
4206
|
+
);
|
|
4207
|
+
return api(cfg, "/me/executor-instances", {
|
|
4208
|
+
method: "POST",
|
|
4209
|
+
body: JSON.stringify({
|
|
4210
|
+
relayId: node.relay ?? "sechroom-cli-fleet",
|
|
4211
|
+
instanceKey: node.instanceKey,
|
|
4212
|
+
laneId: node.lane ?? node.instanceKey,
|
|
4213
|
+
runtimeKind: "Node",
|
|
4214
|
+
activationMode: "Attached",
|
|
4215
|
+
deliverySubscriptionId: subscription.id,
|
|
4216
|
+
connectorId: node.connector,
|
|
4217
|
+
claimedCapabilityKeys: [],
|
|
4218
|
+
claimPolicy: "Open",
|
|
4219
|
+
claimTags: [],
|
|
4220
|
+
toolSetRef: null,
|
|
4221
|
+
parentId: null,
|
|
4222
|
+
ttlSeconds: node.ttl ?? 120
|
|
4223
|
+
})
|
|
4224
|
+
});
|
|
4225
|
+
}
|
|
4226
|
+
async function deregisterInstance(cfg, id) {
|
|
4227
|
+
await api(cfg, `/me/executor-instances/${encodeURIComponent(id)}`, {
|
|
4228
|
+
method: "DELETE",
|
|
4229
|
+
body: JSON.stringify({})
|
|
4230
|
+
});
|
|
4231
|
+
}
|
|
4095
4232
|
async function ensureExecutorInstance(cfg, located) {
|
|
4096
4233
|
const { state, path } = located;
|
|
4097
4234
|
state.laneId ??= state.instanceKey;
|