pitroom 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/pitroom.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import fs36 from "node:fs";
4
+ import fs37 from "node:fs";
5
5
 
6
6
  // src/core/errors.ts
7
7
  var UserError = class extends Error {
@@ -1441,7 +1441,12 @@ var SCHEMA = {
1441
1441
  costs: "numbers",
1442
1442
  audit: "number",
1443
1443
  readIn: "string",
1444
- cacheDays: "number"
1444
+ cacheDays: "number",
1445
+ countRateLimits: "boolean",
1446
+ mcpDash: "boolean",
1447
+ notify: "boolean",
1448
+ notifyCommand: "string",
1449
+ notifyAfter: "number"
1445
1450
  };
1446
1451
  function configPath() {
1447
1452
  if (process.env.PITROOM_CONFIG) return path7.resolve(process.env.PITROOM_CONFIG);
@@ -1503,6 +1508,10 @@ var days = (v) => {
1503
1508
  const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
1504
1509
  return typeof n === "number" && Number.isInteger(n) && n >= 0 ? n : void 0;
1505
1510
  };
1511
+ var flag01 = (v) => {
1512
+ const t = v?.trim().toLowerCase();
1513
+ return t === void 0 || t === "" ? void 0 : ["1", "true", "on", "yes"].includes(t) ? true : ["0", "false", "off", "no"].includes(t) ? false : void 0;
1514
+ };
1506
1515
  var readIn = (v) => v === "auto" || v === "snapshot" || v === "project" ? v : void 0;
1507
1516
  var list = (s) => s?.split(",").map((x) => x.trim()).filter(Boolean);
1508
1517
  function setting(flag2, env, conf, fallback) {
@@ -1530,7 +1539,12 @@ function effective(flags = {}) {
1530
1539
  costs: setting(void 0, void 0, c.costs, {}),
1531
1540
  audit: setting(void 0, rate(e.PITROOM_AUDIT), rate(c.audit), 0),
1532
1541
  readIn: setting(void 0, readIn(e.PITROOM_READ_IN), readIn(c.readIn), "auto"),
1533
- cacheDays: setting(void 0, days(e.PITROOM_CACHE_DAYS), days(c.cacheDays), 7)
1542
+ cacheDays: setting(void 0, days(e.PITROOM_CACHE_DAYS), days(c.cacheDays), 7),
1543
+ countRateLimits: setting(void 0, void 0, c.countRateLimits, false),
1544
+ mcpDash: setting(void 0, flag01(e.PITROOM_MCP_DASH), c.mcpDash, true),
1545
+ notify: setting(void 0, flag01(e.PITROOM_NOTIFY), c.notify, false),
1546
+ notifyCommand: setting(void 0, e.PITROOM_NOTIFY_COMMAND?.trim() || void 0, c.notifyCommand?.trim() || void 0, void 0),
1547
+ notifyAfter: setting(void 0, days(e.PITROOM_NOTIFY_AFTER), days(c.notifyAfter), 15)
1534
1548
  };
1535
1549
  }
1536
1550
 
@@ -1714,9 +1728,9 @@ function effortFlag(p) {
1714
1728
  import readline from "node:readline";
1715
1729
 
1716
1730
  // src/core/run.ts
1717
- import { spawn as spawn2, spawnSync as spawnSync6 } from "node:child_process";
1718
- import fs25 from "node:fs";
1719
- import path20 from "node:path";
1731
+ import { spawn as spawn3, spawnSync as spawnSync6 } from "node:child_process";
1732
+ import fs26 from "node:fs";
1733
+ import path22 from "node:path";
1720
1734
 
1721
1735
  // src/vcs/git.ts
1722
1736
  import { spawnSync as spawnSync5 } from "node:child_process";
@@ -2212,10 +2226,11 @@ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build"
2212
2226
  var MAX_VISITED = 2e4;
2213
2227
  var MAX_DEPTH = 4;
2214
2228
  var TEMPLATE = /\.(example|sample|template|dist|defaults?|tpl)$/i;
2215
- function looksSecret(name) {
2229
+ function looksSecret(file2) {
2230
+ const name = file2.toLowerCase();
2216
2231
  if (TEMPLATE.test(name)) return false;
2217
- if (name === ".env" || name.startsWith(".env.") || /\.env$/i.test(name)) return true;
2218
- return /\.(pem|p12|pfx|key|jks|keystore)$/i.test(name) || /^id_(rsa|dsa|ecdsa|ed25519)$/.test(name) || name === ".netrc" || name === "credentials.json" || /^secrets?\.(json|ya?ml|toml)$/i.test(name);
2232
+ if (name === ".env" || name.startsWith(".env.") || name.endsWith(".env")) return true;
2233
+ return /\.(pem|p12|pfx|key|jks|keystore)$/.test(name) || /^id_(rsa|dsa|ecdsa|ed25519)$/.test(name) || name === ".netrc" || name === "credentials.json" || /^secrets?\.(json|ya?ml|toml)$/.test(name);
2219
2234
  }
2220
2235
  function findSecretFiles(dir) {
2221
2236
  const found = [];
@@ -2384,6 +2399,7 @@ function backToProject(text, snapshotDir, root) {
2384
2399
 
2385
2400
  // src/core/audit.ts
2386
2401
  import crypto4 from "node:crypto";
2402
+ import path13 from "node:path";
2387
2403
  var ANSWER_MAX = 8e3;
2388
2404
  function auditRate(meta) {
2389
2405
  return meta.auditRate ?? effective().audit.value;
@@ -2406,13 +2422,24 @@ function pickAuditor(meta) {
2406
2422
  const candidates = [tiers.audit, tiers.cheap, ...eff.fallback.value, eff.worker.value].filter((s) => !!s);
2407
2423
  return candidates.find((c) => !sameTarget(parseTarget(c, def), ran));
2408
2424
  }
2425
+ function relativeToAuditor(answer, meta) {
2426
+ let out = answer;
2427
+ const roots = [...new Set([meta.dir, meta.repoRoot].filter((r) => !!r))].sort((a, b) => b.length - a.length);
2428
+ for (const root of roots) {
2429
+ const rel = path13.relative(meta.dir, root);
2430
+ const prefix = rel ? `${rel.split(path13.sep).join("/")}/` : "";
2431
+ out = out.split(`${root}${path13.sep}`).join(prefix).replace(new RegExp(`${root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w.-])`, "g"), rel || ".");
2432
+ }
2433
+ return out;
2434
+ }
2409
2435
  function auditTask(meta, answer) {
2410
- const text = answer.trim();
2436
+ const text = relativeToAuditor(answer, meta).trim();
2411
2437
  const shown = text.length > ANSWER_MAX ? `${text.slice(0, ANSWER_MAX)}
2412
2438
  \u2026 (cut: ${text.length - ANSWER_MAX} more characters)` : text;
2413
2439
  return [
2414
2440
  "You are auditing another worker's answer to a read-only question about this project. Do not take the answer on trust:",
2415
2441
  "check its key claims yourself against the files here (open the cited files and lines, search for what it says exists or is missing).",
2442
+ "Paths in the answer are relative to your working directory.",
2416
2443
  "Check the answer against the QUESTION, not only its own claims: every condition the question sets (a directory or scope,",
2417
2444
  'what to leave out, "all", "only", "exactly", a count) must hold. Valid references do not make an answer right:',
2418
2445
  "for a list, look for items that are missing and items that do not belong; for a count, count again yourself.",
@@ -2454,426 +2481,93 @@ function auditBadge(meta) {
2454
2481
  return a.state === "running" || a.state === "queued" ? "PENDING" : "FAILED";
2455
2482
  }
2456
2483
 
2457
- // src/core/plan.ts
2458
- import fs16 from "node:fs";
2459
- import path13 from "node:path";
2460
- var FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
2461
- var RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
2462
- function structure(lines) {
2463
- const headings = [];
2464
- const rules = [];
2465
- const fenced = [];
2466
- let open;
2467
- lines.forEach((l, i) => {
2468
- const f = FENCE.exec(l);
2469
- if (f) {
2470
- fenced[i] = true;
2471
- const mark = f[1];
2472
- if (!open) open = mark;
2473
- else if (mark[0] === open[0] && mark.length >= open.length && !f[2].trim()) open = void 0;
2474
- return;
2475
- }
2476
- fenced[i] = !!open;
2477
- if (open) return;
2478
- const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(l);
2479
- if (h) headings.push({ line: i, level: h[1].length, text: h[2] });
2480
- else if (RULE.test(l)) rules.push(i);
2481
- });
2482
- return { headings, rules, fenced };
2483
- }
2484
- function parsePlan(text, file2 = "") {
2485
- const lines = text.replace(/\r\n?/g, "\n").split("\n");
2486
- const { headings, rules, fenced } = structure(lines);
2487
- const after = (line) => Math.min(headings.find((h) => h.line > line)?.line ?? lines.length, rules.find((r) => r > line) ?? lines.length);
2488
- const titleHeading = headings.find((h) => h.level === 1);
2489
- const start = titleHeading ? titleHeading.line + 1 : 0;
2490
- const header = lines.slice(start, after(start - 1)).filter((l) => !/^\s*>/.test(l)).join("\n").trim();
2491
- const gc = headings.find((h) => /^global constraints\b/i.test(h.text));
2492
- const constraints = gc ? lines.slice(gc.line + 1, after(gc.line)).join("\n").trim() : "";
2493
- const tasks = [];
2494
- for (const h of headings) {
2495
- const m = /^Task\s+(\d+)\b\s*[:.)\-–—]?\s*(.*)$/i.exec(h.text);
2496
- if (!m) continue;
2497
- const end = headings.find((o) => o.line > h.line && o.level <= h.level)?.line ?? lines.length;
2498
- const body = lines.slice(h.line, end).join("\n").replace(/(\n\s*(?:---|\*\*\*|___)\s*)+$/, "").trimEnd();
2499
- const unfenced = lines.slice(h.line, end).filter((_, j) => !fenced[h.line + j]).join("\n");
2500
- const tier = /^\s*[-*]?\s*\*\*Worker:\*\*\s*`?([\w-]+)`?/m.exec(unfenced)?.[1]?.toLowerCase();
2501
- tasks.push({ step: Number(m[1]), title: m[2].trim(), text: body, ...tier ? { tier } : {} });
2502
- }
2503
- return { file: file2, title: titleHeading?.text ?? "", header, constraints, tasks };
2504
- }
2505
- function loadPlan(file2) {
2506
- const abs = path13.resolve(file2);
2507
- if (!fs16.existsSync(abs) || !fs16.statSync(abs).isFile()) throw new UserError(`plan not found: ${file2}`);
2508
- const plan = parsePlan(fs16.readFileSync(abs, "utf8"), fs16.realpathSync(abs));
2509
- if (!plan.tasks.length) throw new UserError(`${file2} has no "Task N" headings (see pitroom-writing-plans)`);
2510
- return plan;
2511
- }
2512
- function planTask(plan, step) {
2513
- const t = plan.tasks.find((x) => x.step === step);
2514
- if (!t) throw new UserError(`no Task ${step} in ${plan.file}; it has ${plan.tasks.map((x) => `Task ${x.step}`).join(", ")}`);
2515
- return t;
2516
- }
2517
- function brief(plan, task) {
2518
- return `${[
2519
- `# ${plan.title || planName(plan.file)}`,
2520
- plan.header,
2521
- "## Global Constraints",
2522
- plan.constraints || "(none stated in the plan)",
2523
- task.text
2524
- ].filter(Boolean).join("\n\n")}
2525
- `;
2526
- }
2527
- var planName = (file2) => path13.basename(file2).replace(/\.md$/i, "");
2484
+ // src/core/notify.ts
2485
+ import { spawn as spawn2 } from "node:child_process";
2486
+ import crypto5 from "node:crypto";
2487
+ import fs21 from "node:fs";
2488
+ import path17 from "node:path";
2528
2489
 
2529
- // src/core/templates.ts
2490
+ // src/core/history.ts
2530
2491
  import fs18 from "node:fs";
2492
+ import { createRequire } from "node:module";
2531
2493
  import path15 from "node:path";
2494
+ import zlib from "node:zlib";
2532
2495
 
2533
- // src/core/install.ts
2534
- import fs17 from "node:fs";
2535
- import os9 from "node:os";
2496
+ // src/core/receipt.ts
2497
+ import fs16 from "node:fs";
2536
2498
  import path14 from "node:path";
2537
- import { fileURLToPath as fileURLToPath2 } from "node:url";
2538
- var LEGACY = ["pitroom", "opencode-worker"];
2539
- function packageRoot() {
2540
- return path14.resolve(path14.dirname(fileURLToPath2(import.meta.url)), "..");
2541
- }
2542
- function skillNames(root = packageRoot()) {
2543
- const dir = path14.join(root, "skills");
2544
- if (!fs17.existsSync(dir)) return [];
2545
- return fs17.readdirSync(dir).filter((d) => fs17.existsSync(path14.join(dir, d, "SKILL.md"))).sort();
2546
- }
2547
- function skillTargets() {
2548
- const home3 = os9.homedir();
2549
- const targets = [path14.join(home3, ".agents", "skills")];
2550
- if (fs17.existsSync(path14.join(home3, ".claude"))) targets.push(path14.join(home3, ".claude", "skills"));
2551
- return targets;
2552
- }
2553
- var launcherPath = () => path14.join(os9.homedir(), ".local", "bin", process.platform === "win32" ? "pitroom.cmd" : "pitroom");
2554
- var LAUNCHER_MARK = "# pitroom launcher";
2555
- function launcherScript(bundle) {
2556
- if (process.platform === "win32") {
2557
- const q2 = (v) => v.replace(/%/g, "%%");
2558
- return `@echo off\r
2559
- rem ${LAUNCHER_MARK} (created by \`pitroom install\`; \`pitroom uninstall\` removes it)\r
2560
- if defined PITROOM_NODE (\r
2561
- "%PITROOM_NODE%" "${q2(bundle)}" %*\r
2562
- ) else (\r
2563
- "${q2(process.execPath)}" "${q2(bundle)}" %*\r
2564
- )\r
2565
- exit /b %ERRORLEVEL%\r
2566
- `;
2567
- }
2568
- const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
2569
- return `#!/bin/sh
2570
- ${LAUNCHER_MARK} (created by \`pitroom install\`; \`pitroom uninstall\` removes it)
2571
- cli=${q(bundle)}
2572
- ok() { [ -n "$1" ] && [ -x "$1" ] && "$1" -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>22||(a===22&&b>=13)?0:1)' 2>/dev/null; }
2573
- for n in "\${PITROOM_NODE:-}" ${q(process.execPath)} "$(command -v node 2>/dev/null)" "$HOME"/.nvm/versions/node/*/bin/node /opt/homebrew/bin/node /usr/local/bin/node; do
2574
- if ok "$n"; then exec "$n" "$cli" "$@"; fi
2575
- done
2576
- echo "pitroom: needs Node.js 22.13 or newer (set PITROOM_NODE to its path)" >&2
2577
- exit 127
2578
- `;
2579
- }
2580
- function isOurLauncher(file2, root) {
2581
- if (linksInto(file2, root)) return true;
2582
- try {
2583
- const s = fs17.readFileSync(file2, "utf8");
2584
- return s.includes(LAUNCHER_MARK) && s.includes(root);
2585
- } catch {
2586
- return false;
2587
- }
2588
- }
2589
- function isPitroomLauncher(file2) {
2590
- try {
2591
- return fs17.readFileSync(file2, "utf8").includes(LAUNCHER_MARK);
2592
- } catch {
2593
- return false;
2594
- }
2595
- }
2596
- function placeLauncher(bundle, root, force) {
2597
- const dest = launcherPath();
2598
- fs17.mkdirSync(path14.dirname(dest), { recursive: true });
2599
- const exists = fs17.lstatSync(dest, { throwIfNoEntry: false });
2600
- if (exists && !isOurLauncher(dest, root)) {
2601
- if (!force) return `! ${dest} exists and is not Pitroom's launcher; kept (use --force to back it up and replace)`;
2602
- fs17.renameSync(dest, `${dest}.bak-${Date.now()}`);
2603
- } else if (exists) {
2604
- fs17.rmSync(dest, { force: true });
2499
+ var PRESETS = {
2500
+ sonnet: { name: "Claude Sonnet", input: 3, output: 15, cachedInput: 0.3 },
2501
+ opus: { name: "Claude Opus", input: 5, output: 25, cachedInput: 0.5 },
2502
+ haiku: { name: "Claude Haiku", input: 1, output: 5, cachedInput: 0.1 },
2503
+ "gpt-5": { name: "GPT-5", input: 1.25, output: 10, cachedInput: 0.125 }
2504
+ };
2505
+ function primaryPrice() {
2506
+ const eff = effective();
2507
+ const custom = eff.price.value?.split(",").map(Number);
2508
+ if (custom && custom.length >= 2 && custom.every((n) => Number.isFinite(n) && n >= 0)) {
2509
+ return { name: "custom", input: custom[0], output: custom[1], cachedInput: custom[2] ?? custom[0] / 10 };
2605
2510
  }
2606
- fs17.writeFileSync(dest, launcherScript(bundle), { mode: 493 });
2607
- return `\u2714 ${dest} \u2192 launcher for ${bundle} (Node 22.13+)`;
2608
- }
2609
- function linksInto(link, dir) {
2610
- const st = fs17.lstatSync(link, { throwIfNoEntry: false });
2611
- if (!st?.isSymbolicLink()) return false;
2612
- const target = path14.resolve(path14.dirname(link), fs17.readlinkSync(link));
2613
- const rel = path14.relative(dir, target);
2614
- return !rel.startsWith("..") && !path14.isAbsolute(rel);
2511
+ return PRESETS[eff.primary.value.toLowerCase()] ?? PRESETS.sonnet;
2615
2512
  }
2616
- function place(src, dest, opts) {
2617
- fs17.mkdirSync(path14.dirname(dest), { recursive: true });
2618
- const st = fs17.lstatSync(dest, { throwIfNoEntry: false });
2619
- if (st && !st.isSymbolicLink()) {
2620
- if (!opts.force) return `! ${dest} exists and is not a link; kept (use --force to back it up and replace)`;
2621
- fs17.renameSync(dest, `${dest}.bak-${Date.now()}`);
2622
- } else if (st) {
2623
- fs17.unlinkSync(dest);
2624
- }
2625
- if (opts.copy) fs17.cpSync(src, dest, { recursive: true });
2626
- else fs17.symlinkSync(src, dest, process.platform === "win32" ? "junction" : fs17.statSync(src).isDirectory() ? "dir" : "file");
2627
- return `\u2714 ${dest} \u2192 ${opts.copy ? "copied" : src}`;
2513
+ var estimateTokens = (text) => Math.ceil(text.length / 4);
2514
+ function savedUsd(usage2, returnedTokens, price = primaryPrice()) {
2515
+ const wouldCost = (usage2.input * price.input + usage2.cacheRead * price.cachedInput + (usage2.output + usage2.reasoning) * price.output) / 1e6;
2516
+ const readingTheReport = returnedTokens * price.input / 1e6;
2517
+ return Math.max(0, wouldCost - (usage2.cost ?? 0) - readingTheReport);
2628
2518
  }
2629
- function install(opts) {
2630
- const root = packageRoot();
2631
- const skills2 = opts.skills === false ? [] : skillNames(root);
2632
- if (opts.skills !== false && !skills2.length) throw new Error(`no skills found under ${path14.join(root, "skills")}`);
2633
- const out = [];
2634
- for (const base2 of opts.skills === false ? [] : skillTargets()) {
2635
- for (const legacy of LEGACY) {
2636
- const l = path14.join(base2, legacy);
2637
- if (!skills2.includes(legacy) && linksInto(l, root)) {
2638
- fs17.unlinkSync(l);
2639
- out.push(`\u2714 removed old link ${l}`);
2640
- }
2641
- }
2642
- for (const name of skills2) out.push(place(path14.join(root, "skills", name), path14.join(base2, name), opts));
2643
- }
2644
- const bundle = path14.join(root, "dist", "pitroom.mjs");
2645
- if (fs17.existsSync(bundle)) {
2646
- out.push(placeLauncher(bundle, root, opts.force));
2647
- const onPath = (process.env.PATH ?? "").split(path14.delimiter).some((d) => path14.resolve(d) === path14.dirname(launcherPath()));
2648
- if (!onPath) out.push(`! ${path14.dirname(launcherPath())} is not on PATH; add it, or run ${launcherPath()} directly`);
2649
- }
2650
- return out;
2519
+ function record(meta) {
2520
+ if (!meta.usage?.steps || meta.auditOf) return;
2521
+ const entry = {
2522
+ id: meta.id,
2523
+ at: meta.endedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2524
+ mode: meta.mode,
2525
+ state: meta.state,
2526
+ backend: (meta.ran ?? meta.worker).backend,
2527
+ model: meta.resolvedModel ?? (meta.ran ?? meta.worker).model,
2528
+ tokens: meta.usage.total,
2529
+ returned: meta.returnedTokens ?? 0,
2530
+ workerCost: meta.usage.cost ?? 0,
2531
+ saved: meta.savedUsd ?? 0,
2532
+ price: primaryPrice().name
2533
+ };
2534
+ fs16.mkdirSync(path14.dirname(ledgerFile()), { recursive: true });
2535
+ fs16.appendFileSync(ledgerFile(), `${JSON.stringify(entry)}
2536
+ `);
2651
2537
  }
2652
- function uninstall() {
2653
- const root = packageRoot();
2654
- const out = [];
2655
- for (const base2 of skillTargets()) {
2656
- if (!fs17.existsSync(base2)) continue;
2657
- for (const name of fs17.readdirSync(base2)) {
2658
- const l = path14.join(base2, name);
2659
- if (linksInto(l, root)) {
2660
- fs17.unlinkSync(l);
2661
- out.push(`\u2714 removed ${l}`);
2662
- }
2538
+ function readLedger(sinceMs2) {
2539
+ if (!fs16.existsSync(ledgerFile())) return [];
2540
+ return fs16.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
2541
+ try {
2542
+ return [JSON.parse(l)];
2543
+ } catch {
2544
+ return [];
2663
2545
  }
2664
- }
2665
- if (isOurLauncher(launcherPath(), root)) {
2666
- fs17.rmSync(launcherPath(), { force: true });
2667
- out.push(`\u2714 removed ${launcherPath()}`);
2668
- }
2669
- return out.length ? out : ["nothing to remove"];
2546
+ }).filter((e) => !sinceMs2 || Date.parse(e.at) >= sinceMs2);
2670
2547
  }
2671
- function installedSkills() {
2672
- const names = skillNames();
2673
- return skillTargets().map((base2) => ({ base: base2, names: names.filter((n) => fs17.existsSync(path14.join(base2, n, "SKILL.md"))) }));
2548
+ function totals(entries) {
2549
+ const t = entries.reduce(
2550
+ (a, e) => ({
2551
+ runs: a.runs + 1,
2552
+ tokens: a.tokens + e.tokens,
2553
+ returned: a.returned + e.returned,
2554
+ workerCost: a.workerCost + e.workerCost,
2555
+ saved: a.saved + e.saved
2556
+ }),
2557
+ { runs: 0, tokens: 0, returned: 0, workerCost: 0, saved: 0 }
2558
+ );
2559
+ return { ...t, ratio: t.returned ? t.tokens / t.returned : 0 };
2674
2560
  }
2675
-
2676
- // src/core/templates.ts
2677
- var FILES = {
2678
- implementer: "pitroom-driven-development/implementer-prompt.md",
2679
- "task-reviewer": "pitroom-driven-development/task-reviewer-prompt.md",
2680
- "re-review": "pitroom-driven-development/re-review-prompt.md",
2681
- "code-reviewer": "pitroom-review/code-reviewer.md"
2682
- };
2683
- function loadTemplate(name, root = packageRoot()) {
2684
- const file2 = path15.join(root, "skills", FILES[name]);
2685
- if (!fs18.existsSync(file2)) throw new UserError(`template missing: ${file2} (broken install? run pitroom doctor)`, 3);
2686
- return fs18.readFileSync(file2, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
2561
+ var usd = (n) => n >= 100 ? `$${n.toFixed(0)}` : n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`;
2562
+ function compact(n) {
2563
+ const fmt = (v) => v.toFixed(v >= 100 ? 0 : 1).replace(/\.0$/, "");
2564
+ if (n >= 1e6) return `${fmt(n / 1e6)}M`;
2565
+ if (n >= 1e3) return `${fmt(n / 1e3)}k`;
2566
+ return String(Math.round(n));
2687
2567
  }
2688
- function fill(template, values) {
2689
- const missing = [...template.matchAll(/\{\{([A-Z_]+)\}\}/g)].map((m) => m[1]).filter((k) => !(k in values));
2690
- if (missing.length) throw new UserError(`no value for template placeholder(s): ${[...new Set(missing)].join(", ")}`, 3);
2691
- return template.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => values[key]);
2692
- }
2693
-
2694
- // src/core/slots.ts
2695
- import crypto5 from "node:crypto";
2696
- import fs19 from "node:fs";
2697
- import path16 from "node:path";
2698
- var slotsDir = () => path16.join(home(), "slots");
2699
- var locksDir = () => path16.join(home(), "locks");
2700
- var STARTUP_GRACE_MS = 3e4;
2701
- function holderActive(runId, self) {
2702
- if (!runId || runId === self) return false;
2703
- try {
2704
- const m = freshMeta(runId);
2705
- if (!isActive(m.state)) return false;
2706
- if (m.pid) return isAlive(m.pid);
2707
- return Date.now() - Date.parse(m.startedAt) < STARTUP_GRACE_MS;
2708
- } catch {
2709
- return false;
2710
- }
2711
- }
2712
- function tryClaim(file2, runId) {
2713
- fs19.mkdirSync(path16.dirname(file2), { recursive: true });
2714
- try {
2715
- fs19.writeFileSync(file2, runId, { flag: "wx" });
2716
- return true;
2717
- } catch (e) {
2718
- if (e.code !== "EEXIST") throw e;
2719
- }
2720
- let owner = "";
2721
- try {
2722
- owner = fs19.readFileSync(file2, "utf8").trim();
2723
- } catch {
2724
- }
2725
- if (owner === runId) return true;
2726
- if (holderActive(owner, runId)) return false;
2727
- fs19.rmSync(file2, { force: true });
2728
- try {
2729
- fs19.writeFileSync(file2, runId, { flag: "wx" });
2730
- return true;
2731
- } catch {
2732
- return false;
2733
- }
2734
- }
2735
- function releaseIfOwner(file2, runId) {
2736
- if (!file2) return;
2737
- try {
2738
- if (fs19.readFileSync(file2, "utf8").trim() === runId) fs19.rmSync(file2, { force: true });
2739
- } catch {
2740
- }
2741
- }
2742
- function tryAcquireSlot(runId, maxParallel) {
2743
- for (let n = 0; n < Math.max(1, maxParallel); n++) {
2744
- const file2 = path16.join(slotsDir(), `slot-${n}`);
2745
- if (tryClaim(file2, runId)) return file2;
2746
- }
2747
- return void 0;
2748
- }
2749
- var releaseSlot = (file2, runId) => releaseIfOwner(file2, runId);
2750
- var lockFile = (repoRoot2) => path16.join(locksDir(), `write-${crypto5.createHash("sha1").update(path16.resolve(repoRoot2)).digest("hex").slice(0, 16)}`);
2751
- function acquireWriteLock(repoRoot2, runId) {
2752
- const file2 = lockFile(repoRoot2);
2753
- if (tryClaim(file2, runId)) return;
2754
- let owner = "";
2755
- try {
2756
- owner = fs19.readFileSync(file2, "utf8").trim();
2757
- } catch {
2758
- }
2759
- throw new UserError(`another --write run (${owner}) is active in this repo; use --isolate for parallel changes`, 3);
2760
- }
2761
- var releaseWriteLock = (repoRoot2, runId) => releaseIfOwner(lockFile(repoRoot2), runId);
2762
-
2763
- // src/core/prompt.ts
2764
- var RULES = {
2765
- read: ["READ-ONLY task: do not create, modify or delete any file. Read, search and analyse only."],
2766
- write: [
2767
- "You may edit files that the task requires. Make the smallest correct change; no drive-by refactors.",
2768
- "Run the relevant tests, type checks or linters if they are cheap and available, and report the result."
2769
- ],
2770
- isolate: [
2771
- "You are in an isolated copy of the project. Edit files as the task requires; the primary agent reviews your diff before anything reaches the real tree.",
2772
- "Make the smallest correct change; no drive-by refactors. Dependency folders (node_modules, virtualenvs, build output) may be missing unless linked.",
2773
- "Run the relevant tests, type checks or linters if they are cheap and available, and report the result."
2774
- ]
2775
- };
2776
- function buildPrompt(mode, task, followUp) {
2777
- if (followUp) return `Follow-up from the primary agent. Same rules and answer format as before.
2778
-
2779
- ${task}`;
2780
- return `You are a worker agent. A primary coding agent delegated this bounded task to you. Your answer is draft work that the primary agent will verify, and it is the only thing the primary sees, so make it self-contained.
2781
-
2782
- Rules:
2783
- - ${RULES[mode].join("\n- ")}
2784
- - Stay inside the task scope and the project directory.
2785
- - Never commit, push, reset, checkout, restore, stash, clean, rebase or merge, and never discard or overwrite uncommitted work you did not create.
2786
- - Never read or reveal secrets (.env files, keys, tokens).
2787
- - Delete a file only when the task explicitly asks for it, and name every file you delete under FILES CHANGED. Whether anything else should be deleted or overwritten is the primary agent's decision: propose it under OPEN ISSUES and leave the file alone.
2788
- - Do not ask questions. If something is ambiguous, choose the safest reasonable interpretation and state the assumption.
2789
- - Be economical: open only what you need.
2790
-
2791
- End with this answer format (plain text, no preamble):
2792
- SUMMARY: 1-5 lines that directly answer the task.
2793
- DETAILS: key findings with file:line references; only what the primary agent needs.
2794
- FILES CHANGED: paths, or "none".
2795
- VERIFICATION: commands you ran and their results, or "not run".
2796
- OPEN ISSUES: risks, uncertainties, follow-ups, or "none".
2797
-
2798
- Task:
2799
- ${task}`;
2800
- }
2801
-
2802
- // src/core/receipt.ts
2803
- import fs20 from "node:fs";
2804
- import path17 from "node:path";
2805
- var PRESETS = {
2806
- sonnet: { name: "Claude Sonnet", input: 3, output: 15, cachedInput: 0.3 },
2807
- opus: { name: "Claude Opus", input: 5, output: 25, cachedInput: 0.5 },
2808
- haiku: { name: "Claude Haiku", input: 1, output: 5, cachedInput: 0.1 },
2809
- "gpt-5": { name: "GPT-5", input: 1.25, output: 10, cachedInput: 0.125 }
2810
- };
2811
- function primaryPrice() {
2812
- const eff = effective();
2813
- const custom = eff.price.value?.split(",").map(Number);
2814
- if (custom && custom.length >= 2 && custom.every((n) => Number.isFinite(n) && n >= 0)) {
2815
- return { name: "custom", input: custom[0], output: custom[1], cachedInput: custom[2] ?? custom[0] / 10 };
2816
- }
2817
- return PRESETS[eff.primary.value.toLowerCase()] ?? PRESETS.sonnet;
2818
- }
2819
- var estimateTokens = (text) => Math.ceil(text.length / 4);
2820
- function savedUsd(usage2, returnedTokens, price = primaryPrice()) {
2821
- const wouldCost = (usage2.input * price.input + usage2.cacheRead * price.cachedInput + (usage2.output + usage2.reasoning) * price.output) / 1e6;
2822
- const readingTheReport = returnedTokens * price.input / 1e6;
2823
- return Math.max(0, wouldCost - (usage2.cost ?? 0) - readingTheReport);
2824
- }
2825
- function record(meta) {
2826
- if (!meta.usage?.steps || meta.auditOf) return;
2827
- const entry = {
2828
- id: meta.id,
2829
- at: meta.endedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2830
- mode: meta.mode,
2831
- state: meta.state,
2832
- backend: (meta.ran ?? meta.worker).backend,
2833
- model: meta.resolvedModel ?? (meta.ran ?? meta.worker).model,
2834
- tokens: meta.usage.total,
2835
- returned: meta.returnedTokens ?? 0,
2836
- workerCost: meta.usage.cost ?? 0,
2837
- saved: meta.savedUsd ?? 0,
2838
- price: primaryPrice().name
2839
- };
2840
- fs20.mkdirSync(path17.dirname(ledgerFile()), { recursive: true });
2841
- fs20.appendFileSync(ledgerFile(), `${JSON.stringify(entry)}
2842
- `);
2843
- }
2844
- function readLedger(sinceMs2) {
2845
- if (!fs20.existsSync(ledgerFile())) return [];
2846
- return fs20.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
2847
- try {
2848
- return [JSON.parse(l)];
2849
- } catch {
2850
- return [];
2851
- }
2852
- }).filter((e) => !sinceMs2 || Date.parse(e.at) >= sinceMs2);
2853
- }
2854
- function totals(entries) {
2855
- const t = entries.reduce(
2856
- (a, e) => ({
2857
- runs: a.runs + 1,
2858
- tokens: a.tokens + e.tokens,
2859
- returned: a.returned + e.returned,
2860
- workerCost: a.workerCost + e.workerCost,
2861
- saved: a.saved + e.saved
2862
- }),
2863
- { runs: 0, tokens: 0, returned: 0, workerCost: 0, saved: 0 }
2864
- );
2865
- return { ...t, ratio: t.returned ? t.tokens / t.returned : 0 };
2866
- }
2867
- var usd = (n) => n >= 100 ? `$${n.toFixed(0)}` : n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`;
2868
- function compact(n) {
2869
- const fmt = (v) => v.toFixed(v >= 100 ? 0 : 1).replace(/\.0$/, "");
2870
- if (n >= 1e6) return `${fmt(n / 1e6)}M`;
2871
- if (n >= 1e3) return `${fmt(n / 1e3)}k`;
2872
- return String(Math.round(n));
2873
- }
2874
- function badgeUrl(t) {
2875
- const msg = encodeURIComponent(`saved ${usd(t.saved)} \xB7 ${compact(t.tokens)} tokens offloaded`).replace(/-/g, "--");
2876
- return `https://img.shields.io/badge/pitroom-${msg}-7c3aed`;
2568
+ function badgeUrl(t) {
2569
+ const msg = encodeURIComponent(`saved ${usd(t.saved)} \xB7 ${compact(t.tokens)} tokens offloaded`).replace(/-/g, "--");
2570
+ return `https://img.shields.io/badge/pitroom-${msg}-7c3aed`;
2877
2571
  }
2878
2572
  var esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
2879
2573
  function card(t, period) {
@@ -2917,194 +2611,34 @@ ${tile(0, compact(t.tokens), "Tokens offloaded")}${tile(1, t.ratio ? `${Math.rou
2917
2611
  `;
2918
2612
  }
2919
2613
 
2920
- // src/core/refs.ts
2921
- import fs21 from "node:fs";
2922
- import path18 from "node:path";
2923
- var FILE_EXTENSIONS = new Set(
2924
- "ts tsx mts cts js jsx mjs cjs json jsonc json5 md mdx txt rst py pyi rb go rs java kt kts swift c h cc cpp cxx hpp hh cs fs php lua r jl sh bash zsh fish ps1 bat yml yaml toml ini cfg conf env html htm css scss sass less vue svelte astro sql graphql gql proto lock xml svg csv tsv gradle tf hcl dart ex exs erl hs ml scala clj vim el mk cmake dockerfile gitignore gitattributes editorconfig npmrc nvmrc".split(" ")
2925
- );
2926
- var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", "target", "vendor", ".venv", "venv", "__pycache__", "coverage", ".turbo", ".cache"]);
2927
- var MAX_INDEXED = 6e4;
2928
- var EXTENSIONLESS = "Makefile|Dockerfile|Containerfile|Gemfile|Rakefile|Procfile|Justfile|Vagrantfile|BUILD|WORKSPACE";
2929
- var REF = new RegExp(
2930
- String.raw`(?<![\w/:.-])(\.{0,2}/?(?:[\w@.+-]+/)*(?:[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,7}|${EXTENSIONLESS})):(\d+)(?:[-–](\d+))?`,
2931
- "g"
2932
- );
2933
- var IDENT = /`([A-Za-z_$][\w$]*)(?:\([^`]*\))?`/g;
2934
- var CLAUSE = /[;|—]|\.\s|:\s/g;
2935
- var MAX_BYTES = 5 * 1024 * 1024;
2936
- var WINDOW = 5;
2937
- var DEFINITION_LOOKBACK = 80;
2938
- function looksLikeSymbol(m) {
2939
- const name = m[1];
2940
- return m[0].includes("(") || /[A-Z_$0-9]/.test(name) || name.length >= 5;
2614
+ // src/core/ui.ts
2615
+ import fs17 from "node:fs";
2616
+ var WEEK_MS = 7 * 24 * 3600 * 1e3;
2617
+ var RECENT = 40;
2618
+ var RECENT_HOURS = 1;
2619
+ var MAX_CARDS = 5;
2620
+ function activeRuns() {
2621
+ const out = [];
2622
+ for (const id of listRunIds().slice(-RECENT)) {
2623
+ try {
2624
+ const m = readMeta(id);
2625
+ if (isActive(m.state) && (m.state === "queued" || isAlive(m.pid))) out.push(m);
2626
+ } catch {
2627
+ }
2628
+ }
2629
+ return out;
2941
2630
  }
2942
- function extractRefs(answer) {
2943
- const seen = /* @__PURE__ */ new Map();
2944
- for (const line of answer.split("\n")) {
2945
- const matches2 = [...line.matchAll(REF)];
2946
- const seps = [...line.matchAll(CLAUSE)].map((s) => s.index ?? 0);
2947
- const sepBetween = (a, b) => seps.some((s) => s >= a && s < b);
2948
- const idents = [...line.matchAll(IDENT)].filter((i) => {
2949
- if (!looksLikeSymbol(i)) return false;
2950
- const pos = i.index ?? 0;
2951
- const before = matches2.filter((m) => (m.index ?? 0) + m[0].length <= pos).at(-1);
2952
- const after = matches2.find((m) => (m.index ?? 0) >= pos + i[0].length);
2953
- return !(before && after && !sepBetween((before.index ?? 0) + before[0].length, after.index ?? 0));
2954
- });
2955
- matches2.forEach((m, k) => {
2956
- const start = Number(m[2]);
2957
- const end = m[3] ? Number(m[3]) : start;
2958
- if (start < 1 || end < start) return;
2959
- const at = m.index ?? 0;
2960
- const prev = matches2[k - 1];
2961
- const next = matches2[k + 1];
2962
- const from = Math.max(at - 80, prev ? (prev.index ?? 0) + prev[0].length : 0, ...seps.filter((s) => s < at).map((s) => s + 1));
2963
- const to = Math.min(at + m[0].length + 40, next?.index ?? Infinity, ...seps.filter((s) => s >= at + m[0].length));
2964
- const near = new Set(idents.filter((i) => (i.index ?? 0) >= from && (i.index ?? 0) < to).map((i) => i[1]));
2965
- const symbol = near.size === 1 ? [...near][0] : void 0;
2966
- const key = `${m[1]}:${start}-${end}:${symbol ?? ""}`;
2967
- if (!seen.has(key)) seen.set(key, { text: m[0], file: m[1], start, end, symbol });
2968
- });
2631
+ function statusLine() {
2632
+ const running = activeRuns();
2633
+ const saved = totals(readLedger(Date.now() - WEEK_MS)).saved;
2634
+ if (!running.length && !saved) return "";
2635
+ const parts = ["\u{1F3C1} pitroom"];
2636
+ if (running.length) {
2637
+ const who = [...new Set(running.map((m) => (m.ran ?? m.worker).backend))].join(", ");
2638
+ parts.push(`${running.length} running (${who})`);
2969
2639
  }
2970
- return [...seen.values()];
2971
- }
2972
- function verifyRefs(refs, dirs) {
2973
- const roots = [...new Set(dirs.filter(Boolean).map((d) => path18.resolve(d)))];
2974
- const invalid = [];
2975
- const lineCache = /* @__PURE__ */ new Map();
2976
- const load = (file2) => {
2977
- if (!lineCache.has(file2)) {
2978
- const size = fs21.statSync(file2).size;
2979
- const lines = size > MAX_BYTES ? null : fs21.readFileSync(file2, "utf8").split("\n");
2980
- if (lines && lines[lines.length - 1] === "") lines.pop();
2981
- lineCache.set(file2, lines);
2982
- }
2983
- return lineCache.get(file2);
2984
- };
2985
- let index;
2986
- const byName = () => index ??= indexFiles(roots);
2987
- const candidates = (ref) => {
2988
- const direct = resolve(ref.file, roots);
2989
- if (direct) return [direct];
2990
- if (path18.isAbsolute(ref.file)) return [];
2991
- const wanted = ref.file.replace(/^(\.{1,2}\/)+/, "");
2992
- return (byName().get(path18.basename(wanted)) ?? []).filter((f) => f.split(path18.sep).join("/").endsWith(`/${wanted}`) || path18.basename(f) === wanted).slice(0, 20);
2993
- };
2994
- const check = (file2, ref) => {
2995
- const lines = load(file2);
2996
- if (!lines) return void 0;
2997
- if (ref.end > lines.length) return `file has ${lines.length} lines`;
2998
- if (ref.symbol && !mentions(lines, ref) && !enclosedBy(lines, ref)) return `\`${ref.symbol}\` not near line ${ref.start}`;
2999
- return void 0;
3000
- };
3001
- let total = 0;
3002
- for (const ref of refs) {
3003
- const files = candidates(ref);
3004
- if (!files.length) {
3005
- if (!ref.file.includes("/") && !isFileName(ref.file)) continue;
3006
- total++;
3007
- invalid.push({ ref: ref.text, reason: path18.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
3008
- continue;
3009
- }
3010
- total++;
3011
- const reasons = files.map((f) => check(f, ref));
3012
- if (!reasons.some((r) => r === void 0)) invalid.push({ ref: ref.text, reason: files.length > 1 ? `${reasons[0]} (${files.length} files match)` : reasons[0] });
3013
- }
3014
- return { total, valid: total - invalid.length, invalid };
3015
- }
3016
- function mentions(lines, ref) {
3017
- const near = lines.slice(Math.max(0, ref.start - 1 - WINDOW), ref.end + WINDOW).join("\n");
3018
- return new RegExp(`(^|[^\\w$])${escape(ref.symbol)}([^\\w$]|$)`).test(near);
3019
- }
3020
- function enclosedBy(lines, ref) {
3021
- const name = escape(ref.symbol);
3022
- const def = new RegExp(
3023
- `\\b(function|def|fn|func|class|interface|struct|impl|type|const|let|var)\\s+\\*?${name}\\b|^\\s*(export\\s+)?(default\\s+)?(async\\s+)?(static\\s+)?${name}\\s*[(:=]`
3024
- );
3025
- return lines.slice(Math.max(0, ref.start - 1 - DEFINITION_LOOKBACK), ref.start).some((l) => def.test(l));
3026
- }
3027
- var isFileName = (name) => {
3028
- const base2 = path18.basename(name);
3029
- return !base2.includes(".") || new RegExp(`^(${EXTENSIONLESS})$`).test(base2) || FILE_EXTENSIONS.has(base2.split(".").pop().toLowerCase());
3030
- };
3031
- function indexFiles(roots) {
3032
- const index = /* @__PURE__ */ new Map();
3033
- let count = 0;
3034
- const walk = (dir) => {
3035
- let entries;
3036
- try {
3037
- entries = fs21.readdirSync(dir, { withFileTypes: true });
3038
- } catch {
3039
- return;
3040
- }
3041
- for (const e of entries) {
3042
- if (count >= MAX_INDEXED) return;
3043
- if (e.isDirectory()) {
3044
- if (!SKIP_DIRS2.has(e.name)) walk(path18.join(dir, e.name));
3045
- } else if (e.isFile()) {
3046
- count++;
3047
- const list2 = index.get(e.name);
3048
- if (list2) list2.push(path18.join(dir, e.name));
3049
- else index.set(e.name, [path18.join(dir, e.name)]);
3050
- }
3051
- }
3052
- };
3053
- for (const r of roots) walk(r);
3054
- return index;
3055
- }
3056
- function resolve(ref, roots) {
3057
- const candidates = path18.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path18.join(r, ref));
3058
- return candidates.find((c) => {
3059
- try {
3060
- return fs21.statSync(c).isFile() && inside(c, roots);
3061
- } catch {
3062
- return false;
3063
- }
3064
- });
3065
- }
3066
- function inside(file2, roots) {
3067
- return roots.some((r) => {
3068
- const rel = path18.relative(r, path18.resolve(file2));
3069
- return rel !== "" && !rel.startsWith("..") && !path18.isAbsolute(rel);
3070
- });
3071
- }
3072
- var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3073
-
3074
- // src/core/history.ts
3075
- import fs23 from "node:fs";
3076
- import { createRequire } from "node:module";
3077
- import path19 from "node:path";
3078
- import zlib from "node:zlib";
3079
-
3080
- // src/core/ui.ts
3081
- import fs22 from "node:fs";
3082
- var WEEK_MS = 7 * 24 * 3600 * 1e3;
3083
- var RECENT = 40;
3084
- var RECENT_HOURS = 1;
3085
- var MAX_CARDS = 5;
3086
- function activeRuns() {
3087
- const out = [];
3088
- for (const id of listRunIds().slice(-RECENT)) {
3089
- try {
3090
- const m = readMeta(id);
3091
- if (isActive(m.state) && (m.state === "queued" || isAlive(m.pid))) out.push(m);
3092
- } catch {
3093
- }
3094
- }
3095
- return out;
3096
- }
3097
- function statusLine() {
3098
- const running = activeRuns();
3099
- const saved = totals(readLedger(Date.now() - WEEK_MS)).saved;
3100
- if (!running.length && !saved) return "";
3101
- const parts = ["\u{1F3C1} pitroom"];
3102
- if (running.length) {
3103
- const who = [...new Set(running.map((m) => (m.ran ?? m.worker).backend))].join(", ");
3104
- parts.push(`${running.length} running (${who})`);
3105
- }
3106
- if (saved) parts.push(`~${usd(saved)} saved this week`);
3107
- return parts.join(" \xB7 ");
2640
+ if (saved) parts.push(`~${usd(saved)} saved this week`);
2641
+ return parts.join(" \xB7 ");
3108
2642
  }
3109
2643
  var RUN_ID = /\b\d{8}-\d{6}-[0-9a-f]{4}\b/g;
3110
2644
  var PITROOM_CALL = /(^|[\s;&|(`])(\S*\/)?pitroom(\.mjs)?(\s|$)/;
@@ -3169,9 +2703,9 @@ function cardsFor(m) {
3169
2703
  const out = [];
3170
2704
  for (const [phase, text] of phases) {
3171
2705
  const marker = runFile(m.id, `card-${phase}`);
3172
- if (fs22.existsSync(marker)) continue;
2706
+ if (fs17.existsSync(marker)) continue;
3173
2707
  if (!(phase === "started" && !isActive(m.state))) out.push(text());
3174
- fs22.writeFileSync(marker, "");
2708
+ fs17.writeFileSync(marker, "");
3175
2709
  }
3176
2710
  return out;
3177
2711
  }
@@ -3182,10 +2716,10 @@ function hookCards(input) {
3182
2716
  const response = event.tool_response;
3183
2717
  const output = typeof response === "string" ? response : [response?.stdout, response?.stderr].filter(Boolean).join("\n");
3184
2718
  const ids = [...new Set(`${command}
3185
- ${output}`.match(RUN_ID) ?? [])].filter((id) => fs22.existsSync(`${runsDir()}/${id}`));
2719
+ ${output}`.match(RUN_ID) ?? [])].filter((id) => fs17.existsSync(`${runsDir()}/${id}`));
3186
2720
  const since2 = Date.now() - RECENT_HOURS * 36e5;
3187
2721
  for (const id of listRunIds().slice(-RECENT)) {
3188
- if (ids.includes(id) || fs22.existsSync(runFile(id, "card-ended"))) continue;
2722
+ if (ids.includes(id) || fs17.existsSync(runFile(id, "card-ended"))) continue;
3189
2723
  try {
3190
2724
  const m = readMeta(id);
3191
2725
  if (!isActive(m.state) && Date.parse(m.endedAt ?? m.startedAt) > since2) ids.push(id);
@@ -3207,7 +2741,7 @@ var SCHEMA_VERSION = 1;
3207
2741
  var PATCH_MAX = 1e6;
3208
2742
  var ANSWER_MAX2 = 2e5;
3209
2743
  var cached2;
3210
- var historyFile = () => path19.join(home(), "history.db");
2744
+ var historyFile = () => path15.join(home(), "history.db");
3211
2745
  function openDb() {
3212
2746
  const file2 = historyFile();
3213
2747
  if (cached2?.file === file2) return cached2.db;
@@ -3221,7 +2755,7 @@ function openDb() {
3221
2755
  } finally {
3222
2756
  process.emitWarning = emit;
3223
2757
  }
3224
- fs23.mkdirSync(path19.dirname(file2), { recursive: true });
2758
+ fs18.mkdirSync(path15.dirname(file2), { recursive: true });
3225
2759
  db = new DatabaseSync(file2);
3226
2760
  db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA synchronous=NORMAL;");
3227
2761
  migrate(db);
@@ -3256,31 +2790,31 @@ function migrate(db) {
3256
2790
  function readRunFile(id, name) {
3257
2791
  const f = runFile(id, name);
3258
2792
  try {
3259
- if (fs23.existsSync(f)) return fs23.readFileSync(f, "utf8");
3260
- if (fs23.existsSync(`${f}.gz`)) return zlib.gunzipSync(fs23.readFileSync(`${f}.gz`)).toString("utf8");
2793
+ if (fs18.existsSync(f)) return fs18.readFileSync(f, "utf8");
2794
+ if (fs18.existsSync(`${f}.gz`)) return zlib.gunzipSync(fs18.readFileSync(`${f}.gz`)).toString("utf8");
3261
2795
  } catch {
3262
2796
  }
3263
2797
  return void 0;
3264
2798
  }
3265
2799
  function gzipFile(f) {
3266
- if (!fs23.existsSync(f)) return;
3267
- fs23.writeFileSync(`${f}.gz`, zlib.gzipSync(fs23.readFileSync(f)));
3268
- fs23.rmSync(f);
2800
+ if (!fs18.existsSync(f)) return;
2801
+ fs18.writeFileSync(`${f}.gz`, zlib.gzipSync(fs18.readFileSync(f)));
2802
+ fs18.rmSync(f);
3269
2803
  }
3270
2804
  function compactRun(meta) {
3271
2805
  if (!TERMINAL.includes(meta.state)) return;
3272
- const dir = path19.dirname(runFile(meta.id, "meta.json"));
2806
+ const dir = path15.dirname(runFile(meta.id, "meta.json"));
3273
2807
  let names;
3274
2808
  try {
3275
- names = fs23.readdirSync(dir);
2809
+ names = fs18.readdirSync(dir);
3276
2810
  } catch {
3277
2811
  return;
3278
2812
  }
3279
2813
  for (const n of names) {
3280
- if (/^events(\.attempt-\d+)?\.jsonl$/.test(n)) gzipFile(path19.join(dir, n));
2814
+ if (/^events(\.attempt-\d+)?\.jsonl$/.test(n)) gzipFile(path15.join(dir, n));
3281
2815
  else if (/^stderr(\.attempt-\d+)?\.log$/.test(n)) {
3282
- if (meta.state === "done" && !/attempt/.test(n)) fs23.rmSync(path19.join(dir, n), { force: true });
3283
- else gzipFile(path19.join(dir, n));
2816
+ if (meta.state === "done" && !/attempt/.test(n)) fs18.rmSync(path15.join(dir, n), { force: true });
2817
+ else gzipFile(path15.join(dir, n));
3284
2818
  }
3285
2819
  }
3286
2820
  }
@@ -3489,14 +3023,14 @@ function auditStats(db, since2) {
3489
3023
  return { total, byWorker };
3490
3024
  }
3491
3025
  function workerRows(rows, ledger, audited) {
3492
- const out = rows.map((r) => ({ backend: r.backend, model: r.model ?? void 0, runs: r.runs, ok: r.ok ?? 0, avgSeconds: r.avg_s, avgTokens: r.avg_t, saved: 0, audited: 0, agreed: 0 }));
3026
+ const out = rows.map((r) => ({ backend: r.backend, model: r.model ?? void 0, runs: r.runs, ok: r.ok ?? 0, limited: r.limited ?? 0, avgSeconds: r.avg_s, avgTokens: r.avg_t, saved: 0, audited: 0, agreed: 0 }));
3493
3027
  const key = (backend, model) => `${backend ?? ""}\0${model ?? ""}`;
3494
3028
  const index = new Map(out.map((r) => [key(r.backend, r.model), r]));
3495
3029
  for (const e of ledger) {
3496
3030
  const backend = e.backend ?? "opencode";
3497
3031
  let row = index.get(key(backend, e.model)) ?? (e.model ? out.find((r) => r.backend === backend && r.model?.endsWith(`/${e.model}`)) : void 0);
3498
3032
  if (!row) {
3499
- row = { backend, model: e.model, runs: 0, ok: 0, avgSeconds: null, avgTokens: null, saved: 0, audited: 0, agreed: 0 };
3033
+ row = { backend, model: e.model, runs: 0, ok: 0, limited: 0, avgSeconds: null, avgTokens: null, saved: 0, audited: 0, agreed: 0 };
3500
3034
  index.set(key(backend, e.model), row);
3501
3035
  out.push(row);
3502
3036
  }
@@ -3508,24 +3042,28 @@ function workerRows(rows, ledger, audited) {
3508
3042
  }
3509
3043
  return out;
3510
3044
  }
3045
+ var LIMITED = `(state='failed' AND (json_extract(meta_json,'$.failureKind')='rate-limited' OR error LIKE '%the model is rate-limited or overloaded%'))`;
3511
3046
  function historyStats(sinceMs2) {
3512
- const empty = { totals: { runs: 0, ok: 0, failed: 0, seconds: 0, tokens: 0, saved: 0 }, audits: { runs: 0, agree: 0, partial: 0, disagree: 0, unclear: 0, tokens: 0 }, byWorker: [], byDay: [] };
3047
+ const counted = effective().countRateLimits.value === true;
3048
+ const K = counted ? "1" : `NOT ${LIMITED}`;
3049
+ const empty = { rateLimits: counted ? "counted" : "excluded", totals: { runs: 0, ok: 0, failed: 0, limited: 0, seconds: 0, tokens: 0, saved: 0 }, audits: { runs: 0, agree: 0, partial: 0, disagree: 0, unclear: 0, tokens: 0 }, byWorker: [], byDay: [] };
3513
3050
  const db = openDb();
3514
3051
  if (!db) return empty;
3515
3052
  const since2 = sinceMs2 ? new Date(sinceMs2).toISOString() : "";
3516
3053
  try {
3517
- const t = db.prepare(`SELECT COUNT(*) runs, COALESCE(SUM(state='done'),0) ok, COALESCE(SUM(seconds),0) seconds, COALESCE(SUM(tokens),0) tokens, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT}`).get(since2);
3518
- const w = db.prepare(`SELECT backend, model, COUNT(*) runs, SUM(state='done') ok, AVG(seconds) avg_s, AVG(tokens) avg_t, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY backend, model ORDER BY runs DESC LIMIT 40`).all(since2);
3519
- const d = db.prepare(`SELECT substr(started_at,1,10) day, COUNT(*) runs, SUM(state='done') ok, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY day ORDER BY day DESC LIMIT 60`).all(since2);
3054
+ const t = db.prepare(`SELECT COALESCE(SUM(${K}),0) runs, COALESCE(SUM(state='done'),0) ok, COALESCE(SUM(${LIMITED}),0) limited, COALESCE(SUM(CASE WHEN ${K} THEN seconds END),0) seconds, COALESCE(SUM(CASE WHEN ${K} THEN tokens END),0) tokens, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT}`).get(since2);
3055
+ const w = db.prepare(`SELECT backend, model, SUM(${K}) runs, SUM(state='done') ok, SUM(${LIMITED}) limited, AVG(CASE WHEN ${K} THEN seconds END) avg_s, AVG(CASE WHEN ${K} THEN tokens END) avg_t, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY backend, model ORDER BY runs DESC LIMIT 40`).all(since2);
3056
+ const d = db.prepare(`SELECT substr(started_at,1,10) day, SUM(${K}) runs, SUM(state='done') ok, SUM(${LIMITED}) limited, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY day ORDER BY day DESC LIMIT 60`).all(since2);
3520
3057
  const audits = auditStats(db, since2);
3521
3058
  const ledger = readLedger(sinceMs2);
3522
3059
  const byDay = /* @__PURE__ */ new Map();
3523
3060
  for (const e of ledger) byDay.set(e.at.slice(0, 10), (byDay.get(e.at.slice(0, 10)) ?? 0) + e.saved);
3524
3061
  return {
3525
- totals: { runs: t.runs, ok: t.ok, failed: t.runs - t.ok, seconds: t.seconds, tokens: t.tokens, saved: totals(ledger).saved },
3062
+ rateLimits: counted ? "counted" : "excluded",
3063
+ totals: { runs: t.runs, ok: t.ok, failed: t.runs - t.ok, limited: t.limited, seconds: t.seconds, tokens: t.tokens, saved: totals(ledger).saved },
3526
3064
  audits: audits.total,
3527
3065
  byWorker: workerRows(w, ledger, audits.byWorker),
3528
- byDay: d.reverse().map((r) => ({ day: r.day, runs: r.runs, ok: r.ok ?? 0, saved: byDay.get(r.day) ?? 0 }))
3066
+ byDay: d.reverse().map((r) => ({ day: r.day, runs: r.runs, ok: r.ok ?? 0, limited: r.limited ?? 0, saved: byDay.get(r.day) ?? 0 }))
3529
3067
  };
3530
3068
  } catch {
3531
3069
  return empty;
@@ -3533,7 +3071,79 @@ function historyStats(sinceMs2) {
3533
3071
  }
3534
3072
 
3535
3073
  // src/core/report.ts
3536
- import fs24 from "node:fs";
3074
+ import fs20 from "node:fs";
3075
+
3076
+ // src/core/plan.ts
3077
+ import fs19 from "node:fs";
3078
+ import path16 from "node:path";
3079
+ var FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
3080
+ var RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
3081
+ function structure(lines) {
3082
+ const headings = [];
3083
+ const rules = [];
3084
+ const fenced = [];
3085
+ let open;
3086
+ lines.forEach((l, i) => {
3087
+ const f = FENCE.exec(l);
3088
+ if (f) {
3089
+ fenced[i] = true;
3090
+ const mark = f[1];
3091
+ if (!open) open = mark;
3092
+ else if (mark[0] === open[0] && mark.length >= open.length && !f[2].trim()) open = void 0;
3093
+ return;
3094
+ }
3095
+ fenced[i] = !!open;
3096
+ if (open) return;
3097
+ const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(l);
3098
+ if (h) headings.push({ line: i, level: h[1].length, text: h[2] });
3099
+ else if (RULE.test(l)) rules.push(i);
3100
+ });
3101
+ return { headings, rules, fenced };
3102
+ }
3103
+ function parsePlan(text, file2 = "") {
3104
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
3105
+ const { headings, rules, fenced } = structure(lines);
3106
+ const after = (line) => Math.min(headings.find((h) => h.line > line)?.line ?? lines.length, rules.find((r) => r > line) ?? lines.length);
3107
+ const titleHeading = headings.find((h) => h.level === 1);
3108
+ const start = titleHeading ? titleHeading.line + 1 : 0;
3109
+ const header = lines.slice(start, after(start - 1)).filter((l) => !/^\s*>/.test(l)).join("\n").trim();
3110
+ const gc = headings.find((h) => /^global constraints\b/i.test(h.text));
3111
+ const constraints = gc ? lines.slice(gc.line + 1, after(gc.line)).join("\n").trim() : "";
3112
+ const tasks = [];
3113
+ for (const h of headings) {
3114
+ const m = /^Task\s+(\d+)\b\s*[:.)\-–—]?\s*(.*)$/i.exec(h.text);
3115
+ if (!m) continue;
3116
+ const end = headings.find((o) => o.line > h.line && o.level <= h.level)?.line ?? lines.length;
3117
+ const body = lines.slice(h.line, end).join("\n").replace(/(\n\s*(?:---|\*\*\*|___)\s*)+$/, "").trimEnd();
3118
+ const unfenced = lines.slice(h.line, end).filter((_, j) => !fenced[h.line + j]).join("\n");
3119
+ const tier = /^\s*[-*]?\s*\*\*Worker:\*\*\s*`?([\w-]+)`?/m.exec(unfenced)?.[1]?.toLowerCase();
3120
+ tasks.push({ step: Number(m[1]), title: m[2].trim(), text: body, ...tier ? { tier } : {} });
3121
+ }
3122
+ return { file: file2, title: titleHeading?.text ?? "", header, constraints, tasks };
3123
+ }
3124
+ function loadPlan(file2) {
3125
+ const abs = path16.resolve(file2);
3126
+ if (!fs19.existsSync(abs) || !fs19.statSync(abs).isFile()) throw new UserError(`plan not found: ${file2}`);
3127
+ const plan = parsePlan(fs19.readFileSync(abs, "utf8"), fs19.realpathSync(abs));
3128
+ if (!plan.tasks.length) throw new UserError(`${file2} has no "Task N" headings (see pitroom-writing-plans)`);
3129
+ return plan;
3130
+ }
3131
+ function planTask(plan, step) {
3132
+ const t = plan.tasks.find((x) => x.step === step);
3133
+ if (!t) throw new UserError(`no Task ${step} in ${plan.file}; it has ${plan.tasks.map((x) => `Task ${x.step}`).join(", ")}`);
3134
+ return t;
3135
+ }
3136
+ function brief(plan, task) {
3137
+ return `${[
3138
+ `# ${plan.title || planName(plan.file)}`,
3139
+ plan.header,
3140
+ "## Global Constraints",
3141
+ plan.constraints || "(none stated in the plan)",
3142
+ task.text
3143
+ ].filter(Boolean).join("\n\n")}
3144
+ `;
3145
+ }
3146
+ var planName = (file2) => path16.basename(file2).replace(/\.md$/i, "");
3537
3147
 
3538
3148
  // src/core/style.ts
3539
3149
  var enabled = () => {
@@ -3593,7 +3203,7 @@ function duration(meta) {
3593
3203
  }
3594
3204
  function readSummary(meta) {
3595
3205
  const f = runFile(meta.id, "summary.md");
3596
- return fs24.existsSync(f) ? fs24.readFileSync(f, "utf8").trim() : archivedRun(meta.id)?.answer ?? "";
3206
+ return fs20.existsSync(f) ? fs20.readFileSync(f, "utf8").trim() : archivedRun(meta.id)?.answer ?? "";
3597
3207
  }
3598
3208
  function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
3599
3209
  const out = [];
@@ -3654,44 +3264,659 @@ function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
3654
3264
  meta.mode === "isolate" ? ` \u26A0 deletes ${deleted} file${deleted === 1 ? "" : "s"}: check they are wanted before applying (apply refuses without --allow-delete)` : ` \u26A0 deleted ${deleted} file${deleted === 1 ? "" : "s"} in your tree: check they are wanted (undo: pitroom revert ${meta.id})`
3655
3265
  );
3656
3266
  }
3657
- if (meta.changes.length) {
3658
- out.push(` diff: pitroom show ${meta.id} --patch`);
3659
- out.push(` review: pitroom review ${meta.id}`);
3660
- if (meta.mode === "isolate" && !meta.applied && !meta.discarded) {
3661
- out.push(` apply: pitroom apply ${meta.id}`, ` discard: pitroom discard ${meta.id}`);
3267
+ if (meta.changes.length) {
3268
+ out.push(` diff: pitroom show ${meta.id} --patch`);
3269
+ out.push(` review: pitroom review ${meta.id}`);
3270
+ if (meta.mode === "isolate" && !meta.applied && !meta.discarded) {
3271
+ out.push(` apply: pitroom apply ${meta.id}`, ` discard: pitroom discard ${meta.id}`);
3272
+ }
3273
+ if (meta.mode === "write" && !meta.reverted) out.push(` undo: pitroom revert ${meta.id}`);
3274
+ }
3275
+ }
3276
+ if (meta.verifyResult) {
3277
+ const v = meta.verifyResult;
3278
+ const notFound = !v.ok && v.code === 127 && /not found|no such file|is not recognized as an internal or external command/i.test(v.tail);
3279
+ out.push(`\u2500\u2500 verify: \`${meta.verify}\` ${v.ok ? "\u2714 passed" : notFound ? "\u2718 could not run (exit 127: command not found)" : `\u2718 failed (exit ${v.code})`}`);
3280
+ if (notFound) out.push(" the command is not on the PATH Pitroom runs with (an app such as an MCP client may start it without your shell's PATH): give its full path, or set PATH in the command");
3281
+ if (!v.ok && v.tail) out.push(...v.tail.split("\n").map((l) => ` ${l}`));
3282
+ }
3283
+ if (isActive(meta.state)) {
3284
+ out.push(`\u2500\u2500 still running: pitroom wait ${meta.id} (stop: pitroom stop ${meta.id})`);
3285
+ } else if (meta.sessionId && !meta.applied && !meta.discarded && !meta.reviewOf) {
3286
+ out.push(`\u2500\u2500 follow up: pitroom run --continue ${meta.id} "\u2026"`);
3287
+ }
3288
+ return out.join("\n");
3289
+ }
3290
+ function live(meta) {
3291
+ const f = runFile(meta.id, "events.jsonl");
3292
+ if (!fs20.existsSync(f)) return { steps: 0, toolCalls: 0 };
3293
+ const p = getBackend((meta.ran ?? meta.worker).backend).parse(fs20.readFileSync(f, "utf8"));
3294
+ return { steps: p.usage.steps, toolCalls: p.usage.toolCalls, last: p.lastActivity };
3295
+ }
3296
+ function progress(meta) {
3297
+ if (meta.state === "queued") return `pitroom \u22EF queued \xB7 ${meta.mode} \xB7 waiting for a free worker slot \xB7 run ${meta.id}`;
3298
+ const l = live(meta);
3299
+ const last = l.last ? ` \xB7 last: ${l.last}` : "";
3300
+ return `pitroom \u2026 running \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 ${l.steps} steps, ${l.toolCalls} tool calls${last} \xB7 run ${meta.id}`;
3301
+ }
3302
+
3303
+ // src/core/group.ts
3304
+ function groupIds(group) {
3305
+ return listRunIds().filter((id) => {
3306
+ try {
3307
+ return readMeta(id).group === group;
3308
+ } catch {
3309
+ return false;
3310
+ }
3311
+ });
3312
+ }
3313
+ var oneLine5 = (s, max) => {
3314
+ const t = s.replace(/\s+/g, " ").trim();
3315
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
3316
+ };
3317
+ function headline(meta, max = 160) {
3318
+ const text = readSummary(meta);
3319
+ const line = text.split("\n").find((l) => /^SUMMARY:/i.test(l.trim())) ?? text.split("\n").find((l) => l.trim()) ?? "";
3320
+ return oneLine5(line.replace(/^SUMMARY:\s*/i, ""), max);
3321
+ }
3322
+ function table(metas) {
3323
+ if (!metas.length) return "no runs";
3324
+ const rows = metas.map((m) => {
3325
+ const l = m.state === "running" ? live(m) : void 0;
3326
+ const steps = l ? String(l.steps) : m.usage ? String(m.usage.steps) : "-";
3327
+ const note = l?.last ? oneLine5(l.last, 48) : isActive(m.state) ? oneLine5(m.task, 48) : headline(m, 48) || oneLine5(m.error ?? m.task, 48);
3328
+ return [m.id, m.state, m.mode, duration(m), steps, describeTarget(m.ran ?? m.worker), note];
3329
+ });
3330
+ const head = ["RUN", "STATE", "MODE", "TIME", "STEPS", "WORKER", "NOW / RESULT"];
3331
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
3332
+ const fmt = (r, header = false) => r.map((c, i) => {
3333
+ const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
3334
+ return header ? dim(cell2) : i === 1 ? stateColour(r[1], cell2) : cell2;
3335
+ }).join(" ");
3336
+ return [fmt(head, true), ...rows.map((r) => fmt(r))].join("\n");
3337
+ }
3338
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3339
+ async function waitMany(ids, opts) {
3340
+ const deadline = Date.now() + opts.timeoutMs;
3341
+ for (; ; ) {
3342
+ const metas = ids.map((id) => freshMeta(id));
3343
+ const done = metas.filter((m) => TERMINAL.includes(m.state)).length;
3344
+ if (opts.any ? done > 0 : done === metas.length) return { metas, timedOut: false };
3345
+ if (Date.now() >= deadline) return { metas, timedOut: true };
3346
+ await sleep(1500);
3347
+ }
3348
+ }
3349
+ async function watch(select, opts) {
3350
+ const seen = /* @__PURE__ */ new Map();
3351
+ const line = (s) => opts.write(`${s}
3352
+ `);
3353
+ const emit = (e, m) => {
3354
+ if (!opts.brief) return opts.write(`${JSON.stringify(e)}
3355
+ `);
3356
+ if (e.event === "started" && m) line(startedCard(m));
3357
+ else if (e.event === "fallback" && m) line(`\u{1F3C1} Pitroom \u21BB ${m.id} fell back from ${String(e.failed)}: ${String(e.reason)}`);
3358
+ else if (m && TERMINAL.includes(m.state)) line(endedCard(m));
3359
+ else if (e.event === "all-done") line(`\u{1F3C1} Pitroom: ${String(e.runs)} run${e.runs === 1 ? "" : "s"} finished (${String(e.ok)} ok${e.failed ? `, ${String(e.failed)} not ok` : ""})`);
3360
+ };
3361
+ const deadline = opts.timeoutMs > 0 ? Date.now() + opts.timeoutMs : Infinity;
3362
+ for (; ; ) {
3363
+ const metas = select().map((id) => freshMeta(id));
3364
+ if (opts.json || opts.brief) {
3365
+ for (const m of metas) {
3366
+ const prev = seen.get(m.id);
3367
+ const l = m.state === "running" ? live(m) : { steps: m.usage?.steps ?? 0, toolCalls: m.usage?.toolCalls ?? 0 };
3368
+ const attempts = m.attempts?.length ?? 0;
3369
+ const base2 = { run: m.id, mode: m.mode, task: oneLine5(m.task, 60) };
3370
+ if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base2 }, m);
3371
+ else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base2 }, m);
3372
+ if (attempts > (prev?.attempts ?? 0)) {
3373
+ const a = m.attempts[attempts - 1];
3374
+ emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine5(a.error, 120) }, m);
3375
+ }
3376
+ if (m.state === "running" && l.steps > (prev?.steps ?? 0)) {
3377
+ emit({ event: "progress", run: m.id, steps: l.steps, tools: l.toolCalls, last: "last" in l && l.last ? oneLine5(String(l.last), 80) : void 0 });
3378
+ }
3379
+ if (TERMINAL.includes(m.state) && prev?.state !== m.state) {
3380
+ emit({
3381
+ event: m.state,
3382
+ run: m.id,
3383
+ time: duration(m),
3384
+ worker: describeTarget(m.ran ?? m.worker),
3385
+ summary: headline(m) || void 0,
3386
+ error: m.error ? oneLine5(m.error, 160) : void 0,
3387
+ refs: m.refs ? `${m.refs.valid}/${m.refs.total}` : void 0,
3388
+ changes: m.changes?.length || void 0
3389
+ }, m);
3390
+ }
3391
+ seen.set(m.id, { state: m.state, steps: l.steps, attempts });
3392
+ }
3393
+ } else {
3394
+ opts.write(`\x1B[H\x1B[2J${table(metas)}
3395
+
3396
+ ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watching (workers keep running)
3397
+ `);
3398
+ }
3399
+ const allDone = metas.length > 0 && metas.every((m) => TERMINAL.includes(m.state));
3400
+ if (allDone || metas.length === 0 && seen.size === 0) {
3401
+ if (opts.json || opts.brief) {
3402
+ const ok = metas.filter((m) => m.state === "done").length;
3403
+ emit({ event: "all-done", runs: metas.length, ok, failed: metas.length - ok });
3404
+ }
3405
+ return { metas, timedOut: false };
3406
+ }
3407
+ if (Date.now() >= deadline) return { metas, timedOut: true };
3408
+ await sleep(opts.intervalMs);
3409
+ }
3410
+ }
3411
+
3412
+ // src/core/notify.ts
3413
+ var MARK = { done: "\u2714", failed: "\u2718", timeout: "\u23F1", stopped: "\u25A0" };
3414
+ function noticeFor(meta) {
3415
+ const group = meta.group ? safeGroup(meta.group) : [];
3416
+ if (group.length > 1) {
3417
+ const count = (state) => group.filter((m) => m.state === state).length;
3418
+ const worse = group.length - count("done");
3419
+ const parts = [`${count("done")} done`, ...["failed", "timeout", "stopped"].filter(count).map((s) => `${count(s)} ${s}`)];
3420
+ const changed2 = group.reduce((n, m) => n + (m.changes?.length ?? 0), 0);
3421
+ return {
3422
+ title: `Pitroom ${worse ? "\u26A0" : "\u2714"} group ${meta.group}`,
3423
+ body: `${group.length} runs ended: ${parts.join(", ")}${changed2 ? ` \xB7 ${changed2} file${changed2 === 1 ? "" : "s"} changed` : ""}`
3424
+ };
3425
+ }
3426
+ const changed = meta.changes?.length ? ` \xB7 ${meta.changes.length} file${meta.changes.length === 1 ? "" : "s"} changed` : "";
3427
+ return { title: `Pitroom ${MARK[meta.state] ?? "\u2022"} ${kind(meta)} ${meta.state}`, body: `${what(meta)} \xB7 ${elapsed(meta)}${changed}` };
3428
+ }
3429
+ function safeGroup(name) {
3430
+ try {
3431
+ return groupIds(name).map((id) => readMeta(id));
3432
+ } catch {
3433
+ return [];
3434
+ }
3435
+ }
3436
+ function shouldNotify(meta) {
3437
+ const eff = effective();
3438
+ if (!eff.notify.value || !meta.background || meta.auditOf || !TERMINAL.includes(meta.state)) return false;
3439
+ const runs = meta.group ? safeGroup(meta.group) : [meta];
3440
+ if (!runs.length || !runs.every((m) => TERMINAL.includes(m.state))) return false;
3441
+ if (runs.every((m) => m.state === "done" || m.state === "stopped")) {
3442
+ const start = Math.min(...runs.map((m) => Date.parse(m.startedAt)));
3443
+ const end = Math.max(...runs.map((m) => Date.parse(m.endedAt ?? "")));
3444
+ if (Number.isFinite(end - start) && (end - start) / 1e3 < eff.notifyAfter.value) return false;
3445
+ if (runs.every((m) => m.state === "stopped")) return false;
3446
+ }
3447
+ return true;
3448
+ }
3449
+ function claimGroup(group) {
3450
+ try {
3451
+ const ids = groupIds(group).sort();
3452
+ if (ids.length < 2) return true;
3453
+ const dir = path17.join(home(), "notified");
3454
+ fs21.mkdirSync(dir, { recursive: true });
3455
+ for (const f of fs21.readdirSync(dir)) {
3456
+ if (Date.now() - fs21.statSync(path17.join(dir, f)).mtimeMs > 30 * 864e5) fs21.rmSync(path17.join(dir, f), { force: true });
3457
+ }
3458
+ fs21.writeFileSync(path17.join(dir, crypto5.createHash("sha1").update(ids.join(",")).digest("hex").slice(0, 16)), "", { flag: "wx" });
3459
+ return true;
3460
+ } catch {
3461
+ return false;
3462
+ }
3463
+ }
3464
+ function send(notice, meta) {
3465
+ const command = effective().notifyCommand.value;
3466
+ const env = { ...process.env, PITROOM_NOTIFY_TITLE: notice.title, PITROOM_NOTIFY_BODY: notice.body, PITROOM_NOTIFY_RUN: meta.id, PITROOM_NOTIFY_STATE: meta.state };
3467
+ try {
3468
+ let child;
3469
+ if (command) {
3470
+ child = spawn2(command, { shell: true, env, detached: true, stdio: "ignore" });
3471
+ } else if (process.platform === "darwin") {
3472
+ child = spawn2("osascript", ["-e", "on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run", notice.title, notice.body], { env, detached: true, stdio: "ignore" });
3473
+ } else if (process.platform === "linux") {
3474
+ const plain2 = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3475
+ child = spawn2("notify-send", ["--", plain2(notice.title), plain2(notice.body)], { env, detached: true, stdio: "ignore" });
3476
+ } else {
3477
+ return;
3478
+ }
3479
+ child.on("error", () => void 0);
3480
+ child.unref();
3481
+ } catch {
3482
+ }
3483
+ }
3484
+ function notifyEnded(meta) {
3485
+ try {
3486
+ if (shouldNotify(meta) && (!meta.group || claimGroup(meta.group))) send(noticeFor(meta), meta);
3487
+ } catch {
3488
+ }
3489
+ }
3490
+
3491
+ // src/core/templates.ts
3492
+ import fs23 from "node:fs";
3493
+ import path19 from "node:path";
3494
+
3495
+ // src/core/install.ts
3496
+ import fs22 from "node:fs";
3497
+ import os9 from "node:os";
3498
+ import path18 from "node:path";
3499
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
3500
+ var LEGACY = ["pitroom", "opencode-worker"];
3501
+ function packageRoot() {
3502
+ return path18.resolve(path18.dirname(fileURLToPath2(import.meta.url)), "..");
3503
+ }
3504
+ function skillNames(root = packageRoot()) {
3505
+ const dir = path18.join(root, "skills");
3506
+ if (!fs22.existsSync(dir)) return [];
3507
+ return fs22.readdirSync(dir).filter((d) => fs22.existsSync(path18.join(dir, d, "SKILL.md"))).sort();
3508
+ }
3509
+ function skillTargets() {
3510
+ const home3 = os9.homedir();
3511
+ const targets = [path18.join(home3, ".agents", "skills")];
3512
+ if (fs22.existsSync(path18.join(home3, ".claude"))) targets.push(path18.join(home3, ".claude", "skills"));
3513
+ return targets;
3514
+ }
3515
+ var launcherPath = () => path18.join(os9.homedir(), ".local", "bin", process.platform === "win32" ? "pitroom.cmd" : "pitroom");
3516
+ var LAUNCHER_MARK = "# pitroom launcher";
3517
+ function launcherScript(bundle) {
3518
+ if (process.platform === "win32") {
3519
+ const q2 = (v) => v.replace(/%/g, "%%");
3520
+ return `@echo off\r
3521
+ rem ${LAUNCHER_MARK} (created by \`pitroom install\`; \`pitroom uninstall\` removes it)\r
3522
+ if defined PITROOM_NODE (\r
3523
+ "%PITROOM_NODE%" "${q2(bundle)}" %*\r
3524
+ ) else (\r
3525
+ "${q2(process.execPath)}" "${q2(bundle)}" %*\r
3526
+ )\r
3527
+ exit /b %ERRORLEVEL%\r
3528
+ `;
3529
+ }
3530
+ const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
3531
+ return `#!/bin/sh
3532
+ ${LAUNCHER_MARK} (created by \`pitroom install\`; \`pitroom uninstall\` removes it)
3533
+ cli=${q(bundle)}
3534
+ ok() { [ -n "$1" ] && [ -x "$1" ] && "$1" -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>22||(a===22&&b>=13)?0:1)' 2>/dev/null; }
3535
+ for n in "\${PITROOM_NODE:-}" ${q(process.execPath)} "$(command -v node 2>/dev/null)" "$HOME"/.nvm/versions/node/*/bin/node /opt/homebrew/bin/node /usr/local/bin/node; do
3536
+ if ok "$n"; then exec "$n" "$cli" "$@"; fi
3537
+ done
3538
+ echo "pitroom: needs Node.js 22.13 or newer (set PITROOM_NODE to its path)" >&2
3539
+ exit 127
3540
+ `;
3541
+ }
3542
+ function isOurLauncher(file2, root) {
3543
+ if (linksInto(file2, root)) return true;
3544
+ try {
3545
+ const s = fs22.readFileSync(file2, "utf8");
3546
+ return s.includes(LAUNCHER_MARK) && s.includes(root);
3547
+ } catch {
3548
+ return false;
3549
+ }
3550
+ }
3551
+ function isPitroomLauncher(file2) {
3552
+ try {
3553
+ return fs22.readFileSync(file2, "utf8").includes(LAUNCHER_MARK);
3554
+ } catch {
3555
+ return false;
3556
+ }
3557
+ }
3558
+ function placeLauncher(bundle, root, force) {
3559
+ const dest = launcherPath();
3560
+ fs22.mkdirSync(path18.dirname(dest), { recursive: true });
3561
+ const exists = fs22.lstatSync(dest, { throwIfNoEntry: false });
3562
+ if (exists && !isOurLauncher(dest, root)) {
3563
+ if (!force) return `! ${dest} exists and is not Pitroom's launcher; kept (use --force to back it up and replace)`;
3564
+ fs22.renameSync(dest, `${dest}.bak-${Date.now()}`);
3565
+ } else if (exists) {
3566
+ fs22.rmSync(dest, { force: true });
3567
+ }
3568
+ fs22.writeFileSync(dest, launcherScript(bundle), { mode: 493 });
3569
+ return `\u2714 ${dest} \u2192 launcher for ${bundle} (Node 22.13+)`;
3570
+ }
3571
+ function linksInto(link, dir) {
3572
+ const st = fs22.lstatSync(link, { throwIfNoEntry: false });
3573
+ if (!st?.isSymbolicLink()) return false;
3574
+ const target = path18.resolve(path18.dirname(link), fs22.readlinkSync(link));
3575
+ const rel = path18.relative(dir, target);
3576
+ return !rel.startsWith("..") && !path18.isAbsolute(rel);
3577
+ }
3578
+ function place(src, dest, opts) {
3579
+ fs22.mkdirSync(path18.dirname(dest), { recursive: true });
3580
+ const st = fs22.lstatSync(dest, { throwIfNoEntry: false });
3581
+ if (st && !st.isSymbolicLink()) {
3582
+ if (!opts.force) return `! ${dest} exists and is not a link; kept (use --force to back it up and replace)`;
3583
+ fs22.renameSync(dest, `${dest}.bak-${Date.now()}`);
3584
+ } else if (st) {
3585
+ fs22.unlinkSync(dest);
3586
+ }
3587
+ if (opts.copy) fs22.cpSync(src, dest, { recursive: true });
3588
+ else fs22.symlinkSync(src, dest, process.platform === "win32" ? "junction" : fs22.statSync(src).isDirectory() ? "dir" : "file");
3589
+ return `\u2714 ${dest} \u2192 ${opts.copy ? "copied" : src}`;
3590
+ }
3591
+ function install(opts) {
3592
+ const root = packageRoot();
3593
+ const skills2 = opts.skills === false ? [] : skillNames(root);
3594
+ if (opts.skills !== false && !skills2.length) throw new Error(`no skills found under ${path18.join(root, "skills")}`);
3595
+ const out = [];
3596
+ for (const base2 of opts.skills === false ? [] : skillTargets()) {
3597
+ for (const legacy of LEGACY) {
3598
+ const l = path18.join(base2, legacy);
3599
+ if (!skills2.includes(legacy) && linksInto(l, root)) {
3600
+ fs22.unlinkSync(l);
3601
+ out.push(`\u2714 removed old link ${l}`);
3602
+ }
3603
+ }
3604
+ for (const name of skills2) out.push(place(path18.join(root, "skills", name), path18.join(base2, name), opts));
3605
+ }
3606
+ const bundle = path18.join(root, "dist", "pitroom.mjs");
3607
+ if (fs22.existsSync(bundle)) {
3608
+ out.push(placeLauncher(bundle, root, opts.force));
3609
+ const onPath = (process.env.PATH ?? "").split(path18.delimiter).some((d) => path18.resolve(d) === path18.dirname(launcherPath()));
3610
+ if (!onPath) out.push(`! ${path18.dirname(launcherPath())} is not on PATH; add it, or run ${launcherPath()} directly`);
3611
+ }
3612
+ return out;
3613
+ }
3614
+ function uninstall() {
3615
+ const root = packageRoot();
3616
+ const out = [];
3617
+ for (const base2 of skillTargets()) {
3618
+ if (!fs22.existsSync(base2)) continue;
3619
+ for (const name of fs22.readdirSync(base2)) {
3620
+ const l = path18.join(base2, name);
3621
+ if (linksInto(l, root)) {
3622
+ fs22.unlinkSync(l);
3623
+ out.push(`\u2714 removed ${l}`);
3624
+ }
3625
+ }
3626
+ }
3627
+ if (isOurLauncher(launcherPath(), root)) {
3628
+ fs22.rmSync(launcherPath(), { force: true });
3629
+ out.push(`\u2714 removed ${launcherPath()}`);
3630
+ }
3631
+ return out.length ? out : ["nothing to remove"];
3632
+ }
3633
+ function installedSkills() {
3634
+ const names = skillNames();
3635
+ return skillTargets().map((base2) => ({ base: base2, names: names.filter((n) => fs22.existsSync(path18.join(base2, n, "SKILL.md"))) }));
3636
+ }
3637
+
3638
+ // src/core/templates.ts
3639
+ var FILES = {
3640
+ implementer: "pitroom-driven-development/implementer-prompt.md",
3641
+ "task-reviewer": "pitroom-driven-development/task-reviewer-prompt.md",
3642
+ "re-review": "pitroom-driven-development/re-review-prompt.md",
3643
+ "code-reviewer": "pitroom-review/code-reviewer.md"
3644
+ };
3645
+ function loadTemplate(name, root = packageRoot()) {
3646
+ const file2 = path19.join(root, "skills", FILES[name]);
3647
+ if (!fs23.existsSync(file2)) throw new UserError(`template missing: ${file2} (broken install? run pitroom doctor)`, 3);
3648
+ return fs23.readFileSync(file2, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
3649
+ }
3650
+ function fill(template, values) {
3651
+ const missing = [...template.matchAll(/\{\{([A-Z_]+)\}\}/g)].map((m) => m[1]).filter((k) => !(k in values));
3652
+ if (missing.length) throw new UserError(`no value for template placeholder(s): ${[...new Set(missing)].join(", ")}`, 3);
3653
+ return template.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => values[key]);
3654
+ }
3655
+
3656
+ // src/core/slots.ts
3657
+ import crypto6 from "node:crypto";
3658
+ import fs24 from "node:fs";
3659
+ import path20 from "node:path";
3660
+ var slotsDir = () => path20.join(home(), "slots");
3661
+ var locksDir = () => path20.join(home(), "locks");
3662
+ var STARTUP_GRACE_MS = 3e4;
3663
+ function holderActive(runId, self) {
3664
+ if (!runId || runId === self) return false;
3665
+ try {
3666
+ const m = freshMeta(runId);
3667
+ if (!isActive(m.state)) return false;
3668
+ if (m.pid) return isAlive(m.pid);
3669
+ return Date.now() - Date.parse(m.startedAt) < STARTUP_GRACE_MS;
3670
+ } catch {
3671
+ return false;
3672
+ }
3673
+ }
3674
+ function tryClaim(file2, runId) {
3675
+ fs24.mkdirSync(path20.dirname(file2), { recursive: true });
3676
+ try {
3677
+ fs24.writeFileSync(file2, runId, { flag: "wx" });
3678
+ return true;
3679
+ } catch (e) {
3680
+ if (e.code !== "EEXIST") throw e;
3681
+ }
3682
+ let owner = "";
3683
+ try {
3684
+ owner = fs24.readFileSync(file2, "utf8").trim();
3685
+ } catch {
3686
+ }
3687
+ if (owner === runId) return true;
3688
+ if (holderActive(owner, runId)) return false;
3689
+ fs24.rmSync(file2, { force: true });
3690
+ try {
3691
+ fs24.writeFileSync(file2, runId, { flag: "wx" });
3692
+ return true;
3693
+ } catch {
3694
+ return false;
3695
+ }
3696
+ }
3697
+ function releaseIfOwner(file2, runId) {
3698
+ if (!file2) return;
3699
+ try {
3700
+ if (fs24.readFileSync(file2, "utf8").trim() === runId) fs24.rmSync(file2, { force: true });
3701
+ } catch {
3702
+ }
3703
+ }
3704
+ function tryAcquireSlot(runId, maxParallel) {
3705
+ for (let n = 0; n < Math.max(1, maxParallel); n++) {
3706
+ const file2 = path20.join(slotsDir(), `slot-${n}`);
3707
+ if (tryClaim(file2, runId)) return file2;
3708
+ }
3709
+ return void 0;
3710
+ }
3711
+ var releaseSlot = (file2, runId) => releaseIfOwner(file2, runId);
3712
+ var lockFile = (repoRoot2) => path20.join(locksDir(), `write-${crypto6.createHash("sha1").update(path20.resolve(repoRoot2)).digest("hex").slice(0, 16)}`);
3713
+ function acquireWriteLock(repoRoot2, runId) {
3714
+ const file2 = lockFile(repoRoot2);
3715
+ if (tryClaim(file2, runId)) return;
3716
+ let owner = "";
3717
+ try {
3718
+ owner = fs24.readFileSync(file2, "utf8").trim();
3719
+ } catch {
3720
+ }
3721
+ throw new UserError(`another --write run (${owner}) is active in this repo; use --isolate for parallel changes`, 3);
3722
+ }
3723
+ var releaseWriteLock = (repoRoot2, runId) => releaseIfOwner(lockFile(repoRoot2), runId);
3724
+
3725
+ // src/core/prompt.ts
3726
+ var RULES = {
3727
+ read: ["READ-ONLY task: do not create, modify or delete any file. Read, search and analyse only."],
3728
+ write: [
3729
+ "You may edit files that the task requires. Make the smallest correct change; no drive-by refactors.",
3730
+ "Run the relevant tests, type checks or linters if they are cheap and available, and report the result."
3731
+ ],
3732
+ isolate: [
3733
+ "You are in an isolated copy of the project. Edit files as the task requires; the primary agent reviews your diff before anything reaches the real tree.",
3734
+ "Make the smallest correct change; no drive-by refactors. Dependency folders (node_modules, virtualenvs, build output) may be missing unless linked.",
3735
+ "Run the relevant tests, type checks or linters if they are cheap and available, and report the result."
3736
+ ]
3737
+ };
3738
+ function buildPrompt(mode, task, followUp) {
3739
+ if (followUp) return `Follow-up from the primary agent. Same rules and answer format as before.
3740
+
3741
+ ${task}`;
3742
+ return `You are a worker agent. A primary coding agent delegated this bounded task to you. Your answer is draft work that the primary agent will verify, and it is the only thing the primary sees, so make it self-contained.
3743
+
3744
+ Rules:
3745
+ - ${RULES[mode].join("\n- ")}
3746
+ - Stay inside the task scope and the project directory.
3747
+ - Never commit, push, reset, checkout, restore, stash, clean, rebase or merge, and never discard or overwrite uncommitted work you did not create.
3748
+ - Never read or reveal secrets (.env files, keys, tokens).
3749
+ - Delete a file only when the task explicitly asks for it, and name every file you delete under FILES CHANGED. Whether anything else should be deleted or overwritten is the primary agent's decision: propose it under OPEN ISSUES and leave the file alone.
3750
+ - Do not ask questions. If something is ambiguous, choose the safest reasonable interpretation and state the assumption.
3751
+ - Be economical: open only what you need.
3752
+
3753
+ End with this answer format (plain text, no preamble):
3754
+ SUMMARY: 1-5 lines that directly answer the task.
3755
+ DETAILS: key findings with file:line references; only what the primary agent needs.
3756
+ FILES CHANGED: paths, or "none".
3757
+ VERIFICATION: commands you ran and their results, or "not run".
3758
+ OPEN ISSUES: risks, uncertainties, follow-ups, or "none".
3759
+
3760
+ Task:
3761
+ ${task}`;
3762
+ }
3763
+
3764
+ // src/core/refs.ts
3765
+ import fs25 from "node:fs";
3766
+ import path21 from "node:path";
3767
+ var FILE_EXTENSIONS = new Set(
3768
+ "ts tsx mts cts js jsx mjs cjs json jsonc json5 md mdx txt rst py pyi rb go rs java kt kts swift c h cc cpp cxx hpp hh cs fs php lua r jl sh bash zsh fish ps1 bat yml yaml toml ini cfg conf env html htm css scss sass less vue svelte astro sql graphql gql proto lock xml svg csv tsv gradle tf hcl dart ex exs erl hs ml scala clj vim el mk cmake dockerfile gitignore gitattributes editorconfig npmrc nvmrc".split(" ")
3769
+ );
3770
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", "target", "vendor", ".venv", "venv", "__pycache__", "coverage", ".turbo", ".cache"]);
3771
+ var MAX_INDEXED = 6e4;
3772
+ var EXTENSIONLESS = "Makefile|Dockerfile|Containerfile|Gemfile|Rakefile|Procfile|Justfile|Vagrantfile|BUILD|WORKSPACE";
3773
+ var REF = new RegExp(
3774
+ String.raw`(?<![\w/:.-])(\.{0,2}/?(?:[\w@.+-]+/)*(?:[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,7}|${EXTENSIONLESS})):(\d+)(?:[-–](\d+))?`,
3775
+ "g"
3776
+ );
3777
+ var IDENT = /`([A-Za-z_$][\w$]*)(?:\([^`]*\))?`/g;
3778
+ var CLAUSE = /[;|—]|\.\s|:\s/g;
3779
+ var MAX_BYTES = 5 * 1024 * 1024;
3780
+ var WINDOW = 5;
3781
+ var DEFINITION_LOOKBACK = 80;
3782
+ function looksLikeSymbol(m) {
3783
+ const name = m[1];
3784
+ return m[0].includes("(") || /[A-Z_$0-9]/.test(name) || name.length >= 5;
3785
+ }
3786
+ function extractRefs(answer) {
3787
+ const seen = /* @__PURE__ */ new Map();
3788
+ for (const line of answer.split("\n")) {
3789
+ const matches2 = [...line.matchAll(REF)];
3790
+ const seps = [...line.matchAll(CLAUSE)].map((s) => s.index ?? 0);
3791
+ const sepBetween = (a, b) => seps.some((s) => s >= a && s < b);
3792
+ const idents = [...line.matchAll(IDENT)].filter((i) => {
3793
+ if (!looksLikeSymbol(i)) return false;
3794
+ const pos = i.index ?? 0;
3795
+ const before = matches2.filter((m) => (m.index ?? 0) + m[0].length <= pos).at(-1);
3796
+ const after = matches2.find((m) => (m.index ?? 0) >= pos + i[0].length);
3797
+ return !(before && after && !sepBetween((before.index ?? 0) + before[0].length, after.index ?? 0));
3798
+ });
3799
+ matches2.forEach((m, k) => {
3800
+ const start = Number(m[2]);
3801
+ const end = m[3] ? Number(m[3]) : start;
3802
+ if (start < 1 || end < start) return;
3803
+ const at = m.index ?? 0;
3804
+ const prev = matches2[k - 1];
3805
+ const next = matches2[k + 1];
3806
+ const from = Math.max(at - 80, prev ? (prev.index ?? 0) + prev[0].length : 0, ...seps.filter((s) => s < at).map((s) => s + 1));
3807
+ const to = Math.min(at + m[0].length + 40, next?.index ?? Infinity, ...seps.filter((s) => s >= at + m[0].length));
3808
+ const near = new Set(idents.filter((i) => (i.index ?? 0) >= from && (i.index ?? 0) < to).map((i) => i[1]));
3809
+ const symbol = near.size === 1 ? [...near][0] : void 0;
3810
+ const key = `${m[1]}:${start}-${end}:${symbol ?? ""}`;
3811
+ if (!seen.has(key)) seen.set(key, { text: m[0], file: m[1], start, end, symbol });
3812
+ });
3813
+ }
3814
+ return [...seen.values()];
3815
+ }
3816
+ function verifyRefs(refs, dirs) {
3817
+ const roots = [...new Set(dirs.filter(Boolean).map((d) => path21.resolve(d)))];
3818
+ const invalid = [];
3819
+ const lineCache = /* @__PURE__ */ new Map();
3820
+ const load = (file2) => {
3821
+ if (!lineCache.has(file2)) {
3822
+ const size = fs25.statSync(file2).size;
3823
+ const lines = size > MAX_BYTES ? null : fs25.readFileSync(file2, "utf8").split("\n");
3824
+ if (lines && lines[lines.length - 1] === "") lines.pop();
3825
+ lineCache.set(file2, lines);
3826
+ }
3827
+ return lineCache.get(file2);
3828
+ };
3829
+ let index;
3830
+ const byName = () => index ??= indexFiles(roots);
3831
+ const candidates = (ref) => {
3832
+ const direct = resolve(ref.file, roots);
3833
+ if (direct) return [direct];
3834
+ if (path21.isAbsolute(ref.file)) return [];
3835
+ const wanted = ref.file.replace(/^(\.{1,2}\/)+/, "");
3836
+ return (byName().get(path21.basename(wanted)) ?? []).filter((f) => f.split(path21.sep).join("/").endsWith(`/${wanted}`) || path21.basename(f) === wanted).slice(0, 20);
3837
+ };
3838
+ const check = (file2, ref) => {
3839
+ const lines = load(file2);
3840
+ if (!lines) return void 0;
3841
+ if (ref.end > lines.length) return `file has ${lines.length} lines`;
3842
+ if (ref.symbol && !mentions(lines, ref) && !enclosedBy(lines, ref)) return `\`${ref.symbol}\` not near line ${ref.start}`;
3843
+ return void 0;
3844
+ };
3845
+ let total = 0;
3846
+ for (const ref of refs) {
3847
+ const files = candidates(ref);
3848
+ if (!files.length) {
3849
+ if (!ref.file.includes("/") && !isFileName(ref.file)) continue;
3850
+ total++;
3851
+ invalid.push({ ref: ref.text, reason: path21.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
3852
+ continue;
3853
+ }
3854
+ total++;
3855
+ const reasons = files.map((f) => check(f, ref));
3856
+ if (!reasons.some((r) => r === void 0)) invalid.push({ ref: ref.text, reason: files.length > 1 ? `${reasons[0]} (${files.length} files match)` : reasons[0] });
3857
+ }
3858
+ return { total, valid: total - invalid.length, invalid };
3859
+ }
3860
+ function mentions(lines, ref) {
3861
+ const near = lines.slice(Math.max(0, ref.start - 1 - WINDOW), ref.end + WINDOW).join("\n");
3862
+ return new RegExp(`(^|[^\\w$])${escape(ref.symbol)}([^\\w$]|$)`).test(near);
3863
+ }
3864
+ function enclosedBy(lines, ref) {
3865
+ const name = escape(ref.symbol);
3866
+ const def = new RegExp(
3867
+ `\\b(function|def|fn|func|class|interface|struct|impl|type|const|let|var)\\s+\\*?${name}\\b|^\\s*(export\\s+)?(default\\s+)?(async\\s+)?(static\\s+)?${name}\\s*[(:=]`
3868
+ );
3869
+ return lines.slice(Math.max(0, ref.start - 1 - DEFINITION_LOOKBACK), ref.start).some((l) => def.test(l));
3870
+ }
3871
+ var isFileName = (name) => {
3872
+ const base2 = path21.basename(name);
3873
+ return !base2.includes(".") || new RegExp(`^(${EXTENSIONLESS})$`).test(base2) || FILE_EXTENSIONS.has(base2.split(".").pop().toLowerCase());
3874
+ };
3875
+ function indexFiles(roots) {
3876
+ const index = /* @__PURE__ */ new Map();
3877
+ let count = 0;
3878
+ const walk = (dir) => {
3879
+ let entries;
3880
+ try {
3881
+ entries = fs25.readdirSync(dir, { withFileTypes: true });
3882
+ } catch {
3883
+ return;
3884
+ }
3885
+ for (const e of entries) {
3886
+ if (count >= MAX_INDEXED) return;
3887
+ if (e.isDirectory()) {
3888
+ if (!SKIP_DIRS2.has(e.name)) walk(path21.join(dir, e.name));
3889
+ } else if (e.isFile()) {
3890
+ count++;
3891
+ const list2 = index.get(e.name);
3892
+ if (list2) list2.push(path21.join(dir, e.name));
3893
+ else index.set(e.name, [path21.join(dir, e.name)]);
3662
3894
  }
3663
- if (meta.mode === "write" && !meta.reverted) out.push(` undo: pitroom revert ${meta.id}`);
3664
3895
  }
3665
- }
3666
- if (meta.verifyResult) {
3667
- const v = meta.verifyResult;
3668
- const notFound = !v.ok && v.code === 127 && /not found|no such file|is not recognized as an internal or external command/i.test(v.tail);
3669
- out.push(`\u2500\u2500 verify: \`${meta.verify}\` ${v.ok ? "\u2714 passed" : notFound ? "\u2718 could not run (exit 127: command not found)" : `\u2718 failed (exit ${v.code})`}`);
3670
- if (notFound) out.push(" the command is not on the PATH Pitroom runs with (an app such as an MCP client may start it without your shell's PATH): give its full path, or set PATH in the command");
3671
- if (!v.ok && v.tail) out.push(...v.tail.split("\n").map((l) => ` ${l}`));
3672
- }
3673
- if (isActive(meta.state)) {
3674
- out.push(`\u2500\u2500 still running: pitroom wait ${meta.id} (stop: pitroom stop ${meta.id})`);
3675
- } else if (meta.sessionId && !meta.applied && !meta.discarded && !meta.reviewOf) {
3676
- out.push(`\u2500\u2500 follow up: pitroom run --continue ${meta.id} "\u2026"`);
3677
- }
3678
- return out.join("\n");
3896
+ };
3897
+ for (const r of roots) walk(r);
3898
+ return index;
3679
3899
  }
3680
- function live(meta) {
3681
- const f = runFile(meta.id, "events.jsonl");
3682
- if (!fs24.existsSync(f)) return { steps: 0, toolCalls: 0 };
3683
- const p = getBackend((meta.ran ?? meta.worker).backend).parse(fs24.readFileSync(f, "utf8"));
3684
- return { steps: p.usage.steps, toolCalls: p.usage.toolCalls, last: p.lastActivity };
3900
+ function resolve(ref, roots) {
3901
+ const candidates = path21.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path21.join(r, ref));
3902
+ return candidates.find((c) => {
3903
+ try {
3904
+ return fs25.statSync(c).isFile() && inside(c, roots);
3905
+ } catch {
3906
+ return false;
3907
+ }
3908
+ });
3685
3909
  }
3686
- function progress(meta) {
3687
- if (meta.state === "queued") return `pitroom \u22EF queued \xB7 ${meta.mode} \xB7 waiting for a free worker slot \xB7 run ${meta.id}`;
3688
- const l = live(meta);
3689
- const last = l.last ? ` \xB7 last: ${l.last}` : "";
3690
- return `pitroom \u2026 running \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 ${l.steps} steps, ${l.toolCalls} tool calls${last} \xB7 run ${meta.id}`;
3910
+ function inside(file2, roots) {
3911
+ return roots.some((r) => {
3912
+ const rel = path21.relative(r, path21.resolve(file2));
3913
+ return rel !== "" && !rel.startsWith("..") && !path21.isAbsolute(rel);
3914
+ });
3691
3915
  }
3916
+ var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3692
3917
 
3693
3918
  // src/core/run.ts
3694
- var VERSION2 = true ? "0.19.0" : "0.0.0-dev";
3919
+ var VERSION2 = true ? "0.20.0" : "0.0.0-dev";
3695
3920
  function planWork(o) {
3696
3921
  if (!o.plan) return { task: o.task.trim(), tier: o.tier };
3697
3922
  if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
@@ -3723,7 +3948,7 @@ function prepareRun(o) {
3723
3948
  parent = readMeta(o.continueFrom);
3724
3949
  if (o.worker || o.tier || o.effort) throw new UserError("a follow-up runs on the same worker as its parent; drop --worker/--tier/--effort");
3725
3950
  if (!parent.sessionId) throw new UserError(`run ${parent.id} has no worker session to continue`, 3);
3726
- if (parent.mode === "isolate" && (!parent.worktree || !fs25.existsSync(parent.worktree))) {
3951
+ if (parent.mode === "isolate" && (!parent.worktree || !fs26.existsSync(parent.worktree))) {
3727
3952
  throw new UserError(`the isolated copy of run ${parent.id} is gone (applied or discarded)`, 3);
3728
3953
  }
3729
3954
  const ran = parent.ran ?? parent.worker;
@@ -3742,18 +3967,18 @@ function prepareRun(o) {
3742
3967
  throw new UserError(`the ${backend.name} worker cannot attach files; put the content in the task`);
3743
3968
  }
3744
3969
  const mode = parent?.mode ?? o.mode;
3745
- const dir = canonical(path20.resolve(parent?.dir ?? o.dir));
3746
- if (!fs25.existsSync(dir) || !fs25.statSync(dir).isDirectory()) throw new UserError(`not a directory: ${dir}`);
3970
+ const dir = canonical(path22.resolve(parent?.dir ?? o.dir));
3971
+ if (!fs26.existsSync(dir) || !fs26.statSync(dir).isDirectory()) throw new UserError(`not a directory: ${dir}`);
3747
3972
  const root = repoRoot(dir);
3748
3973
  if (mode === "isolate" && !root) throw new UserError("--isolate needs a git repository", 3);
3749
3974
  if (mode === "write" && !root && !o.allowNonGit) {
3750
3975
  throw new UserError("--write outside a git repo cannot be tracked or reverted; pass --allow-non-git to accept that", 3);
3751
3976
  }
3752
- const files = o.files.map((f) => path20.resolve(f));
3753
- for (const f of files) if (!fs25.existsSync(f)) throw new UserError(`file not found: ${f}`);
3977
+ const files = o.files.map((f) => path22.resolve(f));
3978
+ for (const f of files) if (!fs26.existsSync(f)) throw new UserError(`file not found: ${f}`);
3754
3979
  const readIn2 = READ_IN.includes(effective().readIn.value) ? effective().readIn.value : "auto";
3755
3980
  const snap = mode === "read" && !o.review && !o.inPlace && !parent ? wantSnapshot(readIn2, root, dir) : void 0;
3756
- if (parent?.snapshot?.dir && !fs25.existsSync(parent.snapshot.dir)) {
3981
+ if (parent?.snapshot?.dir && !fs26.existsSync(parent.snapshot.dir)) {
3757
3982
  throw new UserError(`the read snapshot of run ${parent.id} is gone (cleaned up); ask the question again as a new run`, 3);
3758
3983
  }
3759
3984
  if (!parent && !snap && !process.env.PITROOM_NO_SECRET_WARNING) {
@@ -3800,16 +4025,16 @@ function prepareRun(o) {
3800
4025
  try {
3801
4026
  acquireWriteLock(root, meta.id);
3802
4027
  } catch (e) {
3803
- fs25.rmSync(runDir(meta.id), { recursive: true, force: true });
4028
+ fs26.rmSync(runDir(meta.id), { recursive: true, force: true });
3804
4029
  throw e;
3805
4030
  }
3806
4031
  }
3807
- fs25.writeFileSync(runFile(meta.id, "task.md"), `${meta.task}
4032
+ fs26.writeFileSync(runFile(meta.id, "task.md"), `${meta.task}
3808
4033
  `);
3809
- if (work.brief) fs25.writeFileSync(runFile(meta.id, "brief.md"), work.brief);
4034
+ if (work.brief) fs26.writeFileSync(runFile(meta.id, "brief.md"), work.brief);
3810
4035
  return meta;
3811
4036
  }
3812
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4037
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
3813
4038
  async function waitForSlot(meta) {
3814
4039
  let stopped = false;
3815
4040
  const stop = () => {
@@ -3823,7 +4048,7 @@ async function waitForSlot(meta) {
3823
4048
  const slot = tryAcquireSlot(meta.id, max);
3824
4049
  if (slot) return slot;
3825
4050
  if (stopped) return void 0;
3826
- await sleep(1e3);
4051
+ await sleep2(1e3);
3827
4052
  if (stopped) return void 0;
3828
4053
  }
3829
4054
  } finally {
@@ -3834,7 +4059,9 @@ async function waitForSlot(meta) {
3834
4059
  function startInBackground(meta) {
3835
4060
  const script = process.argv[1];
3836
4061
  if (!script) throw new Error("cannot locate the pitroom executable");
3837
- const child = spawn2(process.execPath, [script, "__exec", meta.id], {
4062
+ meta.background = true;
4063
+ writeMeta(meta);
4064
+ const child = spawn3(process.execPath, [script, "__exec", meta.id], {
3838
4065
  detached: true,
3839
4066
  stdio: "ignore",
3840
4067
  cwd: meta.dir,
@@ -3886,7 +4113,7 @@ async function execute(meta) {
3886
4113
  (meta.attempts ??= []).push({ target: describeTarget(target), error: why.message });
3887
4114
  for (const f of ["events.jsonl", "stderr.log"]) {
3888
4115
  const from = runFile(meta.id, f);
3889
- if (fs25.existsSync(from)) fs25.renameSync(from, runFile(meta.id, f.replace(".", `.attempt-${i + 1}.`)));
4116
+ if (fs26.existsSync(from)) fs26.renameSync(from, runFile(meta.id, f.replace(".", `.attempt-${i + 1}.`)));
3890
4117
  }
3891
4118
  writeMeta(meta);
3892
4119
  }
@@ -3950,8 +4177,8 @@ function prepareTree(meta) {
3950
4177
  const s = readSnapshot(root);
3951
4178
  meta.snapshot.dir = s.dir;
3952
4179
  meta.snapshot.tree = s.tree;
3953
- meta.cwd = path20.join(s.dir, path20.relative(root, meta.dir));
3954
- if (!fs25.existsSync(meta.cwd)) meta.cwd = s.dir;
4180
+ meta.cwd = path22.join(s.dir, path22.relative(root, meta.dir));
4181
+ if (!fs26.existsSync(meta.cwd)) meta.cwd = s.dir;
3955
4182
  if (shown) meta.warnings.push(`read in a clean snapshot of your project: the secret-looking files (${shown}) and git-ignored files are not in it; --in-place reads the directory itself`);
3956
4183
  } catch (e) {
3957
4184
  meta.snapshot = void 0;
@@ -3961,9 +4188,9 @@ function prepareTree(meta) {
3961
4188
  if (root && meta.mode === "write") meta.baseTree = snapshotTree(root);
3962
4189
  if (root && meta.mode === "isolate" && !meta.worktree) {
3963
4190
  meta.baseTree = snapshotTree(root);
3964
- meta.worktree = path20.join(worktreesDir(), meta.id);
4191
+ meta.worktree = path22.join(worktreesDir(), meta.id);
3965
4192
  createIsolatedCopy(root, meta.baseTree, meta.worktree);
3966
- meta.cwd = path20.join(meta.worktree, path20.relative(root, meta.dir));
4193
+ meta.cwd = path22.join(meta.worktree, path22.relative(root, meta.dir));
3967
4194
  const linked = linkIntoWorktree(root, meta.worktree, meta.link);
3968
4195
  if (linked.length) meta.warnings.push(`linked into the isolated copy (shared with your tree): ${linked.join(", ")}`);
3969
4196
  }
@@ -3986,7 +4213,7 @@ function attempt(meta, backend, target) {
3986
4213
  timeoutSec: meta.timeoutSec
3987
4214
  });
3988
4215
  }
3989
- var read2 = (f) => fs25.existsSync(f) ? fs25.readFileSync(f, "utf8") : "";
4216
+ var read2 = (f) => fs26.existsSync(f) ? fs26.readFileSync(f, "utf8") : "";
3990
4217
  function retryableFailure(meta, backend, res) {
3991
4218
  const run2 = backend.parse(read2(runFile(meta.id, "events.jsonl")));
3992
4219
  if (run2.usage.steps > 0) return void 0;
@@ -4006,16 +4233,19 @@ function finalize(meta, res) {
4006
4233
  if (meta.snapshot?.dir && meta.repoRoot) run2.finalText = backToProject(run2.finalText, meta.snapshot.dir, meta.repoRoot);
4007
4234
  meta.sessionId = run2.sessionId ?? meta.sessionId;
4008
4235
  meta.usage = run2.usage;
4009
- fs25.writeFileSync(runFile(meta.id, "summary.md"), `${run2.finalText}
4236
+ fs26.writeFileSync(runFile(meta.id, "summary.md"), `${run2.finalText}
4010
4237
  `);
4011
4238
  if (meta.plan && !meta.reviewOf) meta.taskStatus = parseStatus(run2.finalText);
4012
4239
  if (meta.reviewOf) {
4013
4240
  meta.verdict = parseVerdict(run2.finalText);
4014
- if (meta.packageFile) fs25.rmSync(meta.packageFile, { force: true });
4241
+ if (meta.packageFile) fs26.rmSync(meta.packageFile, { force: true });
4015
4242
  }
4016
4243
  if (!res.timedOut && !res.stopped && !meta.error) {
4017
4244
  const f = backend.failure(run2, read2(runFile(meta.id, "stderr.log")), res.code);
4018
- if (f) meta.error = HINTS[f.kind] ? `${f.message} (${HINTS[f.kind]})` : f.message;
4245
+ if (f) {
4246
+ meta.error = HINTS[f.kind] ? `${f.message} (${HINTS[f.kind]})` : f.message;
4247
+ meta.failureKind = f.kind;
4248
+ }
4019
4249
  }
4020
4250
  try {
4021
4251
  captureChanges(meta);
@@ -4049,6 +4279,7 @@ function finalize(meta, res) {
4049
4279
  if (meta.mode === "write" && meta.repoRoot) releaseWriteLock(meta.repoRoot, meta.id);
4050
4280
  record(meta);
4051
4281
  if (meta.auditOf) settleAudit(meta);
4282
+ notifyEnded(meta);
4052
4283
  return meta;
4053
4284
  }
4054
4285
  function captureChanges(meta) {
@@ -4059,13 +4290,13 @@ function captureChanges(meta) {
4059
4290
  const d = diffTrees(target, meta.baseTree, meta.afterTree);
4060
4291
  meta.changes = d.changes;
4061
4292
  meta.stats = d.stats;
4062
- fs25.writeFileSync(runFile(meta.id, "changes.patch"), d.patch);
4293
+ fs26.writeFileSync(runFile(meta.id, "changes.patch"), d.patch);
4063
4294
  }
4064
4295
  function verifyPath() {
4065
- const extra = [path20.dirname(process.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
4066
- const parts = (process.env.PATH ?? "").split(path20.delimiter).filter(Boolean);
4067
- for (const d of process.platform === "win32" ? [path20.dirname(process.execPath)] : extra) if (!parts.includes(d)) parts.push(d);
4068
- return parts.join(path20.delimiter);
4296
+ const extra = [path22.dirname(process.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
4297
+ const parts = (process.env.PATH ?? "").split(path22.delimiter).filter(Boolean);
4298
+ for (const d of process.platform === "win32" ? [path22.dirname(process.execPath)] : extra) if (!parts.includes(d)) parts.push(d);
4299
+ return parts.join(path22.delimiter);
4069
4300
  }
4070
4301
  function runVerify(meta) {
4071
4302
  const r = spawnSync6(meta.verify, {
@@ -4078,7 +4309,7 @@ function runVerify(meta) {
4078
4309
  env: { ...process.env, PATH: verifyPath(), PWD: meta.cwd, PITROOM_ACTIVE: "1" }
4079
4310
  });
4080
4311
  const output = `${r.stdout ?? ""}${r.stderr ?? ""}`;
4081
- fs25.writeFileSync(runFile(meta.id, "verify.log"), output);
4312
+ fs26.writeFileSync(runFile(meta.id, "verify.log"), output);
4082
4313
  const notFound = process.platform === "win32" && r.status === 1 && /is not recognized as an internal or external command/.test(output);
4083
4314
  return { ok: r.status === 0, code: notFound ? 127 : r.status, tail: output.trimEnd().split("\n").slice(-25).join("\n") };
4084
4315
  }
@@ -4133,126 +4364,15 @@ function discardRun(meta) {
4133
4364
  return `discarded the isolated copy of ${meta.id}; the patch stays in ${runFile(meta.id, "changes.patch")}`;
4134
4365
  }
4135
4366
  function cleanupWorktree(meta) {
4136
- if (meta.worktree && fs25.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
4367
+ if (meta.worktree && fs26.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
4137
4368
  }
4138
4369
 
4139
4370
  // src/cli/mcp-extras.ts
4140
- import fs26 from "node:fs";
4141
- import path21 from "node:path";
4142
-
4143
- // src/cli/mcp-support.ts
4144
- import { spawn as spawn3 } from "node:child_process";
4145
-
4146
- // src/core/group.ts
4147
- function groupIds(group) {
4148
- return listRunIds().filter((id) => {
4149
- try {
4150
- return readMeta(id).group === group;
4151
- } catch {
4152
- return false;
4153
- }
4154
- });
4155
- }
4156
- var oneLine5 = (s, max) => {
4157
- const t = s.replace(/\s+/g, " ").trim();
4158
- return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
4159
- };
4160
- function headline(meta, max = 160) {
4161
- const text = readSummary(meta);
4162
- const line = text.split("\n").find((l) => /^SUMMARY:/i.test(l.trim())) ?? text.split("\n").find((l) => l.trim()) ?? "";
4163
- return oneLine5(line.replace(/^SUMMARY:\s*/i, ""), max);
4164
- }
4165
- function table(metas) {
4166
- if (!metas.length) return "no runs";
4167
- const rows = metas.map((m) => {
4168
- const l = m.state === "running" ? live(m) : void 0;
4169
- const steps = l ? String(l.steps) : m.usage ? String(m.usage.steps) : "-";
4170
- const note = l?.last ? oneLine5(l.last, 48) : isActive(m.state) ? oneLine5(m.task, 48) : headline(m, 48) || oneLine5(m.error ?? m.task, 48);
4171
- return [m.id, m.state, m.mode, duration(m), steps, describeTarget(m.ran ?? m.worker), note];
4172
- });
4173
- const head = ["RUN", "STATE", "MODE", "TIME", "STEPS", "WORKER", "NOW / RESULT"];
4174
- const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
4175
- const fmt = (r, header = false) => r.map((c, i) => {
4176
- const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
4177
- return header ? dim(cell2) : i === 1 ? stateColour(r[1], cell2) : cell2;
4178
- }).join(" ");
4179
- return [fmt(head, true), ...rows.map((r) => fmt(r))].join("\n");
4180
- }
4181
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
4182
- async function waitMany(ids, opts) {
4183
- const deadline = Date.now() + opts.timeoutMs;
4184
- for (; ; ) {
4185
- const metas = ids.map((id) => freshMeta(id));
4186
- const done = metas.filter((m) => TERMINAL.includes(m.state)).length;
4187
- if (opts.any ? done > 0 : done === metas.length) return { metas, timedOut: false };
4188
- if (Date.now() >= deadline) return { metas, timedOut: true };
4189
- await sleep2(1500);
4190
- }
4191
- }
4192
- async function watch(select, opts) {
4193
- const seen = /* @__PURE__ */ new Map();
4194
- const line = (s) => opts.write(`${s}
4195
- `);
4196
- const emit = (e, m) => {
4197
- if (!opts.brief) return opts.write(`${JSON.stringify(e)}
4198
- `);
4199
- if (e.event === "started" && m) line(startedCard(m));
4200
- else if (e.event === "fallback" && m) line(`\u{1F3C1} Pitroom \u21BB ${m.id} fell back from ${String(e.failed)}: ${String(e.reason)}`);
4201
- else if (m && TERMINAL.includes(m.state)) line(endedCard(m));
4202
- else if (e.event === "all-done") line(`\u{1F3C1} Pitroom: ${String(e.runs)} run${e.runs === 1 ? "" : "s"} finished (${String(e.ok)} ok${e.failed ? `, ${String(e.failed)} not ok` : ""})`);
4203
- };
4204
- const deadline = opts.timeoutMs > 0 ? Date.now() + opts.timeoutMs : Infinity;
4205
- for (; ; ) {
4206
- const metas = select().map((id) => freshMeta(id));
4207
- if (opts.json || opts.brief) {
4208
- for (const m of metas) {
4209
- const prev = seen.get(m.id);
4210
- const l = m.state === "running" ? live(m) : { steps: m.usage?.steps ?? 0, toolCalls: m.usage?.toolCalls ?? 0 };
4211
- const attempts = m.attempts?.length ?? 0;
4212
- const base2 = { run: m.id, mode: m.mode, task: oneLine5(m.task, 60) };
4213
- if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base2 }, m);
4214
- else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base2 }, m);
4215
- if (attempts > (prev?.attempts ?? 0)) {
4216
- const a = m.attempts[attempts - 1];
4217
- emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine5(a.error, 120) }, m);
4218
- }
4219
- if (m.state === "running" && l.steps > (prev?.steps ?? 0)) {
4220
- emit({ event: "progress", run: m.id, steps: l.steps, tools: l.toolCalls, last: "last" in l && l.last ? oneLine5(String(l.last), 80) : void 0 });
4221
- }
4222
- if (TERMINAL.includes(m.state) && prev?.state !== m.state) {
4223
- emit({
4224
- event: m.state,
4225
- run: m.id,
4226
- time: duration(m),
4227
- worker: describeTarget(m.ran ?? m.worker),
4228
- summary: headline(m) || void 0,
4229
- error: m.error ? oneLine5(m.error, 160) : void 0,
4230
- refs: m.refs ? `${m.refs.valid}/${m.refs.total}` : void 0,
4231
- changes: m.changes?.length || void 0
4232
- }, m);
4233
- }
4234
- seen.set(m.id, { state: m.state, steps: l.steps, attempts });
4235
- }
4236
- } else {
4237
- opts.write(`\x1B[H\x1B[2J${table(metas)}
4238
-
4239
- ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watching (workers keep running)
4240
- `);
4241
- }
4242
- const allDone = metas.length > 0 && metas.every((m) => TERMINAL.includes(m.state));
4243
- if (allDone || metas.length === 0 && seen.size === 0) {
4244
- if (opts.json || opts.brief) {
4245
- const ok = metas.filter((m) => m.state === "done").length;
4246
- emit({ event: "all-done", runs: metas.length, ok, failed: metas.length - ok });
4247
- }
4248
- return { metas, timedOut: false };
4249
- }
4250
- if (Date.now() >= deadline) return { metas, timedOut: true };
4251
- await sleep2(opts.intervalMs);
4252
- }
4253
- }
4371
+ import fs27 from "node:fs";
4372
+ import path23 from "node:path";
4254
4373
 
4255
4374
  // src/cli/mcp-support.ts
4375
+ import { spawn as spawn4 } from "node:child_process";
4256
4376
  var MAX_OUTPUT = 12e4;
4257
4377
  var DEFAULT_WAIT = 50;
4258
4378
  var MAX_WAIT = 540;
@@ -4320,7 +4440,7 @@ function pit(args, timeoutMs = 60 * 6e4, signal) {
4320
4440
  return new Promise((resolve2) => {
4321
4441
  const script = process.argv[1];
4322
4442
  if (!script) return resolve2({ code: 1, out: "", err: "cannot locate the pitroom executable" });
4323
- const child = spawn3(process.execPath, [script, ...args], { env: process.env, stdio: ["ignore", "pipe", "pipe"] });
4443
+ const child = spawn4(process.execPath, [script, ...args], { env: process.env, stdio: ["ignore", "pipe", "pipe"] });
4324
4444
  let out = "";
4325
4445
  let err = "";
4326
4446
  child.stdout.on("data", (d) => out += d);
@@ -4379,6 +4499,39 @@ async function stopQuietly(args) {
4379
4499
  if (r.code !== 0) process.stderr.write(`pitroom mcp: ${args.join(" ")} failed: ${r.err || r.out}
4380
4500
  `);
4381
4501
  }
4502
+ var dashAsked;
4503
+ function ensureDash() {
4504
+ if (effective().mcpDash.value === false) return Promise.resolve(void 0);
4505
+ if (dashAsked && Date.now() - dashAsked.at < 6e4) return dashAsked.url;
4506
+ const url = pit(["dash", "--detach"], 3e4).then((r) => {
4507
+ const found = /^https?:\/\/127\.0\.0\.1:\d+\/?/m.exec(r.out)?.[0];
4508
+ if (!found) dashAsked = void 0;
4509
+ return found;
4510
+ });
4511
+ dashAsked = { at: Date.now(), url };
4512
+ return url;
4513
+ }
4514
+ function dashIfSlow(ids, delayMs = 3e3) {
4515
+ return new Promise((resolve2) => {
4516
+ const timer2 = setTimeout(() => {
4517
+ try {
4518
+ resolve2(ids().some((id) => isActive(freshMeta(id).state)) ? ensureDash() : void 0);
4519
+ } catch {
4520
+ resolve2(void 0);
4521
+ }
4522
+ }, delayMs);
4523
+ timer2.unref();
4524
+ });
4525
+ }
4526
+ function withLiveView(result, url) {
4527
+ if (!url || !result.text.includes("Not finished yet")) return result;
4528
+ return {
4529
+ ...result,
4530
+ text: `${result.text}
4531
+
4532
+ Live view of every run: ${url} (pitroom dash). If your app has a built-in browser pane (the Claude Code and Codex apps do), open it there; otherwise give the user the address.`
4533
+ };
4534
+ }
4382
4535
  async function startAndWait(start, seconds, ctx, task) {
4383
4536
  const started = await pit([...start, "--bg", "--json", ...task === void 0 ? [] : ["--", task]]);
4384
4537
  if (started.code !== 0) return asResult(started);
@@ -4389,7 +4542,9 @@ async function startAndWait(start, seconds, ctx, task) {
4389
4542
  } catch {
4390
4543
  return { text: `could not read the run id from: ${started.out.slice(0, 200)}`, isError: true };
4391
4544
  }
4392
- const result = await waitFor([id], seconds, ctx);
4545
+ const dash = cached3 ? Promise.resolve(void 0) : dashIfSlow(() => [id]);
4546
+ const waited = await waitFor([id], seconds, ctx);
4547
+ const result = withLiveView(waited, waited.text.includes("Not finished yet") ? await dash : void 0);
4393
4548
  if (cancelled(ctx) && !cached3) await stopQuietly(["stop", id]);
4394
4549
  if (cached3) result.text = `Cached answer: the same question on the same code as run ${id}; no worker ran (fresh: true asks one).
4395
4550
 
@@ -4405,7 +4560,9 @@ async function startCrew(flags, tasks, seconds, ctx) {
4405
4560
  } catch {
4406
4561
  return { text: `could not read the group from: ${started.out.slice(0, 200)}`, isError: true };
4407
4562
  }
4408
- const result = await waitFor([], seconds, ctx, group);
4563
+ const dash = dashIfSlow(() => groupIds(group));
4564
+ const waited = await waitFor([], seconds, ctx, group);
4565
+ const result = withLiveView(waited, waited.text.includes("Not finished yet") ? await dash : void 0);
4409
4566
  if (cancelled(ctx)) await stopQuietly(["stop", "-g", group]);
4410
4567
  return result;
4411
4568
  }
@@ -4497,7 +4654,7 @@ function skillPrompts() {
4497
4654
  const root = packageRoot();
4498
4655
  for (const name of skillNames(root)) {
4499
4656
  try {
4500
- const text = fs26.readFileSync(path21.join(root, "skills", name, "SKILL.md"), "utf8");
4657
+ const text = fs27.readFileSync(path23.join(root, "skills", name, "SKILL.md"), "utf8");
4501
4658
  const front = /^---\n([\s\S]*?)\n---\n/.exec(text);
4502
4659
  const body = (front ? text.slice(front[0].length) : text).trim();
4503
4660
  const description = /^description:\s*(.+)$/m.exec(front?.[1] ?? "")?.[1]?.trim() ?? `The ${name} skill.`;
@@ -4648,7 +4805,8 @@ var TOOLS2 = [
4648
4805
  const runs = strs(a, "runs");
4649
4806
  const group = str(a, "group");
4650
4807
  if (!runs.length && !group) throw new ToolError('give "runs" or "group"');
4651
- return waitFor(runs, waitSeconds(a), ctx, runs.length ? void 0 : group);
4808
+ const result = await waitFor(runs, waitSeconds(a), ctx, runs.length ? void 0 : group);
4809
+ return withLiveView(result, result.text.includes("Not finished yet") ? await ensureDash() : void 0);
4652
4810
  }
4653
4811
  },
4654
4812
  {
@@ -4861,7 +5019,7 @@ var PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
4861
5019
  var INSTRUCTIONS = [
4862
5020
  "Pitroom hands bounded work to cheaper worker agents and returns a verified answer, the exact diff and a cost receipt. You decide, verify and answer.",
4863
5021
  'Use pitroom_run with mode "read" for research and locating code, mode "isolate" for code changes (the worker edits a copy; check it with pitroom_review, then pitroom_apply or pitroom_discard); pitroom_run with "tasks" for independent tasks in parallel; pitroom_info (topic history) finds an earlier answer before you ask again.',
4864
- 'A run can take minutes: pitroom_run waits up to waitSeconds, then returns the run id as "still running"; call pitroom_wait with it.',
5022
+ `A run can take minutes: pitroom_run waits up to waitSeconds, then returns the run id as "still running"; call pitroom_wait with it. A result that is still running also carries a "Live view" address (the dashboard, started for you): open it in your app's built-in browser pane if it has one, else give it to the user.`,
4865
5023
  "Pitroom's skills (using-pitroom, pitroom-research, pitroom-implement, pitroom-review, pitroom-crew, pitroom-debugging, pitroom-tdd and more) say when to delegate and how to check what comes back: if you have them, use the matching one alongside these tools; if not, the prompts of the same names hold them.",
4866
5024
  "It is optional: for a small task you can do yourself, skip it."
4867
5025
  ].join(" ");
@@ -4983,10 +5141,10 @@ var parseError = () => ({ jsonrpc: "2.0", id: null, error: { code: -32700, messa
4983
5141
  async function serveMcp() {
4984
5142
  process.stdout.on("error", () => {
4985
5143
  });
4986
- const send = (msg) => void process.stdout.write(`${JSON.stringify(msg)}
5144
+ const send2 = (msg) => void process.stdout.write(`${JSON.stringify(msg)}
4987
5145
  `);
4988
5146
  const session = newSession();
4989
- session.push = send;
5147
+ session.push = send2;
4990
5148
  const rl = readline.createInterface({ input: process.stdin });
4991
5149
  const pending = /* @__PURE__ */ new Set();
4992
5150
  const handleLine = async (line) => {
@@ -4994,11 +5152,11 @@ async function serveMcp() {
4994
5152
  try {
4995
5153
  parsed = JSON.parse(line);
4996
5154
  } catch {
4997
- send(parseError());
5155
+ send2(parseError());
4998
5156
  return;
4999
5157
  }
5000
- const reply2 = await handlePayload(session, parsed, send);
5001
- if (reply2) send(reply2);
5158
+ const reply2 = await handlePayload(session, parsed, send2);
5159
+ if (reply2) send2(reply2);
5002
5160
  };
5003
5161
  rl.on("line", (line) => {
5004
5162
  if (!line.trim()) return;
@@ -5013,23 +5171,23 @@ async function serveMcp() {
5013
5171
  }
5014
5172
 
5015
5173
  // src/cli/mcp-http.ts
5016
- import crypto7 from "node:crypto";
5017
- import fs28 from "node:fs";
5174
+ import crypto8 from "node:crypto";
5175
+ import fs29 from "node:fs";
5018
5176
  import http2 from "node:http";
5019
- import path23 from "node:path";
5177
+ import path25 from "node:path";
5020
5178
 
5021
5179
  // src/core/dash.ts
5022
- import { spawn as spawn4 } from "node:child_process";
5023
- import crypto6 from "node:crypto";
5024
- import fs27 from "node:fs";
5180
+ import { spawn as spawn5 } from "node:child_process";
5181
+ import crypto7 from "node:crypto";
5182
+ import fs28 from "node:fs";
5025
5183
  import http from "node:http";
5026
- import path22 from "node:path";
5184
+ import path24 from "node:path";
5027
5185
  import { fileURLToPath as fileURLToPath3 } from "node:url";
5028
5186
 
5029
5187
  // src/core/file-diff.ts
5030
5188
  var PREVIEW_LINES = 300;
5031
5189
  function gitPath(value, prefix = true) {
5032
- let path31 = value;
5190
+ let path33 = value;
5033
5191
  if (value.startsWith('"') && value.endsWith('"')) {
5034
5192
  const bytes = [];
5035
5193
  const escapes = { t: " ", n: "\n", r: "\r", b: "\b", f: "\f", v: "\v" };
@@ -5037,9 +5195,9 @@ function gitPath(value, prefix = true) {
5037
5195
  if (match[1] && /^[0-7]+$/.test(match[1])) bytes.push(parseInt(match[1], 8));
5038
5196
  else bytes.push(...new TextEncoder().encode(match[2] ?? escapes[match[1]] ?? match[1]));
5039
5197
  }
5040
- path31 = new TextDecoder().decode(new Uint8Array(bytes));
5198
+ path33 = new TextDecoder().decode(new Uint8Array(bytes));
5041
5199
  }
5042
- return prefix ? path31.replace(/^[ab]\//, "") : path31;
5200
+ return prefix ? path33.replace(/^[ab]\//, "") : path33;
5043
5201
  }
5044
5202
  function headerPath(header) {
5045
5203
  const quoted = header.match(/^diff --git ("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/);
@@ -5283,18 +5441,18 @@ var HEADERS = {
5283
5441
  "x-content-type-options": "nosniff",
5284
5442
  "referrer-policy": "no-referrer",
5285
5443
  // Own files only, plus the hash of the one inline theme script; styles may be inline (the components position popups with them).
5286
- "content-security-policy": `default-src 'none'; script-src 'self' 'sha256-${crypto6.createHash("sha256").update(THEME_SCRIPT).digest("base64")}'; style-src 'self' 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'`
5444
+ "content-security-policy": `default-src 'none'; script-src 'self' 'sha256-${crypto7.createHash("sha256").update(THEME_SCRIPT).digest("base64")}'; style-src 'self' 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'`
5287
5445
  };
5288
5446
  var ASSETS = { "/assets/app.js": "text/javascript; charset=utf-8", "/assets/app.css": "text/css; charset=utf-8" };
5289
5447
  var assetsDir = () => fileURLToPath3(new URL("./ui/", import.meta.url));
5290
5448
  var assetCache = /* @__PURE__ */ new Map();
5291
5449
  function readAsset(route) {
5292
5450
  try {
5293
- const file2 = path22.join(assetsDir(), path22.basename(route));
5294
- const { mtimeMs } = fs27.statSync(file2);
5451
+ const file2 = path24.join(assetsDir(), path24.basename(route));
5452
+ const { mtimeMs } = fs28.statSync(file2);
5295
5453
  const hit = assetCache.get(route);
5296
5454
  if (hit && hit.mtimeMs === mtimeMs) return hit.data;
5297
- const data = fs27.readFileSync(file2);
5455
+ const data = fs28.readFileSync(file2);
5298
5456
  assetCache.set(route, { mtimeMs, data });
5299
5457
  return data;
5300
5458
  } catch {
@@ -5302,16 +5460,16 @@ function readAsset(route) {
5302
5460
  }
5303
5461
  }
5304
5462
  function handler(touch2, token) {
5305
- const send = (res, code, type, body) => {
5463
+ const send2 = (res, code, type, body) => {
5306
5464
  res.writeHead(code, { ...HEADERS, "content-type": type });
5307
5465
  res.end(body);
5308
5466
  };
5309
- const json = (res, code, value) => send(res, code, "application/json; charset=utf-8", JSON.stringify(value));
5467
+ const json = (res, code, value) => send2(res, code, "application/json; charset=utf-8", JSON.stringify(value));
5310
5468
  const tokenBuf = Buffer.from(token);
5311
5469
  const act = (req, res, pathname) => {
5312
5470
  req.resume();
5313
5471
  const given = Buffer.from(String(req.headers["x-pitroom-token"] ?? ""));
5314
- if (given.length !== tokenBuf.length || !crypto6.timingSafeEqual(given, tokenBuf)) return json(res, 403, { error: "forbidden" });
5472
+ if (given.length !== tokenBuf.length || !crypto7.timingSafeEqual(given, tokenBuf)) return json(res, 403, { error: "forbidden" });
5315
5473
  if (req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: "forbidden origin" });
5316
5474
  const m = /^\/api\/run\/([^/]+)\/(stop|discard)$/.exec(pathname);
5317
5475
  if (!m) return json(res, 404, { error: "not found" });
@@ -5338,7 +5496,7 @@ function handler(touch2, token) {
5338
5496
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
5339
5497
  if (req.method === "POST") return act(req, res, url.pathname);
5340
5498
  if (req.method !== "GET" && req.method !== "HEAD") return json(res, 405, { error: "read-only" });
5341
- if (url.pathname === "/") return send(res, 200, "text/html; charset=utf-8", readAsset("/assets/app.js") ? page(token) : MISSING);
5499
+ if (url.pathname === "/") return send2(res, 200, "text/html; charset=utf-8", readAsset("/assets/app.js") ? page(token) : MISSING);
5342
5500
  const type = ASSETS[url.pathname];
5343
5501
  if (type) {
5344
5502
  const data = readAsset(url.pathname);
@@ -5379,7 +5537,7 @@ function handler(touch2, token) {
5379
5537
  }
5380
5538
  function startDash(opts) {
5381
5539
  let lastRequest = Date.now();
5382
- const server = http.createServer(handler(() => lastRequest = Date.now(), crypto6.randomBytes(24).toString("hex")));
5540
+ const server = http.createServer(handler(() => lastRequest = Date.now(), crypto7.randomBytes(24).toString("hex")));
5383
5541
  return new Promise((resolve2, reject) => {
5384
5542
  server.once("error", reject);
5385
5543
  server.listen(opts.port, "127.0.0.1", () => {
@@ -5400,7 +5558,7 @@ function startDash(opts) {
5400
5558
  });
5401
5559
  });
5402
5560
  }
5403
- var registryFile = () => path22.join(home(), "dash.json");
5561
+ var registryFile = () => path24.join(home(), "dash.json");
5404
5562
  async function ping(port) {
5405
5563
  try {
5406
5564
  const r = await fetch(`http://127.0.0.1:${port}/api/state?limit=1`, { signal: AbortSignal.timeout(1500) });
@@ -5412,12 +5570,12 @@ async function ping(port) {
5412
5570
  async function runningDash() {
5413
5571
  let r;
5414
5572
  try {
5415
- r = JSON.parse(fs27.readFileSync(registryFile(), "utf8"));
5573
+ r = JSON.parse(fs28.readFileSync(registryFile(), "utf8"));
5416
5574
  } catch {
5417
5575
  return void 0;
5418
5576
  }
5419
5577
  if (!isAlive(r.pid)) {
5420
- fs27.rmSync(registryFile(), { force: true });
5578
+ fs28.rmSync(registryFile(), { force: true });
5421
5579
  return void 0;
5422
5580
  }
5423
5581
  return await ping(r.port) ? { ...r, url: `http://127.0.0.1:${r.port}/` } : void 0;
@@ -5425,7 +5583,7 @@ async function runningDash() {
5425
5583
  function openBrowser(url) {
5426
5584
  const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
5427
5585
  try {
5428
- spawn4(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
5586
+ spawn5(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
5429
5587
  } catch {
5430
5588
  }
5431
5589
  }
@@ -5434,7 +5592,7 @@ async function dashCommand(o) {
5434
5592
  if (o.stop) {
5435
5593
  if (!existing) throw new UserError("pitroom dash is not running");
5436
5594
  process.kill(existing.pid, "SIGTERM");
5437
- fs27.rmSync(registryFile(), { force: true });
5595
+ fs28.rmSync(registryFile(), { force: true });
5438
5596
  o.log(`stopped pitroom dash (${existing.url})`);
5439
5597
  return 0;
5440
5598
  }
@@ -5444,7 +5602,7 @@ async function dashCommand(o) {
5444
5602
  return 0;
5445
5603
  }
5446
5604
  if (o.detach) {
5447
- const child = spawn4(process.execPath, [process.argv[1], "dash", "--serve", ...o.port !== void 0 ? ["--port", String(o.port)] : [], "--idle", "1h"], {
5605
+ const child = spawn5(process.execPath, [process.argv[1], "dash", "--serve", ...o.port !== void 0 ? ["--port", String(o.port)] : [], "--idle", "1h"], {
5448
5606
  detached: true,
5449
5607
  stdio: "ignore",
5450
5608
  env: process.env
@@ -5471,9 +5629,9 @@ async function dashCommand(o) {
5471
5629
  if (explicit || e.code !== "EADDRINUSE") throw new UserError(`pitroom dash: ${e.message}`);
5472
5630
  dash = await startDash({ port: 0, idleMs: o.idleMs });
5473
5631
  }
5474
- fs27.mkdirSync(home(), { recursive: true });
5632
+ fs28.mkdirSync(home(), { recursive: true });
5475
5633
  const tmp = `${registryFile()}.${process.pid}.tmp`;
5476
- fs27.writeFileSync(tmp, JSON.stringify({ pid: process.pid, port: dash.port }));
5634
+ fs28.writeFileSync(tmp, JSON.stringify({ pid: process.pid, port: dash.port }));
5477
5635
  renameOver(tmp, registryFile());
5478
5636
  const stop = () => void dash.close();
5479
5637
  process.once("SIGINT", stop);
@@ -5483,7 +5641,7 @@ async function dashCommand(o) {
5483
5641
  if (o.open) openBrowser(dash.url);
5484
5642
  await dash.closed;
5485
5643
  try {
5486
- if (JSON.parse(fs27.readFileSync(registryFile(), "utf8")).pid === process.pid) fs27.rmSync(registryFile(), { force: true });
5644
+ if (JSON.parse(fs28.readFileSync(registryFile(), "utf8")).pid === process.pid) fs28.rmSync(registryFile(), { force: true });
5487
5645
  } catch {
5488
5646
  }
5489
5647
  return 0;
@@ -5495,10 +5653,10 @@ var MAX_BODY = 4e6;
5495
5653
  var MAX_SESSIONS = 64;
5496
5654
  var IDLE_MS = 60 * 6e4;
5497
5655
  var PATH = "/mcp";
5498
- var tokenFile = () => path23.join(home(), "mcp-token");
5656
+ var tokenFile = () => path25.join(home(), "mcp-token");
5499
5657
  var readToken = (file2) => {
5500
5658
  try {
5501
- const kept = fs28.readFileSync(file2, "utf8").trim();
5659
+ const kept = fs29.readFileSync(file2, "utf8").trim();
5502
5660
  return kept.length >= 16 ? kept : void 0;
5503
5661
  } catch {
5504
5662
  return void 0;
@@ -5513,10 +5671,10 @@ function mcpToken() {
5513
5671
  const file2 = tokenFile();
5514
5672
  let token = readToken(file2);
5515
5673
  if (!token) {
5516
- fs28.mkdirSync(path23.dirname(file2), { recursive: true });
5517
- token = crypto7.randomBytes(32).toString("hex");
5674
+ fs29.mkdirSync(path25.dirname(file2), { recursive: true });
5675
+ token = crypto8.randomBytes(32).toString("hex");
5518
5676
  try {
5519
- fs28.writeFileSync(file2, `${token}
5677
+ fs29.writeFileSync(file2, `${token}
5520
5678
  `, { mode: 384, flag: "wx" });
5521
5679
  } catch (e) {
5522
5680
  if (e.code !== "EEXIST") throw e;
@@ -5525,16 +5683,16 @@ function mcpToken() {
5525
5683
  }
5526
5684
  }
5527
5685
  try {
5528
- fs28.chmodSync(file2, 384);
5686
+ fs29.chmodSync(file2, 384);
5529
5687
  } catch {
5530
5688
  }
5531
5689
  return { token, from: file2 };
5532
5690
  }
5533
5691
  var LOCAL_ORIGIN = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
5534
5692
  var sameSecret = (given, token) => {
5535
- const a = crypto7.createHash("sha256").update(given).digest();
5536
- const b = crypto7.createHash("sha256").update(token).digest();
5537
- return crypto7.timingSafeEqual(a, b);
5693
+ const a = crypto8.createHash("sha256").update(given).digest();
5694
+ const b = crypto8.createHash("sha256").update(token).digest();
5695
+ return crypto8.timingSafeEqual(a, b);
5538
5696
  };
5539
5697
  var idleSince = (k) => Math.max(k.lastSeen, k.closeStream ? k.streamSeen ?? 0 : 0);
5540
5698
  function drop(sessions, id) {
@@ -5638,7 +5796,7 @@ data: ${JSON.stringify(msg)}
5638
5796
  if (single?.method === "initialize") {
5639
5797
  if (sid) drop(sessions, sid);
5640
5798
  if (sessions.size >= MAX_SESSIONS && !evictIdle(sessions)) return reply(res, 503, failure5(-32e3, `${MAX_SESSIONS} sessions are busy with requests: try again when one ends`));
5641
- const id = crypto7.randomUUID();
5799
+ const id = crypto8.randomUUID();
5642
5800
  kept = { session: newSession(), lastSeen: Date.now() };
5643
5801
  sessions.set(id, kept);
5644
5802
  headers["Mcp-Session-Id"] = id;
@@ -5707,16 +5865,16 @@ async function serveMcpHttp(opts) {
5707
5865
 
5708
5866
  // src/cli/commands.ts
5709
5867
  import { spawnSync as spawnSync8 } from "node:child_process";
5710
- import fs34 from "node:fs";
5711
- import path29 from "node:path";
5868
+ import fs35 from "node:fs";
5869
+ import path31 from "node:path";
5712
5870
  import { fileURLToPath as fileURLToPath4 } from "node:url";
5713
5871
 
5714
5872
  // src/core/init.ts
5715
- import fs29 from "node:fs";
5716
- import path24 from "node:path";
5873
+ import fs30 from "node:fs";
5874
+ import path26 from "node:path";
5717
5875
  var FREE = /-free$/;
5718
5876
  var TIER_ORDER = [["cheap", "opencode"], ["standard", "codex"], ["capable", "claude"]];
5719
- var isFound = (binary5) => path24.isAbsolute(binary5) && fs29.existsSync(binary5);
5877
+ var isFound = (binary5) => path26.isAbsolute(binary5) && fs30.existsSync(binary5);
5720
5878
  function planInit(opts = {}) {
5721
5879
  const file2 = configPath();
5722
5880
  const workers = backendIds().map((id) => {
@@ -5762,15 +5920,15 @@ function planInit(opts = {}) {
5762
5920
  if (Object.keys(tiers).length > 1) config.tiers = tiers;
5763
5921
  if (!workers.some((w) => w.found)) blocked = "no worker CLI found: install OpenCode (https://opencode.ai), Codex CLI, Claude Code or Gemini CLI first";
5764
5922
  else if (!blocked && !Object.keys(config).length) notes.push("nothing to propose: the default model and the single worker need no config");
5765
- return { path: file2, exists: fs29.existsSync(file2), workers, opencode: opencode2, config, blocked, notes };
5923
+ return { path: file2, exists: fs30.existsSync(file2), workers, opencode: opencode2, config, blocked, notes };
5766
5924
  }
5767
5925
  function writeInit(plan, force) {
5768
5926
  if (plan.blocked) throw new UserError(plan.blocked);
5769
5927
  if (!Object.keys(plan.config).length) throw new UserError("nothing to write: the proposal is empty");
5770
- if (plan.exists && !force) throw new UserError(`${plan.path} already exists: pass --force to replace it (the old file is kept as ${path24.basename(plan.path)}.bak)`);
5771
- fs29.mkdirSync(path24.dirname(plan.path), { recursive: true });
5772
- if (plan.exists) fs29.copyFileSync(plan.path, `${plan.path}.bak`);
5773
- fs29.writeFileSync(plan.path, `${JSON.stringify(plan.config, null, 2)}
5928
+ if (plan.exists && !force) throw new UserError(`${plan.path} already exists: pass --force to replace it (the old file is kept as ${path26.basename(plan.path)}.bak)`);
5929
+ fs30.mkdirSync(path26.dirname(plan.path), { recursive: true });
5930
+ if (plan.exists) fs30.copyFileSync(plan.path, `${plan.path}.bak`);
5931
+ fs30.writeFileSync(plan.path, `${JSON.stringify(plan.config, null, 2)}
5774
5932
  `);
5775
5933
  return plan.path;
5776
5934
  }
@@ -5791,17 +5949,17 @@ function formatInit(plan, written) {
5791
5949
 
5792
5950
  // src/core/mcp-install.ts
5793
5951
  import { spawnSync as spawnSync7 } from "node:child_process";
5794
- import fs30 from "node:fs";
5952
+ import fs31 from "node:fs";
5795
5953
  import os10 from "node:os";
5796
- import path25 from "node:path";
5954
+ import path27 from "node:path";
5797
5955
  var CLIENT_IDS = ["claude-code", "codex", "gemini", "cursor", "claude-desktop"];
5798
5956
  var NAME = "pitroom";
5799
5957
  function mcpCommand() {
5800
5958
  const launcher = launcherPath();
5801
5959
  if (isPitroomLauncher(launcher)) return { command: launcher, args: ["mcp"] };
5802
- for (const dir of (process.env.PATH ?? "").split(path25.delimiter).filter(Boolean)) {
5803
- const p = path25.join(dir, process.platform === "win32" ? "pitroom.cmd" : "pitroom");
5804
- if (fs30.existsSync(p)) return { command: path25.resolve(p), args: ["mcp"] };
5960
+ for (const dir of (process.env.PATH ?? "").split(path27.delimiter).filter(Boolean)) {
5961
+ const p = path27.join(dir, process.platform === "win32" ? "pitroom.cmd" : "pitroom");
5962
+ if (fs31.existsSync(p)) return { command: path27.resolve(p), args: ["mcp"] };
5805
5963
  }
5806
5964
  return { command: process.execPath, args: [process.argv[1] ?? "pitroom", "mcp"] };
5807
5965
  }
@@ -5817,7 +5975,7 @@ function run(backendId, args) {
5817
5975
  var runnable = (backendId) => run(backendId, ["--version"]).ok;
5818
5976
  var readJson = (file2) => {
5819
5977
  try {
5820
- return JSON.parse(fs30.readFileSync(file2, "utf8"));
5978
+ return JSON.parse(fs31.readFileSync(file2, "utf8"));
5821
5979
  } catch {
5822
5980
  return void 0;
5823
5981
  }
@@ -5827,7 +5985,7 @@ var jsonState = (file2, c) => {
5827
5985
  return entry === void 0 ? "absent" : same(c, entry) ? "same" : "different";
5828
5986
  };
5829
5987
  var home2 = () => os10.homedir();
5830
- var geminiDir = () => path25.join(process.env.GEMINI_CLI_HOME ?? home2(), ".gemini");
5988
+ var geminiDir = () => path27.join(process.env.GEMINI_CLI_HOME ?? home2(), ".gemini");
5831
5989
  function cliClient(id, backend, name, where, o) {
5832
5990
  return {
5833
5991
  id,
@@ -5850,43 +6008,43 @@ function jsonClient(id, name, file2, dir) {
5850
6008
  return {
5851
6009
  id,
5852
6010
  name,
5853
- found: () => fs30.existsSync(dir()),
6011
+ found: () => fs31.existsSync(dir()),
5854
6012
  state: (c) => jsonState(file2(), c),
5855
6013
  add: (c) => {
5856
6014
  let config = {};
5857
- if (fs30.existsSync(file2())) {
6015
+ if (fs31.existsSync(file2())) {
5858
6016
  config = readJson(file2());
5859
6017
  if (!config || typeof config !== "object" || Array.isArray(config)) return { ok: false, message: `${rel()} is not valid JSON: left alone` };
5860
6018
  const backup = `${file2()}.bak-pitroom`;
5861
- if (!fs30.existsSync(backup)) fs30.copyFileSync(file2(), backup);
6019
+ if (!fs31.existsSync(backup)) fs31.copyFileSync(file2(), backup);
5862
6020
  }
5863
6021
  config.mcpServers = { ...config.mcpServers ?? {}, [NAME]: { command: c.command, args: c.args } };
5864
- fs30.mkdirSync(path25.dirname(file2()), { recursive: true });
6022
+ fs31.mkdirSync(path27.dirname(file2()), { recursive: true });
5865
6023
  const tmp = `${file2()}.${process.pid}.tmp`;
5866
- fs30.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
6024
+ fs31.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
5867
6025
  `);
5868
- fs30.renameSync(tmp, file2());
6026
+ fs31.renameSync(tmp, file2());
5869
6027
  return { ok: true, message: rel() };
5870
6028
  },
5871
6029
  remove: () => {
5872
6030
  const config = readJson(file2());
5873
6031
  if (!config?.mcpServers?.[NAME]) return { ok: true, message: rel() };
5874
6032
  delete config.mcpServers[NAME];
5875
- fs30.writeFileSync(file2(), `${JSON.stringify(config, null, 2)}
6033
+ fs31.writeFileSync(file2(), `${JSON.stringify(config, null, 2)}
5876
6034
  `);
5877
6035
  return { ok: true, message: rel() };
5878
6036
  }
5879
6037
  };
5880
6038
  }
5881
6039
  function desktopFile() {
5882
- if (process.platform === "darwin") return path25.join(home2(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
5883
- if (process.platform === "win32") return path25.join(process.env.APPDATA ?? path25.join(home2(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
5884
- return path25.join(process.env.XDG_CONFIG_HOME ?? path25.join(home2(), ".config"), "Claude", "claude_desktop_config.json");
6040
+ if (process.platform === "darwin") return path27.join(home2(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
6041
+ if (process.platform === "win32") return path27.join(process.env.APPDATA ?? path27.join(home2(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
6042
+ return path27.join(process.env.XDG_CONFIG_HOME ?? path27.join(home2(), ".config"), "Claude", "claude_desktop_config.json");
5885
6043
  }
5886
6044
  function codexState(c) {
5887
6045
  let text;
5888
6046
  try {
5889
- text = fs30.readFileSync(path25.join(process.env.CODEX_HOME ?? path25.join(home2(), ".codex"), "config.toml"), "utf8");
6047
+ text = fs31.readFileSync(path27.join(process.env.CODEX_HOME ?? path27.join(home2(), ".codex"), "config.toml"), "utf8");
5890
6048
  } catch {
5891
6049
  return "absent";
5892
6050
  }
@@ -5904,7 +6062,7 @@ function codexState(c) {
5904
6062
  function clients() {
5905
6063
  return [
5906
6064
  cliClient("claude-code", "claude", "Claude Code", "user settings (~/.claude.json)", {
5907
- state: (c) => jsonState(path25.join(home2(), ".claude.json"), c),
6065
+ state: (c) => jsonState(path27.join(home2(), ".claude.json"), c),
5908
6066
  add: (c) => ["mcp", "add", "--scope", "user", NAME, "--", c.command, ...c.args],
5909
6067
  remove: () => ["mcp", "remove", NAME, "--scope", "user"]
5910
6068
  }),
@@ -5914,12 +6072,12 @@ function clients() {
5914
6072
  remove: () => ["mcp", "remove", NAME]
5915
6073
  }),
5916
6074
  cliClient("gemini", "gemini", "Gemini CLI", "user settings (~/.gemini/settings.json)", {
5917
- state: (c) => jsonState(path25.join(geminiDir(), "settings.json"), c),
6075
+ state: (c) => jsonState(path27.join(geminiDir(), "settings.json"), c),
5918
6076
  add: (c) => ["mcp", "add", "--scope", "user", NAME, c.command, ...c.args],
5919
6077
  remove: () => ["mcp", "remove", "--scope", "user", NAME]
5920
6078
  }),
5921
- jsonClient("cursor", "Cursor", () => path25.join(home2(), ".cursor", "mcp.json"), () => path25.join(home2(), ".cursor")),
5922
- jsonClient("claude-desktop", "Claude Desktop", desktopFile, () => path25.dirname(desktopFile()))
6079
+ jsonClient("cursor", "Cursor", () => path27.join(home2(), ".cursor", "mcp.json"), () => path27.join(home2(), ".cursor")),
6080
+ jsonClient("claude-desktop", "Claude Desktop", desktopFile, () => path27.dirname(desktopFile()))
5923
6081
  ];
5924
6082
  }
5925
6083
  function attempt2(f) {
@@ -5989,17 +6147,17 @@ var parseClients = (value) => {
5989
6147
  };
5990
6148
 
5991
6149
  // src/core/plan-status.ts
5992
- import crypto8 from "node:crypto";
5993
- import fs31 from "node:fs";
5994
- import path26 from "node:path";
6150
+ import crypto9 from "node:crypto";
6151
+ import fs32 from "node:fs";
6152
+ import path28 from "node:path";
5995
6153
  function notesFile(plan) {
5996
- const id = crypto8.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
5997
- return path26.join(home(), "plans", id, "notes.md");
6154
+ const id = crypto9.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
6155
+ return path28.join(home(), "plans", id, "notes.md");
5998
6156
  }
5999
6157
  function readNotes(plan) {
6000
6158
  const f = notesFile(plan);
6001
- if (!fs31.existsSync(f)) return [];
6002
- return fs31.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
6159
+ if (!fs32.existsSync(f)) return [];
6160
+ return fs32.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
6003
6161
  const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
6004
6162
  return m ? { at: m[1], text: m[2] } : { at: "", text: l };
6005
6163
  });
@@ -6007,11 +6165,11 @@ function readNotes(plan) {
6007
6165
  function addNote(planFile, text) {
6008
6166
  const plan = loadPlan(planFile);
6009
6167
  const f = notesFile(plan);
6010
- fs31.mkdirSync(path26.dirname(f), { recursive: true });
6011
- if (!fs31.existsSync(f)) fs31.writeFileSync(f, `# plan: ${plan.file}
6168
+ fs32.mkdirSync(path28.dirname(f), { recursive: true });
6169
+ if (!fs32.existsSync(f)) fs32.writeFileSync(f, `# plan: ${plan.file}
6012
6170
  `);
6013
6171
  const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
6014
- fs31.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
6172
+ fs32.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
6015
6173
  `);
6016
6174
  return f;
6017
6175
  }
@@ -6078,27 +6236,27 @@ ${rulings}`,
6078
6236
  }
6079
6237
 
6080
6238
  // src/core/cache.ts
6081
- import crypto9 from "node:crypto";
6082
- import fs32 from "node:fs";
6083
- import path27 from "node:path";
6239
+ import crypto10 from "node:crypto";
6240
+ import fs33 from "node:fs";
6241
+ import path29 from "node:path";
6084
6242
  var SCAN2 = 500;
6085
6243
  function cacheKey(o) {
6086
6244
  if (o.verify || o.mode !== "read" || o.continueFrom || o.web || o.plan || o.review || o.audit || o.auditRate === 1) return void 0;
6087
6245
  if (!(effective().cacheDays.value > 0)) return void 0;
6088
- const dir = canonical(path27.resolve(o.dir));
6246
+ const dir = canonical(path29.resolve(o.dir));
6089
6247
  const root = repoRoot(dir);
6090
6248
  if (!root) return void 0;
6091
6249
  try {
6092
6250
  const tree = snapshotTree(root);
6093
6251
  const head = commitOf(root, "HEAD") ?? "";
6094
6252
  const files = o.files.map((f) => {
6095
- const abs = path27.resolve(f);
6096
- return [abs, crypto9.createHash("sha256").update(fs32.readFileSync(abs)).digest("hex")];
6253
+ const abs = path29.resolve(f);
6254
+ return [abs, crypto10.createHash("sha256").update(fs33.readFileSync(abs)).digest("hex")];
6097
6255
  });
6098
6256
  const question = o.task.replace(/\s+/g, " ").trim();
6099
6257
  const readIn2 = READ_IN.includes(effective().readIn.value) ? effective().readIn.value : "auto";
6100
6258
  const where = !o.inPlace && wantSnapshot(readIn2, root, dir) ? "snapshot" : "project";
6101
- const key = crypto9.createHash("sha256").update(JSON.stringify([question, path27.relative(root, dir), files, where])).digest("hex");
6259
+ const key = crypto10.createHash("sha256").update(JSON.stringify([question, path29.relative(root, dir), files, where])).digest("hex");
6102
6260
  return { key, state: `${head}:${tree}` };
6103
6261
  } catch {
6104
6262
  return void 0;
@@ -6141,9 +6299,9 @@ function ago(iso, now = Date.now()) {
6141
6299
  var cachedNote = (m) => `pitroom \u27F2 cached answer \xB7 the same question on the same code as run ${m.id} (${ago(m.endedAt ?? m.startedAt)}) \xB7 no worker ran \xB7 --fresh asks one again`;
6142
6300
 
6143
6301
  // src/core/review.ts
6144
- import crypto10 from "node:crypto";
6145
- import fs33 from "node:fs";
6146
- import path28 from "node:path";
6302
+ import crypto11 from "node:crypto";
6303
+ import fs34 from "node:fs";
6304
+ import path30 from "node:path";
6147
6305
  var TEMPLATE2 = { task: "task-reviewer", fix: "re-review", range: "code-reviewer" };
6148
6306
  var section = (title, body) => `## ${title}
6149
6307
 
@@ -6161,7 +6319,7 @@ function ancestors(m) {
6161
6319
  function briefOf(m) {
6162
6320
  const root = [m, ...ancestors(m)].at(-1);
6163
6321
  const f = runFile(root.id, "brief.md");
6164
- return fs33.existsSync(f) ? fs33.readFileSync(f, "utf8") : root.task;
6322
+ return fs34.existsSync(f) ? fs34.readFileSync(f, "utf8") : root.task;
6165
6323
  }
6166
6324
  function latestReview(ids) {
6167
6325
  for (const id of listRunIds().reverse()) {
@@ -6175,7 +6333,7 @@ function latestReview(ids) {
6175
6333
  }
6176
6334
  function diffOf(m, from = m.baseTree) {
6177
6335
  const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
6178
- if (where && fs33.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
6336
+ if (where && fs34.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
6179
6337
  if (from !== m.baseTree) {
6180
6338
  throw new UserError(`the isolated copy of ${m.id} is gone, so its fix round cannot be shown on its own; review a run that is not applied yet`, 3);
6181
6339
  }
@@ -6189,7 +6347,7 @@ function runReview(id) {
6189
6347
  if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
6190
6348
  const chain = ancestors(m);
6191
6349
  const previous = latestReview(chain.map((a) => a.id));
6192
- const dir = m.mode === "isolate" && m.worktree && fs33.existsSync(m.worktree) ? m.cwd : m.dir;
6350
+ const dir = m.mode === "isolate" && m.worktree && fs34.existsSync(m.worktree) ? m.cwd : m.dir;
6193
6351
  const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
6194
6352
  const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
6195
6353
  if (!previous) {
@@ -6264,20 +6422,20 @@ function writePackage(job) {
6264
6422
  const g = gitDir(job.dir);
6265
6423
  const root = repoRoot(job.dir);
6266
6424
  if (!g || !root) throw new UserError(`not a git repository: ${job.dir}`);
6267
- const name = `review-${crypto10.randomBytes(4).toString("hex")}.md`;
6268
- const inside2 = !path28.relative(fs33.realpathSync(root), fs33.realpathSync(g)).startsWith("..");
6269
- const file2 = inside2 ? path28.join(g, "pitroom", name) : path28.join(root, ".pitroom", name);
6425
+ const name = `review-${crypto11.randomBytes(4).toString("hex")}.md`;
6426
+ const inside2 = !path30.relative(fs34.realpathSync(root), fs34.realpathSync(g)).startsWith("..");
6427
+ const file2 = inside2 ? path30.join(g, "pitroom", name) : path30.join(root, ".pitroom", name);
6270
6428
  if (!inside2) ignoreInGit(root, "/.pitroom/");
6271
- fs33.mkdirSync(path28.dirname(file2), { recursive: true });
6272
- fs33.writeFileSync(file2, job.package);
6429
+ fs34.mkdirSync(path30.dirname(file2), { recursive: true });
6430
+ fs34.writeFileSync(file2, job.package);
6273
6431
  return file2;
6274
6432
  }
6275
6433
  function ignoreInGit(root, pattern) {
6276
- const exclude = path28.resolve(root, git(root, ["rev-parse", "--git-path", "info/exclude"]).stdout.trim());
6277
- const text = fs33.existsSync(exclude) ? fs33.readFileSync(exclude, "utf8") : "";
6434
+ const exclude = path30.resolve(root, git(root, ["rev-parse", "--git-path", "info/exclude"]).stdout.trim());
6435
+ const text = fs34.existsSync(exclude) ? fs34.readFileSync(exclude, "utf8") : "";
6278
6436
  if (text.split(/\r?\n/).includes(pattern)) return;
6279
- fs33.mkdirSync(path28.dirname(exclude), { recursive: true });
6280
- fs33.appendFileSync(exclude, `${text && !text.endsWith("\n") ? "\n" : ""}# Pitroom review packages in worktrees (removed after each review)
6437
+ fs34.mkdirSync(path30.dirname(exclude), { recursive: true });
6438
+ fs34.appendFileSync(exclude, `${text && !text.endsWith("\n") ? "\n" : ""}# Pitroom review packages in worktrees (removed after each review)
6281
6439
  ${pattern}
6282
6440
  `);
6283
6441
  }
@@ -6433,10 +6591,10 @@ async function cmdReview(p) {
6433
6591
  writeMeta(meta);
6434
6592
  }
6435
6593
  } catch (e) {
6436
- fs34.rmSync(packageFile, { force: true });
6594
+ fs35.rmSync(packageFile, { force: true });
6437
6595
  throw e;
6438
6596
  }
6439
- fs34.writeFileSync(runFile(meta.id, "package.md"), job.package);
6597
+ fs35.writeFileSync(runFile(meta.id, "package.md"), job.package);
6440
6598
  return launch(p, meta);
6441
6599
  }
6442
6600
  async function cmdAudit(p) {
@@ -6504,7 +6662,7 @@ function cmdPlan(p) {
6504
6662
  }
6505
6663
  function cmdCrew(p) {
6506
6664
  const file2 = flag(p, "task-file");
6507
- const tasks = (file2 ? fs34.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
6665
+ const tasks = (file2 ? fs35.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
6508
6666
  if (!tasks.length) throw new UserError('crew needs tasks: pitroom crew -g NAME "task 1" "task 2" (or --task-file with --- separators)');
6509
6667
  if (has(p, "write") && tasks.length > 1) {
6510
6668
  throw new UserError("parallel --write runs would edit the same tree; use --isolate (each worker gets its own isolated copy)");
@@ -6612,9 +6770,10 @@ function cmdHistory(p) {
6612
6770
  const s = historyStats(since2);
6613
6771
  if (has(p, "json")) return console.log(JSON.stringify(s, null, 2)), 0;
6614
6772
  const t = s.totals;
6615
- console.log(`${t.runs} runs \xB7 ${t.ok} ok \xB7 ${t.failed} not ok \xB7 ${secs(t.seconds)} of worker time \xB7 ${(t.tokens / 1e6).toFixed(1)}M tokens \xB7 ~${usd(t.saved)} saved`);
6616
- const rows2 = s.byWorker.map((w) => [`${w.backend}${w.model ? `:${w.model}` : ""}`, String(w.runs), w.runs ? `${Math.round(100 * w.ok / w.runs)}%` : "-", secs(w.avgSeconds), w.avgTokens ? `${Math.round(w.avgTokens / 1e3)}k` : "-", `~${usd(w.saved)}`]);
6617
- const head2 = ["WORKER", "RUNS", "OK", "AVG TIME", "AVG TOKENS", "SAVED"];
6773
+ const limited = t.limited ? ` \xB7 ${t.limited} rate-limited (${s.rateLimits === "counted" ? "counted as not ok" : 'left out; "countRateLimits": true counts them'})` : "";
6774
+ console.log(`${t.runs} runs \xB7 ${t.ok} ok \xB7 ${t.failed} not ok${limited} \xB7 ${secs(t.seconds)} of worker time \xB7 ${(t.tokens / 1e6).toFixed(1)}M tokens \xB7 ~${usd(t.saved)} saved`);
6775
+ const rows2 = s.byWorker.map((w) => [`${w.backend}${w.model ? `:${w.model}` : ""}`, String(w.runs), w.runs ? `${Math.round(100 * w.ok / w.runs)}%` : "-", w.limited ? String(w.limited) : "-", secs(w.avgSeconds), w.avgTokens ? `${Math.round(w.avgTokens / 1e3)}k` : "-", `~${usd(w.saved)}`]);
6776
+ const head2 = ["WORKER", "RUNS", "OK", "LIMITED", "AVG TIME", "AVG TOKENS", "SAVED"];
6618
6777
  const widths2 = head2.map((h, i) => Math.max(h.length, ...rows2.map((r) => r[i].length)));
6619
6778
  const fmt2 = (r) => r.map((c, i) => i === 0 ? c.padEnd(widths2[i]) : c.padStart(widths2[i])).join(" ");
6620
6779
  if (rows2.length) console.log(`
@@ -6704,7 +6863,7 @@ function sinceMs(s) {
6704
6863
  if (!m) throw new UserError("--since takes 7d, 30d, \u2026 or all");
6705
6864
  return Date.now() - Number(m[1]) * 864e5;
6706
6865
  }
6707
- var readStdin = () => process.stdin.isTTY ? "" : fs34.readFileSync(0, "utf8");
6866
+ var readStdin = () => process.stdin.isTTY ? "" : fs35.readFileSync(0, "utf8");
6708
6867
  function cmdStatusline(p) {
6709
6868
  const input = readStdin();
6710
6869
  const lines = [];
@@ -6744,8 +6903,8 @@ function cmdModels(p) {
6744
6903
  return 0;
6745
6904
  }
6746
6905
  function cmdHookStart() {
6747
- const script = path29.join(path29.dirname(path29.dirname(fileURLToPath4(import.meta.url))), "hooks", "session-start.mjs");
6748
- if (fs34.existsSync(script)) spawnSync8(process.execPath, [script], { stdio: ["ignore", "inherit", "ignore"], env: process.env });
6906
+ const script = path31.join(path31.dirname(path31.dirname(fileURLToPath4(import.meta.url))), "hooks", "session-start.mjs");
6907
+ if (fs35.existsSync(script)) spawnSync8(process.execPath, [script], { stdio: ["ignore", "inherit", "ignore"], env: process.env });
6749
6908
  return 0;
6750
6909
  }
6751
6910
  function cmdInit(p) {
@@ -6785,8 +6944,8 @@ function cmdSavings(p) {
6785
6944
  est. saved ${usd(t.saved)} (vs ${primaryPrice().name} list prices)`);
6786
6945
  const out = flag(p, "card");
6787
6946
  if (out) {
6788
- fs34.writeFileSync(out, card(t, period));
6789
- console.log(`card written to ${path29.resolve(out)}`);
6947
+ fs35.writeFileSync(out, card(t, period));
6948
+ console.log(`card written to ${path31.resolve(out)}`);
6790
6949
  }
6791
6950
  if (has(p, "badge")) console.log(`![pitroom](${badgeUrl(t)})`);
6792
6951
  return 0;
@@ -6828,7 +6987,7 @@ function cmdConfig(p) {
6828
6987
  console.log(JSON.stringify({ path: configPath(), settings: eff, warnings }, null, 2));
6829
6988
  return 0;
6830
6989
  }
6831
- console.log(`config file: ${configPath()}${fs34.existsSync(configPath()) ? "" : " (not present)"}`);
6990
+ console.log(`config file: ${configPath()}${fs35.existsSync(configPath()) ? "" : " (not present)"}`);
6832
6991
  for (const [key, s] of Object.entries(eff)) {
6833
6992
  const v = Array.isArray(s.value) ? s.value.join(", ") || "\u2014" : s.value && typeof s.value === "object" ? Object.entries(s.value).map(([k, m]) => `${k}=${m}`).join(", ") || "\u2014" : s.value === void 0 ? key === "model" ? "the worker's default" : "\u2014" : String(s.value);
6834
6993
  console.log(` ${key.padEnd(15)} ${v} (${s.source})`);
@@ -6852,7 +7011,7 @@ function cmdClean(p) {
6852
7011
  const m = readMeta(id);
6853
7012
  if (m.mode === "isolate" && !m.discarded && !m.applied) discardRun(m);
6854
7013
  recordRun(m);
6855
- fs34.rmSync(runDir(id), { recursive: true, force: true });
7014
+ fs35.rmSync(runDir(id), { recursive: true, force: true });
6856
7015
  }
6857
7016
  const snapshots = prune(Date.now(), 0);
6858
7017
  console.log(`removed ${old.length} run(s)${snapshots ? ` and ${snapshots} read snapshot${snapshots === 1 ? "" : "s"}` : ""}; the history and the savings ledger are kept`);
@@ -6863,10 +7022,10 @@ var cmdDiscard = (p) => (console.log(discardRun(freshMeta(resolveRun(p.positiona
6863
7022
 
6864
7023
  // src/core/doctor.ts
6865
7024
  import { spawnSync as spawnSync9 } from "node:child_process";
6866
- import fs35 from "node:fs";
7025
+ import fs36 from "node:fs";
6867
7026
  import os11 from "node:os";
6868
- import path30 from "node:path";
6869
- var MARK = { ok: () => green("\u2714"), warn: () => yellow("!"), fail: () => red("\u2718") };
7027
+ import path32 from "node:path";
7028
+ var MARK2 = { ok: () => green("\u2714"), warn: () => yellow("!"), fail: () => red("\u2718") };
6870
7029
  var NEXT = [
6871
7030
  { when: /no default model|no fallback workers/, command: "pitroom init", why: "propose a starter config (fallback models from your catalogue)" },
6872
7031
  { when: /pitroom install|no Pitroom skills|launcher on PATH/, command: "pitroom install", why: "link the skills and the pitroom command" },
@@ -6888,10 +7047,10 @@ function usageLine() {
6888
7047
  return `${ids.length} run${ids.length === 1 ? "" : "s"} so far${when2} (pitroom savings shows what they saved)`;
6889
7048
  }
6890
7049
  function firstNodeOnPath() {
6891
- for (const dir of (process.env.PATH ?? "").split(path30.delimiter).filter(Boolean)) {
6892
- const file2 = path30.join(dir, process.platform === "win32" ? "node.exe" : "node");
7050
+ for (const dir of (process.env.PATH ?? "").split(path32.delimiter).filter(Boolean)) {
7051
+ const file2 = path32.join(dir, process.platform === "win32" ? "node.exe" : "node");
6893
7052
  try {
6894
- if (!fs35.statSync(file2).isFile()) continue;
7053
+ if (!fs36.statSync(file2).isFile()) continue;
6895
7054
  } catch {
6896
7055
  continue;
6897
7056
  }
@@ -6918,12 +7077,12 @@ function doctor5(probe) {
6918
7077
  const addAll = (list2) => list2.forEach((c) => add(c.level, c.message));
6919
7078
  add("ok", `pitroom ${VERSION2} \xB7 node ${process.versions.node} \xB7 state in ${home()}`);
6920
7079
  const onPath = firstNodeOnPath();
6921
- if (onPath && onPath.version && tooOld(onPath.version) && path30.resolve(onPath.path) !== path30.resolve(process.execPath)) {
7080
+ if (onPath && onPath.version && tooOld(onPath.version) && path32.resolve(onPath.path) !== path32.resolve(process.execPath)) {
6922
7081
  add("warn", `the first \`node\` on PATH is v${onPath.version} (${onPath.path}), older than the 22.13 Pitroom needs: shells that agent apps start may use it (the pitroom command finds a newer Node itself; other tools may not)`);
6923
7082
  }
6924
7083
  add(gitAvailable() ? "ok" : "warn", gitAvailable() ? "git available" : "git not found: --write/--isolate tracking disabled");
6925
7084
  const cfg = loadConfig();
6926
- add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs35.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
7085
+ add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs36.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
6927
7086
  for (const w of cfg.warnings) add("warn", w);
6928
7087
  if (process.platform === "win32") {
6929
7088
  add("warn", "git guard: not available on Windows yet, so Pitroom does not block a worker's git history changes or pushes there (read-only runs are still limited by each worker CLI's own rules, and --isolate keeps edits in a copy); prefer --isolate and check the patch before apply");
@@ -7019,7 +7178,7 @@ function print(checks) {
7019
7178
  for (const name of [...new Set(checks.map((c) => c.section))]) {
7020
7179
  console.log(`
7021
7180
  ${bold(name)}`);
7022
- for (const c of checks.filter((x) => x.section === name)) console.log(` ${MARK[c.level]()} ${wrapText(c.message, 4)}`);
7181
+ for (const c of checks.filter((x) => x.section === name)) console.log(` ${MARK2[c.level]()} ${wrapText(c.message, 4)}`);
7023
7182
  }
7024
7183
  const count = (level) => checks.filter((c) => c.level === level).length;
7025
7184
  const [ok, warn, fail] = [count("ok"), count("warn"), count("fail")];
@@ -7080,19 +7239,19 @@ function skillChecks() {
7080
7239
  else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${base2}; run \`pitroom install\`` });
7081
7240
  }
7082
7241
  if (geminiExtensionInstalled()) {
7083
- const linked = installedSkills().some((i) => i.base.includes(`${path30.sep}.agents${path30.sep}`) && i.names.length);
7242
+ const linked = installedSkills().some((i) => i.base.includes(`${path32.sep}.agents${path32.sep}`) && i.names.length);
7084
7243
  checks.push(
7085
7244
  linked ? { level: "warn", message: "Pitroom is installed as a Gemini CLI extension and linked into ~/.agents/skills: Gemini loads the skills twice; run `pitroom uninstall` or `gemini extensions uninstall pitroom`" } : { level: "ok", message: "Gemini CLI extension installed (skills + context)" }
7086
7245
  );
7087
7246
  }
7088
7247
  if (viaPlugin) {
7089
- const linked = installedSkills().some((i) => i.base.includes(`${path30.sep}.claude${path30.sep}`) && i.names.length);
7248
+ const linked = installedSkills().some((i) => i.base.includes(`${path32.sep}.claude${path32.sep}`) && i.names.length);
7090
7249
  checks.push(
7091
7250
  linked ? { level: "warn", message: "Pitroom is installed as a Claude Code plugin and linked into ~/.claude/skills: skills load twice; run `pitroom uninstall` or remove the plugin" } : { level: "ok", message: "Claude Code plugin installed (skills + session-start hook)" }
7092
7251
  );
7093
7252
  }
7094
7253
  const launcher = launcherPath();
7095
- if (fs35.existsSync(launcher)) {
7254
+ if (fs36.existsSync(launcher)) {
7096
7255
  const r = spawnSync9(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
7097
7256
  checks.push(
7098
7257
  r.status === 0 ? { level: "ok", message: `launcher ${launcher} \u2192 pitroom ${r.stdout.trim()}` } : { level: "fail", message: `launcher ${launcher} does not start: ${(r.stderr || r.stdout).trim().slice(0, 200)}` }
@@ -7104,12 +7263,12 @@ function skillChecks() {
7104
7263
  }
7105
7264
  function geminiExtensionInstalled() {
7106
7265
  const home3 = process.env.GEMINI_CLI_HOME ?? os11.homedir();
7107
- return fs35.existsSync(path30.join(home3, ".gemini", "extensions", "pitroom"));
7266
+ return fs36.existsSync(path32.join(home3, ".gemini", "extensions", "pitroom"));
7108
7267
  }
7109
7268
  function pluginInstalled() {
7110
7269
  try {
7111
- const f = path30.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
7112
- const plugins = JSON.parse(fs35.readFileSync(f, "utf8")).plugins ?? {};
7270
+ const f = path32.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
7271
+ const plugins = JSON.parse(fs36.readFileSync(f, "utf8")).plugins ?? {};
7113
7272
  return Object.keys(plugins).some((k) => k.startsWith("pitroom@"));
7114
7273
  } catch {
7115
7274
  return false;
@@ -7126,24 +7285,24 @@ function superpowersActive() {
7126
7285
  for (const p of openCodePlugins()) {
7127
7286
  if (/superpowers/i.test(p)) found.push(`OpenCode plugin ${p}`);
7128
7287
  }
7129
- for (const base2 of [path30.join(os11.homedir(), ".agents", "skills"), path30.join(os11.homedir(), ".claude", "skills")]) {
7130
- const dir = path30.join(base2, "using-superpowers");
7131
- if (fs35.existsSync(path30.join(dir, "SKILL.md"))) found.push(dir);
7288
+ for (const base2 of [path32.join(os11.homedir(), ".agents", "skills"), path32.join(os11.homedir(), ".claude", "skills")]) {
7289
+ const dir = path32.join(base2, "using-superpowers");
7290
+ if (fs36.existsSync(path32.join(dir, "SKILL.md"))) found.push(dir);
7132
7291
  }
7133
7292
  return found;
7134
7293
  }
7135
7294
  function claudePlugins() {
7136
7295
  try {
7137
- const f = path30.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
7138
- return Object.keys(JSON.parse(fs35.readFileSync(f, "utf8")).plugins ?? {});
7296
+ const f = path32.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
7297
+ return Object.keys(JSON.parse(fs36.readFileSync(f, "utf8")).plugins ?? {});
7139
7298
  } catch {
7140
7299
  return [];
7141
7300
  }
7142
7301
  }
7143
7302
  function claudePluginEnabled(key) {
7144
7303
  try {
7145
- const f = path30.join(os11.homedir(), ".claude", "settings.json");
7146
- return JSON.parse(fs35.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
7304
+ const f = path32.join(os11.homedir(), ".claude", "settings.json");
7305
+ return JSON.parse(fs36.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
7147
7306
  } catch {
7148
7307
  return true;
7149
7308
  }
@@ -7152,7 +7311,7 @@ function codexPlugins() {
7152
7311
  const plugins = /* @__PURE__ */ new Map();
7153
7312
  let text;
7154
7313
  try {
7155
- text = fs35.readFileSync(path30.join(process.env.CODEX_HOME ?? path30.join(os11.homedir(), ".codex"), "config.toml"), "utf8");
7314
+ text = fs36.readFileSync(path32.join(process.env.CODEX_HOME ?? path32.join(os11.homedir(), ".codex"), "config.toml"), "utf8");
7156
7315
  } catch {
7157
7316
  return plugins;
7158
7317
  }
@@ -7170,11 +7329,11 @@ function codexPlugins() {
7170
7329
  return plugins;
7171
7330
  }
7172
7331
  function openCodePlugins() {
7173
- const dir = path30.join(process.env.XDG_CONFIG_HOME ?? path30.join(os11.homedir(), ".config"), "opencode");
7332
+ const dir = path32.join(process.env.XDG_CONFIG_HOME ?? path32.join(os11.homedir(), ".config"), "opencode");
7174
7333
  const plugins = [];
7175
7334
  for (const name of ["opencode.json", "opencode.jsonc"]) {
7176
7335
  try {
7177
- const text = fs35.readFileSync(path30.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
7336
+ const text = fs36.readFileSync(path32.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
7178
7337
  const list2 = JSON.parse(text).plugin;
7179
7338
  if (Array.isArray(list2)) plugins.push(...list2.filter((p) => typeof p === "string"));
7180
7339
  } catch {
@@ -7182,7 +7341,7 @@ function openCodePlugins() {
7182
7341
  }
7183
7342
  for (const folder of ["plugin", "plugins"]) {
7184
7343
  try {
7185
- for (const f of fs35.readdirSync(path30.join(dir, folder))) plugins.push(path30.join(dir, folder, f));
7344
+ for (const f of fs36.readdirSync(path32.join(dir, folder))) plugins.push(path32.join(dir, folder, f));
7186
7345
  } catch {
7187
7346
  }
7188
7347
  }
@@ -7299,7 +7458,7 @@ var COMMANDS = {
7299
7458
  if (port !== void 0 && !(/^\d+$/.test(port) && Number(port) <= 65535)) throw new UserError("--port takes a number from 0 to 65535 (0 picks a free one)");
7300
7459
  const dir = flag(p, "dir");
7301
7460
  if (dir !== void 0) {
7302
- if (!fs36.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) throw new UserError(`-d ${dir}: no such directory`);
7461
+ if (!fs37.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) throw new UserError(`-d ${dir}: no such directory`);
7303
7462
  process.chdir(dir);
7304
7463
  process.env.PWD = process.cwd();
7305
7464
  }