pitroom 0.19.0 → 0.21.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 fs38 from "node:fs";
5
5
 
6
6
  // src/core/errors.ts
7
7
  var UserError = class extends Error {
@@ -391,10 +391,10 @@ function parseEvents2(jsonl) {
391
391
  }
392
392
  case "turn.completed": {
393
393
  const u = e.usage ?? {};
394
- const cached3 = num2(u.cached_input_tokens);
394
+ const cached4 = num2(u.cached_input_tokens);
395
395
  const reasoning = num2(u.reasoning_output_tokens);
396
- usage2.input += Math.max(0, num2(u.input_tokens) - cached3);
397
- usage2.cacheRead += cached3;
396
+ usage2.input += Math.max(0, num2(u.input_tokens) - cached4);
397
+ usage2.cacheRead += cached4;
398
398
  usage2.cacheWrite += num2(u.cache_write_input_tokens);
399
399
  usage2.output += Math.max(0, num2(u.output_tokens) - reasoning);
400
400
  usage2.reasoning += reasoning;
@@ -768,9 +768,9 @@ function parseEvents3(jsonl) {
768
768
  if (e.severity === "error") error ??= String(e.message ?? "Gemini CLI error");
769
769
  } else if (e.type === "result") {
770
770
  const s = e.stats ?? {};
771
- const cached3 = num3(s.cached);
772
- usage2.cacheRead += cached3;
773
- usage2.input += s.input !== void 0 ? num3(s.input) : Math.max(0, num3(s.input_tokens) - cached3);
771
+ const cached4 = num3(s.cached);
772
+ usage2.cacheRead += cached4;
773
+ usage2.input += s.input !== void 0 ? num3(s.input) : Math.max(0, num3(s.input_tokens) - cached4);
774
774
  usage2.output += num3(s.output_tokens);
775
775
  usage2.total += num3(s.total_tokens) || num3(s.input_tokens) + num3(s.output_tokens);
776
776
  usage2.toolCalls = Math.max(usage2.toolCalls, num3(s.tool_calls));
@@ -1441,7 +1441,16 @@ 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",
1450
+ workerPrices: "record",
1451
+ priceFeed: "boolean",
1452
+ priceFeedUrl: "string",
1453
+ priceFeedHours: "number"
1445
1454
  };
1446
1455
  function configPath() {
1447
1456
  if (process.env.PITROOM_CONFIG) return path7.resolve(process.env.PITROOM_CONFIG);
@@ -1503,6 +1512,10 @@ var days = (v) => {
1503
1512
  const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
1504
1513
  return typeof n === "number" && Number.isInteger(n) && n >= 0 ? n : void 0;
1505
1514
  };
1515
+ var flag01 = (v) => {
1516
+ const t = v?.trim().toLowerCase();
1517
+ return t === void 0 || t === "" ? void 0 : ["1", "true", "on", "yes"].includes(t) ? true : ["0", "false", "off", "no"].includes(t) ? false : void 0;
1518
+ };
1506
1519
  var readIn = (v) => v === "auto" || v === "snapshot" || v === "project" ? v : void 0;
1507
1520
  var list = (s) => s?.split(",").map((x) => x.trim()).filter(Boolean);
1508
1521
  function setting(flag2, env, conf, fallback) {
@@ -1530,7 +1543,16 @@ function effective(flags = {}) {
1530
1543
  costs: setting(void 0, void 0, c.costs, {}),
1531
1544
  audit: setting(void 0, rate(e.PITROOM_AUDIT), rate(c.audit), 0),
1532
1545
  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)
1546
+ cacheDays: setting(void 0, days(e.PITROOM_CACHE_DAYS), days(c.cacheDays), 7),
1547
+ countRateLimits: setting(void 0, void 0, c.countRateLimits, false),
1548
+ mcpDash: setting(void 0, flag01(e.PITROOM_MCP_DASH), c.mcpDash, true),
1549
+ notify: setting(void 0, flag01(e.PITROOM_NOTIFY), c.notify, false),
1550
+ notifyCommand: setting(void 0, e.PITROOM_NOTIFY_COMMAND?.trim() || void 0, c.notifyCommand?.trim() || void 0, void 0),
1551
+ notifyAfter: setting(void 0, days(e.PITROOM_NOTIFY_AFTER), days(c.notifyAfter), 15),
1552
+ workerPrices: setting(void 0, void 0, c.workerPrices, {}),
1553
+ priceFeed: setting(void 0, flag01(e.PITROOM_PRICE_FEED), c.priceFeed, false),
1554
+ priceFeedUrl: setting(void 0, e.PITROOM_PRICE_FEED_URL?.trim() || void 0, c.priceFeedUrl?.trim() || void 0, "https://models.dev/api.json"),
1555
+ priceFeedHours: setting(void 0, positiveInt(e.PITROOM_PRICE_FEED_HOURS), positiveInt(c.priceFeedHours), 24)
1534
1556
  };
1535
1557
  }
1536
1558
 
@@ -1594,6 +1616,8 @@ var BOOL_FLAGS = {
1594
1616
  "--badge": "badge",
1595
1617
  "--probe": "probe",
1596
1618
  "--clear": "clear",
1619
+ "--refresh": "refresh",
1620
+ "--quiet": "quiet",
1597
1621
  "--copy": "copy",
1598
1622
  "--all": "all",
1599
1623
  "--models": "models",
@@ -1714,9 +1738,9 @@ function effortFlag(p) {
1714
1738
  import readline from "node:readline";
1715
1739
 
1716
1740
  // 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";
1741
+ import { spawn as spawn4, spawnSync as spawnSync6 } from "node:child_process";
1742
+ import fs27 from "node:fs";
1743
+ import path23 from "node:path";
1720
1744
 
1721
1745
  // src/vcs/git.ts
1722
1746
  import { spawnSync as spawnSync5 } from "node:child_process";
@@ -1930,7 +1954,9 @@ import fs12 from "node:fs";
1930
1954
  // src/vcs/guard.ts
1931
1955
  import fs11 from "node:fs";
1932
1956
  import path9 from "node:path";
1933
- var VERSION = 1;
1957
+ var VERSION = 2;
1958
+ var WINDOWS = process.platform === "win32";
1959
+ var fwd = (p) => WINDOWS ? p.replace(/\\/g, "/") : p;
1934
1960
  var SHIM = `#!/bin/sh
1935
1961
  # pitroom git guard v${VERSION}. Blocks git commands that change history, refs, the
1936
1962
  # index or discard work. The primary agent reviews and commits, not the worker.
@@ -2007,9 +2033,10 @@ function shimDir() {
2007
2033
  return path9.join(home(), "shim", `v${VERSION}`);
2008
2034
  }
2009
2035
  function findRealGit(skip) {
2036
+ const same2 = (a, b) => WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b;
2010
2037
  for (const dir of (process.env.PATH ?? "").split(path9.delimiter).filter(Boolean)) {
2011
- if (path9.resolve(dir) === skip) continue;
2012
- const p = path9.join(dir, "git");
2038
+ if (same2(path9.resolve(dir), skip)) continue;
2039
+ const p = path9.join(dir, WINDOWS ? "git.exe" : "git");
2013
2040
  try {
2014
2041
  fs11.accessSync(p, fs11.constants.X_OK);
2015
2042
  if (fs11.statSync(p).isFile()) return p;
@@ -2018,6 +2045,24 @@ function findRealGit(skip) {
2018
2045
  }
2019
2046
  return void 0;
2020
2047
  }
2048
+ function findGitSh(realGit) {
2049
+ let dir = path9.dirname(realGit);
2050
+ for (let i = 0; i < 4; i++) {
2051
+ for (const rel of ["bin/sh.exe", "usr/bin/sh.exe"]) {
2052
+ const p = path9.join(dir, rel);
2053
+ if (fs11.existsSync(p)) return p;
2054
+ }
2055
+ const up = path9.dirname(dir);
2056
+ if (up === dir) break;
2057
+ dir = up;
2058
+ }
2059
+ return void 0;
2060
+ }
2061
+ var cmdShim = (sh) => `@echo off\r
2062
+ set "MSYS2_ARG_CONV_EXCL=*"\r
2063
+ "${sh}" "%~dp0git" %*\r
2064
+ exit /b %errorlevel%\r
2065
+ `;
2021
2066
  var HOOK_VERSION = 1;
2022
2067
  var REF_HOOK = `#!/bin/sh
2023
2068
  # pitroom git guard (layer 2, v${HOOK_VERSION}): refuse ref updates made by workers.
@@ -2031,7 +2076,17 @@ function writeIfChanged(file2, content) {
2031
2076
  fs11.mkdirSync(path9.dirname(file2), { recursive: true });
2032
2077
  const tmp = `${file2}.${process.pid}.tmp`;
2033
2078
  fs11.writeFileSync(tmp, content, { mode: 493 });
2034
- fs11.renameSync(tmp, file2);
2079
+ renameOver(tmp, file2);
2080
+ }
2081
+ function pathKey(env) {
2082
+ const keys = Object.keys(env).filter((k) => k.toLowerCase() === "path");
2083
+ return keys.includes("PATH") ? "PATH" : keys[0] ?? "PATH";
2084
+ }
2085
+ function onePath(env) {
2086
+ const key = pathKey(env);
2087
+ const out = { ...env };
2088
+ for (const k of Object.keys(out)) if (k !== key && k.toLowerCase() === "path") delete out[k];
2089
+ return out;
2035
2090
  }
2036
2091
  function withGitConfig(env, entries) {
2037
2092
  const out = { ...env };
@@ -2052,17 +2107,27 @@ function guardEnv(env) {
2052
2107
  GIT_PAGER: "cat",
2053
2108
  PAGER: "cat"
2054
2109
  };
2055
- if (process.platform === "win32") return { ...env, ...quiet };
2056
2110
  writeIfChanged(path9.join(hooksDir(), "reference-transaction"), REF_HOOK);
2057
- env = withGitConfig(env, [
2058
- ["core.hooksPath", hooksDir()],
2111
+ env = withGitConfig(onePath(env), [
2112
+ ["core.hooksPath", fwd(hooksDir())],
2059
2113
  ["url.pitroom-push-blocked://.pushInsteadOf", ""]
2060
2114
  ]);
2061
2115
  const dir = shimDir();
2062
2116
  const real = findRealGit(dir);
2063
2117
  if (!real) return { ...env, ...quiet };
2118
+ let sh;
2119
+ if (WINDOWS) {
2120
+ sh = findGitSh(real);
2121
+ if (!sh) return { ...env, ...quiet };
2122
+ writeIfChanged(path9.join(dir, "git.cmd"), cmdShim(sh));
2123
+ }
2064
2124
  writeIfChanged(path9.join(dir, "git"), SHIM);
2065
- return { ...env, ...quiet, PITROOM_REAL_GIT: real, PATH: `${dir}${path9.delimiter}${env.PATH ?? ""}` };
2125
+ const key = pathKey(env);
2126
+ return { ...env, ...quiet, PITROOM_REAL_GIT: fwd(real), [key]: `${dir}${path9.delimiter}${env[key] ?? ""}` };
2127
+ }
2128
+ function shimReady() {
2129
+ const env = guardEnv({ ...process.env });
2130
+ return Boolean(env[pathKey(env)]?.startsWith(shimDir()));
2066
2131
  }
2067
2132
 
2068
2133
  // src/core/process.ts
@@ -2212,10 +2277,11 @@ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build"
2212
2277
  var MAX_VISITED = 2e4;
2213
2278
  var MAX_DEPTH = 4;
2214
2279
  var TEMPLATE = /\.(example|sample|template|dist|defaults?|tpl)$/i;
2215
- function looksSecret(name) {
2280
+ function looksSecret(file2) {
2281
+ const name = file2.toLowerCase();
2216
2282
  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);
2283
+ if (name === ".env" || name.startsWith(".env.") || name.endsWith(".env")) return true;
2284
+ 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
2285
  }
2220
2286
  function findSecretFiles(dir) {
2221
2287
  const found = [];
@@ -2384,6 +2450,7 @@ function backToProject(text, snapshotDir, root) {
2384
2450
 
2385
2451
  // src/core/audit.ts
2386
2452
  import crypto4 from "node:crypto";
2453
+ import path13 from "node:path";
2387
2454
  var ANSWER_MAX = 8e3;
2388
2455
  function auditRate(meta) {
2389
2456
  return meta.auditRate ?? effective().audit.value;
@@ -2406,13 +2473,24 @@ function pickAuditor(meta) {
2406
2473
  const candidates = [tiers.audit, tiers.cheap, ...eff.fallback.value, eff.worker.value].filter((s) => !!s);
2407
2474
  return candidates.find((c) => !sameTarget(parseTarget(c, def), ran));
2408
2475
  }
2476
+ function relativeToAuditor(answer, meta) {
2477
+ let out = answer;
2478
+ const roots = [...new Set([meta.dir, meta.repoRoot].filter((r) => !!r))].sort((a, b) => b.length - a.length);
2479
+ for (const root of roots) {
2480
+ const rel = path13.relative(meta.dir, root);
2481
+ const prefix = rel ? `${rel.split(path13.sep).join("/")}/` : "";
2482
+ out = out.split(`${root}${path13.sep}`).join(prefix).replace(new RegExp(`${root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w.-])`, "g"), rel || ".");
2483
+ }
2484
+ return out;
2485
+ }
2409
2486
  function auditTask(meta, answer) {
2410
- const text = answer.trim();
2487
+ const text = relativeToAuditor(answer, meta).trim();
2411
2488
  const shown = text.length > ANSWER_MAX ? `${text.slice(0, ANSWER_MAX)}
2412
2489
  \u2026 (cut: ${text.length - ANSWER_MAX} more characters)` : text;
2413
2490
  return [
2414
2491
  "You are auditing another worker's answer to a read-only question about this project. Do not take the answer on trust:",
2415
2492
  "check its key claims yourself against the files here (open the cited files and lines, search for what it says exists or is missing).",
2493
+ "Paths in the answer are relative to your working directory.",
2416
2494
  "Check the answer against the QUESTION, not only its own claims: every condition the question sets (a directory or scope,",
2417
2495
  'what to leave out, "all", "only", "exactly", a count) must hold. Valid references do not make an answer right:',
2418
2496
  "for a list, look for items that are missing and items that do not belong; for a count, count again yourself.",
@@ -2454,373 +2532,195 @@ function auditBadge(meta) {
2454
2532
  return a.state === "running" || a.state === "queued" ? "PENDING" : "FAILED";
2455
2533
  }
2456
2534
 
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, "");
2535
+ // src/core/notify.ts
2536
+ import { spawn as spawn3 } from "node:child_process";
2537
+ import crypto5 from "node:crypto";
2538
+ import fs22 from "node:fs";
2539
+ import path18 from "node:path";
2528
2540
 
2529
- // src/core/templates.ts
2530
- import fs18 from "node:fs";
2531
- import path15 from "node:path";
2541
+ // src/core/history.ts
2542
+ import fs19 from "node:fs";
2543
+ import { createRequire } from "node:module";
2544
+ import path16 from "node:path";
2545
+ import zlib from "node:zlib";
2532
2546
 
2533
- // src/core/install.ts
2547
+ // src/core/receipt.ts
2534
2548
  import fs17 from "node:fs";
2535
- import os9 from "node:os";
2549
+ import path15 from "node:path";
2550
+
2551
+ // src/core/prices.ts
2552
+ import { spawn as spawn2 } from "node:child_process";
2553
+ import fs16 from "node:fs";
2536
2554
  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;
2555
+ var DEFAULT_URL = "https://models.dev/api.json";
2556
+ var RETRY_AFTER_FAILURE_MS = 60 * 6e4;
2557
+ var MIN_MODELS = 100;
2558
+ function catalogLabel() {
2559
+ const url = effective().priceFeedUrl.value;
2560
+ if (url === DEFAULT_URL) return "models.dev";
2561
+ try {
2562
+ return new URL(url).host;
2563
+ } catch {
2564
+ return url;
2565
+ }
2552
2566
  }
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
+ var catalogFile = () => path14.join(home(), "prices.json");
2568
+ var attemptFile = () => path14.join(home(), "prices.attempt");
2569
+ function trim(raw) {
2570
+ const out = {};
2571
+ if (!raw || typeof raw !== "object") return out;
2572
+ for (const [provider, p] of Object.entries(raw)) {
2573
+ for (const [id, m] of Object.entries(p?.models ?? {})) {
2574
+ const c = m?.cost;
2575
+ const ok = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
2576
+ if (!c || !ok(c.input) || !ok(c.output)) continue;
2577
+ out[`${provider}/${id}`] = [c.input, c.output, ok(c.cache_read) ? c.cache_read : c.input / 10];
2578
+ }
2567
2579
  }
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
- `;
2580
+ return out;
2579
2581
  }
2580
- function isOurLauncher(file2, root) {
2581
- if (linksInto(file2, root)) return true;
2582
+ var cached2;
2583
+ function readCatalog() {
2584
+ const file2 = catalogFile();
2582
2585
  try {
2583
- const s = fs17.readFileSync(file2, "utf8");
2584
- return s.includes(LAUNCHER_MARK) && s.includes(root);
2586
+ const { mtimeMs } = fs16.statSync(file2);
2587
+ if (cached2?.file === file2 && cached2.mtimeMs === mtimeMs) return cached2.catalog;
2588
+ const parsed = JSON.parse(fs16.readFileSync(file2, "utf8"));
2589
+ const catalog5 = parsed && typeof parsed.models === "object" && parsed.models && Number.isFinite(Date.parse(parsed.fetchedAt)) ? parsed : void 0;
2590
+ cached2 = { file: file2, mtimeMs, catalog: catalog5 };
2591
+ return catalog5;
2585
2592
  } catch {
2586
- return false;
2593
+ return void 0;
2587
2594
  }
2588
2595
  }
2589
- function isPitroomLauncher(file2) {
2596
+ var ageMs = (file2) => {
2590
2597
  try {
2591
- return fs17.readFileSync(file2, "utf8").includes(LAUNCHER_MARK);
2598
+ return Date.now() - fs16.statSync(file2).mtimeMs;
2592
2599
  } catch {
2593
- return false;
2600
+ return Infinity;
2594
2601
  }
2602
+ };
2603
+ function refreshDue() {
2604
+ const eff = effective();
2605
+ if (!eff.priceFeed.value) return false;
2606
+ if (ageMs(catalogFile()) < eff.priceFeedHours.value * 36e5) return false;
2607
+ return ageMs(attemptFile()) > RETRY_AFTER_FAILURE_MS;
2595
2608
  }
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 });
2609
+ async function refreshCatalog() {
2610
+ const url = effective().priceFeedUrl.value;
2611
+ try {
2612
+ const r = await fetch(url, { signal: AbortSignal.timeout(3e4), headers: { accept: "application/json" } });
2613
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
2614
+ const models = trim(await r.json());
2615
+ const n = Object.keys(models).length;
2616
+ if (n < MIN_MODELS) throw new Error(`only ${n} priced models in it (a catalog has thousands): not used`);
2617
+ fs16.mkdirSync(home(), { recursive: true });
2618
+ const tmp = `${catalogFile()}.${process.pid}.tmp`;
2619
+ const catalog5 = { fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), source: url, models };
2620
+ fs16.writeFileSync(tmp, JSON.stringify(catalog5));
2621
+ renameOver(tmp, catalogFile());
2622
+ fs16.rmSync(attemptFile(), { force: true });
2623
+ return { state: "updated", models: n };
2624
+ } catch (e) {
2625
+ try {
2626
+ fs16.mkdirSync(home(), { recursive: true });
2627
+ fs16.writeFileSync(attemptFile(), `${(/* @__PURE__ */ new Date()).toISOString()} ${e.message}
2628
+ `);
2629
+ } catch {
2630
+ }
2631
+ return { state: "failed", error: e.message };
2605
2632
  }
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);
2615
2633
  }
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);
2634
+ function refreshInBackground() {
2635
+ try {
2636
+ if (!refreshDue()) return;
2637
+ const script = process.argv[1];
2638
+ if (!script) return;
2639
+ fs16.mkdirSync(home(), { recursive: true });
2640
+ fs16.writeFileSync(attemptFile(), `${(/* @__PURE__ */ new Date()).toISOString()} refresh started
2641
+ `);
2642
+ const child = spawn2(process.execPath, [script, "prices", "--refresh", "--quiet"], { detached: true, stdio: "ignore", env: process.env });
2643
+ child.on("error", () => void 0);
2644
+ child.unref();
2645
+ } catch {
2624
2646
  }
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}`;
2628
2647
  }
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
- }
2648
+ var OWN = { codex: ["openai"], claude: ["anthropic"], gemini: ["google"] };
2649
+ function catalogPrice(backend, ...models) {
2650
+ const catalog5 = readCatalog();
2651
+ if (!catalog5) return void 0;
2652
+ for (const raw of models) {
2653
+ const id = raw?.split("#")[0];
2654
+ if (!id) continue;
2655
+ const keys = [id, ...[...OWN[backend] ?? [], "anthropic", "openai", "google"].map((p) => `${p}/${id}`)];
2656
+ const any = keys.find((k) => catalog5.models[k]) ?? Object.keys(catalog5.models).filter((k) => k.endsWith(`/${id}`)).sort()[0];
2657
+ const triple = any ? catalog5.models[any] : void 0;
2658
+ if (any && triple && triple.length === 3 && triple.every((n) => Number.isFinite(n) && n >= 0)) {
2659
+ const [input, output, cachedInput] = triple;
2660
+ return { input, output, cachedInput, key: any };
2641
2661
  }
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
2662
  }
2650
- return out;
2663
+ return void 0;
2651
2664
  }
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
- }
2663
- }
2664
- }
2665
- if (isOurLauncher(launcherPath(), root)) {
2666
- fs17.rmSync(launcherPath(), { force: true });
2667
- out.push(`\u2714 removed ${launcherPath()}`);
2665
+ function catalogStatus() {
2666
+ const eff = effective();
2667
+ const c = readCatalog();
2668
+ let lastFailure;
2669
+ try {
2670
+ const t = fs16.readFileSync(attemptFile(), "utf8").trim();
2671
+ if (t && !t.endsWith("refresh started")) lastFailure = t;
2672
+ } catch {
2668
2673
  }
2669
- return out.length ? out : ["nothing to remove"];
2670
- }
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"))) }));
2674
+ return {
2675
+ enabled: eff.priceFeed.value,
2676
+ url: eff.priceFeedUrl.value,
2677
+ hours: eff.priceFeedHours.value,
2678
+ file: catalogFile(),
2679
+ models: c ? Object.keys(c.models).length : void 0,
2680
+ fetchedAt: c?.fetchedAt,
2681
+ ageHours: c ? (Date.now() - Date.parse(c.fetchedAt)) / 36e5 : void 0,
2682
+ lastFailure
2683
+ };
2674
2684
  }
2675
2685
 
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"
2686
+ // src/core/receipt.ts
2687
+ var PRESETS = {
2688
+ sonnet: { name: "Claude Sonnet", input: 3, output: 15, cachedInput: 0.3 },
2689
+ opus: { name: "Claude Opus", input: 5, output: 25, cachedInput: 0.5 },
2690
+ haiku: { name: "Claude Haiku", input: 1, output: 5, cachedInput: 0.1 },
2691
+ "gpt-5": { name: "GPT-5", input: 1.25, output: 10, cachedInput: 0.125 }
2682
2692
  };
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*/, "");
2693
+ function primaryPrice() {
2694
+ const eff = effective();
2695
+ const custom = eff.price.value?.split(",").map(Number);
2696
+ if (custom && custom.length >= 2 && custom.every((n) => Number.isFinite(n) && n >= 0)) {
2697
+ return { name: "custom", input: custom[0], output: custom[1], cachedInput: custom[2] ?? custom[0] / 10 };
2698
+ }
2699
+ const named = eff.primary.value;
2700
+ const preset = PRESETS[named.toLowerCase()];
2701
+ if (preset) return preset;
2702
+ const c = eff.priceFeed.value ? catalogPrice("", named) : void 0;
2703
+ return c ? { name: named, source: "catalog", input: c.input, output: c.output, cachedInput: c.cachedInput } : PRESETS.sonnet;
2687
2704
  }
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]);
2705
+ var estimateTokens = (text) => Math.ceil(text.length / 4);
2706
+ function parsePriceSpec(spec, name = "worker") {
2707
+ const parts = typeof spec === "string" ? spec.split(",").map((x) => Number(x.trim())) : [];
2708
+ if (parts.length < 2 || parts.length > 3 || !parts.every((n) => Number.isFinite(n) && n >= 0)) return void 0;
2709
+ return { name, source: "config", input: parts[0], output: parts[1], cachedInput: parts[2] ?? parts[0] / 10 };
2692
2710
  }
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;
2711
+ function workerPrice(backend, ...models) {
2712
+ const prices = effective().workerPrices.value;
2713
+ for (const key of [...models.filter((m) => !!m).map((m) => `${backend}:${m.split("#")[0]}`), backend]) {
2714
+ if (prices[key] !== void 0) return parsePriceSpec(prices[key], key);
2710
2715
  }
2716
+ const c = effective().priceFeed.value ? catalogPrice(backend, ...models) : void 0;
2717
+ return c ? { name: c.key, source: "catalog", input: c.input, output: c.output, cachedInput: c.cachedInput } : void 0;
2711
2718
  }
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);
2719
+ var costAt = (usage2, price) => (usage2.input * price.input + usage2.cacheRead * price.cachedInput + (usage2.output + usage2.reasoning) * price.output) / 1e6;
2720
+ var workerCostOf = (usage2) => usage2?.cost ?? usage2?.costEstimate;
2820
2721
  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
2722
  const readingTheReport = returnedTokens * price.input / 1e6;
2823
- return Math.max(0, wouldCost - (usage2.cost ?? 0) - readingTheReport);
2723
+ return Math.max(0, costAt(usage2, price) - (workerCostOf(usage2) ?? 0) - readingTheReport);
2824
2724
  }
2825
2725
  function record(meta) {
2826
2726
  if (!meta.usage?.steps || meta.auditOf) return;
@@ -2833,17 +2733,18 @@ function record(meta) {
2833
2733
  model: meta.resolvedModel ?? (meta.ran ?? meta.worker).model,
2834
2734
  tokens: meta.usage.total,
2835
2735
  returned: meta.returnedTokens ?? 0,
2836
- workerCost: meta.usage.cost ?? 0,
2736
+ workerCost: workerCostOf(meta.usage) ?? 0,
2737
+ ...meta.usage.cost === void 0 && meta.usage.costEstimate !== void 0 ? { costEstimated: true, costSource: meta.usage.costSource } : {},
2837
2738
  saved: meta.savedUsd ?? 0,
2838
2739
  price: primaryPrice().name
2839
2740
  };
2840
- fs20.mkdirSync(path17.dirname(ledgerFile()), { recursive: true });
2841
- fs20.appendFileSync(ledgerFile(), `${JSON.stringify(entry)}
2741
+ fs17.mkdirSync(path15.dirname(ledgerFile()), { recursive: true });
2742
+ fs17.appendFileSync(ledgerFile(), `${JSON.stringify(entry)}
2842
2743
  `);
2843
2744
  }
2844
2745
  function readLedger(sinceMs2) {
2845
- if (!fs20.existsSync(ledgerFile())) return [];
2846
- return fs20.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
2746
+ if (!fs17.existsSync(ledgerFile())) return [];
2747
+ return fs17.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
2847
2748
  try {
2848
2749
  return [JSON.parse(l)];
2849
2750
  } catch {
@@ -2917,168 +2818,8 @@ ${tile(0, compact(t.tokens), "Tokens offloaded")}${tile(1, t.ratio ? `${Math.rou
2917
2818
  `;
2918
2819
  }
2919
2820
 
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;
2941
- }
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
- });
2969
- }
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
2821
  // src/core/ui.ts
3081
- import fs22 from "node:fs";
2822
+ import fs18 from "node:fs";
3082
2823
  var WEEK_MS = 7 * 24 * 3600 * 1e3;
3083
2824
  var RECENT = 40;
3084
2825
  var RECENT_HOURS = 1;
@@ -3169,9 +2910,9 @@ function cardsFor(m) {
3169
2910
  const out = [];
3170
2911
  for (const [phase, text] of phases) {
3171
2912
  const marker = runFile(m.id, `card-${phase}`);
3172
- if (fs22.existsSync(marker)) continue;
2913
+ if (fs18.existsSync(marker)) continue;
3173
2914
  if (!(phase === "started" && !isActive(m.state))) out.push(text());
3174
- fs22.writeFileSync(marker, "");
2915
+ fs18.writeFileSync(marker, "");
3175
2916
  }
3176
2917
  return out;
3177
2918
  }
@@ -3182,10 +2923,10 @@ function hookCards(input) {
3182
2923
  const response = event.tool_response;
3183
2924
  const output = typeof response === "string" ? response : [response?.stdout, response?.stderr].filter(Boolean).join("\n");
3184
2925
  const ids = [...new Set(`${command}
3185
- ${output}`.match(RUN_ID) ?? [])].filter((id) => fs22.existsSync(`${runsDir()}/${id}`));
2926
+ ${output}`.match(RUN_ID) ?? [])].filter((id) => fs18.existsSync(`${runsDir()}/${id}`));
3186
2927
  const since2 = Date.now() - RECENT_HOURS * 36e5;
3187
2928
  for (const id of listRunIds().slice(-RECENT)) {
3188
- if (ids.includes(id) || fs22.existsSync(runFile(id, "card-ended"))) continue;
2929
+ if (ids.includes(id) || fs18.existsSync(runFile(id, "card-ended"))) continue;
3189
2930
  try {
3190
2931
  const m = readMeta(id);
3191
2932
  if (!isActive(m.state) && Date.parse(m.endedAt ?? m.startedAt) > since2) ids.push(id);
@@ -3206,11 +2947,11 @@ ${output}`.match(RUN_ID) ?? [])].filter((id) => fs22.existsSync(`${runsDir()}/${
3206
2947
  var SCHEMA_VERSION = 1;
3207
2948
  var PATCH_MAX = 1e6;
3208
2949
  var ANSWER_MAX2 = 2e5;
3209
- var cached2;
3210
- var historyFile = () => path19.join(home(), "history.db");
2950
+ var cached3;
2951
+ var historyFile = () => path16.join(home(), "history.db");
3211
2952
  function openDb() {
3212
2953
  const file2 = historyFile();
3213
- if (cached2?.file === file2) return cached2.db;
2954
+ if (cached3?.file === file2) return cached3.db;
3214
2955
  let db;
3215
2956
  try {
3216
2957
  const emit = process.emitWarning;
@@ -3221,14 +2962,14 @@ function openDb() {
3221
2962
  } finally {
3222
2963
  process.emitWarning = emit;
3223
2964
  }
3224
- fs23.mkdirSync(path19.dirname(file2), { recursive: true });
2965
+ fs19.mkdirSync(path16.dirname(file2), { recursive: true });
3225
2966
  db = new DatabaseSync(file2);
3226
2967
  db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA synchronous=NORMAL;");
3227
2968
  migrate(db);
3228
2969
  } catch {
3229
2970
  db = void 0;
3230
2971
  }
3231
- cached2 = { file: file2, db };
2972
+ cached3 = { file: file2, db };
3232
2973
  return db;
3233
2974
  }
3234
2975
  function migrate(db) {
@@ -3256,31 +2997,31 @@ function migrate(db) {
3256
2997
  function readRunFile(id, name) {
3257
2998
  const f = runFile(id, name);
3258
2999
  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");
3000
+ if (fs19.existsSync(f)) return fs19.readFileSync(f, "utf8");
3001
+ if (fs19.existsSync(`${f}.gz`)) return zlib.gunzipSync(fs19.readFileSync(`${f}.gz`)).toString("utf8");
3261
3002
  } catch {
3262
3003
  }
3263
3004
  return void 0;
3264
3005
  }
3265
3006
  function gzipFile(f) {
3266
- if (!fs23.existsSync(f)) return;
3267
- fs23.writeFileSync(`${f}.gz`, zlib.gzipSync(fs23.readFileSync(f)));
3268
- fs23.rmSync(f);
3007
+ if (!fs19.existsSync(f)) return;
3008
+ fs19.writeFileSync(`${f}.gz`, zlib.gzipSync(fs19.readFileSync(f)));
3009
+ fs19.rmSync(f);
3269
3010
  }
3270
3011
  function compactRun(meta) {
3271
3012
  if (!TERMINAL.includes(meta.state)) return;
3272
- const dir = path19.dirname(runFile(meta.id, "meta.json"));
3013
+ const dir = path16.dirname(runFile(meta.id, "meta.json"));
3273
3014
  let names;
3274
3015
  try {
3275
- names = fs23.readdirSync(dir);
3016
+ names = fs19.readdirSync(dir);
3276
3017
  } catch {
3277
3018
  return;
3278
3019
  }
3279
3020
  for (const n of names) {
3280
- if (/^events(\.attempt-\d+)?\.jsonl$/.test(n)) gzipFile(path19.join(dir, n));
3021
+ if (/^events(\.attempt-\d+)?\.jsonl$/.test(n)) gzipFile(path16.join(dir, n));
3281
3022
  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));
3023
+ if (meta.state === "done" && !/attempt/.test(n)) fs19.rmSync(path16.join(dir, n), { force: true });
3024
+ else gzipFile(path16.join(dir, n));
3284
3025
  }
3285
3026
  }
3286
3027
  }
@@ -3324,7 +3065,7 @@ function recordRun(meta) {
3324
3065
  tool_calls: u?.toolCalls ?? null,
3325
3066
  tokens: u?.total ?? null,
3326
3067
  returned_tokens: meta.returnedTokens ?? null,
3327
- cost: u?.cost ?? null,
3068
+ cost: u?.cost ?? u?.costEstimate ?? null,
3328
3069
  saved: meta.savedUsd ?? null,
3329
3070
  files_changed: meta.changes?.length ?? 0,
3330
3071
  applied: meta.applied ? 1 : 0,
@@ -3489,14 +3230,14 @@ function auditStats(db, since2) {
3489
3230
  return { total, byWorker };
3490
3231
  }
3491
3232
  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 }));
3233
+ 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
3234
  const key = (backend, model) => `${backend ?? ""}\0${model ?? ""}`;
3494
3235
  const index = new Map(out.map((r) => [key(r.backend, r.model), r]));
3495
3236
  for (const e of ledger) {
3496
3237
  const backend = e.backend ?? "opencode";
3497
3238
  let row = index.get(key(backend, e.model)) ?? (e.model ? out.find((r) => r.backend === backend && r.model?.endsWith(`/${e.model}`)) : void 0);
3498
3239
  if (!row) {
3499
- row = { backend, model: e.model, runs: 0, ok: 0, avgSeconds: null, avgTokens: null, saved: 0, audited: 0, agreed: 0 };
3240
+ row = { backend, model: e.model, runs: 0, ok: 0, limited: 0, avgSeconds: null, avgTokens: null, saved: 0, audited: 0, agreed: 0 };
3500
3241
  index.set(key(backend, e.model), row);
3501
3242
  out.push(row);
3502
3243
  }
@@ -3508,24 +3249,28 @@ function workerRows(rows, ledger, audited) {
3508
3249
  }
3509
3250
  return out;
3510
3251
  }
3252
+ var LIMITED = `(state='failed' AND (json_extract(meta_json,'$.failureKind')='rate-limited' OR error LIKE '%the model is rate-limited or overloaded%'))`;
3511
3253
  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: [] };
3254
+ const counted = effective().countRateLimits.value === true;
3255
+ const K = counted ? "1" : `NOT ${LIMITED}`;
3256
+ 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
3257
  const db = openDb();
3514
3258
  if (!db) return empty;
3515
3259
  const since2 = sinceMs2 ? new Date(sinceMs2).toISOString() : "";
3516
3260
  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);
3261
+ 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);
3262
+ 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);
3263
+ 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
3264
  const audits = auditStats(db, since2);
3521
3265
  const ledger = readLedger(sinceMs2);
3522
3266
  const byDay = /* @__PURE__ */ new Map();
3523
3267
  for (const e of ledger) byDay.set(e.at.slice(0, 10), (byDay.get(e.at.slice(0, 10)) ?? 0) + e.saved);
3524
3268
  return {
3525
- totals: { runs: t.runs, ok: t.ok, failed: t.runs - t.ok, seconds: t.seconds, tokens: t.tokens, saved: totals(ledger).saved },
3269
+ rateLimits: counted ? "counted" : "excluded",
3270
+ 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
3271
  audits: audits.total,
3527
3272
  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 }))
3273
+ 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
3274
  };
3530
3275
  } catch {
3531
3276
  return empty;
@@ -3533,19 +3278,91 @@ function historyStats(sinceMs2) {
3533
3278
  }
3534
3279
 
3535
3280
  // src/core/report.ts
3536
- import fs24 from "node:fs";
3281
+ import fs21 from "node:fs";
3537
3282
 
3538
- // src/core/style.ts
3539
- var enabled = () => {
3540
- if (process.env.NO_COLOR) return false;
3541
- if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
3542
- return !!process.stdout.isTTY && process.env.TERM !== "dumb";
3543
- };
3544
- var wrap = (open, close) => (text) => enabled() ? `\x1B[${open}m${text}\x1B[${close}m` : text;
3545
- var bold = wrap(1, 22);
3546
- var dim = wrap(2, 22);
3547
- var red = wrap(31, 39);
3548
- var green = wrap(32, 39);
3283
+ // src/core/plan.ts
3284
+ import fs20 from "node:fs";
3285
+ import path17 from "node:path";
3286
+ var FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
3287
+ var RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
3288
+ function structure(lines) {
3289
+ const headings = [];
3290
+ const rules = [];
3291
+ const fenced = [];
3292
+ let open;
3293
+ lines.forEach((l, i) => {
3294
+ const f = FENCE.exec(l);
3295
+ if (f) {
3296
+ fenced[i] = true;
3297
+ const mark = f[1];
3298
+ if (!open) open = mark;
3299
+ else if (mark[0] === open[0] && mark.length >= open.length && !f[2].trim()) open = void 0;
3300
+ return;
3301
+ }
3302
+ fenced[i] = !!open;
3303
+ if (open) return;
3304
+ const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(l);
3305
+ if (h) headings.push({ line: i, level: h[1].length, text: h[2] });
3306
+ else if (RULE.test(l)) rules.push(i);
3307
+ });
3308
+ return { headings, rules, fenced };
3309
+ }
3310
+ function parsePlan(text, file2 = "") {
3311
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
3312
+ const { headings, rules, fenced } = structure(lines);
3313
+ const after = (line) => Math.min(headings.find((h) => h.line > line)?.line ?? lines.length, rules.find((r) => r > line) ?? lines.length);
3314
+ const titleHeading = headings.find((h) => h.level === 1);
3315
+ const start = titleHeading ? titleHeading.line + 1 : 0;
3316
+ const header = lines.slice(start, after(start - 1)).filter((l) => !/^\s*>/.test(l)).join("\n").trim();
3317
+ const gc = headings.find((h) => /^global constraints\b/i.test(h.text));
3318
+ const constraints = gc ? lines.slice(gc.line + 1, after(gc.line)).join("\n").trim() : "";
3319
+ const tasks = [];
3320
+ for (const h of headings) {
3321
+ const m = /^Task\s+(\d+)\b\s*[:.)\-–—]?\s*(.*)$/i.exec(h.text);
3322
+ if (!m) continue;
3323
+ const end = headings.find((o) => o.line > h.line && o.level <= h.level)?.line ?? lines.length;
3324
+ const body = lines.slice(h.line, end).join("\n").replace(/(\n\s*(?:---|\*\*\*|___)\s*)+$/, "").trimEnd();
3325
+ const unfenced = lines.slice(h.line, end).filter((_, j) => !fenced[h.line + j]).join("\n");
3326
+ const tier = /^\s*[-*]?\s*\*\*Worker:\*\*\s*`?([\w-]+)`?/m.exec(unfenced)?.[1]?.toLowerCase();
3327
+ tasks.push({ step: Number(m[1]), title: m[2].trim(), text: body, ...tier ? { tier } : {} });
3328
+ }
3329
+ return { file: file2, title: titleHeading?.text ?? "", header, constraints, tasks };
3330
+ }
3331
+ function loadPlan(file2) {
3332
+ const abs = path17.resolve(file2);
3333
+ if (!fs20.existsSync(abs) || !fs20.statSync(abs).isFile()) throw new UserError(`plan not found: ${file2}`);
3334
+ const plan = parsePlan(fs20.readFileSync(abs, "utf8"), fs20.realpathSync(abs));
3335
+ if (!plan.tasks.length) throw new UserError(`${file2} has no "Task N" headings (see pitroom-writing-plans)`);
3336
+ return plan;
3337
+ }
3338
+ function planTask(plan, step) {
3339
+ const t = plan.tasks.find((x) => x.step === step);
3340
+ if (!t) throw new UserError(`no Task ${step} in ${plan.file}; it has ${plan.tasks.map((x) => `Task ${x.step}`).join(", ")}`);
3341
+ return t;
3342
+ }
3343
+ function brief(plan, task) {
3344
+ return `${[
3345
+ `# ${plan.title || planName(plan.file)}`,
3346
+ plan.header,
3347
+ "## Global Constraints",
3348
+ plan.constraints || "(none stated in the plan)",
3349
+ task.text
3350
+ ].filter(Boolean).join("\n\n")}
3351
+ `;
3352
+ }
3353
+ var planName = (file2) => path17.basename(file2).replace(/\.md$/i, "");
3354
+
3355
+ // src/core/style.ts
3356
+ var enabled = () => {
3357
+ if (process.env.NO_COLOR) return false;
3358
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
3359
+ return !!process.stdout.isTTY && process.env.TERM !== "dumb";
3360
+ };
3361
+ var wrap = (open, close) => (text) => enabled() ? `\x1B[${open}m${text}\x1B[${close}m` : text;
3362
+ var bold = wrap(1, 22);
3363
+ var dim = wrap(2, 22);
3364
+ var red = wrap(31, 39);
3365
+ var green = wrap(32, 39);
3549
3366
  var yellow = wrap(33, 39);
3550
3367
  var blue = wrap(34, 39);
3551
3368
  var cyan = wrap(36, 39);
@@ -3593,7 +3410,7 @@ function duration(meta) {
3593
3410
  }
3594
3411
  function readSummary(meta) {
3595
3412
  const f = runFile(meta.id, "summary.md");
3596
- return fs24.existsSync(f) ? fs24.readFileSync(f, "utf8").trim() : archivedRun(meta.id)?.answer ?? "";
3413
+ return fs21.existsSync(f) ? fs21.readFileSync(f, "utf8").trim() : archivedRun(meta.id)?.answer ?? "";
3597
3414
  }
3598
3415
  function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
3599
3416
  const out = [];
@@ -3636,7 +3453,7 @@ function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
3636
3453
  if (u && u.steps) {
3637
3454
  const ratio = meta.returnedTokens ? `, ${Math.max(1, Math.round(u.total / meta.returnedTokens))}\xD7 compression` : "";
3638
3455
  out.push(
3639
- `\u2500\u2500 receipt: worker processed ${compact(u.total)} tokens in ${plural(u.steps, "step")} (${plural(u.toolCalls, "tool call")}${u.denied ? `, ${u.denied} blocked` : ""}) \xB7 worker cost ${u.cost === void 0 ? "n/a" : usd(u.cost)} \xB7 returned ~${compact(meta.returnedTokens ?? 0)} tokens${ratio}` + (meta.savedUsd !== void 0 ? ` \xB7 est. saved ${usd(meta.savedUsd)} vs ${primaryPrice().name}` : "")
3456
+ `\u2500\u2500 receipt: worker processed ${compact(u.total)} tokens in ${plural(u.steps, "step")} (${plural(u.toolCalls, "tool call")}${u.denied ? `, ${u.denied} blocked` : ""}) \xB7 worker cost ${u.cost !== void 0 ? usd(u.cost) : u.costEstimate !== void 0 ? `~${usd(u.costEstimate)} (estimated from ${u.costSource === "catalog" ? `${catalogLabel()} prices` : "your workerPrices"})` : "n/a"} \xB7 returned ~${compact(meta.returnedTokens ?? 0)} tokens${ratio}` + (meta.savedUsd !== void 0 ? ` \xB7 est. saved ${usd(meta.savedUsd)} vs ${primaryPrice().name}` : "")
3640
3457
  );
3641
3458
  }
3642
3459
  if (meta.changes) {
@@ -3654,44 +3471,659 @@ function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
3654
3471
  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
3472
  );
3656
3473
  }
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}`);
3474
+ if (meta.changes.length) {
3475
+ out.push(` diff: pitroom show ${meta.id} --patch`);
3476
+ out.push(` review: pitroom review ${meta.id}`);
3477
+ if (meta.mode === "isolate" && !meta.applied && !meta.discarded) {
3478
+ out.push(` apply: pitroom apply ${meta.id}`, ` discard: pitroom discard ${meta.id}`);
3479
+ }
3480
+ if (meta.mode === "write" && !meta.reverted) out.push(` undo: pitroom revert ${meta.id}`);
3481
+ }
3482
+ }
3483
+ if (meta.verifyResult) {
3484
+ const v = meta.verifyResult;
3485
+ 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);
3486
+ 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})`}`);
3487
+ 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");
3488
+ if (!v.ok && v.tail) out.push(...v.tail.split("\n").map((l) => ` ${l}`));
3489
+ }
3490
+ if (isActive(meta.state)) {
3491
+ out.push(`\u2500\u2500 still running: pitroom wait ${meta.id} (stop: pitroom stop ${meta.id})`);
3492
+ } else if (meta.sessionId && !meta.applied && !meta.discarded && !meta.reviewOf) {
3493
+ out.push(`\u2500\u2500 follow up: pitroom run --continue ${meta.id} "\u2026"`);
3494
+ }
3495
+ return out.join("\n");
3496
+ }
3497
+ function live(meta) {
3498
+ const f = runFile(meta.id, "events.jsonl");
3499
+ if (!fs21.existsSync(f)) return { steps: 0, toolCalls: 0 };
3500
+ const p = getBackend((meta.ran ?? meta.worker).backend).parse(fs21.readFileSync(f, "utf8"));
3501
+ return { steps: p.usage.steps, toolCalls: p.usage.toolCalls, last: p.lastActivity };
3502
+ }
3503
+ function progress(meta) {
3504
+ if (meta.state === "queued") return `pitroom \u22EF queued \xB7 ${meta.mode} \xB7 waiting for a free worker slot \xB7 run ${meta.id}`;
3505
+ const l = live(meta);
3506
+ const last = l.last ? ` \xB7 last: ${l.last}` : "";
3507
+ return `pitroom \u2026 running \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 ${l.steps} steps, ${l.toolCalls} tool calls${last} \xB7 run ${meta.id}`;
3508
+ }
3509
+
3510
+ // src/core/group.ts
3511
+ function groupIds(group) {
3512
+ return listRunIds().filter((id) => {
3513
+ try {
3514
+ return readMeta(id).group === group;
3515
+ } catch {
3516
+ return false;
3517
+ }
3518
+ });
3519
+ }
3520
+ var oneLine5 = (s, max) => {
3521
+ const t = s.replace(/\s+/g, " ").trim();
3522
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
3523
+ };
3524
+ function headline(meta, max = 160) {
3525
+ const text = readSummary(meta);
3526
+ const line = text.split("\n").find((l) => /^SUMMARY:/i.test(l.trim())) ?? text.split("\n").find((l) => l.trim()) ?? "";
3527
+ return oneLine5(line.replace(/^SUMMARY:\s*/i, ""), max);
3528
+ }
3529
+ function table(metas) {
3530
+ if (!metas.length) return "no runs";
3531
+ const rows = metas.map((m) => {
3532
+ const l = m.state === "running" ? live(m) : void 0;
3533
+ const steps = l ? String(l.steps) : m.usage ? String(m.usage.steps) : "-";
3534
+ const note = l?.last ? oneLine5(l.last, 48) : isActive(m.state) ? oneLine5(m.task, 48) : headline(m, 48) || oneLine5(m.error ?? m.task, 48);
3535
+ return [m.id, m.state, m.mode, duration(m), steps, describeTarget(m.ran ?? m.worker), note];
3536
+ });
3537
+ const head = ["RUN", "STATE", "MODE", "TIME", "STEPS", "WORKER", "NOW / RESULT"];
3538
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
3539
+ const fmt = (r, header = false) => r.map((c, i) => {
3540
+ const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
3541
+ return header ? dim(cell2) : i === 1 ? stateColour(r[1], cell2) : cell2;
3542
+ }).join(" ");
3543
+ return [fmt(head, true), ...rows.map((r) => fmt(r))].join("\n");
3544
+ }
3545
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3546
+ async function waitMany(ids, opts) {
3547
+ const deadline = Date.now() + opts.timeoutMs;
3548
+ for (; ; ) {
3549
+ const metas = ids.map((id) => freshMeta(id));
3550
+ const done = metas.filter((m) => TERMINAL.includes(m.state)).length;
3551
+ if (opts.any ? done > 0 : done === metas.length) return { metas, timedOut: false };
3552
+ if (Date.now() >= deadline) return { metas, timedOut: true };
3553
+ await sleep(1500);
3554
+ }
3555
+ }
3556
+ async function watch(select, opts) {
3557
+ const seen = /* @__PURE__ */ new Map();
3558
+ const line = (s) => opts.write(`${s}
3559
+ `);
3560
+ const emit = (e, m) => {
3561
+ if (!opts.brief) return opts.write(`${JSON.stringify(e)}
3562
+ `);
3563
+ if (e.event === "started" && m) line(startedCard(m));
3564
+ else if (e.event === "fallback" && m) line(`\u{1F3C1} Pitroom \u21BB ${m.id} fell back from ${String(e.failed)}: ${String(e.reason)}`);
3565
+ else if (m && TERMINAL.includes(m.state)) line(endedCard(m));
3566
+ 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` : ""})`);
3567
+ };
3568
+ const deadline = opts.timeoutMs > 0 ? Date.now() + opts.timeoutMs : Infinity;
3569
+ for (; ; ) {
3570
+ const metas = select().map((id) => freshMeta(id));
3571
+ if (opts.json || opts.brief) {
3572
+ for (const m of metas) {
3573
+ const prev = seen.get(m.id);
3574
+ const l = m.state === "running" ? live(m) : { steps: m.usage?.steps ?? 0, toolCalls: m.usage?.toolCalls ?? 0 };
3575
+ const attempts = m.attempts?.length ?? 0;
3576
+ const base2 = { run: m.id, mode: m.mode, task: oneLine5(m.task, 60) };
3577
+ if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base2 }, m);
3578
+ else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base2 }, m);
3579
+ if (attempts > (prev?.attempts ?? 0)) {
3580
+ const a = m.attempts[attempts - 1];
3581
+ emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine5(a.error, 120) }, m);
3582
+ }
3583
+ if (m.state === "running" && l.steps > (prev?.steps ?? 0)) {
3584
+ 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 });
3585
+ }
3586
+ if (TERMINAL.includes(m.state) && prev?.state !== m.state) {
3587
+ emit({
3588
+ event: m.state,
3589
+ run: m.id,
3590
+ time: duration(m),
3591
+ worker: describeTarget(m.ran ?? m.worker),
3592
+ summary: headline(m) || void 0,
3593
+ error: m.error ? oneLine5(m.error, 160) : void 0,
3594
+ refs: m.refs ? `${m.refs.valid}/${m.refs.total}` : void 0,
3595
+ changes: m.changes?.length || void 0
3596
+ }, m);
3597
+ }
3598
+ seen.set(m.id, { state: m.state, steps: l.steps, attempts });
3599
+ }
3600
+ } else {
3601
+ opts.write(`\x1B[H\x1B[2J${table(metas)}
3602
+
3603
+ ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watching (workers keep running)
3604
+ `);
3605
+ }
3606
+ const allDone = metas.length > 0 && metas.every((m) => TERMINAL.includes(m.state));
3607
+ if (allDone || metas.length === 0 && seen.size === 0) {
3608
+ if (opts.json || opts.brief) {
3609
+ const ok = metas.filter((m) => m.state === "done").length;
3610
+ emit({ event: "all-done", runs: metas.length, ok, failed: metas.length - ok });
3611
+ }
3612
+ return { metas, timedOut: false };
3613
+ }
3614
+ if (Date.now() >= deadline) return { metas, timedOut: true };
3615
+ await sleep(opts.intervalMs);
3616
+ }
3617
+ }
3618
+
3619
+ // src/core/notify.ts
3620
+ var MARK = { done: "\u2714", failed: "\u2718", timeout: "\u23F1", stopped: "\u25A0" };
3621
+ function noticeFor(meta) {
3622
+ const group = meta.group ? safeGroup(meta.group) : [];
3623
+ if (group.length > 1) {
3624
+ const count = (state) => group.filter((m) => m.state === state).length;
3625
+ const worse = group.length - count("done");
3626
+ const parts = [`${count("done")} done`, ...["failed", "timeout", "stopped"].filter(count).map((s) => `${count(s)} ${s}`)];
3627
+ const changed2 = group.reduce((n, m) => n + (m.changes?.length ?? 0), 0);
3628
+ return {
3629
+ title: `Pitroom ${worse ? "\u26A0" : "\u2714"} group ${meta.group}`,
3630
+ body: `${group.length} runs ended: ${parts.join(", ")}${changed2 ? ` \xB7 ${changed2} file${changed2 === 1 ? "" : "s"} changed` : ""}`
3631
+ };
3632
+ }
3633
+ const changed = meta.changes?.length ? ` \xB7 ${meta.changes.length} file${meta.changes.length === 1 ? "" : "s"} changed` : "";
3634
+ return { title: `Pitroom ${MARK[meta.state] ?? "\u2022"} ${kind(meta)} ${meta.state}`, body: `${what(meta)} \xB7 ${elapsed(meta)}${changed}` };
3635
+ }
3636
+ function safeGroup(name) {
3637
+ try {
3638
+ return groupIds(name).map((id) => readMeta(id));
3639
+ } catch {
3640
+ return [];
3641
+ }
3642
+ }
3643
+ function shouldNotify(meta) {
3644
+ const eff = effective();
3645
+ if (!eff.notify.value || !meta.background || meta.auditOf || !TERMINAL.includes(meta.state)) return false;
3646
+ const runs = meta.group ? safeGroup(meta.group) : [meta];
3647
+ if (!runs.length || !runs.every((m) => TERMINAL.includes(m.state))) return false;
3648
+ if (runs.every((m) => m.state === "done" || m.state === "stopped")) {
3649
+ const start = Math.min(...runs.map((m) => Date.parse(m.startedAt)));
3650
+ const end = Math.max(...runs.map((m) => Date.parse(m.endedAt ?? "")));
3651
+ if (Number.isFinite(end - start) && (end - start) / 1e3 < eff.notifyAfter.value) return false;
3652
+ if (runs.every((m) => m.state === "stopped")) return false;
3653
+ }
3654
+ return true;
3655
+ }
3656
+ function claimGroup(group) {
3657
+ try {
3658
+ const ids = groupIds(group).sort();
3659
+ if (ids.length < 2) return true;
3660
+ const dir = path18.join(home(), "notified");
3661
+ fs22.mkdirSync(dir, { recursive: true });
3662
+ for (const f of fs22.readdirSync(dir)) {
3663
+ if (Date.now() - fs22.statSync(path18.join(dir, f)).mtimeMs > 30 * 864e5) fs22.rmSync(path18.join(dir, f), { force: true });
3664
+ }
3665
+ fs22.writeFileSync(path18.join(dir, crypto5.createHash("sha1").update(ids.join(",")).digest("hex").slice(0, 16)), "", { flag: "wx" });
3666
+ return true;
3667
+ } catch {
3668
+ return false;
3669
+ }
3670
+ }
3671
+ function send(notice, meta) {
3672
+ const command = effective().notifyCommand.value;
3673
+ const env = { ...process.env, PITROOM_NOTIFY_TITLE: notice.title, PITROOM_NOTIFY_BODY: notice.body, PITROOM_NOTIFY_RUN: meta.id, PITROOM_NOTIFY_STATE: meta.state };
3674
+ try {
3675
+ let child;
3676
+ if (command) {
3677
+ child = spawn3(command, { shell: true, env, detached: true, stdio: "ignore" });
3678
+ } else if (process.platform === "darwin") {
3679
+ child = spawn3("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" });
3680
+ } else if (process.platform === "linux") {
3681
+ const plain2 = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3682
+ child = spawn3("notify-send", ["--", plain2(notice.title), plain2(notice.body)], { env, detached: true, stdio: "ignore" });
3683
+ } else {
3684
+ return;
3685
+ }
3686
+ child.on("error", () => void 0);
3687
+ child.unref();
3688
+ } catch {
3689
+ }
3690
+ }
3691
+ function notifyEnded(meta) {
3692
+ try {
3693
+ if (shouldNotify(meta) && (!meta.group || claimGroup(meta.group))) send(noticeFor(meta), meta);
3694
+ } catch {
3695
+ }
3696
+ }
3697
+
3698
+ // src/core/templates.ts
3699
+ import fs24 from "node:fs";
3700
+ import path20 from "node:path";
3701
+
3702
+ // src/core/install.ts
3703
+ import fs23 from "node:fs";
3704
+ import os9 from "node:os";
3705
+ import path19 from "node:path";
3706
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
3707
+ var LEGACY = ["pitroom", "opencode-worker"];
3708
+ function packageRoot() {
3709
+ return path19.resolve(path19.dirname(fileURLToPath2(import.meta.url)), "..");
3710
+ }
3711
+ function skillNames(root = packageRoot()) {
3712
+ const dir = path19.join(root, "skills");
3713
+ if (!fs23.existsSync(dir)) return [];
3714
+ return fs23.readdirSync(dir).filter((d) => fs23.existsSync(path19.join(dir, d, "SKILL.md"))).sort();
3715
+ }
3716
+ function skillTargets() {
3717
+ const home3 = os9.homedir();
3718
+ const targets = [path19.join(home3, ".agents", "skills")];
3719
+ if (fs23.existsSync(path19.join(home3, ".claude"))) targets.push(path19.join(home3, ".claude", "skills"));
3720
+ return targets;
3721
+ }
3722
+ var launcherPath = () => path19.join(os9.homedir(), ".local", "bin", process.platform === "win32" ? "pitroom.cmd" : "pitroom");
3723
+ var LAUNCHER_MARK = "# pitroom launcher";
3724
+ function launcherScript(bundle) {
3725
+ if (process.platform === "win32") {
3726
+ const q2 = (v) => v.replace(/%/g, "%%");
3727
+ return `@echo off\r
3728
+ rem ${LAUNCHER_MARK} (created by \`pitroom install\`; \`pitroom uninstall\` removes it)\r
3729
+ if defined PITROOM_NODE (\r
3730
+ "%PITROOM_NODE%" "${q2(bundle)}" %*\r
3731
+ ) else (\r
3732
+ "${q2(process.execPath)}" "${q2(bundle)}" %*\r
3733
+ )\r
3734
+ exit /b %ERRORLEVEL%\r
3735
+ `;
3736
+ }
3737
+ const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
3738
+ return `#!/bin/sh
3739
+ ${LAUNCHER_MARK} (created by \`pitroom install\`; \`pitroom uninstall\` removes it)
3740
+ cli=${q(bundle)}
3741
+ 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; }
3742
+ 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
3743
+ if ok "$n"; then exec "$n" "$cli" "$@"; fi
3744
+ done
3745
+ echo "pitroom: needs Node.js 22.13 or newer (set PITROOM_NODE to its path)" >&2
3746
+ exit 127
3747
+ `;
3748
+ }
3749
+ function isOurLauncher(file2, root) {
3750
+ if (linksInto(file2, root)) return true;
3751
+ try {
3752
+ const s = fs23.readFileSync(file2, "utf8");
3753
+ return s.includes(LAUNCHER_MARK) && s.includes(root);
3754
+ } catch {
3755
+ return false;
3756
+ }
3757
+ }
3758
+ function isPitroomLauncher(file2) {
3759
+ try {
3760
+ return fs23.readFileSync(file2, "utf8").includes(LAUNCHER_MARK);
3761
+ } catch {
3762
+ return false;
3763
+ }
3764
+ }
3765
+ function placeLauncher(bundle, root, force) {
3766
+ const dest = launcherPath();
3767
+ fs23.mkdirSync(path19.dirname(dest), { recursive: true });
3768
+ const exists = fs23.lstatSync(dest, { throwIfNoEntry: false });
3769
+ if (exists && !isOurLauncher(dest, root)) {
3770
+ if (!force) return `! ${dest} exists and is not Pitroom's launcher; kept (use --force to back it up and replace)`;
3771
+ fs23.renameSync(dest, `${dest}.bak-${Date.now()}`);
3772
+ } else if (exists) {
3773
+ fs23.rmSync(dest, { force: true });
3774
+ }
3775
+ fs23.writeFileSync(dest, launcherScript(bundle), { mode: 493 });
3776
+ return `\u2714 ${dest} \u2192 launcher for ${bundle} (Node 22.13+)`;
3777
+ }
3778
+ function linksInto(link, dir) {
3779
+ const st = fs23.lstatSync(link, { throwIfNoEntry: false });
3780
+ if (!st?.isSymbolicLink()) return false;
3781
+ const target = path19.resolve(path19.dirname(link), fs23.readlinkSync(link));
3782
+ const rel = path19.relative(dir, target);
3783
+ return !rel.startsWith("..") && !path19.isAbsolute(rel);
3784
+ }
3785
+ function place(src, dest, opts) {
3786
+ fs23.mkdirSync(path19.dirname(dest), { recursive: true });
3787
+ const st = fs23.lstatSync(dest, { throwIfNoEntry: false });
3788
+ if (st && !st.isSymbolicLink()) {
3789
+ if (!opts.force) return `! ${dest} exists and is not a link; kept (use --force to back it up and replace)`;
3790
+ fs23.renameSync(dest, `${dest}.bak-${Date.now()}`);
3791
+ } else if (st) {
3792
+ fs23.unlinkSync(dest);
3793
+ }
3794
+ if (opts.copy) fs23.cpSync(src, dest, { recursive: true });
3795
+ else fs23.symlinkSync(src, dest, process.platform === "win32" ? "junction" : fs23.statSync(src).isDirectory() ? "dir" : "file");
3796
+ return `\u2714 ${dest} \u2192 ${opts.copy ? "copied" : src}`;
3797
+ }
3798
+ function install(opts) {
3799
+ const root = packageRoot();
3800
+ const skills2 = opts.skills === false ? [] : skillNames(root);
3801
+ if (opts.skills !== false && !skills2.length) throw new Error(`no skills found under ${path19.join(root, "skills")}`);
3802
+ const out = [];
3803
+ for (const base2 of opts.skills === false ? [] : skillTargets()) {
3804
+ for (const legacy of LEGACY) {
3805
+ const l = path19.join(base2, legacy);
3806
+ if (!skills2.includes(legacy) && linksInto(l, root)) {
3807
+ fs23.unlinkSync(l);
3808
+ out.push(`\u2714 removed old link ${l}`);
3809
+ }
3810
+ }
3811
+ for (const name of skills2) out.push(place(path19.join(root, "skills", name), path19.join(base2, name), opts));
3812
+ }
3813
+ const bundle = path19.join(root, "dist", "pitroom.mjs");
3814
+ if (fs23.existsSync(bundle)) {
3815
+ out.push(placeLauncher(bundle, root, opts.force));
3816
+ const onPath = (process.env.PATH ?? "").split(path19.delimiter).some((d) => path19.resolve(d) === path19.dirname(launcherPath()));
3817
+ if (!onPath) out.push(`! ${path19.dirname(launcherPath())} is not on PATH; add it, or run ${launcherPath()} directly`);
3818
+ }
3819
+ return out;
3820
+ }
3821
+ function uninstall() {
3822
+ const root = packageRoot();
3823
+ const out = [];
3824
+ for (const base2 of skillTargets()) {
3825
+ if (!fs23.existsSync(base2)) continue;
3826
+ for (const name of fs23.readdirSync(base2)) {
3827
+ const l = path19.join(base2, name);
3828
+ if (linksInto(l, root)) {
3829
+ fs23.unlinkSync(l);
3830
+ out.push(`\u2714 removed ${l}`);
3831
+ }
3832
+ }
3833
+ }
3834
+ if (isOurLauncher(launcherPath(), root)) {
3835
+ fs23.rmSync(launcherPath(), { force: true });
3836
+ out.push(`\u2714 removed ${launcherPath()}`);
3837
+ }
3838
+ return out.length ? out : ["nothing to remove"];
3839
+ }
3840
+ function installedSkills() {
3841
+ const names = skillNames();
3842
+ return skillTargets().map((base2) => ({ base: base2, names: names.filter((n) => fs23.existsSync(path19.join(base2, n, "SKILL.md"))) }));
3843
+ }
3844
+
3845
+ // src/core/templates.ts
3846
+ var FILES = {
3847
+ implementer: "pitroom-driven-development/implementer-prompt.md",
3848
+ "task-reviewer": "pitroom-driven-development/task-reviewer-prompt.md",
3849
+ "re-review": "pitroom-driven-development/re-review-prompt.md",
3850
+ "code-reviewer": "pitroom-review/code-reviewer.md"
3851
+ };
3852
+ function loadTemplate(name, root = packageRoot()) {
3853
+ const file2 = path20.join(root, "skills", FILES[name]);
3854
+ if (!fs24.existsSync(file2)) throw new UserError(`template missing: ${file2} (broken install? run pitroom doctor)`, 3);
3855
+ return fs24.readFileSync(file2, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
3856
+ }
3857
+ function fill(template, values) {
3858
+ const missing = [...template.matchAll(/\{\{([A-Z_]+)\}\}/g)].map((m) => m[1]).filter((k) => !(k in values));
3859
+ if (missing.length) throw new UserError(`no value for template placeholder(s): ${[...new Set(missing)].join(", ")}`, 3);
3860
+ return template.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => values[key]);
3861
+ }
3862
+
3863
+ // src/core/slots.ts
3864
+ import crypto6 from "node:crypto";
3865
+ import fs25 from "node:fs";
3866
+ import path21 from "node:path";
3867
+ var slotsDir = () => path21.join(home(), "slots");
3868
+ var locksDir = () => path21.join(home(), "locks");
3869
+ var STARTUP_GRACE_MS = 3e4;
3870
+ function holderActive(runId, self) {
3871
+ if (!runId || runId === self) return false;
3872
+ try {
3873
+ const m = freshMeta(runId);
3874
+ if (!isActive(m.state)) return false;
3875
+ if (m.pid) return isAlive(m.pid);
3876
+ return Date.now() - Date.parse(m.startedAt) < STARTUP_GRACE_MS;
3877
+ } catch {
3878
+ return false;
3879
+ }
3880
+ }
3881
+ function tryClaim(file2, runId) {
3882
+ fs25.mkdirSync(path21.dirname(file2), { recursive: true });
3883
+ try {
3884
+ fs25.writeFileSync(file2, runId, { flag: "wx" });
3885
+ return true;
3886
+ } catch (e) {
3887
+ if (e.code !== "EEXIST") throw e;
3888
+ }
3889
+ let owner = "";
3890
+ try {
3891
+ owner = fs25.readFileSync(file2, "utf8").trim();
3892
+ } catch {
3893
+ }
3894
+ if (owner === runId) return true;
3895
+ if (holderActive(owner, runId)) return false;
3896
+ fs25.rmSync(file2, { force: true });
3897
+ try {
3898
+ fs25.writeFileSync(file2, runId, { flag: "wx" });
3899
+ return true;
3900
+ } catch {
3901
+ return false;
3902
+ }
3903
+ }
3904
+ function releaseIfOwner(file2, runId) {
3905
+ if (!file2) return;
3906
+ try {
3907
+ if (fs25.readFileSync(file2, "utf8").trim() === runId) fs25.rmSync(file2, { force: true });
3908
+ } catch {
3909
+ }
3910
+ }
3911
+ function tryAcquireSlot(runId, maxParallel) {
3912
+ for (let n = 0; n < Math.max(1, maxParallel); n++) {
3913
+ const file2 = path21.join(slotsDir(), `slot-${n}`);
3914
+ if (tryClaim(file2, runId)) return file2;
3915
+ }
3916
+ return void 0;
3917
+ }
3918
+ var releaseSlot = (file2, runId) => releaseIfOwner(file2, runId);
3919
+ var lockFile = (repoRoot2) => path21.join(locksDir(), `write-${crypto6.createHash("sha1").update(path21.resolve(repoRoot2)).digest("hex").slice(0, 16)}`);
3920
+ function acquireWriteLock(repoRoot2, runId) {
3921
+ const file2 = lockFile(repoRoot2);
3922
+ if (tryClaim(file2, runId)) return;
3923
+ let owner = "";
3924
+ try {
3925
+ owner = fs25.readFileSync(file2, "utf8").trim();
3926
+ } catch {
3927
+ }
3928
+ throw new UserError(`another --write run (${owner}) is active in this repo; use --isolate for parallel changes`, 3);
3929
+ }
3930
+ var releaseWriteLock = (repoRoot2, runId) => releaseIfOwner(lockFile(repoRoot2), runId);
3931
+
3932
+ // src/core/prompt.ts
3933
+ var RULES = {
3934
+ read: ["READ-ONLY task: do not create, modify or delete any file. Read, search and analyse only."],
3935
+ write: [
3936
+ "You may edit files that the task requires. Make the smallest correct change; no drive-by refactors.",
3937
+ "Run the relevant tests, type checks or linters if they are cheap and available, and report the result."
3938
+ ],
3939
+ isolate: [
3940
+ "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.",
3941
+ "Make the smallest correct change; no drive-by refactors. Dependency folders (node_modules, virtualenvs, build output) may be missing unless linked.",
3942
+ "Run the relevant tests, type checks or linters if they are cheap and available, and report the result."
3943
+ ]
3944
+ };
3945
+ function buildPrompt(mode, task, followUp) {
3946
+ if (followUp) return `Follow-up from the primary agent. Same rules and answer format as before.
3947
+
3948
+ ${task}`;
3949
+ 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.
3950
+
3951
+ Rules:
3952
+ - ${RULES[mode].join("\n- ")}
3953
+ - Stay inside the task scope and the project directory.
3954
+ - Never commit, push, reset, checkout, restore, stash, clean, rebase or merge, and never discard or overwrite uncommitted work you did not create.
3955
+ - Never read or reveal secrets (.env files, keys, tokens).
3956
+ - 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.
3957
+ - Do not ask questions. If something is ambiguous, choose the safest reasonable interpretation and state the assumption.
3958
+ - Be economical: open only what you need.
3959
+
3960
+ End with this answer format (plain text, no preamble):
3961
+ SUMMARY: 1-5 lines that directly answer the task.
3962
+ DETAILS: key findings with file:line references; only what the primary agent needs.
3963
+ FILES CHANGED: paths, or "none".
3964
+ VERIFICATION: commands you ran and their results, or "not run".
3965
+ OPEN ISSUES: risks, uncertainties, follow-ups, or "none".
3966
+
3967
+ Task:
3968
+ ${task}`;
3969
+ }
3970
+
3971
+ // src/core/refs.ts
3972
+ import fs26 from "node:fs";
3973
+ import path22 from "node:path";
3974
+ var FILE_EXTENSIONS = new Set(
3975
+ "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(" ")
3976
+ );
3977
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", "target", "vendor", ".venv", "venv", "__pycache__", "coverage", ".turbo", ".cache"]);
3978
+ var MAX_INDEXED = 6e4;
3979
+ var EXTENSIONLESS = "Makefile|Dockerfile|Containerfile|Gemfile|Rakefile|Procfile|Justfile|Vagrantfile|BUILD|WORKSPACE";
3980
+ var REF = new RegExp(
3981
+ String.raw`(?<![\w/:.-])(\.{0,2}/?(?:[\w@.+-]+/)*(?:[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,7}|${EXTENSIONLESS})):(\d+)(?:[-–](\d+))?`,
3982
+ "g"
3983
+ );
3984
+ var IDENT = /`([A-Za-z_$][\w$]*)(?:\([^`]*\))?`/g;
3985
+ var CLAUSE = /[;|—]|\.\s|:\s/g;
3986
+ var MAX_BYTES = 5 * 1024 * 1024;
3987
+ var WINDOW = 5;
3988
+ var DEFINITION_LOOKBACK = 80;
3989
+ function looksLikeSymbol(m) {
3990
+ const name = m[1];
3991
+ return m[0].includes("(") || /[A-Z_$0-9]/.test(name) || name.length >= 5;
3992
+ }
3993
+ function extractRefs(answer) {
3994
+ const seen = /* @__PURE__ */ new Map();
3995
+ for (const line of answer.split("\n")) {
3996
+ const matches2 = [...line.matchAll(REF)];
3997
+ const seps = [...line.matchAll(CLAUSE)].map((s) => s.index ?? 0);
3998
+ const sepBetween = (a, b) => seps.some((s) => s >= a && s < b);
3999
+ const idents = [...line.matchAll(IDENT)].filter((i) => {
4000
+ if (!looksLikeSymbol(i)) return false;
4001
+ const pos = i.index ?? 0;
4002
+ const before = matches2.filter((m) => (m.index ?? 0) + m[0].length <= pos).at(-1);
4003
+ const after = matches2.find((m) => (m.index ?? 0) >= pos + i[0].length);
4004
+ return !(before && after && !sepBetween((before.index ?? 0) + before[0].length, after.index ?? 0));
4005
+ });
4006
+ matches2.forEach((m, k) => {
4007
+ const start = Number(m[2]);
4008
+ const end = m[3] ? Number(m[3]) : start;
4009
+ if (start < 1 || end < start) return;
4010
+ const at = m.index ?? 0;
4011
+ const prev = matches2[k - 1];
4012
+ const next = matches2[k + 1];
4013
+ const from = Math.max(at - 80, prev ? (prev.index ?? 0) + prev[0].length : 0, ...seps.filter((s) => s < at).map((s) => s + 1));
4014
+ const to = Math.min(at + m[0].length + 40, next?.index ?? Infinity, ...seps.filter((s) => s >= at + m[0].length));
4015
+ const near = new Set(idents.filter((i) => (i.index ?? 0) >= from && (i.index ?? 0) < to).map((i) => i[1]));
4016
+ const symbol = near.size === 1 ? [...near][0] : void 0;
4017
+ const key = `${m[1]}:${start}-${end}:${symbol ?? ""}`;
4018
+ if (!seen.has(key)) seen.set(key, { text: m[0], file: m[1], start, end, symbol });
4019
+ });
4020
+ }
4021
+ return [...seen.values()];
4022
+ }
4023
+ function verifyRefs(refs, dirs) {
4024
+ const roots = [...new Set(dirs.filter(Boolean).map((d) => path22.resolve(d)))];
4025
+ const invalid = [];
4026
+ const lineCache = /* @__PURE__ */ new Map();
4027
+ const load = (file2) => {
4028
+ if (!lineCache.has(file2)) {
4029
+ const size = fs26.statSync(file2).size;
4030
+ const lines = size > MAX_BYTES ? null : fs26.readFileSync(file2, "utf8").split("\n");
4031
+ if (lines && lines[lines.length - 1] === "") lines.pop();
4032
+ lineCache.set(file2, lines);
4033
+ }
4034
+ return lineCache.get(file2);
4035
+ };
4036
+ let index;
4037
+ const byName = () => index ??= indexFiles(roots);
4038
+ const candidates = (ref) => {
4039
+ const direct = resolve(ref.file, roots);
4040
+ if (direct) return [direct];
4041
+ if (path22.isAbsolute(ref.file)) return [];
4042
+ const wanted = ref.file.replace(/^(\.{1,2}\/)+/, "");
4043
+ return (byName().get(path22.basename(wanted)) ?? []).filter((f) => f.split(path22.sep).join("/").endsWith(`/${wanted}`) || path22.basename(f) === wanted).slice(0, 20);
4044
+ };
4045
+ const check = (file2, ref) => {
4046
+ const lines = load(file2);
4047
+ if (!lines) return void 0;
4048
+ if (ref.end > lines.length) return `file has ${lines.length} lines`;
4049
+ if (ref.symbol && !mentions(lines, ref) && !enclosedBy(lines, ref)) return `\`${ref.symbol}\` not near line ${ref.start}`;
4050
+ return void 0;
4051
+ };
4052
+ let total = 0;
4053
+ for (const ref of refs) {
4054
+ const files = candidates(ref);
4055
+ if (!files.length) {
4056
+ if (!ref.file.includes("/") && !isFileName(ref.file)) continue;
4057
+ total++;
4058
+ invalid.push({ ref: ref.text, reason: path22.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
4059
+ continue;
4060
+ }
4061
+ total++;
4062
+ const reasons = files.map((f) => check(f, ref));
4063
+ if (!reasons.some((r) => r === void 0)) invalid.push({ ref: ref.text, reason: files.length > 1 ? `${reasons[0]} (${files.length} files match)` : reasons[0] });
4064
+ }
4065
+ return { total, valid: total - invalid.length, invalid };
4066
+ }
4067
+ function mentions(lines, ref) {
4068
+ const near = lines.slice(Math.max(0, ref.start - 1 - WINDOW), ref.end + WINDOW).join("\n");
4069
+ return new RegExp(`(^|[^\\w$])${escape(ref.symbol)}([^\\w$]|$)`).test(near);
4070
+ }
4071
+ function enclosedBy(lines, ref) {
4072
+ const name = escape(ref.symbol);
4073
+ const def = new RegExp(
4074
+ `\\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*[(:=]`
4075
+ );
4076
+ return lines.slice(Math.max(0, ref.start - 1 - DEFINITION_LOOKBACK), ref.start).some((l) => def.test(l));
4077
+ }
4078
+ var isFileName = (name) => {
4079
+ const base2 = path22.basename(name);
4080
+ return !base2.includes(".") || new RegExp(`^(${EXTENSIONLESS})$`).test(base2) || FILE_EXTENSIONS.has(base2.split(".").pop().toLowerCase());
4081
+ };
4082
+ function indexFiles(roots) {
4083
+ const index = /* @__PURE__ */ new Map();
4084
+ let count = 0;
4085
+ const walk = (dir) => {
4086
+ let entries;
4087
+ try {
4088
+ entries = fs26.readdirSync(dir, { withFileTypes: true });
4089
+ } catch {
4090
+ return;
4091
+ }
4092
+ for (const e of entries) {
4093
+ if (count >= MAX_INDEXED) return;
4094
+ if (e.isDirectory()) {
4095
+ if (!SKIP_DIRS2.has(e.name)) walk(path22.join(dir, e.name));
4096
+ } else if (e.isFile()) {
4097
+ count++;
4098
+ const list2 = index.get(e.name);
4099
+ if (list2) list2.push(path22.join(dir, e.name));
4100
+ else index.set(e.name, [path22.join(dir, e.name)]);
3662
4101
  }
3663
- if (meta.mode === "write" && !meta.reverted) out.push(` undo: pitroom revert ${meta.id}`);
3664
4102
  }
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");
4103
+ };
4104
+ for (const r of roots) walk(r);
4105
+ return index;
3679
4106
  }
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 };
4107
+ function resolve(ref, roots) {
4108
+ const candidates = path22.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path22.join(r, ref));
4109
+ return candidates.find((c) => {
4110
+ try {
4111
+ return fs26.statSync(c).isFile() && inside(c, roots);
4112
+ } catch {
4113
+ return false;
4114
+ }
4115
+ });
3685
4116
  }
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}`;
4117
+ function inside(file2, roots) {
4118
+ return roots.some((r) => {
4119
+ const rel = path22.relative(r, path22.resolve(file2));
4120
+ return rel !== "" && !rel.startsWith("..") && !path22.isAbsolute(rel);
4121
+ });
3691
4122
  }
4123
+ var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3692
4124
 
3693
4125
  // src/core/run.ts
3694
- var VERSION2 = true ? "0.19.0" : "0.0.0-dev";
4126
+ var VERSION2 = true ? "0.21.0" : "0.0.0-dev";
3695
4127
  function planWork(o) {
3696
4128
  if (!o.plan) return { task: o.task.trim(), tier: o.tier };
3697
4129
  if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
@@ -3723,7 +4155,7 @@ function prepareRun(o) {
3723
4155
  parent = readMeta(o.continueFrom);
3724
4156
  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
4157
  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))) {
4158
+ if (parent.mode === "isolate" && (!parent.worktree || !fs27.existsSync(parent.worktree))) {
3727
4159
  throw new UserError(`the isolated copy of run ${parent.id} is gone (applied or discarded)`, 3);
3728
4160
  }
3729
4161
  const ran = parent.ran ?? parent.worker;
@@ -3742,18 +4174,18 @@ function prepareRun(o) {
3742
4174
  throw new UserError(`the ${backend.name} worker cannot attach files; put the content in the task`);
3743
4175
  }
3744
4176
  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}`);
4177
+ const dir = canonical(path23.resolve(parent?.dir ?? o.dir));
4178
+ if (!fs27.existsSync(dir) || !fs27.statSync(dir).isDirectory()) throw new UserError(`not a directory: ${dir}`);
3747
4179
  const root = repoRoot(dir);
3748
4180
  if (mode === "isolate" && !root) throw new UserError("--isolate needs a git repository", 3);
3749
4181
  if (mode === "write" && !root && !o.allowNonGit) {
3750
4182
  throw new UserError("--write outside a git repo cannot be tracked or reverted; pass --allow-non-git to accept that", 3);
3751
4183
  }
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}`);
4184
+ const files = o.files.map((f) => path23.resolve(f));
4185
+ for (const f of files) if (!fs27.existsSync(f)) throw new UserError(`file not found: ${f}`);
3754
4186
  const readIn2 = READ_IN.includes(effective().readIn.value) ? effective().readIn.value : "auto";
3755
4187
  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)) {
4188
+ if (parent?.snapshot?.dir && !fs27.existsSync(parent.snapshot.dir)) {
3757
4189
  throw new UserError(`the read snapshot of run ${parent.id} is gone (cleaned up); ask the question again as a new run`, 3);
3758
4190
  }
3759
4191
  if (!parent && !snap && !process.env.PITROOM_NO_SECRET_WARNING) {
@@ -3800,16 +4232,16 @@ function prepareRun(o) {
3800
4232
  try {
3801
4233
  acquireWriteLock(root, meta.id);
3802
4234
  } catch (e) {
3803
- fs25.rmSync(runDir(meta.id), { recursive: true, force: true });
4235
+ fs27.rmSync(runDir(meta.id), { recursive: true, force: true });
3804
4236
  throw e;
3805
4237
  }
3806
4238
  }
3807
- fs25.writeFileSync(runFile(meta.id, "task.md"), `${meta.task}
4239
+ fs27.writeFileSync(runFile(meta.id, "task.md"), `${meta.task}
3808
4240
  `);
3809
- if (work.brief) fs25.writeFileSync(runFile(meta.id, "brief.md"), work.brief);
4241
+ if (work.brief) fs27.writeFileSync(runFile(meta.id, "brief.md"), work.brief);
3810
4242
  return meta;
3811
4243
  }
3812
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4244
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
3813
4245
  async function waitForSlot(meta) {
3814
4246
  let stopped = false;
3815
4247
  const stop = () => {
@@ -3823,7 +4255,7 @@ async function waitForSlot(meta) {
3823
4255
  const slot = tryAcquireSlot(meta.id, max);
3824
4256
  if (slot) return slot;
3825
4257
  if (stopped) return void 0;
3826
- await sleep(1e3);
4258
+ await sleep2(1e3);
3827
4259
  if (stopped) return void 0;
3828
4260
  }
3829
4261
  } finally {
@@ -3834,7 +4266,9 @@ async function waitForSlot(meta) {
3834
4266
  function startInBackground(meta) {
3835
4267
  const script = process.argv[1];
3836
4268
  if (!script) throw new Error("cannot locate the pitroom executable");
3837
- const child = spawn2(process.execPath, [script, "__exec", meta.id], {
4269
+ meta.background = true;
4270
+ writeMeta(meta);
4271
+ const child = spawn4(process.execPath, [script, "__exec", meta.id], {
3838
4272
  detached: true,
3839
4273
  stdio: "ignore",
3840
4274
  cwd: meta.dir,
@@ -3886,7 +4320,7 @@ async function execute(meta) {
3886
4320
  (meta.attempts ??= []).push({ target: describeTarget(target), error: why.message });
3887
4321
  for (const f of ["events.jsonl", "stderr.log"]) {
3888
4322
  const from = runFile(meta.id, f);
3889
- if (fs25.existsSync(from)) fs25.renameSync(from, runFile(meta.id, f.replace(".", `.attempt-${i + 1}.`)));
4323
+ if (fs27.existsSync(from)) fs27.renameSync(from, runFile(meta.id, f.replace(".", `.attempt-${i + 1}.`)));
3890
4324
  }
3891
4325
  writeMeta(meta);
3892
4326
  }
@@ -3950,8 +4384,8 @@ function prepareTree(meta) {
3950
4384
  const s = readSnapshot(root);
3951
4385
  meta.snapshot.dir = s.dir;
3952
4386
  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;
4387
+ meta.cwd = path23.join(s.dir, path23.relative(root, meta.dir));
4388
+ if (!fs27.existsSync(meta.cwd)) meta.cwd = s.dir;
3955
4389
  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
4390
  } catch (e) {
3957
4391
  meta.snapshot = void 0;
@@ -3961,9 +4395,9 @@ function prepareTree(meta) {
3961
4395
  if (root && meta.mode === "write") meta.baseTree = snapshotTree(root);
3962
4396
  if (root && meta.mode === "isolate" && !meta.worktree) {
3963
4397
  meta.baseTree = snapshotTree(root);
3964
- meta.worktree = path20.join(worktreesDir(), meta.id);
4398
+ meta.worktree = path23.join(worktreesDir(), meta.id);
3965
4399
  createIsolatedCopy(root, meta.baseTree, meta.worktree);
3966
- meta.cwd = path20.join(meta.worktree, path20.relative(root, meta.dir));
4400
+ meta.cwd = path23.join(meta.worktree, path23.relative(root, meta.dir));
3967
4401
  const linked = linkIntoWorktree(root, meta.worktree, meta.link);
3968
4402
  if (linked.length) meta.warnings.push(`linked into the isolated copy (shared with your tree): ${linked.join(", ")}`);
3969
4403
  }
@@ -3986,7 +4420,7 @@ function attempt(meta, backend, target) {
3986
4420
  timeoutSec: meta.timeoutSec
3987
4421
  });
3988
4422
  }
3989
- var read2 = (f) => fs25.existsSync(f) ? fs25.readFileSync(f, "utf8") : "";
4423
+ var read2 = (f) => fs27.existsSync(f) ? fs27.readFileSync(f, "utf8") : "";
3990
4424
  function retryableFailure(meta, backend, res) {
3991
4425
  const run2 = backend.parse(read2(runFile(meta.id, "events.jsonl")));
3992
4426
  if (run2.usage.steps > 0) return void 0;
@@ -4006,16 +4440,19 @@ function finalize(meta, res) {
4006
4440
  if (meta.snapshot?.dir && meta.repoRoot) run2.finalText = backToProject(run2.finalText, meta.snapshot.dir, meta.repoRoot);
4007
4441
  meta.sessionId = run2.sessionId ?? meta.sessionId;
4008
4442
  meta.usage = run2.usage;
4009
- fs25.writeFileSync(runFile(meta.id, "summary.md"), `${run2.finalText}
4443
+ fs27.writeFileSync(runFile(meta.id, "summary.md"), `${run2.finalText}
4010
4444
  `);
4011
4445
  if (meta.plan && !meta.reviewOf) meta.taskStatus = parseStatus(run2.finalText);
4012
4446
  if (meta.reviewOf) {
4013
4447
  meta.verdict = parseVerdict(run2.finalText);
4014
- if (meta.packageFile) fs25.rmSync(meta.packageFile, { force: true });
4448
+ if (meta.packageFile) fs27.rmSync(meta.packageFile, { force: true });
4015
4449
  }
4016
4450
  if (!res.timedOut && !res.stopped && !meta.error) {
4017
4451
  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;
4452
+ if (f) {
4453
+ meta.error = HINTS[f.kind] ? `${f.message} (${HINTS[f.kind]})` : f.message;
4454
+ meta.failureKind = f.kind;
4455
+ }
4019
4456
  }
4020
4457
  try {
4021
4458
  captureChanges(meta);
@@ -4043,12 +4480,21 @@ function finalize(meta, res) {
4043
4480
  if (meta.state === "done" && !run2.finalText) meta.warnings.push("worker finished without a written answer");
4044
4481
  if (meta.state === "done" && meta.verify) meta.verifyResult = runVerify(meta);
4045
4482
  meta.endedAt = (/* @__PURE__ */ new Date()).toISOString();
4483
+ if (meta.usage && meta.usage.cost === void 0) {
4484
+ const price = workerPrice(ran.backend, meta.resolvedModel ?? run2.model, ran.model);
4485
+ if (price) {
4486
+ meta.usage.costEstimate = costAt(meta.usage, price);
4487
+ meta.usage.costSource = price.source ?? "config";
4488
+ }
4489
+ }
4046
4490
  meta.returnedTokens = estimateTokens(formatReport(meta, run2.finalText));
4047
4491
  meta.savedUsd = meta.auditOf ? 0 : savedUsd(meta.usage, meta.returnedTokens);
4048
4492
  writeMeta(meta);
4049
4493
  if (meta.mode === "write" && meta.repoRoot) releaseWriteLock(meta.repoRoot, meta.id);
4050
4494
  record(meta);
4051
4495
  if (meta.auditOf) settleAudit(meta);
4496
+ notifyEnded(meta);
4497
+ refreshInBackground();
4052
4498
  return meta;
4053
4499
  }
4054
4500
  function captureChanges(meta) {
@@ -4059,13 +4505,13 @@ function captureChanges(meta) {
4059
4505
  const d = diffTrees(target, meta.baseTree, meta.afterTree);
4060
4506
  meta.changes = d.changes;
4061
4507
  meta.stats = d.stats;
4062
- fs25.writeFileSync(runFile(meta.id, "changes.patch"), d.patch);
4508
+ fs27.writeFileSync(runFile(meta.id, "changes.patch"), d.patch);
4063
4509
  }
4064
4510
  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);
4511
+ const extra = [path23.dirname(process.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
4512
+ const parts = (process.env.PATH ?? "").split(path23.delimiter).filter(Boolean);
4513
+ for (const d of process.platform === "win32" ? [path23.dirname(process.execPath)] : extra) if (!parts.includes(d)) parts.push(d);
4514
+ return parts.join(path23.delimiter);
4069
4515
  }
4070
4516
  function runVerify(meta) {
4071
4517
  const r = spawnSync6(meta.verify, {
@@ -4078,7 +4524,7 @@ function runVerify(meta) {
4078
4524
  env: { ...process.env, PATH: verifyPath(), PWD: meta.cwd, PITROOM_ACTIVE: "1" }
4079
4525
  });
4080
4526
  const output = `${r.stdout ?? ""}${r.stderr ?? ""}`;
4081
- fs25.writeFileSync(runFile(meta.id, "verify.log"), output);
4527
+ fs27.writeFileSync(runFile(meta.id, "verify.log"), output);
4082
4528
  const notFound = process.platform === "win32" && r.status === 1 && /is not recognized as an internal or external command/.test(output);
4083
4529
  return { ok: r.status === 0, code: notFound ? 127 : r.status, tail: output.trimEnd().split("\n").slice(-25).join("\n") };
4084
4530
  }
@@ -4133,126 +4579,15 @@ function discardRun(meta) {
4133
4579
  return `discarded the isolated copy of ${meta.id}; the patch stays in ${runFile(meta.id, "changes.patch")}`;
4134
4580
  }
4135
4581
  function cleanupWorktree(meta) {
4136
- if (meta.worktree && fs25.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
4582
+ if (meta.worktree && fs27.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
4137
4583
  }
4138
4584
 
4139
4585
  // 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
- }
4586
+ import fs28 from "node:fs";
4587
+ import path24 from "node:path";
4254
4588
 
4255
4589
  // src/cli/mcp-support.ts
4590
+ import { spawn as spawn5 } from "node:child_process";
4256
4591
  var MAX_OUTPUT = 12e4;
4257
4592
  var DEFAULT_WAIT = 50;
4258
4593
  var MAX_WAIT = 540;
@@ -4320,7 +4655,7 @@ function pit(args, timeoutMs = 60 * 6e4, signal) {
4320
4655
  return new Promise((resolve2) => {
4321
4656
  const script = process.argv[1];
4322
4657
  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"] });
4658
+ const child = spawn5(process.execPath, [script, ...args], { env: process.env, stdio: ["ignore", "pipe", "pipe"] });
4324
4659
  let out = "";
4325
4660
  let err = "";
4326
4661
  child.stdout.on("data", (d) => out += d);
@@ -4379,19 +4714,54 @@ async function stopQuietly(args) {
4379
4714
  if (r.code !== 0) process.stderr.write(`pitroom mcp: ${args.join(" ")} failed: ${r.err || r.out}
4380
4715
  `);
4381
4716
  }
4717
+ var dashAsked;
4718
+ function ensureDash() {
4719
+ if (effective().mcpDash.value === false) return Promise.resolve(void 0);
4720
+ if (dashAsked && Date.now() - dashAsked.at < 6e4) return dashAsked.url;
4721
+ const url = pit(["dash", "--detach"], 3e4).then((r) => {
4722
+ const found = /^https?:\/\/127\.0\.0\.1:\d+\/?/m.exec(r.out)?.[0];
4723
+ if (!found) dashAsked = void 0;
4724
+ return found;
4725
+ });
4726
+ dashAsked = { at: Date.now(), url };
4727
+ return url;
4728
+ }
4729
+ function dashIfSlow(ids, delayMs = 3e3) {
4730
+ return new Promise((resolve2) => {
4731
+ const timer2 = setTimeout(() => {
4732
+ try {
4733
+ resolve2(ids().some((id) => isActive(freshMeta(id).state)) ? ensureDash() : void 0);
4734
+ } catch {
4735
+ resolve2(void 0);
4736
+ }
4737
+ }, delayMs);
4738
+ timer2.unref();
4739
+ });
4740
+ }
4741
+ function withLiveView(result, url) {
4742
+ if (!url || !result.text.includes("Not finished yet")) return result;
4743
+ return {
4744
+ ...result,
4745
+ text: `${result.text}
4746
+
4747
+ 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.`
4748
+ };
4749
+ }
4382
4750
  async function startAndWait(start, seconds, ctx, task) {
4383
4751
  const started = await pit([...start, "--bg", "--json", ...task === void 0 ? [] : ["--", task]]);
4384
4752
  if (started.code !== 0) return asResult(started);
4385
4753
  let id;
4386
- let cached3 = false;
4754
+ let cached4 = false;
4387
4755
  try {
4388
- ({ id, cached: cached3 = false } = JSON.parse(started.out));
4756
+ ({ id, cached: cached4 = false } = JSON.parse(started.out));
4389
4757
  } catch {
4390
4758
  return { text: `could not read the run id from: ${started.out.slice(0, 200)}`, isError: true };
4391
4759
  }
4392
- const result = await waitFor([id], seconds, ctx);
4393
- if (cancelled(ctx) && !cached3) await stopQuietly(["stop", id]);
4394
- if (cached3) result.text = `Cached answer: the same question on the same code as run ${id}; no worker ran (fresh: true asks one).
4760
+ const dash = cached4 ? Promise.resolve(void 0) : dashIfSlow(() => [id]);
4761
+ const waited = await waitFor([id], seconds, ctx);
4762
+ const result = withLiveView(waited, waited.text.includes("Not finished yet") ? await dash : void 0);
4763
+ if (cancelled(ctx) && !cached4) await stopQuietly(["stop", id]);
4764
+ if (cached4) result.text = `Cached answer: the same question on the same code as run ${id}; no worker ran (fresh: true asks one).
4395
4765
 
4396
4766
  ${result.text}`;
4397
4767
  return result;
@@ -4405,7 +4775,9 @@ async function startCrew(flags, tasks, seconds, ctx) {
4405
4775
  } catch {
4406
4776
  return { text: `could not read the group from: ${started.out.slice(0, 200)}`, isError: true };
4407
4777
  }
4408
- const result = await waitFor([], seconds, ctx, group);
4778
+ const dash = dashIfSlow(() => groupIds(group));
4779
+ const waited = await waitFor([], seconds, ctx, group);
4780
+ const result = withLiveView(waited, waited.text.includes("Not finished yet") ? await dash : void 0);
4409
4781
  if (cancelled(ctx)) await stopQuietly(["stop", "-g", group]);
4410
4782
  return result;
4411
4783
  }
@@ -4497,7 +4869,7 @@ function skillPrompts() {
4497
4869
  const root = packageRoot();
4498
4870
  for (const name of skillNames(root)) {
4499
4871
  try {
4500
- const text = fs26.readFileSync(path21.join(root, "skills", name, "SKILL.md"), "utf8");
4872
+ const text = fs28.readFileSync(path24.join(root, "skills", name, "SKILL.md"), "utf8");
4501
4873
  const front = /^---\n([\s\S]*?)\n---\n/.exec(text);
4502
4874
  const body = (front ? text.slice(front[0].length) : text).trim();
4503
4875
  const description = /^description:\s*(.+)$/m.exec(front?.[1] ?? "")?.[1]?.trim() ?? `The ${name} skill.`;
@@ -4648,7 +5020,8 @@ var TOOLS2 = [
4648
5020
  const runs = strs(a, "runs");
4649
5021
  const group = str(a, "group");
4650
5022
  if (!runs.length && !group) throw new ToolError('give "runs" or "group"');
4651
- return waitFor(runs, waitSeconds(a), ctx, runs.length ? void 0 : group);
5023
+ const result = await waitFor(runs, waitSeconds(a), ctx, runs.length ? void 0 : group);
5024
+ return withLiveView(result, result.text.includes("Not finished yet") ? await ensureDash() : void 0);
4652
5025
  }
4653
5026
  },
4654
5027
  {
@@ -4861,7 +5234,7 @@ var PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
4861
5234
  var INSTRUCTIONS = [
4862
5235
  "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
5236
  '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.',
5237
+ `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
5238
  "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
5239
  "It is optional: for a small task you can do yourself, skip it."
4867
5240
  ].join(" ");
@@ -4983,10 +5356,10 @@ var parseError = () => ({ jsonrpc: "2.0", id: null, error: { code: -32700, messa
4983
5356
  async function serveMcp() {
4984
5357
  process.stdout.on("error", () => {
4985
5358
  });
4986
- const send = (msg) => void process.stdout.write(`${JSON.stringify(msg)}
5359
+ const send2 = (msg) => void process.stdout.write(`${JSON.stringify(msg)}
4987
5360
  `);
4988
5361
  const session = newSession();
4989
- session.push = send;
5362
+ session.push = send2;
4990
5363
  const rl = readline.createInterface({ input: process.stdin });
4991
5364
  const pending = /* @__PURE__ */ new Set();
4992
5365
  const handleLine = async (line) => {
@@ -4994,11 +5367,11 @@ async function serveMcp() {
4994
5367
  try {
4995
5368
  parsed = JSON.parse(line);
4996
5369
  } catch {
4997
- send(parseError());
5370
+ send2(parseError());
4998
5371
  return;
4999
5372
  }
5000
- const reply2 = await handlePayload(session, parsed, send);
5001
- if (reply2) send(reply2);
5373
+ const reply2 = await handlePayload(session, parsed, send2);
5374
+ if (reply2) send2(reply2);
5002
5375
  };
5003
5376
  rl.on("line", (line) => {
5004
5377
  if (!line.trim()) return;
@@ -5013,23 +5386,23 @@ async function serveMcp() {
5013
5386
  }
5014
5387
 
5015
5388
  // src/cli/mcp-http.ts
5016
- import crypto7 from "node:crypto";
5017
- import fs28 from "node:fs";
5389
+ import crypto8 from "node:crypto";
5390
+ import fs30 from "node:fs";
5018
5391
  import http2 from "node:http";
5019
- import path23 from "node:path";
5392
+ import path26 from "node:path";
5020
5393
 
5021
5394
  // 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";
5395
+ import { spawn as spawn6 } from "node:child_process";
5396
+ import crypto7 from "node:crypto";
5397
+ import fs29 from "node:fs";
5025
5398
  import http from "node:http";
5026
- import path22 from "node:path";
5399
+ import path25 from "node:path";
5027
5400
  import { fileURLToPath as fileURLToPath3 } from "node:url";
5028
5401
 
5029
5402
  // src/core/file-diff.ts
5030
5403
  var PREVIEW_LINES = 300;
5031
5404
  function gitPath(value, prefix = true) {
5032
- let path31 = value;
5405
+ let path34 = value;
5033
5406
  if (value.startsWith('"') && value.endsWith('"')) {
5034
5407
  const bytes = [];
5035
5408
  const escapes = { t: " ", n: "\n", r: "\r", b: "\b", f: "\f", v: "\v" };
@@ -5037,9 +5410,9 @@ function gitPath(value, prefix = true) {
5037
5410
  if (match[1] && /^[0-7]+$/.test(match[1])) bytes.push(parseInt(match[1], 8));
5038
5411
  else bytes.push(...new TextEncoder().encode(match[2] ?? escapes[match[1]] ?? match[1]));
5039
5412
  }
5040
- path31 = new TextDecoder().decode(new Uint8Array(bytes));
5413
+ path34 = new TextDecoder().decode(new Uint8Array(bytes));
5041
5414
  }
5042
- return prefix ? path31.replace(/^[ab]\//, "") : path31;
5415
+ return prefix ? path34.replace(/^[ab]\//, "") : path34;
5043
5416
  }
5044
5417
  function headerPath(header) {
5045
5418
  const quoted = header.match(/^diff --git ("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/);
@@ -5264,7 +5637,8 @@ function runDetail(id) {
5264
5637
  toolCalls: u?.toolCalls,
5265
5638
  tokens: u?.total,
5266
5639
  returnedTokens: m.returnedTokens,
5267
- cost: u?.cost,
5640
+ cost: u?.cost ?? u?.costEstimate,
5641
+ costEstimated: u?.cost === void 0 && u?.costEstimate !== void 0 ? 1 : void 0,
5268
5642
  saved: m.savedUsd,
5269
5643
  group: m.group,
5270
5644
  directory: m.dir
@@ -5283,18 +5657,18 @@ var HEADERS = {
5283
5657
  "x-content-type-options": "nosniff",
5284
5658
  "referrer-policy": "no-referrer",
5285
5659
  // 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'`
5660
+ "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
5661
  };
5288
5662
  var ASSETS = { "/assets/app.js": "text/javascript; charset=utf-8", "/assets/app.css": "text/css; charset=utf-8" };
5289
5663
  var assetsDir = () => fileURLToPath3(new URL("./ui/", import.meta.url));
5290
5664
  var assetCache = /* @__PURE__ */ new Map();
5291
5665
  function readAsset(route) {
5292
5666
  try {
5293
- const file2 = path22.join(assetsDir(), path22.basename(route));
5294
- const { mtimeMs } = fs27.statSync(file2);
5667
+ const file2 = path25.join(assetsDir(), path25.basename(route));
5668
+ const { mtimeMs } = fs29.statSync(file2);
5295
5669
  const hit = assetCache.get(route);
5296
5670
  if (hit && hit.mtimeMs === mtimeMs) return hit.data;
5297
- const data = fs27.readFileSync(file2);
5671
+ const data = fs29.readFileSync(file2);
5298
5672
  assetCache.set(route, { mtimeMs, data });
5299
5673
  return data;
5300
5674
  } catch {
@@ -5302,16 +5676,16 @@ function readAsset(route) {
5302
5676
  }
5303
5677
  }
5304
5678
  function handler(touch2, token) {
5305
- const send = (res, code, type, body) => {
5679
+ const send2 = (res, code, type, body) => {
5306
5680
  res.writeHead(code, { ...HEADERS, "content-type": type });
5307
5681
  res.end(body);
5308
5682
  };
5309
- const json = (res, code, value) => send(res, code, "application/json; charset=utf-8", JSON.stringify(value));
5683
+ const json = (res, code, value) => send2(res, code, "application/json; charset=utf-8", JSON.stringify(value));
5310
5684
  const tokenBuf = Buffer.from(token);
5311
5685
  const act = (req, res, pathname) => {
5312
5686
  req.resume();
5313
5687
  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" });
5688
+ if (given.length !== tokenBuf.length || !crypto7.timingSafeEqual(given, tokenBuf)) return json(res, 403, { error: "forbidden" });
5315
5689
  if (req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: "forbidden origin" });
5316
5690
  const m = /^\/api\/run\/([^/]+)\/(stop|discard)$/.exec(pathname);
5317
5691
  if (!m) return json(res, 404, { error: "not found" });
@@ -5338,7 +5712,7 @@ function handler(touch2, token) {
5338
5712
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
5339
5713
  if (req.method === "POST") return act(req, res, url.pathname);
5340
5714
  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);
5715
+ if (url.pathname === "/") return send2(res, 200, "text/html; charset=utf-8", readAsset("/assets/app.js") ? page(token) : MISSING);
5342
5716
  const type = ASSETS[url.pathname];
5343
5717
  if (type) {
5344
5718
  const data = readAsset(url.pathname);
@@ -5379,7 +5753,7 @@ function handler(touch2, token) {
5379
5753
  }
5380
5754
  function startDash(opts) {
5381
5755
  let lastRequest = Date.now();
5382
- const server = http.createServer(handler(() => lastRequest = Date.now(), crypto6.randomBytes(24).toString("hex")));
5756
+ const server = http.createServer(handler(() => lastRequest = Date.now(), crypto7.randomBytes(24).toString("hex")));
5383
5757
  return new Promise((resolve2, reject) => {
5384
5758
  server.once("error", reject);
5385
5759
  server.listen(opts.port, "127.0.0.1", () => {
@@ -5400,7 +5774,7 @@ function startDash(opts) {
5400
5774
  });
5401
5775
  });
5402
5776
  }
5403
- var registryFile = () => path22.join(home(), "dash.json");
5777
+ var registryFile = () => path25.join(home(), "dash.json");
5404
5778
  async function ping(port) {
5405
5779
  try {
5406
5780
  const r = await fetch(`http://127.0.0.1:${port}/api/state?limit=1`, { signal: AbortSignal.timeout(1500) });
@@ -5412,12 +5786,12 @@ async function ping(port) {
5412
5786
  async function runningDash() {
5413
5787
  let r;
5414
5788
  try {
5415
- r = JSON.parse(fs27.readFileSync(registryFile(), "utf8"));
5789
+ r = JSON.parse(fs29.readFileSync(registryFile(), "utf8"));
5416
5790
  } catch {
5417
5791
  return void 0;
5418
5792
  }
5419
5793
  if (!isAlive(r.pid)) {
5420
- fs27.rmSync(registryFile(), { force: true });
5794
+ fs29.rmSync(registryFile(), { force: true });
5421
5795
  return void 0;
5422
5796
  }
5423
5797
  return await ping(r.port) ? { ...r, url: `http://127.0.0.1:${r.port}/` } : void 0;
@@ -5425,7 +5799,7 @@ async function runningDash() {
5425
5799
  function openBrowser(url) {
5426
5800
  const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
5427
5801
  try {
5428
- spawn4(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
5802
+ spawn6(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
5429
5803
  } catch {
5430
5804
  }
5431
5805
  }
@@ -5434,7 +5808,7 @@ async function dashCommand(o) {
5434
5808
  if (o.stop) {
5435
5809
  if (!existing) throw new UserError("pitroom dash is not running");
5436
5810
  process.kill(existing.pid, "SIGTERM");
5437
- fs27.rmSync(registryFile(), { force: true });
5811
+ fs29.rmSync(registryFile(), { force: true });
5438
5812
  o.log(`stopped pitroom dash (${existing.url})`);
5439
5813
  return 0;
5440
5814
  }
@@ -5444,7 +5818,7 @@ async function dashCommand(o) {
5444
5818
  return 0;
5445
5819
  }
5446
5820
  if (o.detach) {
5447
- const child = spawn4(process.execPath, [process.argv[1], "dash", "--serve", ...o.port !== void 0 ? ["--port", String(o.port)] : [], "--idle", "1h"], {
5821
+ const child = spawn6(process.execPath, [process.argv[1], "dash", "--serve", ...o.port !== void 0 ? ["--port", String(o.port)] : [], "--idle", "1h"], {
5448
5822
  detached: true,
5449
5823
  stdio: "ignore",
5450
5824
  env: process.env
@@ -5471,9 +5845,9 @@ async function dashCommand(o) {
5471
5845
  if (explicit || e.code !== "EADDRINUSE") throw new UserError(`pitroom dash: ${e.message}`);
5472
5846
  dash = await startDash({ port: 0, idleMs: o.idleMs });
5473
5847
  }
5474
- fs27.mkdirSync(home(), { recursive: true });
5848
+ fs29.mkdirSync(home(), { recursive: true });
5475
5849
  const tmp = `${registryFile()}.${process.pid}.tmp`;
5476
- fs27.writeFileSync(tmp, JSON.stringify({ pid: process.pid, port: dash.port }));
5850
+ fs29.writeFileSync(tmp, JSON.stringify({ pid: process.pid, port: dash.port }));
5477
5851
  renameOver(tmp, registryFile());
5478
5852
  const stop = () => void dash.close();
5479
5853
  process.once("SIGINT", stop);
@@ -5483,7 +5857,7 @@ async function dashCommand(o) {
5483
5857
  if (o.open) openBrowser(dash.url);
5484
5858
  await dash.closed;
5485
5859
  try {
5486
- if (JSON.parse(fs27.readFileSync(registryFile(), "utf8")).pid === process.pid) fs27.rmSync(registryFile(), { force: true });
5860
+ if (JSON.parse(fs29.readFileSync(registryFile(), "utf8")).pid === process.pid) fs29.rmSync(registryFile(), { force: true });
5487
5861
  } catch {
5488
5862
  }
5489
5863
  return 0;
@@ -5495,10 +5869,10 @@ var MAX_BODY = 4e6;
5495
5869
  var MAX_SESSIONS = 64;
5496
5870
  var IDLE_MS = 60 * 6e4;
5497
5871
  var PATH = "/mcp";
5498
- var tokenFile = () => path23.join(home(), "mcp-token");
5872
+ var tokenFile = () => path26.join(home(), "mcp-token");
5499
5873
  var readToken = (file2) => {
5500
5874
  try {
5501
- const kept = fs28.readFileSync(file2, "utf8").trim();
5875
+ const kept = fs30.readFileSync(file2, "utf8").trim();
5502
5876
  return kept.length >= 16 ? kept : void 0;
5503
5877
  } catch {
5504
5878
  return void 0;
@@ -5513,10 +5887,10 @@ function mcpToken() {
5513
5887
  const file2 = tokenFile();
5514
5888
  let token = readToken(file2);
5515
5889
  if (!token) {
5516
- fs28.mkdirSync(path23.dirname(file2), { recursive: true });
5517
- token = crypto7.randomBytes(32).toString("hex");
5890
+ fs30.mkdirSync(path26.dirname(file2), { recursive: true });
5891
+ token = crypto8.randomBytes(32).toString("hex");
5518
5892
  try {
5519
- fs28.writeFileSync(file2, `${token}
5893
+ fs30.writeFileSync(file2, `${token}
5520
5894
  `, { mode: 384, flag: "wx" });
5521
5895
  } catch (e) {
5522
5896
  if (e.code !== "EEXIST") throw e;
@@ -5525,16 +5899,16 @@ function mcpToken() {
5525
5899
  }
5526
5900
  }
5527
5901
  try {
5528
- fs28.chmodSync(file2, 384);
5902
+ fs30.chmodSync(file2, 384);
5529
5903
  } catch {
5530
5904
  }
5531
5905
  return { token, from: file2 };
5532
5906
  }
5533
5907
  var LOCAL_ORIGIN = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
5534
5908
  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);
5909
+ const a = crypto8.createHash("sha256").update(given).digest();
5910
+ const b = crypto8.createHash("sha256").update(token).digest();
5911
+ return crypto8.timingSafeEqual(a, b);
5538
5912
  };
5539
5913
  var idleSince = (k) => Math.max(k.lastSeen, k.closeStream ? k.streamSeen ?? 0 : 0);
5540
5914
  function drop(sessions, id) {
@@ -5638,7 +6012,7 @@ data: ${JSON.stringify(msg)}
5638
6012
  if (single?.method === "initialize") {
5639
6013
  if (sid) drop(sessions, sid);
5640
6014
  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();
6015
+ const id = crypto8.randomUUID();
5642
6016
  kept = { session: newSession(), lastSeen: Date.now() };
5643
6017
  sessions.set(id, kept);
5644
6018
  headers["Mcp-Session-Id"] = id;
@@ -5707,16 +6081,16 @@ async function serveMcpHttp(opts) {
5707
6081
 
5708
6082
  // src/cli/commands.ts
5709
6083
  import { spawnSync as spawnSync8 } from "node:child_process";
5710
- import fs34 from "node:fs";
5711
- import path29 from "node:path";
6084
+ import fs36 from "node:fs";
6085
+ import path32 from "node:path";
5712
6086
  import { fileURLToPath as fileURLToPath4 } from "node:url";
5713
6087
 
5714
6088
  // src/core/init.ts
5715
- import fs29 from "node:fs";
5716
- import path24 from "node:path";
6089
+ import fs31 from "node:fs";
6090
+ import path27 from "node:path";
5717
6091
  var FREE = /-free$/;
5718
6092
  var TIER_ORDER = [["cheap", "opencode"], ["standard", "codex"], ["capable", "claude"]];
5719
- var isFound = (binary5) => path24.isAbsolute(binary5) && fs29.existsSync(binary5);
6093
+ var isFound = (binary5) => path27.isAbsolute(binary5) && fs31.existsSync(binary5);
5720
6094
  function planInit(opts = {}) {
5721
6095
  const file2 = configPath();
5722
6096
  const workers = backendIds().map((id) => {
@@ -5762,15 +6136,15 @@ function planInit(opts = {}) {
5762
6136
  if (Object.keys(tiers).length > 1) config.tiers = tiers;
5763
6137
  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
6138
  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 };
6139
+ return { path: file2, exists: fs31.existsSync(file2), workers, opencode: opencode2, config, blocked, notes };
5766
6140
  }
5767
6141
  function writeInit(plan, force) {
5768
6142
  if (plan.blocked) throw new UserError(plan.blocked);
5769
6143
  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)}
6144
+ if (plan.exists && !force) throw new UserError(`${plan.path} already exists: pass --force to replace it (the old file is kept as ${path27.basename(plan.path)}.bak)`);
6145
+ fs31.mkdirSync(path27.dirname(plan.path), { recursive: true });
6146
+ if (plan.exists) fs31.copyFileSync(plan.path, `${plan.path}.bak`);
6147
+ fs31.writeFileSync(plan.path, `${JSON.stringify(plan.config, null, 2)}
5774
6148
  `);
5775
6149
  return plan.path;
5776
6150
  }
@@ -5791,17 +6165,17 @@ function formatInit(plan, written) {
5791
6165
 
5792
6166
  // src/core/mcp-install.ts
5793
6167
  import { spawnSync as spawnSync7 } from "node:child_process";
5794
- import fs30 from "node:fs";
6168
+ import fs32 from "node:fs";
5795
6169
  import os10 from "node:os";
5796
- import path25 from "node:path";
6170
+ import path28 from "node:path";
5797
6171
  var CLIENT_IDS = ["claude-code", "codex", "gemini", "cursor", "claude-desktop"];
5798
6172
  var NAME = "pitroom";
5799
6173
  function mcpCommand() {
5800
6174
  const launcher = launcherPath();
5801
6175
  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"] };
6176
+ for (const dir of (process.env.PATH ?? "").split(path28.delimiter).filter(Boolean)) {
6177
+ const p = path28.join(dir, process.platform === "win32" ? "pitroom.cmd" : "pitroom");
6178
+ if (fs32.existsSync(p)) return { command: path28.resolve(p), args: ["mcp"] };
5805
6179
  }
5806
6180
  return { command: process.execPath, args: [process.argv[1] ?? "pitroom", "mcp"] };
5807
6181
  }
@@ -5817,7 +6191,7 @@ function run(backendId, args) {
5817
6191
  var runnable = (backendId) => run(backendId, ["--version"]).ok;
5818
6192
  var readJson = (file2) => {
5819
6193
  try {
5820
- return JSON.parse(fs30.readFileSync(file2, "utf8"));
6194
+ return JSON.parse(fs32.readFileSync(file2, "utf8"));
5821
6195
  } catch {
5822
6196
  return void 0;
5823
6197
  }
@@ -5827,7 +6201,7 @@ var jsonState = (file2, c) => {
5827
6201
  return entry === void 0 ? "absent" : same(c, entry) ? "same" : "different";
5828
6202
  };
5829
6203
  var home2 = () => os10.homedir();
5830
- var geminiDir = () => path25.join(process.env.GEMINI_CLI_HOME ?? home2(), ".gemini");
6204
+ var geminiDir = () => path28.join(process.env.GEMINI_CLI_HOME ?? home2(), ".gemini");
5831
6205
  function cliClient(id, backend, name, where, o) {
5832
6206
  return {
5833
6207
  id,
@@ -5850,43 +6224,43 @@ function jsonClient(id, name, file2, dir) {
5850
6224
  return {
5851
6225
  id,
5852
6226
  name,
5853
- found: () => fs30.existsSync(dir()),
6227
+ found: () => fs32.existsSync(dir()),
5854
6228
  state: (c) => jsonState(file2(), c),
5855
6229
  add: (c) => {
5856
6230
  let config = {};
5857
- if (fs30.existsSync(file2())) {
6231
+ if (fs32.existsSync(file2())) {
5858
6232
  config = readJson(file2());
5859
6233
  if (!config || typeof config !== "object" || Array.isArray(config)) return { ok: false, message: `${rel()} is not valid JSON: left alone` };
5860
6234
  const backup = `${file2()}.bak-pitroom`;
5861
- if (!fs30.existsSync(backup)) fs30.copyFileSync(file2(), backup);
6235
+ if (!fs32.existsSync(backup)) fs32.copyFileSync(file2(), backup);
5862
6236
  }
5863
6237
  config.mcpServers = { ...config.mcpServers ?? {}, [NAME]: { command: c.command, args: c.args } };
5864
- fs30.mkdirSync(path25.dirname(file2()), { recursive: true });
6238
+ fs32.mkdirSync(path28.dirname(file2()), { recursive: true });
5865
6239
  const tmp = `${file2()}.${process.pid}.tmp`;
5866
- fs30.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
6240
+ fs32.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
5867
6241
  `);
5868
- fs30.renameSync(tmp, file2());
6242
+ fs32.renameSync(tmp, file2());
5869
6243
  return { ok: true, message: rel() };
5870
6244
  },
5871
6245
  remove: () => {
5872
6246
  const config = readJson(file2());
5873
6247
  if (!config?.mcpServers?.[NAME]) return { ok: true, message: rel() };
5874
6248
  delete config.mcpServers[NAME];
5875
- fs30.writeFileSync(file2(), `${JSON.stringify(config, null, 2)}
6249
+ fs32.writeFileSync(file2(), `${JSON.stringify(config, null, 2)}
5876
6250
  `);
5877
6251
  return { ok: true, message: rel() };
5878
6252
  }
5879
6253
  };
5880
6254
  }
5881
6255
  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");
6256
+ if (process.platform === "darwin") return path28.join(home2(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
6257
+ if (process.platform === "win32") return path28.join(process.env.APPDATA ?? path28.join(home2(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
6258
+ return path28.join(process.env.XDG_CONFIG_HOME ?? path28.join(home2(), ".config"), "Claude", "claude_desktop_config.json");
5885
6259
  }
5886
6260
  function codexState(c) {
5887
6261
  let text;
5888
6262
  try {
5889
- text = fs30.readFileSync(path25.join(process.env.CODEX_HOME ?? path25.join(home2(), ".codex"), "config.toml"), "utf8");
6263
+ text = fs32.readFileSync(path28.join(process.env.CODEX_HOME ?? path28.join(home2(), ".codex"), "config.toml"), "utf8");
5890
6264
  } catch {
5891
6265
  return "absent";
5892
6266
  }
@@ -5904,7 +6278,7 @@ function codexState(c) {
5904
6278
  function clients() {
5905
6279
  return [
5906
6280
  cliClient("claude-code", "claude", "Claude Code", "user settings (~/.claude.json)", {
5907
- state: (c) => jsonState(path25.join(home2(), ".claude.json"), c),
6281
+ state: (c) => jsonState(path28.join(home2(), ".claude.json"), c),
5908
6282
  add: (c) => ["mcp", "add", "--scope", "user", NAME, "--", c.command, ...c.args],
5909
6283
  remove: () => ["mcp", "remove", NAME, "--scope", "user"]
5910
6284
  }),
@@ -5914,12 +6288,12 @@ function clients() {
5914
6288
  remove: () => ["mcp", "remove", NAME]
5915
6289
  }),
5916
6290
  cliClient("gemini", "gemini", "Gemini CLI", "user settings (~/.gemini/settings.json)", {
5917
- state: (c) => jsonState(path25.join(geminiDir(), "settings.json"), c),
6291
+ state: (c) => jsonState(path28.join(geminiDir(), "settings.json"), c),
5918
6292
  add: (c) => ["mcp", "add", "--scope", "user", NAME, c.command, ...c.args],
5919
6293
  remove: () => ["mcp", "remove", "--scope", "user", NAME]
5920
6294
  }),
5921
- jsonClient("cursor", "Cursor", () => path25.join(home2(), ".cursor", "mcp.json"), () => path25.join(home2(), ".cursor")),
5922
- jsonClient("claude-desktop", "Claude Desktop", desktopFile, () => path25.dirname(desktopFile()))
6295
+ jsonClient("cursor", "Cursor", () => path28.join(home2(), ".cursor", "mcp.json"), () => path28.join(home2(), ".cursor")),
6296
+ jsonClient("claude-desktop", "Claude Desktop", desktopFile, () => path28.dirname(desktopFile()))
5923
6297
  ];
5924
6298
  }
5925
6299
  function attempt2(f) {
@@ -5989,17 +6363,17 @@ var parseClients = (value) => {
5989
6363
  };
5990
6364
 
5991
6365
  // src/core/plan-status.ts
5992
- import crypto8 from "node:crypto";
5993
- import fs31 from "node:fs";
5994
- import path26 from "node:path";
6366
+ import crypto9 from "node:crypto";
6367
+ import fs33 from "node:fs";
6368
+ import path29 from "node:path";
5995
6369
  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");
6370
+ const id = crypto9.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
6371
+ return path29.join(home(), "plans", id, "notes.md");
5998
6372
  }
5999
6373
  function readNotes(plan) {
6000
6374
  const f = notesFile(plan);
6001
- if (!fs31.existsSync(f)) return [];
6002
- return fs31.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
6375
+ if (!fs33.existsSync(f)) return [];
6376
+ return fs33.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
6003
6377
  const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
6004
6378
  return m ? { at: m[1], text: m[2] } : { at: "", text: l };
6005
6379
  });
@@ -6007,11 +6381,11 @@ function readNotes(plan) {
6007
6381
  function addNote(planFile, text) {
6008
6382
  const plan = loadPlan(planFile);
6009
6383
  const f = notesFile(plan);
6010
- fs31.mkdirSync(path26.dirname(f), { recursive: true });
6011
- if (!fs31.existsSync(f)) fs31.writeFileSync(f, `# plan: ${plan.file}
6384
+ fs33.mkdirSync(path29.dirname(f), { recursive: true });
6385
+ if (!fs33.existsSync(f)) fs33.writeFileSync(f, `# plan: ${plan.file}
6012
6386
  `);
6013
6387
  const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
6014
- fs31.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
6388
+ fs33.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
6015
6389
  `);
6016
6390
  return f;
6017
6391
  }
@@ -6078,27 +6452,27 @@ ${rulings}`,
6078
6452
  }
6079
6453
 
6080
6454
  // src/core/cache.ts
6081
- import crypto9 from "node:crypto";
6082
- import fs32 from "node:fs";
6083
- import path27 from "node:path";
6455
+ import crypto10 from "node:crypto";
6456
+ import fs34 from "node:fs";
6457
+ import path30 from "node:path";
6084
6458
  var SCAN2 = 500;
6085
6459
  function cacheKey(o) {
6086
6460
  if (o.verify || o.mode !== "read" || o.continueFrom || o.web || o.plan || o.review || o.audit || o.auditRate === 1) return void 0;
6087
6461
  if (!(effective().cacheDays.value > 0)) return void 0;
6088
- const dir = canonical(path27.resolve(o.dir));
6462
+ const dir = canonical(path30.resolve(o.dir));
6089
6463
  const root = repoRoot(dir);
6090
6464
  if (!root) return void 0;
6091
6465
  try {
6092
6466
  const tree = snapshotTree(root);
6093
6467
  const head = commitOf(root, "HEAD") ?? "";
6094
6468
  const files = o.files.map((f) => {
6095
- const abs = path27.resolve(f);
6096
- return [abs, crypto9.createHash("sha256").update(fs32.readFileSync(abs)).digest("hex")];
6469
+ const abs = path30.resolve(f);
6470
+ return [abs, crypto10.createHash("sha256").update(fs34.readFileSync(abs)).digest("hex")];
6097
6471
  });
6098
6472
  const question = o.task.replace(/\s+/g, " ").trim();
6099
6473
  const readIn2 = READ_IN.includes(effective().readIn.value) ? effective().readIn.value : "auto";
6100
6474
  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");
6475
+ const key = crypto10.createHash("sha256").update(JSON.stringify([question, path30.relative(root, dir), files, where])).digest("hex");
6102
6476
  return { key, state: `${head}:${tree}` };
6103
6477
  } catch {
6104
6478
  return void 0;
@@ -6141,9 +6515,9 @@ function ago(iso, now = Date.now()) {
6141
6515
  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
6516
 
6143
6517
  // src/core/review.ts
6144
- import crypto10 from "node:crypto";
6145
- import fs33 from "node:fs";
6146
- import path28 from "node:path";
6518
+ import crypto11 from "node:crypto";
6519
+ import fs35 from "node:fs";
6520
+ import path31 from "node:path";
6147
6521
  var TEMPLATE2 = { task: "task-reviewer", fix: "re-review", range: "code-reviewer" };
6148
6522
  var section = (title, body) => `## ${title}
6149
6523
 
@@ -6161,7 +6535,7 @@ function ancestors(m) {
6161
6535
  function briefOf(m) {
6162
6536
  const root = [m, ...ancestors(m)].at(-1);
6163
6537
  const f = runFile(root.id, "brief.md");
6164
- return fs33.existsSync(f) ? fs33.readFileSync(f, "utf8") : root.task;
6538
+ return fs35.existsSync(f) ? fs35.readFileSync(f, "utf8") : root.task;
6165
6539
  }
6166
6540
  function latestReview(ids) {
6167
6541
  for (const id of listRunIds().reverse()) {
@@ -6175,7 +6549,7 @@ function latestReview(ids) {
6175
6549
  }
6176
6550
  function diffOf(m, from = m.baseTree) {
6177
6551
  const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
6178
- if (where && fs33.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
6552
+ if (where && fs35.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
6179
6553
  if (from !== m.baseTree) {
6180
6554
  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
6555
  }
@@ -6189,7 +6563,7 @@ function runReview(id) {
6189
6563
  if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
6190
6564
  const chain = ancestors(m);
6191
6565
  const previous = latestReview(chain.map((a) => a.id));
6192
- const dir = m.mode === "isolate" && m.worktree && fs33.existsSync(m.worktree) ? m.cwd : m.dir;
6566
+ const dir = m.mode === "isolate" && m.worktree && fs35.existsSync(m.worktree) ? m.cwd : m.dir;
6193
6567
  const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
6194
6568
  const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
6195
6569
  if (!previous) {
@@ -6264,20 +6638,20 @@ function writePackage(job) {
6264
6638
  const g = gitDir(job.dir);
6265
6639
  const root = repoRoot(job.dir);
6266
6640
  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);
6641
+ const name = `review-${crypto11.randomBytes(4).toString("hex")}.md`;
6642
+ const inside2 = !path31.relative(fs35.realpathSync(root), fs35.realpathSync(g)).startsWith("..");
6643
+ const file2 = inside2 ? path31.join(g, "pitroom", name) : path31.join(root, ".pitroom", name);
6270
6644
  if (!inside2) ignoreInGit(root, "/.pitroom/");
6271
- fs33.mkdirSync(path28.dirname(file2), { recursive: true });
6272
- fs33.writeFileSync(file2, job.package);
6645
+ fs35.mkdirSync(path31.dirname(file2), { recursive: true });
6646
+ fs35.writeFileSync(file2, job.package);
6273
6647
  return file2;
6274
6648
  }
6275
6649
  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") : "";
6650
+ const exclude = path31.resolve(root, git(root, ["rev-parse", "--git-path", "info/exclude"]).stdout.trim());
6651
+ const text = fs35.existsSync(exclude) ? fs35.readFileSync(exclude, "utf8") : "";
6278
6652
  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)
6653
+ fs35.mkdirSync(path31.dirname(exclude), { recursive: true });
6654
+ fs35.appendFileSync(exclude, `${text && !text.endsWith("\n") ? "\n" : ""}# Pitroom review packages in worktrees (removed after each review)
6281
6655
  ${pattern}
6282
6656
  `);
6283
6657
  }
@@ -6433,10 +6807,10 @@ async function cmdReview(p) {
6433
6807
  writeMeta(meta);
6434
6808
  }
6435
6809
  } catch (e) {
6436
- fs34.rmSync(packageFile, { force: true });
6810
+ fs36.rmSync(packageFile, { force: true });
6437
6811
  throw e;
6438
6812
  }
6439
- fs34.writeFileSync(runFile(meta.id, "package.md"), job.package);
6813
+ fs36.writeFileSync(runFile(meta.id, "package.md"), job.package);
6440
6814
  return launch(p, meta);
6441
6815
  }
6442
6816
  async function cmdAudit(p) {
@@ -6466,6 +6840,47 @@ async function cmdAudit(p) {
6466
6840
  writeMeta(m);
6467
6841
  return launch(p, a);
6468
6842
  }
6843
+ async function cmdPrices(p) {
6844
+ if (has(p, "refresh")) {
6845
+ const r = await refreshCatalog();
6846
+ if (r.state === "failed") {
6847
+ if (!has(p, "quiet")) console.error(`could not refresh the price catalog: ${r.error}`);
6848
+ return 1;
6849
+ }
6850
+ if (!has(p, "quiet")) console.log(`price catalog updated: ${r.models} priced models`);
6851
+ }
6852
+ const st = catalogStatus();
6853
+ const used = (() => {
6854
+ try {
6855
+ const chain = resolveChain();
6856
+ return [chain.worker, ...chain.fallback].map((t) => {
6857
+ const backend = getBackend(t.backend);
6858
+ const model = (t.model ?? "").split("#")[0];
6859
+ const price = workerPrice(t.backend, model);
6860
+ return {
6861
+ worker: model ? `${t.backend}:${model}` : t.backend,
6862
+ reportsCost: backend.capabilities.reportsCost,
6863
+ price: price ? { name: price.name, source: price.source, input: price.input, output: price.output, cachedInput: price.cachedInput } : void 0
6864
+ };
6865
+ });
6866
+ } catch {
6867
+ return [];
6868
+ }
6869
+ })();
6870
+ if (has(p, "json")) return console.log(JSON.stringify({ ...st, workers: used }, null, 2)), 0;
6871
+ console.log(`price feed: ${st.enabled ? "on" : 'off ("priceFeed": true in the config turns it on: Pitroom then fetches one public file, see PRIVACY.md)'}`);
6872
+ console.log(`source: ${st.url}${st.enabled ? `, refreshed after a run when older than ${st.hours} h` : ""}`);
6873
+ console.log(st.models ? `catalog: ${st.models} priced models, updated ${st.ageHours < 1 ? "within the hour" : `${st.ageHours.toFixed(1)} h ago`}` : "catalog: none yet (pitroom prices --refresh fetches it)");
6874
+ if (st.lastFailure) console.log(`last try: failed: ${st.lastFailure.replace(/^\S+ /, "")}`);
6875
+ if (used.length) {
6876
+ console.log("\nthe workers in use:");
6877
+ for (const u of used) {
6878
+ const what2 = u.reportsCost ? "reports its own cost" : u.price ? `in ${u.price.input} \xB7 out ${u.price.output} \xB7 cached ${u.price.cachedInput} USD/1M (${u.price.source === "catalog" ? `price catalog ${catalogLabel()}` : "your workerPrices"})` : "no price: its runs count as free in the savings";
6879
+ console.log(` ${u.worker.padEnd(36)} ${what2}`);
6880
+ }
6881
+ }
6882
+ return 0;
6883
+ }
6469
6884
  function cmdCooldown(p) {
6470
6885
  if (has(p, "clear")) {
6471
6886
  const n = clearCooldowns();
@@ -6504,7 +6919,7 @@ function cmdPlan(p) {
6504
6919
  }
6505
6920
  function cmdCrew(p) {
6506
6921
  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);
6922
+ const tasks = (file2 ? fs36.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
6508
6923
  if (!tasks.length) throw new UserError('crew needs tasks: pitroom crew -g NAME "task 1" "task 2" (or --task-file with --- separators)');
6509
6924
  if (has(p, "write") && tasks.length > 1) {
6510
6925
  throw new UserError("parallel --write runs would edit the same tree; use --isolate (each worker gets its own isolated copy)");
@@ -6612,9 +7027,10 @@ function cmdHistory(p) {
6612
7027
  const s = historyStats(since2);
6613
7028
  if (has(p, "json")) return console.log(JSON.stringify(s, null, 2)), 0;
6614
7029
  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"];
7030
+ const limited = t.limited ? ` \xB7 ${t.limited} rate-limited (${s.rateLimits === "counted" ? "counted as not ok" : 'left out; "countRateLimits": true counts them'})` : "";
7031
+ 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`);
7032
+ 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)}`]);
7033
+ const head2 = ["WORKER", "RUNS", "OK", "LIMITED", "AVG TIME", "AVG TOKENS", "SAVED"];
6618
7034
  const widths2 = head2.map((h, i) => Math.max(h.length, ...rows2.map((r) => r[i].length)));
6619
7035
  const fmt2 = (r) => r.map((c, i) => i === 0 ? c.padEnd(widths2[i]) : c.padStart(widths2[i])).join(" ");
6620
7036
  if (rows2.length) console.log(`
@@ -6704,7 +7120,7 @@ function sinceMs(s) {
6704
7120
  if (!m) throw new UserError("--since takes 7d, 30d, \u2026 or all");
6705
7121
  return Date.now() - Number(m[1]) * 864e5;
6706
7122
  }
6707
- var readStdin = () => process.stdin.isTTY ? "" : fs34.readFileSync(0, "utf8");
7123
+ var readStdin = () => process.stdin.isTTY ? "" : fs36.readFileSync(0, "utf8");
6708
7124
  function cmdStatusline(p) {
6709
7125
  const input = readStdin();
6710
7126
  const lines = [];
@@ -6744,8 +7160,8 @@ function cmdModels(p) {
6744
7160
  return 0;
6745
7161
  }
6746
7162
  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 });
7163
+ const script = path32.join(path32.dirname(path32.dirname(fileURLToPath4(import.meta.url))), "hooks", "session-start.mjs");
7164
+ if (fs36.existsSync(script)) spawnSync8(process.execPath, [script], { stdio: ["ignore", "inherit", "ignore"], env: process.env });
6749
7165
  return 0;
6750
7166
  }
6751
7167
  function cmdInit(p) {
@@ -6785,8 +7201,8 @@ function cmdSavings(p) {
6785
7201
  est. saved ${usd(t.saved)} (vs ${primaryPrice().name} list prices)`);
6786
7202
  const out = flag(p, "card");
6787
7203
  if (out) {
6788
- fs34.writeFileSync(out, card(t, period));
6789
- console.log(`card written to ${path29.resolve(out)}`);
7204
+ fs36.writeFileSync(out, card(t, period));
7205
+ console.log(`card written to ${path32.resolve(out)}`);
6790
7206
  }
6791
7207
  if (has(p, "badge")) console.log(`![pitroom](${badgeUrl(t)})`);
6792
7208
  return 0;
@@ -6828,7 +7244,7 @@ function cmdConfig(p) {
6828
7244
  console.log(JSON.stringify({ path: configPath(), settings: eff, warnings }, null, 2));
6829
7245
  return 0;
6830
7246
  }
6831
- console.log(`config file: ${configPath()}${fs34.existsSync(configPath()) ? "" : " (not present)"}`);
7247
+ console.log(`config file: ${configPath()}${fs36.existsSync(configPath()) ? "" : " (not present)"}`);
6832
7248
  for (const [key, s] of Object.entries(eff)) {
6833
7249
  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
7250
  console.log(` ${key.padEnd(15)} ${v} (${s.source})`);
@@ -6852,7 +7268,7 @@ function cmdClean(p) {
6852
7268
  const m = readMeta(id);
6853
7269
  if (m.mode === "isolate" && !m.discarded && !m.applied) discardRun(m);
6854
7270
  recordRun(m);
6855
- fs34.rmSync(runDir(id), { recursive: true, force: true });
7271
+ fs36.rmSync(runDir(id), { recursive: true, force: true });
6856
7272
  }
6857
7273
  const snapshots = prune(Date.now(), 0);
6858
7274
  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 +7279,10 @@ var cmdDiscard = (p) => (console.log(discardRun(freshMeta(resolveRun(p.positiona
6863
7279
 
6864
7280
  // src/core/doctor.ts
6865
7281
  import { spawnSync as spawnSync9 } from "node:child_process";
6866
- import fs35 from "node:fs";
7282
+ import fs37 from "node:fs";
6867
7283
  import os11 from "node:os";
6868
- import path30 from "node:path";
6869
- var MARK = { ok: () => green("\u2714"), warn: () => yellow("!"), fail: () => red("\u2718") };
7284
+ import path33 from "node:path";
7285
+ var MARK2 = { ok: () => green("\u2714"), warn: () => yellow("!"), fail: () => red("\u2718") };
6870
7286
  var NEXT = [
6871
7287
  { when: /no default model|no fallback workers/, command: "pitroom init", why: "propose a starter config (fallback models from your catalogue)" },
6872
7288
  { when: /pitroom install|no Pitroom skills|launcher on PATH/, command: "pitroom install", why: "link the skills and the pitroom command" },
@@ -6888,10 +7304,10 @@ function usageLine() {
6888
7304
  return `${ids.length} run${ids.length === 1 ? "" : "s"} so far${when2} (pitroom savings shows what they saved)`;
6889
7305
  }
6890
7306
  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");
7307
+ for (const dir of (process.env.PATH ?? "").split(path33.delimiter).filter(Boolean)) {
7308
+ const file2 = path33.join(dir, process.platform === "win32" ? "node.exe" : "node");
6893
7309
  try {
6894
- if (!fs35.statSync(file2).isFile()) continue;
7310
+ if (!fs37.statSync(file2).isFile()) continue;
6895
7311
  } catch {
6896
7312
  continue;
6897
7313
  }
@@ -6918,19 +7334,16 @@ function doctor5(probe) {
6918
7334
  const addAll = (list2) => list2.forEach((c) => add(c.level, c.message));
6919
7335
  add("ok", `pitroom ${VERSION2} \xB7 node ${process.versions.node} \xB7 state in ${home()}`);
6920
7336
  const onPath = firstNodeOnPath();
6921
- if (onPath && onPath.version && tooOld(onPath.version) && path30.resolve(onPath.path) !== path30.resolve(process.execPath)) {
7337
+ if (onPath && onPath.version && tooOld(onPath.version) && path33.resolve(onPath.path) !== path33.resolve(process.execPath)) {
6922
7338
  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
7339
  }
6924
7340
  add(gitAvailable() ? "ok" : "warn", gitAvailable() ? "git available" : "git not found: --write/--isolate tracking disabled");
6925
7341
  const cfg = loadConfig();
6926
- add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs35.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
7342
+ add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs37.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
6927
7343
  for (const w of cfg.warnings) add("warn", w);
6928
- if (process.platform === "win32") {
6929
- 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");
6930
- } else {
6931
- const guarded = guardEnv(process.env).PATH?.startsWith(shimDir());
6932
- add(guarded ? "ok" : "warn", guarded ? "git guard shim ready" : "git guard unavailable (git not on PATH)");
6933
- }
7344
+ if (shimReady()) add("ok", "git guard shim ready");
7345
+ else if (process.platform === "win32") add("warn", "git guard: the ref and push guard is on, but the git shim needs the sh.exe of Git for Windows next to git (not found), so a worker's `git add`, `restore` or `clean` is not blocked; prefer --isolate and check the patch before apply");
7346
+ else add("warn", "git guard unavailable (git not on PATH)");
6934
7347
  section2("Worker chain");
6935
7348
  const chain = [];
6936
7349
  try {
@@ -6967,6 +7380,25 @@ function doctor5(probe) {
6967
7380
  add("ok", `cost: ${key} = ${mine}${cheaper ? `; you priced ${cheaper[0]} cheaper (${cheaper[1]}): is the dearer one needed?` : ""}`);
6968
7381
  }
6969
7382
  }
7383
+ const seenUnpriced = /* @__PURE__ */ new Set();
7384
+ for (const t of [...chain, ...tierTargets]) {
7385
+ let backend;
7386
+ try {
7387
+ backend = getBackend(t.backend);
7388
+ } catch {
7389
+ continue;
7390
+ }
7391
+ const model = (t.model ?? "").split("#")[0];
7392
+ const key = model ? `${t.backend}:${model}` : t.backend;
7393
+ if (backend.capabilities.reportsCost || seenUnpriced.has(key) || workerPrice(t.backend, model)) continue;
7394
+ seenUnpriced.add(key);
7395
+ add("warn", `${key} reports no cost, so its runs count as free in the savings: give its price in the config, "workerPrices": {"${key}": "<in>,<out>[,<cachedIn>]"} (USD per 1M tokens), or turn on "priceFeed": true to use the public price catalog`);
7396
+ }
7397
+ const feed = catalogStatus();
7398
+ if (feed.enabled) {
7399
+ if (feed.models) add(feed.lastFailure ? "warn" : "ok", `price catalog: ${feed.models} priced models from ${feed.url}, updated ${feed.ageHours < 1 ? "within the hour" : `${feed.ageHours.toFixed(1)} h ago`}${feed.lastFailure ? `; the last refresh failed (${feed.lastFailure.replace(/^\S+ /, "")})` : ""}`);
7400
+ else add("warn", `price feed is on but no catalog has been fetched yet${feed.lastFailure ? ` (the last try failed: ${feed.lastFailure.replace(/^\S+ /, "")})` : ""}: it is fetched after a run, or now with \`pitroom prices --refresh\``);
7401
+ }
6970
7402
  for (const c of Object.values(activeCooldowns())) {
6971
7403
  add("warn", `${c.target} is cooling down ${untilText(c)} (${c.reason.slice(0, 80)}): runs skip it while a fallback is left; \`pitroom cooldown --clear\` tries it again`);
6972
7404
  }
@@ -7019,7 +7451,7 @@ function print(checks) {
7019
7451
  for (const name of [...new Set(checks.map((c) => c.section))]) {
7020
7452
  console.log(`
7021
7453
  ${bold(name)}`);
7022
- for (const c of checks.filter((x) => x.section === name)) console.log(` ${MARK[c.level]()} ${wrapText(c.message, 4)}`);
7454
+ for (const c of checks.filter((x) => x.section === name)) console.log(` ${MARK2[c.level]()} ${wrapText(c.message, 4)}`);
7023
7455
  }
7024
7456
  const count = (level) => checks.filter((c) => c.level === level).length;
7025
7457
  const [ok, warn, fail] = [count("ok"), count("warn"), count("fail")];
@@ -7080,19 +7512,19 @@ function skillChecks() {
7080
7512
  else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${base2}; run \`pitroom install\`` });
7081
7513
  }
7082
7514
  if (geminiExtensionInstalled()) {
7083
- const linked = installedSkills().some((i) => i.base.includes(`${path30.sep}.agents${path30.sep}`) && i.names.length);
7515
+ const linked = installedSkills().some((i) => i.base.includes(`${path33.sep}.agents${path33.sep}`) && i.names.length);
7084
7516
  checks.push(
7085
7517
  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
7518
  );
7087
7519
  }
7088
7520
  if (viaPlugin) {
7089
- const linked = installedSkills().some((i) => i.base.includes(`${path30.sep}.claude${path30.sep}`) && i.names.length);
7521
+ const linked = installedSkills().some((i) => i.base.includes(`${path33.sep}.claude${path33.sep}`) && i.names.length);
7090
7522
  checks.push(
7091
7523
  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
7524
  );
7093
7525
  }
7094
7526
  const launcher = launcherPath();
7095
- if (fs35.existsSync(launcher)) {
7527
+ if (fs37.existsSync(launcher)) {
7096
7528
  const r = spawnSync9(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
7097
7529
  checks.push(
7098
7530
  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 +7536,12 @@ function skillChecks() {
7104
7536
  }
7105
7537
  function geminiExtensionInstalled() {
7106
7538
  const home3 = process.env.GEMINI_CLI_HOME ?? os11.homedir();
7107
- return fs35.existsSync(path30.join(home3, ".gemini", "extensions", "pitroom"));
7539
+ return fs37.existsSync(path33.join(home3, ".gemini", "extensions", "pitroom"));
7108
7540
  }
7109
7541
  function pluginInstalled() {
7110
7542
  try {
7111
- const f = path30.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
7112
- const plugins = JSON.parse(fs35.readFileSync(f, "utf8")).plugins ?? {};
7543
+ const f = path33.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
7544
+ const plugins = JSON.parse(fs37.readFileSync(f, "utf8")).plugins ?? {};
7113
7545
  return Object.keys(plugins).some((k) => k.startsWith("pitroom@"));
7114
7546
  } catch {
7115
7547
  return false;
@@ -7126,24 +7558,24 @@ function superpowersActive() {
7126
7558
  for (const p of openCodePlugins()) {
7127
7559
  if (/superpowers/i.test(p)) found.push(`OpenCode plugin ${p}`);
7128
7560
  }
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);
7561
+ for (const base2 of [path33.join(os11.homedir(), ".agents", "skills"), path33.join(os11.homedir(), ".claude", "skills")]) {
7562
+ const dir = path33.join(base2, "using-superpowers");
7563
+ if (fs37.existsSync(path33.join(dir, "SKILL.md"))) found.push(dir);
7132
7564
  }
7133
7565
  return found;
7134
7566
  }
7135
7567
  function claudePlugins() {
7136
7568
  try {
7137
- const f = path30.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
7138
- return Object.keys(JSON.parse(fs35.readFileSync(f, "utf8")).plugins ?? {});
7569
+ const f = path33.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
7570
+ return Object.keys(JSON.parse(fs37.readFileSync(f, "utf8")).plugins ?? {});
7139
7571
  } catch {
7140
7572
  return [];
7141
7573
  }
7142
7574
  }
7143
7575
  function claudePluginEnabled(key) {
7144
7576
  try {
7145
- const f = path30.join(os11.homedir(), ".claude", "settings.json");
7146
- return JSON.parse(fs35.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
7577
+ const f = path33.join(os11.homedir(), ".claude", "settings.json");
7578
+ return JSON.parse(fs37.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
7147
7579
  } catch {
7148
7580
  return true;
7149
7581
  }
@@ -7152,7 +7584,7 @@ function codexPlugins() {
7152
7584
  const plugins = /* @__PURE__ */ new Map();
7153
7585
  let text;
7154
7586
  try {
7155
- text = fs35.readFileSync(path30.join(process.env.CODEX_HOME ?? path30.join(os11.homedir(), ".codex"), "config.toml"), "utf8");
7587
+ text = fs37.readFileSync(path33.join(process.env.CODEX_HOME ?? path33.join(os11.homedir(), ".codex"), "config.toml"), "utf8");
7156
7588
  } catch {
7157
7589
  return plugins;
7158
7590
  }
@@ -7170,11 +7602,11 @@ function codexPlugins() {
7170
7602
  return plugins;
7171
7603
  }
7172
7604
  function openCodePlugins() {
7173
- const dir = path30.join(process.env.XDG_CONFIG_HOME ?? path30.join(os11.homedir(), ".config"), "opencode");
7605
+ const dir = path33.join(process.env.XDG_CONFIG_HOME ?? path33.join(os11.homedir(), ".config"), "opencode");
7174
7606
  const plugins = [];
7175
7607
  for (const name of ["opencode.json", "opencode.jsonc"]) {
7176
7608
  try {
7177
- const text = fs35.readFileSync(path30.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
7609
+ const text = fs37.readFileSync(path33.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
7178
7610
  const list2 = JSON.parse(text).plugin;
7179
7611
  if (Array.isArray(list2)) plugins.push(...list2.filter((p) => typeof p === "string"));
7180
7612
  } catch {
@@ -7182,7 +7614,7 @@ function openCodePlugins() {
7182
7614
  }
7183
7615
  for (const folder of ["plugin", "plugins"]) {
7184
7616
  try {
7185
- for (const f of fs35.readdirSync(path30.join(dir, folder))) plugins.push(path30.join(dir, folder, f));
7617
+ for (const f of fs37.readdirSync(path33.join(dir, folder))) plugins.push(path33.join(dir, folder, f));
7186
7618
  } catch {
7187
7619
  }
7188
7620
  }
@@ -7232,6 +7664,7 @@ Usage
7232
7664
  pitroom statusline [--then CMD] status-bar line: running workers, savings this week (after CMD's)
7233
7665
  pitroom hook-card PostToolUse hook: a card after each Bash \`pitroom\` command
7234
7666
  pitroom hook-start SessionStart hook: introduces Pitroom to the agent (Codex; Claude Code uses its plugin)
7667
+ pitroom prices [--refresh] [--json] the price catalog (config "priceFeed") and the price each worker in use gets
7235
7668
  pitroom cooldown [--clear] models that said "rate limited" and are skipped for a while (a quota used up);
7236
7669
  --clear tries them again
7237
7670
  pitroom mcp [-d DIR] [--http [--port N]]
@@ -7285,21 +7718,24 @@ Exit codes: 0 ok \xB7 1 worker failed \xB7 2 usage \xB7 3 refused/setup \xB7 4 t
7285
7718
  Workers: ${backendIds().join(", ")} (targets: "opencode", "opencode:provider/model", or a bare model)
7286
7719
  Env: PITROOM_WORKER, PITROOM_MODEL, PITROOM_FALLBACK="t1,t2", PITROOM_TIMEOUT, PITROOM_MAX_PARALLEL,
7287
7720
  PITROOM_PRIMARY=sonnet|opus|haiku|gpt-5, PITROOM_PRICE="in,out", PITROOM_HOME, PITROOM_CONFIG, PITROOM_CACHE_DAYS,
7288
- PITROOM_<WORKER>_BIN
7289
- Config: ~/.config/pitroom/config.json (worker, fallback, models, tiers, timeout, primary, price, link, web, maxParallel, audit, readIn, cacheDays)`;
7721
+ PITROOM_NOTIFY, PITROOM_NOTIFY_COMMAND, PITROOM_NOTIFY_AFTER, PITROOM_MCP_DASH, PITROOM_PRICE_FEED, PITROOM_PRICE_FEED_URL,
7722
+ PITROOM_PRICE_FEED_HOURS, PITROOM_<WORKER>_BIN
7723
+ Config: ~/.config/pitroom/config.json (worker, fallback, models, tiers, timeout, primary, price, link, web, maxParallel, audit, readIn, cacheDays,
7724
+ countRateLimits, mcpDash, notify, notifyCommand, notifyAfter, workerPrices, priceFeed, priceFeedUrl, priceFeedHours)`;
7290
7725
  var COMMANDS = {
7291
7726
  run: cmdRun,
7292
7727
  crew: cmdCrew,
7293
7728
  review: cmdReview,
7294
7729
  audit: cmdAudit,
7295
7730
  cooldown: cmdCooldown,
7731
+ prices: cmdPrices,
7296
7732
  mcp: (p) => {
7297
7733
  const port = flag(p, "port");
7298
7734
  if (port !== void 0 && !has(p, "http")) throw new UserError("--port goes with --http (on stdio there is no port)");
7299
7735
  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
7736
  const dir = flag(p, "dir");
7301
7737
  if (dir !== void 0) {
7302
- if (!fs36.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) throw new UserError(`-d ${dir}: no such directory`);
7738
+ if (!fs38.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) throw new UserError(`-d ${dir}: no such directory`);
7303
7739
  process.chdir(dir);
7304
7740
  process.env.PWD = process.cwd();
7305
7741
  }