@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.
Files changed (2) hide show
  1. package/dist/index.js +1088 -951
  2. 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/fleet.ts
2375
- import { readFile } from "fs/promises";
2376
- import { resolve } from "path";
2377
- import { spawn as spawn2 } from "child_process";
2378
- async function readFleetConfig(path) {
2379
- const parsed = JSON.parse(await readFile(resolve(path), "utf8"));
2380
- if (!Array.isArray(parsed.instances) || parsed.instances.length === 0)
2381
- throw new Error("fleet config must contain a non-empty 'instances' array");
2382
- const keys = /* @__PURE__ */ new Set();
2383
- for (const entry of parsed.instances) {
2384
- if (!entry || typeof entry.root !== "string" || typeof entry.instanceKey !== "string")
2385
- throw new Error("each fleet instance requires string 'root' and 'instanceKey'");
2386
- if (keys.has(entry.instanceKey)) throw new Error(`duplicate fleet instanceKey '${entry.instanceKey}'`);
2387
- keys.add(entry.instanceKey);
2388
- }
2389
- return parsed;
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 entryArgs(entry) {
2392
- const args = ["executor", "run", "--root", resolve(entry.root), "--instance-key", entry.instanceKey];
2393
- const value = (flag, v) => {
2394
- if (v !== void 0) args.push(flag, String(v));
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 superviseFleet(config2, options = {}) {
2411
- const log = options.log ?? ((line) => process.stderr.write(`${line}
2412
- `));
2413
- const states = /* @__PURE__ */ new Map();
2414
- const children = /* @__PURE__ */ new Map();
2415
- let stopping = false;
2416
- let resolveDone;
2417
- const done = new Promise((resolvePromise) => {
2418
- resolveDone = resolvePromise;
2419
- });
2420
- const status = () => log(`[fleet] ${[...states].map(([key, state]) => `${key}=${state}`).join(" ")}`);
2421
- const spawnEntry = options.spawnEntry ?? ((entry, args) => {
2422
- const script = process.argv[1];
2423
- if (!script) throw new Error("cannot locate the sechroom CLI entrypoint");
2424
- return spawn2(process.execPath, [script, ...args], {
2425
- cwd: resolve(entry.root),
2426
- stdio: ["ignore", "pipe", "pipe"],
2427
- env: process.env
2428
- });
2429
- });
2430
- for (const entry of config2.instances) {
2431
- const child = spawnEntry(entry, entryArgs(entry));
2432
- children.set(entry.instanceKey, child);
2433
- states.set(entry.instanceKey, "live");
2434
- const prefix = (text2) => {
2435
- for (const line of text2.replace(/\n$/, "").split("\n")) log(`[${entry.instanceKey}] ${line}`);
2436
- };
2437
- const concrete = child;
2438
- concrete.stdout?.on("data", (chunk) => prefix(String(chunk)));
2439
- concrete.stderr?.on("data", (chunk) => prefix(String(chunk)));
2440
- child.on("exit", (code, signal) => {
2441
- states.set(entry.instanceKey, "exited");
2442
- log(`[${entry.instanceKey}] exited (${signal ?? code ?? "unknown"})`);
2443
- status();
2444
- if ([...states.values()].every((state) => state === "exited")) resolveDone();
2445
- });
2446
- }
2447
- status();
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
- done,
2450
- shutdown(signal = "SIGINT") {
2451
- if (stopping) return done;
2452
- stopping = true;
2453
- for (const [key, child] of children) {
2454
- if (states.get(key) !== "exited") {
2455
- states.set(key, "stopping");
2456
- child.kill(signal);
2457
- }
2458
- }
2459
- status();
2460
- return done;
2461
- },
2462
- states
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
- // src/commands/telemetry.ts
2467
- import {
2468
- existsSync as existsSync7,
2469
- mkdirSync as mkdirSync7,
2470
- readFileSync as readFileSync5,
2471
- rmSync as rmSync3,
2472
- writeFileSync as writeFileSync6
2473
- } from "fs";
2474
- import { dirname as dirname6, join as join8 } from "path";
2488
+ Driven-executor delivery (FR-sechroom-496): work produced by the sandboxed codex turn, landed by the driver.
2475
2489
 
2476
- // src/commands/hook-install.ts
2477
- import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
2478
- import { delimiter, dirname as dirname5, join as join7 } from "path";
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
- // src/setup/clients.ts
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 clientTargets(cwd, opts = {}) {
2495
- const home = homedir3();
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/commands/hook-install.ts
2551
- var CLAUDE_HOOK_COMMANDS = {
2552
- SessionStart: "sechroom hook session-start",
2553
- PreCompact: "sechroom hook pre-compact",
2554
- SessionEnd: "sechroom hook session-end",
2555
- // WLP telemetry tap (D-WLP-10 + FR-352 Tier 1) — per-turn executor self-report. The one
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 CODEX_HOOK_COMMANDS = {
2566
- SessionStart: "sechroom hook session-start",
2567
- PreCompact: "sechroom hook pre-compact",
2568
- Stop: "sechroom hook session-end --debounce-minutes 10"
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 hookCommandsForSurface(surface) {
2571
- return surface === "claude" ? CLAUDE_HOOK_COMMANDS : CODEX_HOOK_COMMANDS;
2572
- }
2573
- function hasHookCommand(config2, event, command) {
2574
- const groups = config2.hooks?.[event] ?? [];
2575
- return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
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 mergeHooks(config2, commands) {
2578
- config2.hooks ??= {};
2579
- let added = 0;
2580
- for (const [event, command] of Object.entries(commands)) {
2581
- if (hasHookCommand(config2, event, command)) continue;
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
- function readJsonConfig2(path) {
2589
- if (!existsSync6(path)) return {};
2590
- const raw = readFileSync4(path, "utf8");
2591
- if (!raw.trim()) return {};
2592
- return JSON.parse(raw);
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 installHooksJson(path, commands, dryRun) {
2595
- const existed = existsSync6(path) && readFileSync4(path, "utf8").trim().length > 0;
2596
- const config2 = readJsonConfig2(path);
2597
- const added = mergeHooks(config2, commands);
2598
- if (added === 0 && existed) return { path, status: "current" };
2599
- if (!dryRun) {
2600
- mkdirSync6(dirname5(path), { recursive: true });
2601
- writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
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 { path, status: existed ? "merged" : "created" };
2683
+ return summary;
2604
2684
  }
2605
- function installClaudeCommands(claudeDir, commands, dryRun) {
2606
- return installHooksJson(join7(claudeDir, "settings.json"), commands, dryRun);
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 installCodexCommands(codexHome, commands, dryRun) {
2609
- return [
2610
- installHooksJson(join7(codexHome, "hooks.json"), commands, dryRun),
2611
- installCodexFeatureFlag(join7(codexHome, "config.toml"), dryRun)
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 events;
3003
+ return { path, status: existed ? "merged" : "created" };
3013
3004
  }
3014
- function isPermissionNotification(input) {
3015
- const t = (input.notification_type ?? input.type ?? "").toLowerCase();
3016
- if (t) return t.includes("permission");
3017
- return (input.message ?? "").toLowerCase().includes("permission");
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
- async function readStdin() {
3020
- if (process.stdin.isTTY) return "";
3021
- const chunks = [];
3022
- for await (const chunk of process.stdin) chunks.push(chunk);
3023
- return Buffer.concat(chunks).toString("utf8");
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
- function parseHookInput(raw) {
3026
- if (!raw.trim()) return {};
3027
- try {
3028
- return JSON.parse(raw);
3029
- } catch {
3030
- return {};
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
- var KINDS = {
3034
- raw: "Raw",
3035
- parsed: "Parsed",
3036
- approval: "Approval",
3037
- terminal: "Terminal"
3038
- };
3039
- function normalizeKind(k) {
3040
- const v = KINDS[k.toLowerCase()];
3041
- if (!v)
3042
- fail(
3043
- `Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
3044
- );
3045
- return v;
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 parseIntOpt(v) {
3048
- const n = Number.parseInt(v, 10);
3049
- if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
3050
- return n;
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/executor-run/delivery.ts
3054
- import { execFile as execFile2 } from "child_process";
3055
- function createGitRunner(rootDir) {
3056
- return (bin, args) => new Promise((resolve5) => {
3057
- execFile2(
3058
- bin,
3059
- bin === "git" ? ["-C", rootDir, ...args] : args,
3060
- { cwd: rootDir, maxBuffer: 10 * 1024 * 1024 },
3061
- (error, stdout, stderr) => resolve5({ ok: !error, stdout: String(stdout), stderr: String(stderr) })
3062
- );
3063
+ // 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
- function porcelainPaths(stdout) {
3066
- return stdout.split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 3).map((line) => {
3067
- const path = line.slice(3);
3068
- const arrow = path.indexOf(" -> ");
3069
- return arrow >= 0 ? path.slice(arrow + 4) : path;
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
- async function snapshotRoot(git) {
3073
- const branch = await git("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
3074
- const status = await git("git", ["status", "--porcelain"]);
3075
- return {
3076
- baseBranch: branch.ok ? branch.stdout.trim() : "HEAD",
3077
- dirtyPaths: status.ok ? porcelainPaths(status.stdout) : []
3078
- };
3079
- }
3080
- async function checkRootReady(git, allowDirty) {
3081
- const snapshot = await snapshotRoot(git);
3082
- if (snapshot.dirtyPaths.length === 0 || allowDirty)
3083
- return { ok: true, snapshot };
3084
- return {
3085
- ok: false,
3086
- snapshot,
3087
- reason: `root has ${snapshot.dirtyPaths.length} uncommitted path(s) (e.g. ${snapshot.dirtyPaths[0]}) \u2014 refusing to claim; commit/clean it or pass --allow-dirty-root`
3088
- };
3089
- }
3090
- async function deliverTurn(git, options) {
3091
- const status = await git("git", ["status", "--porcelain"]);
3092
- if (!status.ok)
3093
- return { delivered: false, note: `delivery skipped \u2014 git status failed: ${status.stderr.trim()}` };
3094
- const preDirty = new Set(options.snapshot.dirtyPaths);
3095
- const turnPaths = porcelainPaths(status.stdout).filter((p) => !preDirty.has(p));
3096
- if (turnPaths.length === 0)
3097
- return { delivered: false, note: "no file changes produced by the turn \u2014 nothing to deliver" };
3098
- const branch = await freeBranchName(git, `task/${slug(options.taskId)}`);
3099
- const created = await git("git", ["checkout", "-b", branch]);
3100
- if (!created.ok)
3101
- return {
3102
- delivered: false,
3103
- note: `delivery FAILED \u2014 could not create branch ${branch}: ${created.stderr.trim()} (changes remain uncommitted in the root)`
3104
- };
3105
- const notes = [];
3106
- try {
3107
- const added = await git("git", ["add", "--", ...turnPaths]);
3108
- if (!added.ok) return failBack(`git add failed: ${added.stderr.trim()}`);
3109
- const committed = await git("git", [
3110
- "commit",
3111
- "-m",
3112
- commitMessage(options)
3113
- ]);
3114
- if (!committed.ok) return failBack(`git commit failed: ${committed.stderr.trim()}`);
3115
- const sha = (await git("git", ["rev-parse", "--short", "HEAD"])).stdout.trim();
3116
- const pushed = await git("git", ["push", "-u", "origin", branch]);
3117
- if (!pushed.ok)
3118
- notes.push(`push failed (${firstLine(pushed.stderr)}) \u2014 branch is local-only`);
3119
- let prUrl;
3120
- if (options.raisePr && pushed.ok) {
3121
- const pr = await git("gh", [
3122
- "pr",
3123
- "create",
3124
- "--head",
3125
- branch,
3126
- "--title",
3127
- `task(${options.taskId}): ${options.title}`,
3128
- "--body",
3129
- prBody(options, sha)
3130
- ]);
3131
- if (pr.ok) prUrl = firstLine(pr.stdout);
3132
- else notes.push(`PR raise failed (${firstLine(pr.stderr)}) \u2014 raise manually from ${branch}`);
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
- notes.unshift(
3135
- `delivered ${turnPaths.length} path(s) to ${branch} @ ${sha}${prUrl ? ` \u2014 PR ${prUrl}` : ""}`
3136
- );
3137
- return { delivered: true, branch, sha, prUrl, note: notes.join("; ") };
3138
- } finally {
3139
- const back = await git("git", ["checkout", options.snapshot.baseBranch]);
3140
- if (!back.ok)
3141
- options.log(
3142
- `delivery: could not return root to ${options.snapshot.baseBranch}: ${back.stderr.trim()}`
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
- function failBack(reason) {
3146
- return { delivered: false, branch, note: `delivery FAILED \u2014 ${reason}` };
3147
- }
3148
- }
3149
- async function freeBranchName(git, base) {
3150
- for (let i = 0; ; i++) {
3151
- const candidate = i === 0 ? base : `${base}-${i + 1}`;
3152
- const exists = await git("git", [
3153
- "rev-parse",
3154
- "--verify",
3155
- "--quiet",
3156
- `refs/heads/${candidate}`
3157
- ]);
3158
- if (!exists.ok) return candidate;
3159
- }
3160
- }
3161
- function slug(taskId) {
3162
- return taskId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3163
- }
3164
- function commitMessage(options) {
3165
- return `task(${options.taskId}): ${options.title} [verdict:${options.verdict}]
3166
-
3167
- Driven-executor delivery (FR-sechroom-496): work produced by the sandboxed codex turn, landed by the driver.
3168
-
3169
- Delivered-By: sechroom executor run (${options.deliveredBy})`;
3170
- }
3171
- function prBody(options, sha) {
3172
- return `Driven-executor delivery for WLP task \`${options.taskId}\` (verdict: ${options.verdict}, commit ${sha}).
3173
-
3174
- Work produced by a sandboxed codex turn and landed by the unsandboxed driver (FR-sechroom-496). Review against the task's acceptance before merge.`;
3175
- }
3176
- function firstLine(text2) {
3177
- return text2.trim().split("\n")[0] ?? "";
3178
- }
3179
-
3180
- // src/executor-run/request.ts
3181
- var AuthExpiredError = class extends Error {
3182
- constructor(message) {
3183
- super(message);
3184
- this.name = "AuthExpiredError";
3185
- }
3186
- };
3187
- var HttpError = class extends Error {
3188
- constructor(status, method, path, body) {
3189
- super(`${method} ${path} failed (${status}): ${body}`);
3190
- this.status = status;
3191
- this.method = method;
3192
- this.path = path;
3193
- this.body = body;
3194
- this.name = "HttpError";
3195
- }
3196
- status;
3197
- method;
3198
- path;
3199
- body;
3200
- };
3201
- function createAuthedRequest(cfg, deps = {}) {
3202
- const getToken = deps.getToken ?? requireToken;
3203
- const refresh = deps.refreshToken ?? forceRefreshToken;
3204
- const doFetch = deps.fetch ?? fetch;
3205
- const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
3206
- ...init,
3207
- headers: {
3208
- authorization: `Bearer ${token}`,
3209
- tenant: cfg.tenant,
3210
- "content-type": "application/json",
3211
- "x-sechroom-surface": "cli",
3212
- ...init?.headers
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
- return async (path, init) => {
3216
- const method = init?.method ?? "GET";
3217
- let token;
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
- token = await getToken(cfg);
3220
- } catch (error) {
3221
- throw new AuthExpiredError(
3222
- error instanceof Error ? error.message : String(error)
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
- let response = await call(path, init, token);
3226
- if (response.status === 401) {
3227
- let fresh;
3228
- try {
3229
- fresh = await refresh(cfg);
3230
- } catch (error) {
3231
- throw new AuthExpiredError(
3232
- error instanceof Error ? error.message : String(error)
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
- response = await call(path, init, fresh);
3236
- if (response.status === 401)
3237
- throw new AuthExpiredError(
3238
- `${method} ${path} still 401 after token refresh \u2014 re-authenticate (\`sechroom login\`).`
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
- if (!response.ok)
3242
- throw new HttpError(
3243
- response.status,
3244
- method,
3245
- path,
3246
- await safeText(response)
3247
+ }
3248
+ } catch (err2) {
3249
+ process.stderr.write(
3250
+ `telemetry install failed: ${err2.message}
3251
+ `
3247
3252
  );
3248
- return await response.json();
3249
- };
3250
- }
3251
- async function safeText(response) {
3252
- try {
3253
- return await response.text();
3254
- } catch {
3255
- return "";
3256
- }
3257
- }
3258
-
3259
- // src/executor-run/driver.ts
3260
- function verdictFor(terminalStatus) {
3261
- switch (terminalStatus) {
3262
- case "completed":
3263
- return "pass";
3264
- case "needs_approval":
3265
- case "cancelled":
3266
- case "canceled":
3267
- return "blocked";
3268
- case "error":
3269
- default:
3270
- return "soft-fail";
3271
- }
3253
+ return process.exit(1);
3254
+ }
3255
+ warnIfSechroomNotOnPath();
3256
+ return process.exit(0);
3257
+ });
3272
3258
  }
3273
- async function runDriverLoop(ports, options) {
3274
- const summary = { processed: 0, completed: 0, abandoned: 0 };
3275
- let admissionDeferred = false;
3276
- while (!options.stopping()) {
3277
- if (ports.checkAdmission) {
3278
- const admission = await ports.checkAdmission();
3279
- if (!admission.ok) {
3280
- admissionDeferred = true;
3281
- ports.log(
3282
- `ADMISSION DEFERRED \u2014 not claiming: ${admission.reason ?? "usage budget exhausted"}`
3283
- );
3284
- await ports.waitForWake(options.pollMs);
3285
- continue;
3286
- }
3287
- if (admissionDeferred) {
3288
- admissionDeferred = false;
3289
- ports.log("admission recovered \u2014 resuming claims");
3290
- }
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
- if (ports.checkRootReady) {
3293
- const ready = await ports.checkRootReady();
3294
- if (!ready.ok) {
3295
- ports.log(`root not ready \u2014 not claiming: ${ready.reason ?? "unknown"}`);
3296
- await ports.waitForWake(options.pollMs);
3297
- continue;
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 claim = await ports.claimNext();
3301
- if (!claim) {
3302
- await ports.waitForWake(options.pollMs);
3303
- continue;
3304
- }
3305
- summary.processed++;
3306
- ports.log(`claimed ${claim.memoryId} (lease ${claim.leaseId})`);
3307
- const task = await ports.loadTask(claim.memoryId);
3308
- const stopHeartbeat = ports.startLeaseHeartbeat(claim);
3309
- let result;
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
- result = await ports.runTurn(task, claim);
3312
- } catch (e) {
3313
- if (e instanceof AuthExpiredError) throw e;
3314
- result = { status: "crashed", reason: String(e) };
3315
- } finally {
3316
- stopHeartbeat();
3308
+ obj = JSON.parse(line);
3309
+ } catch {
3310
+ continue;
3317
3311
  }
3318
- if (result.status === "crashed" || result.status === "timeout") {
3319
- summary.abandoned++;
3320
- ports.log(
3321
- `ABANDONED ${claim.memoryId}: ${result.status === "timeout" ? "turn timed out" : result.reason} \u2014 lease will expire and the task re-offers (work may re-run).`
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
- } else {
3324
- const verdict = verdictFor(result.packet?.terminal_status);
3325
- let text2 = closeoutText(task, result);
3326
- if (ports.deliver) {
3327
- try {
3328
- const delivery = await ports.deliver(claim, task, verdict);
3329
- ports.log(`delivery: ${delivery.note}`);
3330
- text2 += `
3331
-
3332
- Delivery: ${delivery.note}`;
3333
- } catch (e) {
3334
- if (e instanceof AuthExpiredError) throw e;
3335
- ports.log(`delivery threw (continuing to completion): ${String(e)}`);
3336
- text2 += `
3337
-
3338
- Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
3339
- }
3340
- }
3341
- try {
3342
- const done = await ports.completeLease(
3343
- claim,
3344
- verdict,
3345
- text2,
3346
- `${task.title} \u2014 driven closeout`
3347
- );
3348
- summary.completed++;
3349
- ports.log(
3350
- `completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
3351
- );
3352
- } catch (e) {
3353
- if (e instanceof AuthExpiredError) throw e;
3354
- summary.abandoned++;
3355
- ports.log(
3356
- `COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
3357
- );
3358
- }
3359
- }
3360
- if (options.once) break;
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 summary;
3372
+ return events;
3363
3373
  }
3364
- function closeoutText(task, result) {
3365
- if (!result.packet)
3366
- return `Driven codex run ended without a sechroom_closeout packet (soft-fail). Last agent message:
3367
-
3368
- ${result.lastAgentMessage || "(none)"}`;
3369
- const evidence = result.packet.evidence?.length ? `
3370
-
3371
- Evidence:
3372
- ${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
3373
- return `${result.packet.summary}${evidence}
3374
-
3375
- (terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
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 startLeaseHeartbeat(beat, log, intervalMs = 3e4, timers = {}) {
3378
- const schedule = timers.setInterval ?? setInterval;
3379
- const cancel = timers.clearInterval ?? clearInterval;
3380
- const timer = schedule(() => {
3381
- void beat().catch(
3382
- (e) => log(`lease heartbeat failed (retrying next beat): ${String(e)}`)
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
- }, intervalMs);
3385
- timer.unref?.();
3386
- return () => cancel(timer);
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("Run multiple isolated driven codex executors from one config file").requiredOption("--config <file>", "JSON fleet config").action(async (opts) => {
3392
- const fleet = superviseFleet(await readFleetConfig(String(opts.config)));
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("executor run drives runtime codex only (claude-code stays attached)");
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(`deregister failed (${String(e)}) \u2014 advertisement will expire by TTL`);
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({ request, executorInstanceId: instanceId, log });
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 { title: card.title ?? memoryId, text: assemblePrompt(card, packText) };
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("--tool-set-ref <ref>", "Optional governed tool-set reference").option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).action(async (opts, cmd) => {
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;