pitroom 0.6.10 → 0.8.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
@@ -1220,10 +1220,13 @@ function isAlive(pid) {
1220
1220
  function freshMeta(id) {
1221
1221
  const meta = readMeta(id);
1222
1222
  if (!TERMINAL.includes(meta.state) && meta.pid && !isAlive(meta.pid)) {
1223
- meta.state = "failed";
1224
- meta.error ??= "worker process exited unexpectedly";
1225
- meta.endedAt ??= (/* @__PURE__ */ new Date()).toISOString();
1226
- writeMeta(meta);
1223
+ const latest = readMeta(id);
1224
+ if (TERMINAL.includes(latest.state)) return latest;
1225
+ latest.state = "failed";
1226
+ latest.error ??= "worker process exited unexpectedly";
1227
+ latest.endedAt ??= (/* @__PURE__ */ new Date()).toISOString();
1228
+ writeMeta(latest);
1229
+ return latest;
1227
1230
  }
1228
1231
  return meta;
1229
1232
  }
@@ -1260,7 +1263,8 @@ var VALUE_FLAGS = {
1260
1263
  "--state": "state",
1261
1264
  "--limit": "limit",
1262
1265
  "--plan": "plan",
1263
- "--step": "step"
1266
+ "--step": "step",
1267
+ "--fallback": "fallback"
1264
1268
  };
1265
1269
  var BOOL_FLAGS = {
1266
1270
  "-r": "read",
@@ -1391,21 +1395,140 @@ function effortFlag(p) {
1391
1395
 
1392
1396
  // src/cli/commands.ts
1393
1397
  import { spawnSync as spawnSync6 } from "node:child_process";
1394
- import fs25 from "node:fs";
1395
- import path21 from "node:path";
1398
+ import fs26 from "node:fs";
1399
+ import path22 from "node:path";
1400
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
1401
+
1402
+ // src/core/init.ts
1403
+ import fs8 from "node:fs";
1404
+ import path7 from "node:path";
1405
+
1406
+ // src/core/style.ts
1407
+ var enabled = () => {
1408
+ if (process.env.NO_COLOR) return false;
1409
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
1410
+ return !!process.stdout.isTTY && process.env.TERM !== "dumb";
1411
+ };
1412
+ var wrap = (open, close) => (text) => enabled() ? `\x1B[${open}m${text}\x1B[${close}m` : text;
1413
+ var bold = wrap(1, 22);
1414
+ var dim = wrap(2, 22);
1415
+ var red = wrap(31, 39);
1416
+ var green = wrap(32, 39);
1417
+ var yellow = wrap(33, 39);
1418
+ var blue = wrap(34, 39);
1419
+ var cyan = wrap(36, 39);
1420
+ function stateColour(state, text = state) {
1421
+ if (state === "done") return green(text);
1422
+ if (state === "running") return blue(text);
1423
+ if (state === "queued") return yellow(text);
1424
+ if (state === "failed") return red(text);
1425
+ if (state === "timeout") return yellow(text);
1426
+ return dim(text);
1427
+ }
1428
+ function wrapText(text, indent) {
1429
+ const columns = process.stdout.isTTY ? process.stdout.columns : void 0;
1430
+ if (!columns || text.length + indent <= columns) return text;
1431
+ const width = Math.max(40, Math.min(columns, 110) - indent);
1432
+ const lines = [];
1433
+ let line = "";
1434
+ for (const word of text.split(" ")) {
1435
+ if (line && line.length + 1 + word.length > width) {
1436
+ lines.push(line);
1437
+ line = word;
1438
+ } else {
1439
+ line = line ? `${line} ${word}` : word;
1440
+ }
1441
+ }
1442
+ if (line) lines.push(line);
1443
+ return lines.join(`
1444
+ ${" ".repeat(indent)}`);
1445
+ }
1446
+
1447
+ // src/core/init.ts
1448
+ var FREE = /-free$/;
1449
+ var TIER_ORDER = [["cheap", "opencode"], ["standard", "codex"], ["capable", "claude"]];
1450
+ var isFound = (binary4) => path7.isAbsolute(binary4) && fs8.existsSync(binary4);
1451
+ function planInit(opts = {}) {
1452
+ const file = configPath();
1453
+ const workers = backendIds().map((id) => {
1454
+ const b = getBackend(id);
1455
+ const binary4 = b.binary();
1456
+ return { id, name: b.name, found: isFound(binary4), binary: binary4 };
1457
+ });
1458
+ const notes = [];
1459
+ const config = {};
1460
+ let blocked;
1461
+ let opencode2;
1462
+ if (workers.find((w) => w.id === "opencode")?.found) {
1463
+ const b = getBackend("opencode");
1464
+ let models = [];
1465
+ try {
1466
+ models = b.catalog ? b.catalog().models.map((m) => m.id) : b.listModels?.() ?? [];
1467
+ } catch {
1468
+ notes.push("could not list OpenCode models (is it logged in?)");
1469
+ }
1470
+ const defaultModel3 = b.defaultModel?.();
1471
+ const chosen = opts.model ?? defaultModel3;
1472
+ const free = models.filter((m) => FREE.test(m) && m !== chosen);
1473
+ opencode2 = { defaultModel: defaultModel3, models: models.length, free: free.slice(0, 6) };
1474
+ if (opts.model) {
1475
+ if (models.length && !models.includes(opts.model)) throw new UserError(`"${opts.model}" is not in \`opencode models\``);
1476
+ if (!models.length) notes.push(`could not check "${opts.model}": \`opencode models\` listed nothing`);
1477
+ config.models = { opencode: opts.model };
1478
+ } else if (!defaultModel3) {
1479
+ blocked = `OpenCode has no default model: choose one with --model <id>${free.length ? ` (free models you have: ${free.slice(0, 4).join(", ")})` : ""}`;
1480
+ }
1481
+ const fallback = opts.fallback ?? free.slice(0, 2);
1482
+ for (const m of fallback) {
1483
+ if (models.length && !models.includes(m)) throw new UserError(`"${m}" is not in \`opencode models\``);
1484
+ }
1485
+ if (fallback.length) {
1486
+ config.fallback = fallback.map((m) => `opencode:${m}`);
1487
+ if (!opts.fallback) notes.push(`fallback suggested from the free models in your catalogue: ${fallback.join(", ")} (change it with --fallback a,b)`);
1488
+ } else {
1489
+ notes.push("no fallback: no free OpenCode model found to suggest (pass --fallback a,b to name some)");
1490
+ }
1491
+ }
1492
+ const tiers = Object.fromEntries(TIER_ORDER.filter(([, id]) => workers.find((w) => w.id === id)?.found));
1493
+ if (Object.keys(tiers).length > 1) config.tiers = tiers;
1494
+ if (!workers.some((w) => w.found)) blocked = "no worker CLI found: install OpenCode (https://opencode.ai), Codex CLI or Claude Code first";
1495
+ else if (!blocked && !Object.keys(config).length) notes.push("nothing to propose: the default model and the single worker need no config");
1496
+ return { path: file, exists: fs8.existsSync(file), workers, opencode: opencode2, config, blocked, notes };
1497
+ }
1498
+ function writeInit(plan, force) {
1499
+ if (plan.blocked) throw new UserError(plan.blocked);
1500
+ if (!Object.keys(plan.config).length) throw new UserError("nothing to write: the proposal is empty");
1501
+ if (plan.exists && !force) throw new UserError(`${plan.path} already exists: pass --force to replace it (the old file is kept as ${path7.basename(plan.path)}.bak)`);
1502
+ fs8.mkdirSync(path7.dirname(plan.path), { recursive: true });
1503
+ if (plan.exists) fs8.copyFileSync(plan.path, `${plan.path}.bak`);
1504
+ fs8.writeFileSync(plan.path, `${JSON.stringify(plan.config, null, 2)}
1505
+ `);
1506
+ return plan.path;
1507
+ }
1508
+ function formatInit(plan, written) {
1509
+ const out = [bold("pitroom init") + dim(": worker CLIs on this machine")];
1510
+ for (const w of plan.workers) out.push(` ${w.found ? green("\u2714") : dim("\xB7")} ${w.name.padEnd(12)} ${w.found ? w.binary : dim("not found")}`);
1511
+ if (plan.opencode) {
1512
+ const o = plan.opencode;
1513
+ out.push(` OpenCode: ${o.models} models, default ${o.defaultModel ?? "none"}${o.free.length ? `, free: ${o.free.slice(0, 3).join(", ")}${o.free.length > 3 ? ", \u2026" : ""}` : ""}`);
1514
+ }
1515
+ out.push("", `config file: ${plan.path}${plan.exists ? " (exists)" : " (not present)"}`, JSON.stringify(plan.config, null, 2));
1516
+ for (const n of plan.notes) out.push(` note: ${n}`);
1517
+ if (written) out.push("", green(`\u2714 written to ${written}`), dim(" next: pitroom doctor, then pitroom install"));
1518
+ else if (plan.blocked) out.push("", yellow(`! ${plan.blocked}`));
1519
+ else out.push("", `nothing written yet: pass --yes to write it${plan.exists ? " (with --force, since the file exists)" : ""}`);
1520
+ return out.join("\n");
1521
+ }
1396
1522
 
1397
1523
  // src/core/history.ts
1398
- import fs10 from "node:fs";
1524
+ import fs11 from "node:fs";
1399
1525
  import { createRequire } from "node:module";
1400
- import path8 from "node:path";
1526
+ import path9 from "node:path";
1401
1527
  import zlib from "node:zlib";
1402
1528
 
1403
- // src/core/ui.ts
1404
- import fs9 from "node:fs";
1405
-
1406
1529
  // src/core/receipt.ts
1407
- import fs8 from "node:fs";
1408
- import path7 from "node:path";
1530
+ import fs9 from "node:fs";
1531
+ import path8 from "node:path";
1409
1532
  var PRESETS = {
1410
1533
  sonnet: { name: "Claude Sonnet", input: 3, output: 15, cachedInput: 0.3 },
1411
1534
  opus: { name: "Claude Opus", input: 5, output: 25, cachedInput: 0.5 },
@@ -1441,13 +1564,13 @@ function record(meta) {
1441
1564
  saved: meta.savedUsd ?? 0,
1442
1565
  price: primaryPrice().name
1443
1566
  };
1444
- fs8.mkdirSync(path7.dirname(ledgerFile()), { recursive: true });
1445
- fs8.appendFileSync(ledgerFile(), `${JSON.stringify(entry)}
1567
+ fs9.mkdirSync(path8.dirname(ledgerFile()), { recursive: true });
1568
+ fs9.appendFileSync(ledgerFile(), `${JSON.stringify(entry)}
1446
1569
  `);
1447
1570
  }
1448
1571
  function readLedger(sinceMs2) {
1449
- if (!fs8.existsSync(ledgerFile())) return [];
1450
- return fs8.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
1572
+ if (!fs9.existsSync(ledgerFile())) return [];
1573
+ return fs9.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
1451
1574
  try {
1452
1575
  return [JSON.parse(l)];
1453
1576
  } catch {
@@ -1522,6 +1645,7 @@ ${tile(0, compact(t.tokens), "Tokens offloaded")}${tile(1, t.ratio ? `${Math.rou
1522
1645
  }
1523
1646
 
1524
1647
  // src/core/ui.ts
1648
+ import fs10 from "node:fs";
1525
1649
  var WEEK_MS = 7 * 24 * 3600 * 1e3;
1526
1650
  var RECENT = 40;
1527
1651
  var RECENT_HOURS = 1;
@@ -1570,6 +1694,10 @@ function workerName(t) {
1570
1694
  const model = (t.model.split("/").pop() ?? t.model).replace(/-(contributor-)?free$/, "").replace(/-\d+\.\d+$/, "");
1571
1695
  return `${t.backend} (${model.length > MODEL_MAX ? `${model.slice(0, MODEL_MAX - 1)}\u2026` : model})`;
1572
1696
  }
1697
+ function ranTarget(m) {
1698
+ const t = m.ran ?? m.worker;
1699
+ return t.model || !m.resolvedModel ? t : { ...t, model: m.resolvedModel };
1700
+ }
1573
1701
  function what(m) {
1574
1702
  if (m.plan) return short(`Task ${m.plan.step}: ${m.plan.title}`);
1575
1703
  if (m.reviewOf) return `of ${m.reviewOf.replace(/\b([0-9a-f]{9})[0-9a-f]{31}\b/g, "$1")}`;
@@ -1587,7 +1715,7 @@ var startedCard = (m) => `\u{1F3C1} Pitroom \u25B6 ${kind(m)} on ${workerName(m.
1587
1715
  function endedCard(m) {
1588
1716
  const v = m.verdict;
1589
1717
  const bits = [
1590
- `\u{1F3C1} Pitroom ${ICON[m.state] ?? "\u2022"} ${kind(m)} ${m.state} on ${workerName(m.ran ?? m.worker)}`,
1718
+ `\u{1F3C1} Pitroom ${ICON[m.state] ?? "\u2022"} ${kind(m)} ${m.state} on ${workerName(ranTarget(m))}`,
1591
1719
  what(m),
1592
1720
  elapsed(m),
1593
1721
  v && `SPEC ${v.spec.toUpperCase()} \xB7 QUALITY ${v.quality.toUpperCase()}`,
@@ -1605,9 +1733,9 @@ function cardsFor(m) {
1605
1733
  const out = [];
1606
1734
  for (const [phase, text] of phases) {
1607
1735
  const marker = runFile(m.id, `card-${phase}`);
1608
- if (fs9.existsSync(marker)) continue;
1736
+ if (fs10.existsSync(marker)) continue;
1609
1737
  if (!(phase === "started" && !isActive(m.state))) out.push(text());
1610
- fs9.writeFileSync(marker, "");
1738
+ fs10.writeFileSync(marker, "");
1611
1739
  }
1612
1740
  return out;
1613
1741
  }
@@ -1618,10 +1746,10 @@ function hookCards(input) {
1618
1746
  const response = event.tool_response;
1619
1747
  const output = typeof response === "string" ? response : [response?.stdout, response?.stderr].filter(Boolean).join("\n");
1620
1748
  const ids = [...new Set(`${command}
1621
- ${output}`.match(RUN_ID) ?? [])].filter((id) => fs9.existsSync(`${runsDir()}/${id}`));
1749
+ ${output}`.match(RUN_ID) ?? [])].filter((id) => fs10.existsSync(`${runsDir()}/${id}`));
1622
1750
  const since = Date.now() - RECENT_HOURS * 36e5;
1623
1751
  for (const id of listRunIds().slice(-RECENT)) {
1624
- if (ids.includes(id) || fs9.existsSync(runFile(id, "card-ended"))) continue;
1752
+ if (ids.includes(id) || fs10.existsSync(runFile(id, "card-ended"))) continue;
1625
1753
  try {
1626
1754
  const m = readMeta(id);
1627
1755
  if (!isActive(m.state) && Date.parse(m.endedAt ?? m.startedAt) > since) ids.push(id);
@@ -1643,7 +1771,7 @@ var SCHEMA_VERSION = 1;
1643
1771
  var PATCH_MAX = 1e6;
1644
1772
  var ANSWER_MAX = 2e5;
1645
1773
  var cached2;
1646
- var historyFile = () => path8.join(home(), "history.db");
1774
+ var historyFile = () => path9.join(home(), "history.db");
1647
1775
  function openDb() {
1648
1776
  const file = historyFile();
1649
1777
  if (cached2?.file === file) return cached2.db;
@@ -1657,7 +1785,7 @@ function openDb() {
1657
1785
  } finally {
1658
1786
  process.emitWarning = emit;
1659
1787
  }
1660
- fs10.mkdirSync(path8.dirname(file), { recursive: true });
1788
+ fs11.mkdirSync(path9.dirname(file), { recursive: true });
1661
1789
  db = new DatabaseSync(file);
1662
1790
  db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA synchronous=NORMAL;");
1663
1791
  migrate(db);
@@ -1692,31 +1820,31 @@ function migrate(db) {
1692
1820
  function readRunFile(id, name) {
1693
1821
  const f = runFile(id, name);
1694
1822
  try {
1695
- if (fs10.existsSync(f)) return fs10.readFileSync(f, "utf8");
1696
- if (fs10.existsSync(`${f}.gz`)) return zlib.gunzipSync(fs10.readFileSync(`${f}.gz`)).toString("utf8");
1823
+ if (fs11.existsSync(f)) return fs11.readFileSync(f, "utf8");
1824
+ if (fs11.existsSync(`${f}.gz`)) return zlib.gunzipSync(fs11.readFileSync(`${f}.gz`)).toString("utf8");
1697
1825
  } catch {
1698
1826
  }
1699
1827
  return void 0;
1700
1828
  }
1701
1829
  function gzipFile(f) {
1702
- if (!fs10.existsSync(f)) return;
1703
- fs10.writeFileSync(`${f}.gz`, zlib.gzipSync(fs10.readFileSync(f)));
1704
- fs10.rmSync(f);
1830
+ if (!fs11.existsSync(f)) return;
1831
+ fs11.writeFileSync(`${f}.gz`, zlib.gzipSync(fs11.readFileSync(f)));
1832
+ fs11.rmSync(f);
1705
1833
  }
1706
1834
  function compactRun(meta) {
1707
1835
  if (!TERMINAL.includes(meta.state)) return;
1708
- const dir = path8.dirname(runFile(meta.id, "meta.json"));
1836
+ const dir = path9.dirname(runFile(meta.id, "meta.json"));
1709
1837
  let names;
1710
1838
  try {
1711
- names = fs10.readdirSync(dir);
1839
+ names = fs11.readdirSync(dir);
1712
1840
  } catch {
1713
1841
  return;
1714
1842
  }
1715
1843
  for (const n of names) {
1716
- if (/^events(\.attempt-\d+)?\.jsonl$/.test(n)) gzipFile(path8.join(dir, n));
1844
+ if (/^events(\.attempt-\d+)?\.jsonl$/.test(n)) gzipFile(path9.join(dir, n));
1717
1845
  else if (/^stderr(\.attempt-\d+)?\.log$/.test(n)) {
1718
- if (meta.state === "done" && !/attempt/.test(n)) fs10.rmSync(path8.join(dir, n), { force: true });
1719
- else gzipFile(path8.join(dir, n));
1846
+ if (meta.state === "done" && !/attempt/.test(n)) fs11.rmSync(path9.join(dir, n), { force: true });
1847
+ else gzipFile(path9.join(dir, n));
1720
1848
  }
1721
1849
  }
1722
1850
  }
@@ -1822,7 +1950,7 @@ function archivedRun(id) {
1822
1950
  at: s.at ?? void 0,
1823
1951
  t: s.at && Number.isFinite(start) ? Math.max(0, Math.round((s.at - start) / 1e3)) : void 0
1824
1952
  }));
1825
- return { meta: JSON.parse(r.meta_json), answer: r.answer ?? "", patch: r.patch ?? void 0, steps };
1953
+ return { meta: JSON.parse(r.meta_json), answer: r.answer ?? "", patch: r.patch ?? void 0, patchTruncated: r.patch?.length >= PATCH_MAX, steps };
1826
1954
  } catch {
1827
1955
  return void 0;
1828
1956
  }
@@ -1896,6 +2024,21 @@ function listHistory(q = {}) {
1896
2024
  return { rows: [], total: 0 };
1897
2025
  }
1898
2026
  }
2027
+ function workerRows(rows, ledger) {
2028
+ 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 }));
2029
+ const key = (backend, model) => `${backend ?? ""}\0${model ?? ""}`;
2030
+ const index = new Map(out.map((r) => [key(r.backend, r.model), r]));
2031
+ for (const e of ledger) {
2032
+ let row = index.get(key(e.backend, e.model));
2033
+ if (!row) {
2034
+ row = { backend: e.backend ?? "unknown", model: e.model, runs: 0, ok: 0, avgSeconds: null, avgTokens: null, saved: 0 };
2035
+ index.set(key(e.backend, e.model), row);
2036
+ out.push(row);
2037
+ }
2038
+ row.saved += e.saved;
2039
+ }
2040
+ return out;
2041
+ }
1899
2042
  function historyStats(sinceMs2) {
1900
2043
  const empty = { totals: { runs: 0, ok: 0, failed: 0, seconds: 0, tokens: 0, saved: 0 }, byWorker: [], byDay: [] };
1901
2044
  const db = openDb();
@@ -1905,10 +2048,13 @@ function historyStats(sinceMs2) {
1905
2048
  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 >= ?").get(since);
1906
2049
  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 >= ? GROUP BY backend, model ORDER BY runs DESC LIMIT 40").all(since);
1907
2050
  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 >= ? GROUP BY day ORDER BY day DESC LIMIT 60").all(since);
2051
+ const ledger = readLedger(sinceMs2);
2052
+ const byDay = /* @__PURE__ */ new Map();
2053
+ for (const e of ledger) byDay.set(e.at.slice(0, 10), (byDay.get(e.at.slice(0, 10)) ?? 0) + e.saved);
1908
2054
  return {
1909
- totals: { runs: t.runs, ok: t.ok, failed: t.runs - t.ok, seconds: t.seconds, tokens: t.tokens, saved: t.saved },
1910
- byWorker: w.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: r.saved })),
1911
- byDay: d.reverse().map((r) => ({ day: r.day, runs: r.runs, ok: r.ok ?? 0, saved: r.saved }))
2055
+ totals: { runs: t.runs, ok: t.ok, failed: t.runs - t.ok, seconds: t.seconds, tokens: t.tokens, saved: totals(ledger).saved },
2056
+ byWorker: workerRows(w, ledger),
2057
+ byDay: d.reverse().map((r) => ({ day: r.day, runs: r.runs, ok: r.ok ?? 0, saved: byDay.get(r.day) ?? 0 }))
1912
2058
  };
1913
2059
  } catch {
1914
2060
  return empty;
@@ -1916,11 +2062,11 @@ function historyStats(sinceMs2) {
1916
2062
  }
1917
2063
 
1918
2064
  // src/core/report.ts
1919
- import fs12 from "node:fs";
2065
+ import fs13 from "node:fs";
1920
2066
 
1921
2067
  // src/core/plan.ts
1922
- import fs11 from "node:fs";
1923
- import path9 from "node:path";
2068
+ import fs12 from "node:fs";
2069
+ import path10 from "node:path";
1924
2070
  var FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
1925
2071
  var RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
1926
2072
  function structure(lines) {
@@ -1967,9 +2113,9 @@ function parsePlan(text, file = "") {
1967
2113
  return { file, title: titleHeading?.text ?? "", header, constraints, tasks };
1968
2114
  }
1969
2115
  function loadPlan(file) {
1970
- const abs = path9.resolve(file);
1971
- if (!fs11.existsSync(abs) || !fs11.statSync(abs).isFile()) throw new UserError(`plan not found: ${file}`);
1972
- const plan = parsePlan(fs11.readFileSync(abs, "utf8"), fs11.realpathSync(abs));
2116
+ const abs = path10.resolve(file);
2117
+ if (!fs12.existsSync(abs) || !fs12.statSync(abs).isFile()) throw new UserError(`plan not found: ${file}`);
2118
+ const plan = parsePlan(fs12.readFileSync(abs, "utf8"), fs12.realpathSync(abs));
1973
2119
  if (!plan.tasks.length) throw new UserError(`${file} has no "Task N" headings (see pitroom-writing-plans)`);
1974
2120
  return plan;
1975
2121
  }
@@ -1988,7 +2134,7 @@ function brief(plan, task) {
1988
2134
  ].filter(Boolean).join("\n\n")}
1989
2135
  `;
1990
2136
  }
1991
- var planName = (file) => path9.basename(file).replace(/\.md$/i, "");
2137
+ var planName = (file) => path10.basename(file).replace(/\.md$/i, "");
1992
2138
 
1993
2139
  // src/core/report.ts
1994
2140
  var ICON2 = {
@@ -2007,11 +2153,11 @@ function duration(meta) {
2007
2153
  }
2008
2154
  function readSummary(meta) {
2009
2155
  const f = runFile(meta.id, "summary.md");
2010
- return fs12.existsSync(f) ? fs12.readFileSync(f, "utf8").trim() : archivedRun(meta.id)?.answer ?? "";
2156
+ return fs13.existsSync(f) ? fs13.readFileSync(f, "utf8").trim() : archivedRun(meta.id)?.answer ?? "";
2011
2157
  }
2012
2158
  function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
2013
2159
  const out = [];
2014
- out.push(`pitroom ${ICON2[meta.state]} ${meta.state} \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 run ${meta.id}`);
2160
+ out.push(`${bold("pitroom")} ${stateColour(meta.state, `${ICON2[meta.state]} ${meta.state}`)} \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 run ${dim(meta.id)}`);
2015
2161
  const ran = meta.ran ?? meta.worker;
2016
2162
  const ids = [`worker ${ran.backend}`, meta.resolvedModel && `model ${meta.resolvedModel}`, meta.sessionId && `session ${meta.sessionId}`];
2017
2163
  out.push(ids.filter(Boolean).join(" \xB7 "));
@@ -2026,6 +2172,12 @@ function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
2026
2172
  for (const a of meta.attempts ?? []) out.push(`fallback: ${a.target} failed (${a.error.slice(0, 160)})`);
2027
2173
  if (meta.error) out.push(`error: ${meta.error}`);
2028
2174
  for (const w of meta.warnings) out.push(`warning: ${w}`);
2175
+ if (meta.state === "timeout") {
2176
+ const limit = meta.timeoutSec >= 60 ? `${Math.round(meta.timeoutSec / 60)}m` : `${meta.timeoutSec}s`;
2177
+ out.push(
2178
+ `hint: the worker hit its time limit (${limit}). What it did is kept. Most runs finish in minutes, so first check it was not going in circles; for a genuinely long job raise the limit with -t 1h (or PITROOM_TIMEOUT, or "timeout" in the config)${meta.sessionId ? `, or carry on: pitroom run --continue ${meta.id} "\u2026"` : ""}`
2179
+ );
2180
+ }
2029
2181
  if (finalText) {
2030
2182
  const lines = finalText.split("\n");
2031
2183
  out.push("", ...lines.slice(0, maxLines));
@@ -2082,8 +2234,8 @@ function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
2082
2234
  }
2083
2235
  function live(meta) {
2084
2236
  const f = runFile(meta.id, "events.jsonl");
2085
- if (!fs12.existsSync(f)) return { steps: 0, toolCalls: 0 };
2086
- const p = getBackend((meta.ran ?? meta.worker).backend).parse(fs12.readFileSync(f, "utf8"));
2237
+ if (!fs13.existsSync(f)) return { steps: 0, toolCalls: 0 };
2238
+ const p = getBackend((meta.ran ?? meta.worker).backend).parse(fs13.readFileSync(f, "utf8"));
2087
2239
  return { steps: p.usage.steps, toolCalls: p.usage.toolCalls, last: p.lastActivity };
2088
2240
  }
2089
2241
  function progress(meta) {
@@ -2138,8 +2290,11 @@ function table(metas) {
2138
2290
  });
2139
2291
  const head = ["RUN", "STATE", "MODE", "TIME", "STEPS", "WORKER", "NOW / RESULT"];
2140
2292
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2141
- const fmt = (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" ");
2142
- return [fmt(head), ...rows.map(fmt)].join("\n");
2293
+ const fmt = (r, header = false) => r.map((c, i) => {
2294
+ const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
2295
+ return header ? dim(cell2) : i === 1 ? stateColour(r[1], cell2) : cell2;
2296
+ }).join(" ");
2297
+ return [fmt(head, true), ...rows.map((r) => fmt(r))].join("\n");
2143
2298
  }
2144
2299
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2145
2300
  async function waitMany(ids, opts) {
@@ -2216,26 +2371,26 @@ ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watchin
2216
2371
  }
2217
2372
 
2218
2373
  // src/core/install.ts
2219
- import fs13 from "node:fs";
2374
+ import fs14 from "node:fs";
2220
2375
  import os7 from "node:os";
2221
- import path10 from "node:path";
2376
+ import path11 from "node:path";
2222
2377
  import { fileURLToPath } from "node:url";
2223
2378
  var LEGACY = ["pitroom", "opencode-worker"];
2224
2379
  function packageRoot() {
2225
- return path10.resolve(path10.dirname(fileURLToPath(import.meta.url)), "..");
2380
+ return path11.resolve(path11.dirname(fileURLToPath(import.meta.url)), "..");
2226
2381
  }
2227
2382
  function skillNames(root = packageRoot()) {
2228
- const dir = path10.join(root, "skills");
2229
- if (!fs13.existsSync(dir)) return [];
2230
- return fs13.readdirSync(dir).filter((d) => fs13.existsSync(path10.join(dir, d, "SKILL.md"))).sort();
2383
+ const dir = path11.join(root, "skills");
2384
+ if (!fs14.existsSync(dir)) return [];
2385
+ return fs14.readdirSync(dir).filter((d) => fs14.existsSync(path11.join(dir, d, "SKILL.md"))).sort();
2231
2386
  }
2232
2387
  function skillTargets() {
2233
2388
  const home2 = os7.homedir();
2234
- const targets = [path10.join(home2, ".agents", "skills")];
2235
- if (fs13.existsSync(path10.join(home2, ".claude"))) targets.push(path10.join(home2, ".claude", "skills"));
2389
+ const targets = [path11.join(home2, ".agents", "skills")];
2390
+ if (fs14.existsSync(path11.join(home2, ".claude"))) targets.push(path11.join(home2, ".claude", "skills"));
2236
2391
  return targets;
2237
2392
  }
2238
- var launcherPath = () => path10.join(os7.homedir(), ".local", "bin", "pitroom");
2393
+ var launcherPath = () => path11.join(os7.homedir(), ".local", "bin", "pitroom");
2239
2394
  var LAUNCHER_MARK = "# pitroom launcher";
2240
2395
  function launcherScript(bundle) {
2241
2396
  const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
@@ -2253,7 +2408,7 @@ exit 127
2253
2408
  function isOurLauncher(file, root) {
2254
2409
  if (linksInto(file, root)) return true;
2255
2410
  try {
2256
- const s = fs13.readFileSync(file, "utf8");
2411
+ const s = fs14.readFileSync(file, "utf8");
2257
2412
  return s.includes(LAUNCHER_MARK) && s.includes(root);
2258
2413
  } catch {
2259
2414
  return false;
@@ -2261,57 +2416,57 @@ function isOurLauncher(file, root) {
2261
2416
  }
2262
2417
  function placeLauncher(bundle, root, force) {
2263
2418
  const dest = launcherPath();
2264
- fs13.mkdirSync(path10.dirname(dest), { recursive: true });
2265
- const exists = fs13.lstatSync(dest, { throwIfNoEntry: false });
2419
+ fs14.mkdirSync(path11.dirname(dest), { recursive: true });
2420
+ const exists = fs14.lstatSync(dest, { throwIfNoEntry: false });
2266
2421
  if (exists && !isOurLauncher(dest, root)) {
2267
2422
  if (!force) return `! ${dest} exists and is not Pitroom's launcher; kept (use --force to back it up and replace)`;
2268
- fs13.renameSync(dest, `${dest}.bak-${Date.now()}`);
2423
+ fs14.renameSync(dest, `${dest}.bak-${Date.now()}`);
2269
2424
  } else if (exists) {
2270
- fs13.rmSync(dest, { force: true });
2425
+ fs14.rmSync(dest, { force: true });
2271
2426
  }
2272
- fs13.writeFileSync(dest, launcherScript(bundle), { mode: 493 });
2427
+ fs14.writeFileSync(dest, launcherScript(bundle), { mode: 493 });
2273
2428
  return `\u2714 ${dest} \u2192 launcher for ${bundle} (Node 22.13+)`;
2274
2429
  }
2275
2430
  function linksInto(link, dir) {
2276
- const st = fs13.lstatSync(link, { throwIfNoEntry: false });
2431
+ const st = fs14.lstatSync(link, { throwIfNoEntry: false });
2277
2432
  if (!st?.isSymbolicLink()) return false;
2278
- const target = path10.resolve(path10.dirname(link), fs13.readlinkSync(link));
2279
- const rel = path10.relative(dir, target);
2280
- return !rel.startsWith("..") && !path10.isAbsolute(rel);
2433
+ const target = path11.resolve(path11.dirname(link), fs14.readlinkSync(link));
2434
+ const rel = path11.relative(dir, target);
2435
+ return !rel.startsWith("..") && !path11.isAbsolute(rel);
2281
2436
  }
2282
2437
  function place(src, dest, opts) {
2283
- fs13.mkdirSync(path10.dirname(dest), { recursive: true });
2284
- const st = fs13.lstatSync(dest, { throwIfNoEntry: false });
2438
+ fs14.mkdirSync(path11.dirname(dest), { recursive: true });
2439
+ const st = fs14.lstatSync(dest, { throwIfNoEntry: false });
2285
2440
  if (st && !st.isSymbolicLink()) {
2286
2441
  if (!opts.force) return `! ${dest} exists and is not a link; kept (use --force to back it up and replace)`;
2287
- fs13.renameSync(dest, `${dest}.bak-${Date.now()}`);
2442
+ fs14.renameSync(dest, `${dest}.bak-${Date.now()}`);
2288
2443
  } else if (st) {
2289
- fs13.unlinkSync(dest);
2444
+ fs14.unlinkSync(dest);
2290
2445
  }
2291
- if (opts.copy) fs13.cpSync(src, dest, { recursive: true });
2292
- else fs13.symlinkSync(src, dest, process.platform === "win32" ? "junction" : fs13.statSync(src).isDirectory() ? "dir" : "file");
2446
+ if (opts.copy) fs14.cpSync(src, dest, { recursive: true });
2447
+ else fs14.symlinkSync(src, dest, process.platform === "win32" ? "junction" : fs14.statSync(src).isDirectory() ? "dir" : "file");
2293
2448
  return `\u2714 ${dest} \u2192 ${opts.copy ? "copied" : src}`;
2294
2449
  }
2295
2450
  function install(opts) {
2296
2451
  const root = packageRoot();
2297
2452
  const skills = skillNames(root);
2298
- if (!skills.length) throw new Error(`no skills found under ${path10.join(root, "skills")}`);
2453
+ if (!skills.length) throw new Error(`no skills found under ${path11.join(root, "skills")}`);
2299
2454
  const out = [];
2300
2455
  for (const base2 of skillTargets()) {
2301
2456
  for (const legacy of LEGACY) {
2302
- const l = path10.join(base2, legacy);
2457
+ const l = path11.join(base2, legacy);
2303
2458
  if (!skills.includes(legacy) && linksInto(l, root)) {
2304
- fs13.unlinkSync(l);
2459
+ fs14.unlinkSync(l);
2305
2460
  out.push(`\u2714 removed old link ${l}`);
2306
2461
  }
2307
2462
  }
2308
- for (const name of skills) out.push(place(path10.join(root, "skills", name), path10.join(base2, name), opts));
2463
+ for (const name of skills) out.push(place(path11.join(root, "skills", name), path11.join(base2, name), opts));
2309
2464
  }
2310
- const bundle = path10.join(root, "dist", "pitroom.mjs");
2311
- if (fs13.existsSync(bundle)) {
2465
+ const bundle = path11.join(root, "dist", "pitroom.mjs");
2466
+ if (fs14.existsSync(bundle)) {
2312
2467
  out.push(placeLauncher(bundle, root, opts.force));
2313
- const onPath = (process.env.PATH ?? "").split(path10.delimiter).some((d) => path10.resolve(d) === path10.dirname(launcherPath()));
2314
- if (!onPath) out.push(`! ${path10.dirname(launcherPath())} is not on PATH; add it, or run ${launcherPath()} directly`);
2468
+ const onPath = (process.env.PATH ?? "").split(path11.delimiter).some((d) => path11.resolve(d) === path11.dirname(launcherPath()));
2469
+ if (!onPath) out.push(`! ${path11.dirname(launcherPath())} is not on PATH; add it, or run ${launcherPath()} directly`);
2315
2470
  }
2316
2471
  return out;
2317
2472
  }
@@ -2319,38 +2474,38 @@ function uninstall() {
2319
2474
  const root = packageRoot();
2320
2475
  const out = [];
2321
2476
  for (const base2 of skillTargets()) {
2322
- if (!fs13.existsSync(base2)) continue;
2323
- for (const name of fs13.readdirSync(base2)) {
2324
- const l = path10.join(base2, name);
2477
+ if (!fs14.existsSync(base2)) continue;
2478
+ for (const name of fs14.readdirSync(base2)) {
2479
+ const l = path11.join(base2, name);
2325
2480
  if (linksInto(l, root)) {
2326
- fs13.unlinkSync(l);
2481
+ fs14.unlinkSync(l);
2327
2482
  out.push(`\u2714 removed ${l}`);
2328
2483
  }
2329
2484
  }
2330
2485
  }
2331
2486
  if (isOurLauncher(launcherPath(), root)) {
2332
- fs13.rmSync(launcherPath(), { force: true });
2487
+ fs14.rmSync(launcherPath(), { force: true });
2333
2488
  out.push(`\u2714 removed ${launcherPath()}`);
2334
2489
  }
2335
2490
  return out.length ? out : ["nothing to remove"];
2336
2491
  }
2337
2492
  function installedSkills() {
2338
2493
  const names = skillNames();
2339
- return skillTargets().map((base2) => ({ base: base2, names: names.filter((n) => fs13.existsSync(path10.join(base2, n, "SKILL.md"))) }));
2494
+ return skillTargets().map((base2) => ({ base: base2, names: names.filter((n) => fs14.existsSync(path11.join(base2, n, "SKILL.md"))) }));
2340
2495
  }
2341
2496
 
2342
2497
  // src/core/plan-status.ts
2343
2498
  import crypto2 from "node:crypto";
2344
- import fs14 from "node:fs";
2345
- import path11 from "node:path";
2499
+ import fs15 from "node:fs";
2500
+ import path12 from "node:path";
2346
2501
  function notesFile(plan) {
2347
2502
  const id = crypto2.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
2348
- return path11.join(home(), "plans", id, "notes.md");
2503
+ return path12.join(home(), "plans", id, "notes.md");
2349
2504
  }
2350
2505
  function readNotes(plan) {
2351
2506
  const f = notesFile(plan);
2352
- if (!fs14.existsSync(f)) return [];
2353
- return fs14.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
2507
+ if (!fs15.existsSync(f)) return [];
2508
+ return fs15.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
2354
2509
  const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
2355
2510
  return m ? { at: m[1], text: m[2] } : { at: "", text: l };
2356
2511
  });
@@ -2358,11 +2513,11 @@ function readNotes(plan) {
2358
2513
  function addNote(planFile, text) {
2359
2514
  const plan = loadPlan(planFile);
2360
2515
  const f = notesFile(plan);
2361
- fs14.mkdirSync(path11.dirname(f), { recursive: true });
2362
- if (!fs14.existsSync(f)) fs14.writeFileSync(f, `# plan: ${plan.file}
2516
+ fs15.mkdirSync(path12.dirname(f), { recursive: true });
2517
+ if (!fs15.existsSync(f)) fs15.writeFileSync(f, `# plan: ${plan.file}
2363
2518
  `);
2364
2519
  const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
2365
- fs14.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
2520
+ fs15.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
2366
2521
  `);
2367
2522
  return f;
2368
2523
  }
@@ -2430,15 +2585,15 @@ ${rulings}`,
2430
2585
 
2431
2586
  // src/core/review.ts
2432
2587
  import crypto4 from "node:crypto";
2433
- import fs16 from "node:fs";
2434
- import path13 from "node:path";
2588
+ import fs17 from "node:fs";
2589
+ import path14 from "node:path";
2435
2590
 
2436
2591
  // src/vcs/git.ts
2437
2592
  import { spawnSync as spawnSync4 } from "node:child_process";
2438
2593
  import crypto3 from "node:crypto";
2439
- import fs15 from "node:fs";
2594
+ import fs16 from "node:fs";
2440
2595
  import os8 from "node:os";
2441
- import path12 from "node:path";
2596
+ import path13 from "node:path";
2442
2597
  var SAFE = [
2443
2598
  "-c",
2444
2599
  "color.ui=false",
@@ -2470,20 +2625,20 @@ function gitAvailable() {
2470
2625
  }
2471
2626
  function repoRoot(dir) {
2472
2627
  const r = git(dir, ["rev-parse", "--show-toplevel"]);
2473
- return r.code === 0 ? path12.resolve(r.stdout.trim()) : void 0;
2628
+ return r.code === 0 ? path13.resolve(r.stdout.trim()) : void 0;
2474
2629
  }
2475
2630
  function snapshotTree(root, exclude = []) {
2476
- const tmp = path12.join(os8.tmpdir(), `pitroom-index-${process.pid}-${crypto3.randomBytes(4).toString("hex")}`);
2477
- const real = path12.resolve(root, must(root, ["rev-parse", "--git-path", "index"]).trim());
2631
+ const tmp = path13.join(os8.tmpdir(), `pitroom-index-${process.pid}-${crypto3.randomBytes(4).toString("hex")}`);
2632
+ const real = path13.resolve(root, must(root, ["rev-parse", "--git-path", "index"]).trim());
2478
2633
  const env = { ...process.env, GIT_INDEX_FILE: tmp };
2479
2634
  try {
2480
- if (fs15.existsSync(real)) fs15.copyFileSync(real, tmp);
2635
+ if (fs16.existsSync(real)) fs16.copyFileSync(real, tmp);
2481
2636
  else must(root, ["read-tree", "--empty"], env);
2482
2637
  must(root, ["add", "-A", "--", ":/", ...exclude.map((p) => `:(top,exclude)${p}`)], env);
2483
2638
  return must(root, ["write-tree"], env).trim();
2484
2639
  } finally {
2485
- fs15.rmSync(tmp, { force: true });
2486
- fs15.rmSync(`${tmp}.lock`, { force: true });
2640
+ fs16.rmSync(tmp, { force: true });
2641
+ fs16.rmSync(`${tmp}.lock`, { force: true });
2487
2642
  }
2488
2643
  }
2489
2644
  var DIFF = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames"];
@@ -2503,27 +2658,27 @@ function diffTrees(root, a, b) {
2503
2658
  return { changes, stats: { files: changes.length, insertions, deletions }, patch };
2504
2659
  }
2505
2660
  function createIsolatedCopy(root, tree, dest) {
2506
- const objects = path12.join(path12.resolve(root, must(root, ["rev-parse", "--git-common-dir"]).trim()), "objects");
2507
- fs15.mkdirSync(path12.dirname(dest), { recursive: true });
2508
- must(path12.dirname(dest), ["init", "--quiet", dest]);
2509
- fs15.writeFileSync(path12.join(dest, ".git", "objects", "info", "alternates"), `${objects}
2661
+ const objects = path13.join(path13.resolve(root, must(root, ["rev-parse", "--git-common-dir"]).trim()), "objects");
2662
+ fs16.mkdirSync(path13.dirname(dest), { recursive: true });
2663
+ must(path13.dirname(dest), ["init", "--quiet", dest]);
2664
+ fs16.writeFileSync(path13.join(dest, ".git", "objects", "info", "alternates"), `${objects}
2510
2665
  `);
2511
2666
  must(dest, ["read-tree", tree]);
2512
2667
  must(dest, ["checkout-index", "--all", "--force"]);
2513
2668
  }
2514
2669
  function removeIsolatedCopy(dest, ownedBy) {
2515
- const rel = path12.relative(ownedBy, dest);
2516
- if (!rel || rel.startsWith("..") || path12.isAbsolute(rel)) throw new Error(`refusing to remove ${dest}`);
2517
- fs15.rmSync(dest, { recursive: true, force: true });
2670
+ const rel = path13.relative(ownedBy, dest);
2671
+ if (!rel || rel.startsWith("..") || path13.isAbsolute(rel)) throw new Error(`refusing to remove ${dest}`);
2672
+ fs16.rmSync(dest, { recursive: true, force: true });
2518
2673
  }
2519
2674
  function linkIntoWorktree(root, worktree, rels) {
2520
2675
  const linked = [];
2521
2676
  for (const rel of rels) {
2522
- const src = path12.join(root, rel);
2523
- const dst = path12.join(worktree, rel);
2524
- if (!fs15.existsSync(src) || fs15.existsSync(dst)) continue;
2525
- fs15.mkdirSync(path12.dirname(dst), { recursive: true });
2526
- fs15.symlinkSync(src, dst, process.platform === "win32" ? "junction" : void 0);
2677
+ const src = path13.join(root, rel);
2678
+ const dst = path13.join(worktree, rel);
2679
+ if (!fs16.existsSync(src) || fs16.existsSync(dst)) continue;
2680
+ fs16.mkdirSync(path13.dirname(dst), { recursive: true });
2681
+ fs16.symlinkSync(src, dst, process.platform === "win32" ? "junction" : void 0);
2527
2682
  linked.push(rel);
2528
2683
  }
2529
2684
  return linked;
@@ -2582,7 +2737,7 @@ function ancestors(m) {
2582
2737
  function briefOf(m) {
2583
2738
  const root = [m, ...ancestors(m)].at(-1);
2584
2739
  const f = runFile(root.id, "brief.md");
2585
- return fs16.existsSync(f) ? fs16.readFileSync(f, "utf8") : root.task;
2740
+ return fs17.existsSync(f) ? fs17.readFileSync(f, "utf8") : root.task;
2586
2741
  }
2587
2742
  function latestReview(ids) {
2588
2743
  for (const id of listRunIds().reverse()) {
@@ -2596,7 +2751,7 @@ function latestReview(ids) {
2596
2751
  }
2597
2752
  function diffOf(m, from = m.baseTree) {
2598
2753
  const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
2599
- if (where && fs16.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
2754
+ if (where && fs17.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
2600
2755
  if (from !== m.baseTree) {
2601
2756
  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);
2602
2757
  }
@@ -2610,7 +2765,7 @@ function runReview(id) {
2610
2765
  if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
2611
2766
  const chain = ancestors(m);
2612
2767
  const previous = latestReview(chain.map((a) => a.id));
2613
- const dir = m.mode === "isolate" && m.worktree && fs16.existsSync(m.worktree) ? m.cwd : m.dir;
2768
+ const dir = m.mode === "isolate" && m.worktree && fs17.existsSync(m.worktree) ? m.cwd : m.dir;
2614
2769
  const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
2615
2770
  const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
2616
2771
  if (!previous) {
@@ -2684,15 +2839,15 @@ function pickReviewer(job) {
2684
2839
  function writePackage(job) {
2685
2840
  const g = gitDir(job.dir);
2686
2841
  if (!g) throw new UserError(`not a git repository: ${job.dir}`);
2687
- const file = path13.join(g, "pitroom", `review-${crypto4.randomBytes(4).toString("hex")}.md`);
2688
- fs16.mkdirSync(path13.dirname(file), { recursive: true });
2689
- fs16.writeFileSync(file, job.package);
2842
+ const file = path14.join(g, "pitroom", `review-${crypto4.randomBytes(4).toString("hex")}.md`);
2843
+ fs17.mkdirSync(path14.dirname(file), { recursive: true });
2844
+ fs17.writeFileSync(file, job.package);
2690
2845
  return file;
2691
2846
  }
2692
2847
 
2693
2848
  // src/core/templates.ts
2694
- import fs17 from "node:fs";
2695
- import path14 from "node:path";
2849
+ import fs18 from "node:fs";
2850
+ import path15 from "node:path";
2696
2851
  var FILES = {
2697
2852
  implementer: "pitroom-driven-development/implementer-prompt.md",
2698
2853
  "task-reviewer": "pitroom-driven-development/task-reviewer-prompt.md",
@@ -2700,9 +2855,9 @@ var FILES = {
2700
2855
  "code-reviewer": "pitroom-review/code-reviewer.md"
2701
2856
  };
2702
2857
  function loadTemplate(name, root = packageRoot()) {
2703
- const file = path14.join(root, "skills", FILES[name]);
2704
- if (!fs17.existsSync(file)) throw new UserError(`template missing: ${file} (broken install? run pitroom doctor)`, 3);
2705
- return fs17.readFileSync(file, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
2858
+ const file = path15.join(root, "skills", FILES[name]);
2859
+ if (!fs18.existsSync(file)) throw new UserError(`template missing: ${file} (broken install? run pitroom doctor)`, 3);
2860
+ return fs18.readFileSync(file, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
2706
2861
  }
2707
2862
  function fill(template, values) {
2708
2863
  const missing = [...template.matchAll(/\{\{([A-Z_]+)\}\}/g)].map((m) => m[1]).filter((k) => !(k in values));
@@ -2809,11 +2964,123 @@ function formatModels(t) {
2809
2964
  // src/core/dash.ts
2810
2965
  import { spawn } from "node:child_process";
2811
2966
  import crypto5 from "node:crypto";
2812
- import fs18 from "node:fs";
2967
+ import fs19 from "node:fs";
2813
2968
  import http from "node:http";
2814
- import path15 from "node:path";
2969
+ import path16 from "node:path";
2815
2970
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2816
2971
 
2972
+ // src/core/file-diff.ts
2973
+ var PREVIEW_LINES = 300;
2974
+ function gitPath(value, prefix = true) {
2975
+ let path24 = value;
2976
+ if (value.startsWith('"') && value.endsWith('"')) {
2977
+ const bytes = [];
2978
+ const escapes = { t: " ", n: "\n", r: "\r", b: "\b", f: "\f", v: "\v" };
2979
+ for (const match of value.slice(1, -1).matchAll(/\\([0-7]{1,3}|.)|([^\\]+)/g)) {
2980
+ if (match[1] && /^[0-7]+$/.test(match[1])) bytes.push(parseInt(match[1], 8));
2981
+ else bytes.push(...new TextEncoder().encode(match[2] ?? escapes[match[1]] ?? match[1]));
2982
+ }
2983
+ path24 = new TextDecoder().decode(new Uint8Array(bytes));
2984
+ }
2985
+ return prefix ? path24.replace(/^[ab]\//, "") : path24;
2986
+ }
2987
+ function headerPath(header) {
2988
+ const quoted = header.match(/^diff --git ("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/);
2989
+ if (quoted) return gitPath(quoted[2]);
2990
+ return gitPath(header.slice(header.lastIndexOf(" b/") + 1));
2991
+ }
2992
+ function fileDiffs(patch, changes, sourceTruncated = false) {
2993
+ const files = [];
2994
+ let file;
2995
+ let oldLine = 0;
2996
+ let newLine = 0;
2997
+ let oldLeft = 0;
2998
+ let newLeft = 0;
2999
+ let inHunk = false;
3000
+ let row = 0;
3001
+ const finishHunk = () => {
3002
+ if (file && (oldLeft > 0 || newLeft > 0)) file.incomplete = true;
3003
+ oldLeft = newLeft = 0;
3004
+ inHunk = false;
3005
+ };
3006
+ const add = (line) => {
3007
+ if (!file) return;
3008
+ if (file.lines.length < PREVIEW_LINES) file.lines.push({ ...line, id: String(row) });
3009
+ else file.omittedLines++;
3010
+ row++;
3011
+ };
3012
+ for (const line of patch.split("\n")) {
3013
+ if (line.startsWith("diff --git ")) {
3014
+ finishHunk();
3015
+ file = { path: headerPath(line), status: "M", lines: [], additions: 0, deletions: 0, omittedLines: 0, binary: false, incomplete: false };
3016
+ files.push(file);
3017
+ row = 0;
3018
+ continue;
3019
+ }
3020
+ if (!file) continue;
3021
+ const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
3022
+ if (hunk) {
3023
+ finishHunk();
3024
+ oldLine = Number(hunk[1]);
3025
+ newLine = Number(hunk[3]);
3026
+ oldLeft = Number(hunk[2] ?? 1);
3027
+ newLeft = Number(hunk[4] ?? 1);
3028
+ inHunk = true;
3029
+ add({ type: "hunk", content: line });
3030
+ } else if (inHunk && line.startsWith("\")) {
3031
+ add({ type: "meta", content: line });
3032
+ } else if (inHunk && (oldLeft > 0 || newLeft > 0)) {
3033
+ if (line.startsWith("+") && newLeft > 0) {
3034
+ file.additions++;
3035
+ newLeft--;
3036
+ add({ type: "added", newLine: newLine++, content: line.slice(1) });
3037
+ } else if (line.startsWith("-") && oldLeft > 0) {
3038
+ file.deletions++;
3039
+ oldLeft--;
3040
+ add({ type: "removed", oldLine: oldLine++, content: line.slice(1) });
3041
+ } else if (line.startsWith(" ") && oldLeft > 0 && newLeft > 0) {
3042
+ oldLeft--;
3043
+ newLeft--;
3044
+ add({ type: "context", oldLine: oldLine++, newLine: newLine++, content: line.slice(1) });
3045
+ } else {
3046
+ file.incomplete = true;
3047
+ }
3048
+ } else if (!inHunk) {
3049
+ if (line.startsWith("+++ ") && line !== "+++ /dev/null") file.path = gitPath(line.slice(4).replace(/\t$/, ""));
3050
+ else if (line.startsWith("--- ") && line !== "--- /dev/null") file.path = gitPath(line.slice(4).replace(/\t$/, ""));
3051
+ else if (/^(GIT binary patch|Binary files .* differ)$/.test(line)) file.binary = true;
3052
+ else if (/^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|similarity index) /.test(line)) {
3053
+ if (line.startsWith("new file mode ")) file.status = "A";
3054
+ if (line.startsWith("deleted file mode ")) file.status = "D";
3055
+ if (line.startsWith("rename to ")) {
3056
+ file.path = gitPath(line.slice(10), false);
3057
+ file.status = "R";
3058
+ }
3059
+ add({ type: "meta", content: line });
3060
+ }
3061
+ }
3062
+ }
3063
+ finishHunk();
3064
+ if (sourceTruncated && file) file.incomplete = true;
3065
+ for (const f of files) {
3066
+ if (f.incomplete || f.binary) f.additions = f.deletions = void 0;
3067
+ }
3068
+ const byPath = new Map(files.map((f) => [f.path, f]));
3069
+ const result = changes.map((change) => {
3070
+ const diff = byPath.get(change.path);
3071
+ byPath.delete(change.path);
3072
+ return diff ? { ...diff, status: change.status } : {
3073
+ ...change,
3074
+ lines: [],
3075
+ omittedLines: 0,
3076
+ binary: false,
3077
+ incomplete: sourceTruncated,
3078
+ unavailable: true
3079
+ };
3080
+ });
3081
+ return [...result, ...byPath.values()];
3082
+ }
3083
+
2817
3084
  // src/core/dash-page.ts
2818
3085
  var THEME_SCRIPT = "(function(){try{var t=localStorage.getItem('pitroom-theme');var d=t?t==='dark':matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.toggle('dark',d)}catch(e){}})()";
2819
3086
  var ICON3 = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23ff6a2b'/%3E%3Cpath d='M9 8h4v4H9zm8 0h4v4h-4zm-4 4h4v4h-4zm8 0h4v4h-4zM9 16h4v4H9zm8 0h4v4h-4zm-4 4h4v4h-4zm8 0h4v4h-4z' fill='%23fff'/%3E%3C/svg%3E";
@@ -2846,7 +3113,8 @@ function toRun(m) {
2846
3113
  id: m.id,
2847
3114
  state: m.state,
2848
3115
  kind: kind(m),
2849
- worker: workerName(m.ran ?? m.worker),
3116
+ // the model that really ran, once the CLI has said which; until then just the worker, never a guess
3117
+ worker: workerName(ranTarget(m)),
2850
3118
  task: what(m),
2851
3119
  group: m.group,
2852
3120
  startedAt: m.startedAt,
@@ -2885,10 +3153,6 @@ function dashState(opts = {}) {
2885
3153
  }
2886
3154
  var TASK_MAX = 6e3;
2887
3155
  var PATCH_LINES = 300;
2888
- function readFile(id, name) {
2889
- const f = runFile(id, name);
2890
- return fs18.existsSync(f) ? fs18.readFileSync(f, "utf8") : "";
2891
- }
2892
3156
  function runDetail(id) {
2893
3157
  if (!RUN_ID2.test(id)) return void 0;
2894
3158
  let m;
@@ -2913,16 +3177,19 @@ function runDetail(id) {
2913
3177
  const answer = isActive(m.state) ? "" : readSummary(m).replace(/^SUMMARY:\s*SPEC[^\n]*\n+/i, "").replace(/^DETAILS:[ \t]*\n+/i, "").trim();
2914
3178
  const lastSay = steps.at(-1);
2915
3179
  if (lastSay?.kind === "say" && answer && (answer.includes(lastSay.text.slice(0, 80)) || lastSay.text.includes(answer.slice(0, 80)))) steps.pop();
2916
- const patch = kept?.patch ?? readFile(id, "changes.patch");
3180
+ const fullPatch = readRunFile(id, "changes.patch");
3181
+ const patch = fullPatch ?? kept?.patch ?? "";
2917
3182
  const patchLines = patch.split("\n");
2918
3183
  const u = m.usage;
2919
3184
  return {
2920
3185
  id,
2921
3186
  state: m.state,
3187
+ card: toRun(m),
2922
3188
  task: (m.reviewOf ? `Review of ${m.reviewOf.replace(/\b([0-9a-f]{9})[0-9a-f]{31}\b/g, "$1")}` : m.task).slice(0, TASK_MAX),
2923
3189
  steps,
2924
3190
  answer,
2925
3191
  changes: m.changes ?? [],
3192
+ fileDiffs: fileDiffs(patch, m.changes ?? [], fullPatch === void 0 && kept?.patchTruncated),
2926
3193
  patch: patch ? `${patchLines.slice(0, PATCH_LINES).join("\n")}${patchLines.length > PATCH_LINES ? `
2927
3194
  \u2026 ${patchLines.length - PATCH_LINES} more lines (pitroom show ${id} --patch)` : ""}` : void 0,
2928
3195
  info: {
@@ -2961,11 +3228,11 @@ var assetsDir = () => fileURLToPath2(new URL("./ui/", import.meta.url));
2961
3228
  var assetCache = /* @__PURE__ */ new Map();
2962
3229
  function readAsset(route) {
2963
3230
  try {
2964
- const file = path15.join(assetsDir(), path15.basename(route));
2965
- const { mtimeMs } = fs18.statSync(file);
3231
+ const file = path16.join(assetsDir(), path16.basename(route));
3232
+ const { mtimeMs } = fs19.statSync(file);
2966
3233
  const hit = assetCache.get(route);
2967
3234
  if (hit && hit.mtimeMs === mtimeMs) return hit.data;
2968
- const data = fs18.readFileSync(file);
3235
+ const data = fs19.readFileSync(file);
2969
3236
  assetCache.set(route, { mtimeMs, data });
2970
3237
  return data;
2971
3238
  } catch {
@@ -3045,7 +3312,7 @@ function startDash(opts) {
3045
3312
  });
3046
3313
  });
3047
3314
  }
3048
- var registryFile = () => path15.join(home(), "dash.json");
3315
+ var registryFile = () => path16.join(home(), "dash.json");
3049
3316
  async function ping(port) {
3050
3317
  try {
3051
3318
  const r = await fetch(`http://127.0.0.1:${port}/api/state?limit=1`, { signal: AbortSignal.timeout(1500) });
@@ -3056,11 +3323,11 @@ async function ping(port) {
3056
3323
  }
3057
3324
  async function runningDash() {
3058
3325
  try {
3059
- const r = JSON.parse(fs18.readFileSync(registryFile(), "utf8"));
3326
+ const r = JSON.parse(fs19.readFileSync(registryFile(), "utf8"));
3060
3327
  if (isAlive(r.pid) && await ping(r.port)) return { ...r, url: `http://127.0.0.1:${r.port}/` };
3061
3328
  } catch {
3062
3329
  }
3063
- fs18.rmSync(registryFile(), { force: true });
3330
+ fs19.rmSync(registryFile(), { force: true });
3064
3331
  return void 0;
3065
3332
  }
3066
3333
  function openBrowser(url) {
@@ -3075,7 +3342,7 @@ async function dashCommand(o) {
3075
3342
  if (o.stop) {
3076
3343
  if (!existing) throw new UserError("pitroom dash is not running");
3077
3344
  process.kill(existing.pid, "SIGTERM");
3078
- fs18.rmSync(registryFile(), { force: true });
3345
+ fs19.rmSync(registryFile(), { force: true });
3079
3346
  o.log(`stopped pitroom dash (${existing.url})`);
3080
3347
  return 0;
3081
3348
  }
@@ -3112,8 +3379,8 @@ async function dashCommand(o) {
3112
3379
  if (explicit || e.code !== "EADDRINUSE") throw new UserError(`pitroom dash: ${e.message}`);
3113
3380
  dash = await startDash({ port: 0, idleMs: o.idleMs });
3114
3381
  }
3115
- fs18.mkdirSync(home(), { recursive: true });
3116
- fs18.writeFileSync(registryFile(), JSON.stringify({ pid: process.pid, port: dash.port }));
3382
+ fs19.mkdirSync(home(), { recursive: true });
3383
+ fs19.writeFileSync(registryFile(), JSON.stringify({ pid: process.pid, port: dash.port }));
3117
3384
  const stop = () => void dash.close();
3118
3385
  process.once("SIGINT", stop);
3119
3386
  process.once("SIGTERM", stop);
@@ -3122,7 +3389,7 @@ async function dashCommand(o) {
3122
3389
  if (o.open) openBrowser(dash.url);
3123
3390
  await dash.closed;
3124
3391
  try {
3125
- if (JSON.parse(fs18.readFileSync(registryFile(), "utf8")).pid === process.pid) fs18.rmSync(registryFile(), { force: true });
3392
+ if (JSON.parse(fs19.readFileSync(registryFile(), "utf8")).pid === process.pid) fs19.rmSync(registryFile(), { force: true });
3126
3393
  } catch {
3127
3394
  }
3128
3395
  return 0;
@@ -3130,8 +3397,8 @@ async function dashCommand(o) {
3130
3397
 
3131
3398
  // src/core/run.ts
3132
3399
  import { spawn as spawn3, spawnSync as spawnSync5 } from "node:child_process";
3133
- import fs24 from "node:fs";
3134
- import path20 from "node:path";
3400
+ import fs25 from "node:fs";
3401
+ import path21 from "node:path";
3135
3402
 
3136
3403
  // src/core/chain.ts
3137
3404
  function resolveChain(flags = {}) {
@@ -3181,11 +3448,11 @@ function resolveChain(flags = {}) {
3181
3448
 
3182
3449
  // src/core/process.ts
3183
3450
  import { spawn as spawn2 } from "node:child_process";
3184
- import fs20 from "node:fs";
3451
+ import fs21 from "node:fs";
3185
3452
 
3186
3453
  // src/vcs/guard.ts
3187
- import fs19 from "node:fs";
3188
- import path16 from "node:path";
3454
+ import fs20 from "node:fs";
3455
+ import path17 from "node:path";
3189
3456
  var VERSION = 1;
3190
3457
  var SHIM = `#!/bin/sh
3191
3458
  # pitroom git guard v${VERSION}. Blocks git commands that change history, refs, the
@@ -3260,15 +3527,15 @@ esac
3260
3527
  exec "$real" "$@"
3261
3528
  `;
3262
3529
  function shimDir() {
3263
- return path16.join(home(), "shim", `v${VERSION}`);
3530
+ return path17.join(home(), "shim", `v${VERSION}`);
3264
3531
  }
3265
3532
  function findRealGit(skip) {
3266
- for (const dir of (process.env.PATH ?? "").split(path16.delimiter).filter(Boolean)) {
3267
- if (path16.resolve(dir) === skip) continue;
3268
- const p = path16.join(dir, "git");
3533
+ for (const dir of (process.env.PATH ?? "").split(path17.delimiter).filter(Boolean)) {
3534
+ if (path17.resolve(dir) === skip) continue;
3535
+ const p = path17.join(dir, "git");
3269
3536
  try {
3270
- fs19.accessSync(p, fs19.constants.X_OK);
3271
- if (fs19.statSync(p).isFile()) return p;
3537
+ fs20.accessSync(p, fs20.constants.X_OK);
3538
+ if (fs20.statSync(p).isFile()) return p;
3272
3539
  } catch {
3273
3540
  }
3274
3541
  }
@@ -3281,13 +3548,13 @@ var REF_HOOK = `#!/bin/sh
3281
3548
  echo "pitroom: git ref updates (commit, reset, branch, tag, stash, rebase, merge) are blocked for workers" >&2
3282
3549
  exit 1
3283
3550
  `;
3284
- var hooksDir = () => path16.join(home(), "git-hooks", `v${HOOK_VERSION}`);
3551
+ var hooksDir = () => path17.join(home(), "git-hooks", `v${HOOK_VERSION}`);
3285
3552
  function writeIfChanged(file, content) {
3286
- if (fs19.existsSync(file) && fs19.readFileSync(file, "utf8") === content) return;
3287
- fs19.mkdirSync(path16.dirname(file), { recursive: true });
3553
+ if (fs20.existsSync(file) && fs20.readFileSync(file, "utf8") === content) return;
3554
+ fs20.mkdirSync(path17.dirname(file), { recursive: true });
3288
3555
  const tmp = `${file}.${process.pid}.tmp`;
3289
- fs19.writeFileSync(tmp, content, { mode: 493 });
3290
- fs19.renameSync(tmp, file);
3556
+ fs20.writeFileSync(tmp, content, { mode: 493 });
3557
+ fs20.renameSync(tmp, file);
3291
3558
  }
3292
3559
  function withGitConfig(env, entries) {
3293
3560
  const out = { ...env };
@@ -3309,7 +3576,7 @@ function guardEnv(env) {
3309
3576
  PAGER: "cat"
3310
3577
  };
3311
3578
  if (process.platform === "win32") return { ...env, ...quiet };
3312
- writeIfChanged(path16.join(hooksDir(), "reference-transaction"), REF_HOOK);
3579
+ writeIfChanged(path17.join(hooksDir(), "reference-transaction"), REF_HOOK);
3313
3580
  env = withGitConfig(env, [
3314
3581
  ["core.hooksPath", hooksDir()],
3315
3582
  ["url.pitroom-push-blocked://.pushInsteadOf", ""]
@@ -3317,14 +3584,14 @@ function guardEnv(env) {
3317
3584
  const dir = shimDir();
3318
3585
  const real = findRealGit(dir);
3319
3586
  if (!real) return { ...env, ...quiet };
3320
- writeIfChanged(path16.join(dir, "git"), SHIM);
3321
- return { ...env, ...quiet, PITROOM_REAL_GIT: real, PATH: `${dir}${path16.delimiter}${env.PATH ?? ""}` };
3587
+ writeIfChanged(path17.join(dir, "git"), SHIM);
3588
+ return { ...env, ...quiet, PITROOM_REAL_GIT: real, PATH: `${dir}${path17.delimiter}${env.PATH ?? ""}` };
3322
3589
  }
3323
3590
 
3324
3591
  // src/core/process.ts
3325
3592
  async function spawnWorker(inv, opts) {
3326
- const out = fs20.openSync(opts.stdoutFile, "w");
3327
- const err = fs20.openSync(opts.stderrFile, "w");
3593
+ const out = fs21.openSync(opts.stdoutFile, "w");
3594
+ const err = fs21.openSync(opts.stderrFile, "w");
3328
3595
  const res = { code: null, timedOut: false, stopped: false };
3329
3596
  const child = spawn2(inv.command, inv.args, {
3330
3597
  cwd: opts.cwd,
@@ -3356,8 +3623,8 @@ async function spawnWorker(inv, opts) {
3356
3623
  if (killer) clearTimeout(killer);
3357
3624
  process.off("SIGINT", stop);
3358
3625
  process.off("SIGTERM", stop);
3359
- fs20.closeSync(out);
3360
- fs20.closeSync(err);
3626
+ fs21.closeSync(out);
3627
+ fs21.closeSync(err);
3361
3628
  return res;
3362
3629
  }
3363
3630
 
@@ -3383,10 +3650,10 @@ function parseVerdict(text) {
3383
3650
 
3384
3651
  // src/core/slots.ts
3385
3652
  import crypto6 from "node:crypto";
3386
- import fs21 from "node:fs";
3387
- import path17 from "node:path";
3388
- var slotsDir = () => path17.join(home(), "slots");
3389
- var locksDir = () => path17.join(home(), "locks");
3653
+ import fs22 from "node:fs";
3654
+ import path18 from "node:path";
3655
+ var slotsDir = () => path18.join(home(), "slots");
3656
+ var locksDir = () => path18.join(home(), "locks");
3390
3657
  var STARTUP_GRACE_MS = 3e4;
3391
3658
  function holderActive(runId, self) {
3392
3659
  if (!runId || runId === self) return false;
@@ -3400,23 +3667,23 @@ function holderActive(runId, self) {
3400
3667
  }
3401
3668
  }
3402
3669
  function tryClaim(file, runId) {
3403
- fs21.mkdirSync(path17.dirname(file), { recursive: true });
3670
+ fs22.mkdirSync(path18.dirname(file), { recursive: true });
3404
3671
  try {
3405
- fs21.writeFileSync(file, runId, { flag: "wx" });
3672
+ fs22.writeFileSync(file, runId, { flag: "wx" });
3406
3673
  return true;
3407
3674
  } catch (e) {
3408
3675
  if (e.code !== "EEXIST") throw e;
3409
3676
  }
3410
3677
  let owner = "";
3411
3678
  try {
3412
- owner = fs21.readFileSync(file, "utf8").trim();
3679
+ owner = fs22.readFileSync(file, "utf8").trim();
3413
3680
  } catch {
3414
3681
  }
3415
3682
  if (owner === runId) return true;
3416
3683
  if (holderActive(owner, runId)) return false;
3417
- fs21.rmSync(file, { force: true });
3684
+ fs22.rmSync(file, { force: true });
3418
3685
  try {
3419
- fs21.writeFileSync(file, runId, { flag: "wx" });
3686
+ fs22.writeFileSync(file, runId, { flag: "wx" });
3420
3687
  return true;
3421
3688
  } catch {
3422
3689
  return false;
@@ -3425,25 +3692,25 @@ function tryClaim(file, runId) {
3425
3692
  function releaseIfOwner(file, runId) {
3426
3693
  if (!file) return;
3427
3694
  try {
3428
- if (fs21.readFileSync(file, "utf8").trim() === runId) fs21.rmSync(file, { force: true });
3695
+ if (fs22.readFileSync(file, "utf8").trim() === runId) fs22.rmSync(file, { force: true });
3429
3696
  } catch {
3430
3697
  }
3431
3698
  }
3432
3699
  function tryAcquireSlot(runId, maxParallel) {
3433
3700
  for (let n = 0; n < Math.max(1, maxParallel); n++) {
3434
- const file = path17.join(slotsDir(), `slot-${n}`);
3701
+ const file = path18.join(slotsDir(), `slot-${n}`);
3435
3702
  if (tryClaim(file, runId)) return file;
3436
3703
  }
3437
3704
  return void 0;
3438
3705
  }
3439
3706
  var releaseSlot = (file, runId) => releaseIfOwner(file, runId);
3440
- var lockFile = (repoRoot2) => path17.join(locksDir(), `write-${crypto6.createHash("sha1").update(path17.resolve(repoRoot2)).digest("hex").slice(0, 16)}`);
3707
+ var lockFile = (repoRoot2) => path18.join(locksDir(), `write-${crypto6.createHash("sha1").update(path18.resolve(repoRoot2)).digest("hex").slice(0, 16)}`);
3441
3708
  function acquireWriteLock(repoRoot2, runId) {
3442
3709
  const file = lockFile(repoRoot2);
3443
3710
  if (tryClaim(file, runId)) return;
3444
3711
  let owner = "";
3445
3712
  try {
3446
- owner = fs21.readFileSync(file, "utf8").trim();
3713
+ owner = fs22.readFileSync(file, "utf8").trim();
3447
3714
  } catch {
3448
3715
  }
3449
3716
  throw new UserError(`another --write run (${owner}) is active in this repo; use --isolate for parallel changes`, 3);
@@ -3490,8 +3757,8 @@ ${task}`;
3490
3757
  }
3491
3758
 
3492
3759
  // src/core/refs.ts
3493
- import fs22 from "node:fs";
3494
- import path18 from "node:path";
3760
+ import fs23 from "node:fs";
3761
+ import path19 from "node:path";
3495
3762
  var FILE_EXTENSIONS = new Set(
3496
3763
  "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(" ")
3497
3764
  );
@@ -3542,13 +3809,13 @@ function extractRefs(answer) {
3542
3809
  return [...seen.values()];
3543
3810
  }
3544
3811
  function verifyRefs(refs, dirs) {
3545
- const roots = [...new Set(dirs.filter(Boolean).map((d) => path18.resolve(d)))];
3812
+ const roots = [...new Set(dirs.filter(Boolean).map((d) => path19.resolve(d)))];
3546
3813
  const invalid = [];
3547
3814
  const lineCache = /* @__PURE__ */ new Map();
3548
3815
  const load = (file) => {
3549
3816
  if (!lineCache.has(file)) {
3550
- const size = fs22.statSync(file).size;
3551
- const lines = size > MAX_BYTES ? null : fs22.readFileSync(file, "utf8").split("\n");
3817
+ const size = fs23.statSync(file).size;
3818
+ const lines = size > MAX_BYTES ? null : fs23.readFileSync(file, "utf8").split("\n");
3552
3819
  if (lines && lines[lines.length - 1] === "") lines.pop();
3553
3820
  lineCache.set(file, lines);
3554
3821
  }
@@ -3559,9 +3826,9 @@ function verifyRefs(refs, dirs) {
3559
3826
  const candidates = (ref) => {
3560
3827
  const direct = resolve(ref.file, roots);
3561
3828
  if (direct) return [direct];
3562
- if (path18.isAbsolute(ref.file)) return [];
3829
+ if (path19.isAbsolute(ref.file)) return [];
3563
3830
  const wanted = ref.file.replace(/^(\.{1,2}\/)+/, "");
3564
- return (byName().get(path18.basename(wanted)) ?? []).filter((f) => f.endsWith(`/${wanted}`) || path18.basename(f) === wanted).slice(0, 20);
3831
+ return (byName().get(path19.basename(wanted)) ?? []).filter((f) => f.endsWith(`/${wanted}`) || path19.basename(f) === wanted).slice(0, 20);
3565
3832
  };
3566
3833
  const check = (file, ref) => {
3567
3834
  const lines = load(file);
@@ -3576,7 +3843,7 @@ function verifyRefs(refs, dirs) {
3576
3843
  if (!files.length) {
3577
3844
  if (!ref.file.includes("/") && !isFileName(ref.file)) continue;
3578
3845
  total++;
3579
- invalid.push({ ref: ref.text, reason: path18.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
3846
+ invalid.push({ ref: ref.text, reason: path19.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
3580
3847
  continue;
3581
3848
  }
3582
3849
  total++;
@@ -3597,7 +3864,7 @@ function enclosedBy(lines, ref) {
3597
3864
  return lines.slice(Math.max(0, ref.start - 1 - DEFINITION_LOOKBACK), ref.start).some((l) => def.test(l));
3598
3865
  }
3599
3866
  var isFileName = (name) => {
3600
- const base2 = path18.basename(name);
3867
+ const base2 = path19.basename(name);
3601
3868
  return !base2.includes(".") || new RegExp(`^(${EXTENSIONLESS})$`).test(base2) || FILE_EXTENSIONS.has(base2.split(".").pop().toLowerCase());
3602
3869
  };
3603
3870
  function indexFiles(roots) {
@@ -3606,19 +3873,19 @@ function indexFiles(roots) {
3606
3873
  const walk = (dir) => {
3607
3874
  let entries;
3608
3875
  try {
3609
- entries = fs22.readdirSync(dir, { withFileTypes: true });
3876
+ entries = fs23.readdirSync(dir, { withFileTypes: true });
3610
3877
  } catch {
3611
3878
  return;
3612
3879
  }
3613
3880
  for (const e of entries) {
3614
3881
  if (count >= MAX_INDEXED) return;
3615
3882
  if (e.isDirectory()) {
3616
- if (!SKIP_DIRS.has(e.name)) walk(path18.join(dir, e.name));
3883
+ if (!SKIP_DIRS.has(e.name)) walk(path19.join(dir, e.name));
3617
3884
  } else if (e.isFile()) {
3618
3885
  count++;
3619
3886
  const list2 = index.get(e.name);
3620
- if (list2) list2.push(path18.join(dir, e.name));
3621
- else index.set(e.name, [path18.join(dir, e.name)]);
3887
+ if (list2) list2.push(path19.join(dir, e.name));
3888
+ else index.set(e.name, [path19.join(dir, e.name)]);
3622
3889
  }
3623
3890
  }
3624
3891
  };
@@ -3626,10 +3893,10 @@ function indexFiles(roots) {
3626
3893
  return index;
3627
3894
  }
3628
3895
  function resolve(ref, roots) {
3629
- const candidates = path18.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path18.join(r, ref));
3896
+ const candidates = path19.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path19.join(r, ref));
3630
3897
  return candidates.find((c) => {
3631
3898
  try {
3632
- return fs22.statSync(c).isFile() && inside(c, roots);
3899
+ return fs23.statSync(c).isFile() && inside(c, roots);
3633
3900
  } catch {
3634
3901
  return false;
3635
3902
  }
@@ -3637,16 +3904,16 @@ function resolve(ref, roots) {
3637
3904
  }
3638
3905
  function inside(file, roots) {
3639
3906
  return roots.some((r) => {
3640
- const rel = path18.relative(r, path18.resolve(file));
3641
- return rel !== "" && !rel.startsWith("..") && !path18.isAbsolute(rel);
3907
+ const rel = path19.relative(r, path19.resolve(file));
3908
+ return rel !== "" && !rel.startsWith("..") && !path19.isAbsolute(rel);
3642
3909
  });
3643
3910
  }
3644
3911
  var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3645
3912
 
3646
3913
  // src/core/secrets.ts
3647
3914
  import { execFileSync } from "node:child_process";
3648
- import fs23 from "node:fs";
3649
- import path19 from "node:path";
3915
+ import fs24 from "node:fs";
3916
+ import path20 from "node:path";
3650
3917
  var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", ".next", "target", "vendor", ".venv", "venv", "__pycache__", "coverage", ".turbo", ".cache"]);
3651
3918
  var MAX_VISITED = 2e4;
3652
3919
  var MAX_DEPTH = 4;
@@ -3662,16 +3929,16 @@ function findSecretFiles(dir) {
3662
3929
  if (depth > MAX_DEPTH || visited > MAX_VISITED) return;
3663
3930
  let entries;
3664
3931
  try {
3665
- entries = fs23.readdirSync(d, { withFileTypes: true });
3932
+ entries = fs24.readdirSync(d, { withFileTypes: true });
3666
3933
  } catch {
3667
3934
  return;
3668
3935
  }
3669
3936
  for (const e of entries) {
3670
3937
  visited++;
3671
3938
  if (e.isDirectory()) {
3672
- if (!SKIP_DIRS2.has(e.name)) walk(path19.join(d, e.name), depth + 1);
3939
+ if (!SKIP_DIRS2.has(e.name)) walk(path20.join(d, e.name), depth + 1);
3673
3940
  } else if (e.isFile() && looksSecret(e.name)) {
3674
- found.push(path19.relative(dir, path19.join(d, e.name)));
3941
+ found.push(path20.relative(dir, path20.join(d, e.name)));
3675
3942
  }
3676
3943
  }
3677
3944
  };
@@ -3681,7 +3948,7 @@ function findSecretFiles(dir) {
3681
3948
  function findSecretFilesInTree(root, dir) {
3682
3949
  try {
3683
3950
  const out = execFileSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
3684
- return out.split("\0").filter((f) => f && looksSecret(path19.basename(f)) && !path19.relative(dir, path19.join(root, f)).startsWith("..")).map((f) => path19.relative(dir, path19.join(root, f))).sort();
3951
+ return out.split("\0").filter((f) => f && looksSecret(path20.basename(f)) && !path20.relative(dir, path20.join(root, f)).startsWith("..")).map((f) => path20.relative(dir, path20.join(root, f))).sort();
3685
3952
  } catch {
3686
3953
  return [];
3687
3954
  }
@@ -3695,7 +3962,7 @@ function secretWarning(files, mode) {
3695
3962
  }
3696
3963
 
3697
3964
  // src/core/run.ts
3698
- var VERSION2 = true ? "0.6.10" : "0.0.0-dev";
3965
+ var VERSION2 = true ? "0.8.0" : "0.0.0-dev";
3699
3966
  function planWork(o) {
3700
3967
  if (!o.plan) return { task: o.task.trim(), tier: o.tier };
3701
3968
  if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
@@ -3727,7 +3994,7 @@ function prepareRun(o) {
3727
3994
  parent = readMeta(o.continueFrom);
3728
3995
  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");
3729
3996
  if (!parent.sessionId) throw new UserError(`run ${parent.id} has no worker session to continue`, 3);
3730
- if (parent.mode === "isolate" && (!parent.worktree || !fs24.existsSync(parent.worktree))) {
3997
+ if (parent.mode === "isolate" && (!parent.worktree || !fs25.existsSync(parent.worktree))) {
3731
3998
  throw new UserError(`the isolated copy of run ${parent.id} is gone (applied or discarded)`, 3);
3732
3999
  }
3733
4000
  const ran = parent.ran ?? parent.worker;
@@ -3746,15 +4013,15 @@ function prepareRun(o) {
3746
4013
  throw new UserError(`the ${backend.name} worker cannot attach files; put the content in the task`);
3747
4014
  }
3748
4015
  const mode = parent?.mode ?? o.mode;
3749
- const dir = path20.resolve(parent?.dir ?? o.dir);
3750
- if (!fs24.existsSync(dir) || !fs24.statSync(dir).isDirectory()) throw new UserError(`not a directory: ${dir}`);
4016
+ const dir = path21.resolve(parent?.dir ?? o.dir);
4017
+ if (!fs25.existsSync(dir) || !fs25.statSync(dir).isDirectory()) throw new UserError(`not a directory: ${dir}`);
3751
4018
  const root = repoRoot(dir);
3752
4019
  if (mode === "isolate" && !root) throw new UserError("--isolate needs a git repository", 3);
3753
4020
  if (mode === "write" && !root && !o.allowNonGit) {
3754
4021
  throw new UserError("--write outside a git repo cannot be tracked or reverted; pass --allow-non-git to accept that", 3);
3755
4022
  }
3756
- const files = o.files.map((f) => path20.resolve(f));
3757
- for (const f of files) if (!fs24.existsSync(f)) throw new UserError(`file not found: ${f}`);
4023
+ const files = o.files.map((f) => path21.resolve(f));
4024
+ for (const f of files) if (!fs25.existsSync(f)) throw new UserError(`file not found: ${f}`);
3758
4025
  if (!parent && !process.env.PITROOM_NO_SECRET_WARNING) {
3759
4026
  const secrets = mode === "isolate" && root ? findSecretFilesInTree(root, dir) : findSecretFiles(dir);
3760
4027
  const note = secretWarning(secrets, mode);
@@ -3794,13 +4061,13 @@ function prepareRun(o) {
3794
4061
  try {
3795
4062
  acquireWriteLock(root, meta.id);
3796
4063
  } catch (e) {
3797
- fs24.rmSync(runDir(meta.id), { recursive: true, force: true });
4064
+ fs25.rmSync(runDir(meta.id), { recursive: true, force: true });
3798
4065
  throw e;
3799
4066
  }
3800
4067
  }
3801
- fs24.writeFileSync(runFile(meta.id, "task.md"), `${meta.task}
4068
+ fs25.writeFileSync(runFile(meta.id, "task.md"), `${meta.task}
3802
4069
  `);
3803
- if (work.brief) fs24.writeFileSync(runFile(meta.id, "brief.md"), work.brief);
4070
+ if (work.brief) fs25.writeFileSync(runFile(meta.id, "brief.md"), work.brief);
3804
4071
  return meta;
3805
4072
  }
3806
4073
  var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -3873,7 +4140,7 @@ async function execute(meta) {
3873
4140
  (meta.attempts ??= []).push({ target: describeTarget(target), error: why.message });
3874
4141
  for (const f of ["events.jsonl", "stderr.log"]) {
3875
4142
  const from = runFile(meta.id, f);
3876
- if (fs24.existsSync(from)) fs24.renameSync(from, runFile(meta.id, f.replace(".", `.attempt-${i + 1}.`)));
4143
+ if (fs25.existsSync(from)) fs25.renameSync(from, runFile(meta.id, f.replace(".", `.attempt-${i + 1}.`)));
3877
4144
  }
3878
4145
  writeMeta(meta);
3879
4146
  }
@@ -3893,9 +4160,9 @@ function prepareTree(meta) {
3893
4160
  if (root && meta.mode === "write") meta.baseTree = snapshotTree(root);
3894
4161
  if (root && meta.mode === "isolate" && !meta.worktree) {
3895
4162
  meta.baseTree = snapshotTree(root);
3896
- meta.worktree = path20.join(worktreesDir(), meta.id);
4163
+ meta.worktree = path21.join(worktreesDir(), meta.id);
3897
4164
  createIsolatedCopy(root, meta.baseTree, meta.worktree);
3898
- meta.cwd = path20.join(meta.worktree, path20.relative(root, meta.dir));
4165
+ meta.cwd = path21.join(meta.worktree, path21.relative(root, meta.dir));
3899
4166
  const linked = linkIntoWorktree(root, meta.worktree, meta.link);
3900
4167
  if (linked.length) meta.warnings.push(`linked into the isolated copy (shared with your tree): ${linked.join(", ")}`);
3901
4168
  }
@@ -3918,7 +4185,7 @@ function attempt(meta, backend, target) {
3918
4185
  timeoutSec: meta.timeoutSec
3919
4186
  });
3920
4187
  }
3921
- var read = (f) => fs24.existsSync(f) ? fs24.readFileSync(f, "utf8") : "";
4188
+ var read = (f) => fs25.existsSync(f) ? fs25.readFileSync(f, "utf8") : "";
3922
4189
  function retryableFailure(meta, backend, res) {
3923
4190
  const run = backend.parse(read(runFile(meta.id, "events.jsonl")));
3924
4191
  if (run.usage.steps > 0) return void 0;
@@ -3937,12 +4204,12 @@ function finalize(meta, res) {
3937
4204
  const run = backend.parse(read(runFile(meta.id, "events.jsonl")));
3938
4205
  meta.sessionId = run.sessionId ?? meta.sessionId;
3939
4206
  meta.usage = run.usage;
3940
- fs24.writeFileSync(runFile(meta.id, "summary.md"), `${run.finalText}
4207
+ fs25.writeFileSync(runFile(meta.id, "summary.md"), `${run.finalText}
3941
4208
  `);
3942
4209
  if (meta.plan && !meta.reviewOf) meta.taskStatus = parseStatus(run.finalText);
3943
4210
  if (meta.reviewOf) {
3944
4211
  meta.verdict = parseVerdict(run.finalText);
3945
- if (meta.packageFile) fs24.rmSync(meta.packageFile, { force: true });
4212
+ if (meta.packageFile) fs25.rmSync(meta.packageFile, { force: true });
3946
4213
  }
3947
4214
  if (!res.timedOut && !res.stopped && !meta.error) {
3948
4215
  const f = backend.failure(run, read(runFile(meta.id, "stderr.log")), res.code);
@@ -3984,7 +4251,7 @@ function captureChanges(meta) {
3984
4251
  const d = diffTrees(target, meta.baseTree, meta.afterTree);
3985
4252
  meta.changes = d.changes;
3986
4253
  meta.stats = d.stats;
3987
- fs24.writeFileSync(runFile(meta.id, "changes.patch"), d.patch);
4254
+ fs25.writeFileSync(runFile(meta.id, "changes.patch"), d.patch);
3988
4255
  }
3989
4256
  function runVerify(meta) {
3990
4257
  const r = spawnSync5(meta.verify, {
@@ -3997,7 +4264,7 @@ function runVerify(meta) {
3997
4264
  env: { ...process.env, PWD: meta.cwd, PITROOM_ACTIVE: "1" }
3998
4265
  });
3999
4266
  const output = `${r.stdout ?? ""}${r.stderr ?? ""}`;
4000
- fs24.writeFileSync(runFile(meta.id, "verify.log"), output);
4267
+ fs25.writeFileSync(runFile(meta.id, "verify.log"), output);
4001
4268
  return { ok: r.status === 0, code: r.status, tail: output.trimEnd().split("\n").slice(-25).join("\n") };
4002
4269
  }
4003
4270
  function applyRun(meta, allowDelete = false) {
@@ -4042,7 +4309,7 @@ function discardRun(meta) {
4042
4309
  return `discarded the isolated copy of ${meta.id}; the patch stays in ${runFile(meta.id, "changes.patch")}`;
4043
4310
  }
4044
4311
  function cleanupWorktree(meta) {
4045
- if (meta.worktree && fs24.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
4312
+ if (meta.worktree && fs25.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
4046
4313
  }
4047
4314
 
4048
4315
  // src/cli/commands.ts
@@ -4092,10 +4359,10 @@ async function cmdReview(p) {
4092
4359
  writeMeta(meta);
4093
4360
  }
4094
4361
  } catch (e) {
4095
- fs25.rmSync(packageFile, { force: true });
4362
+ fs26.rmSync(packageFile, { force: true });
4096
4363
  throw e;
4097
4364
  }
4098
- fs25.writeFileSync(runFile(meta.id, "package.md"), job.package);
4365
+ fs26.writeFileSync(runFile(meta.id, "package.md"), job.package);
4099
4366
  return launch(p, meta);
4100
4367
  }
4101
4368
  function cmdPlan(p) {
@@ -4115,7 +4382,7 @@ function cmdPlan(p) {
4115
4382
  }
4116
4383
  function cmdCrew(p) {
4117
4384
  const file = flag(p, "task-file");
4118
- const tasks = (file ? fs25.readFileSync(file, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
4385
+ const tasks = (file ? fs26.readFileSync(file, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
4119
4386
  if (!tasks.length) throw new UserError('crew needs tasks: pitroom crew -g NAME "task 1" "task 2" (or --task-file with --- separators)');
4120
4387
  if (has(p, "write") && tasks.length > 1) {
4121
4388
  throw new UserError("parallel --write runs would edit the same tree; use --isolate (each worker gets its own isolated copy)");
@@ -4224,7 +4491,7 @@ function cmdHistory(p) {
4224
4491
  if (has(p, "json")) return console.log(JSON.stringify(s, null, 2)), 0;
4225
4492
  const t = s.totals;
4226
4493
  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`);
4227
- const rows2 = s.byWorker.map((w) => [`${w.backend}${w.model ? `:${w.model}` : ""}`, String(w.runs), `${Math.round(100 * w.ok / w.runs)}%`, secs(w.avgSeconds), w.avgTokens ? `${Math.round(w.avgTokens / 1e3)}k` : "-", `~${usd(w.saved)}`]);
4494
+ 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)}`]);
4228
4495
  const head2 = ["WORKER", "RUNS", "OK", "AVG TIME", "AVG TOKENS", "SAVED"];
4229
4496
  const widths2 = head2.map((h, i) => Math.max(h.length, ...rows2.map((r) => r[i].length)));
4230
4497
  const fmt2 = (r) => r.map((c, i) => i === 0 ? c.padEnd(widths2[i]) : c.padStart(widths2[i])).join(" ");
@@ -4239,8 +4506,11 @@ ${[fmt2(head2), ...rows2.map(fmt2)].join("\n")}`);
4239
4506
  const body = rows.map((r) => [r.id, when(r.startedAt), r.state, `${r.backend}${r.model ? ` (${r.model.split("/").pop()})` : ""}`, secs(r.seconds), r.task.length > 60 ? `${r.task.slice(0, 59)}\u2026` : r.task]);
4240
4507
  const head = ["RUN", "WHEN", "STATE", "WORKER", "TIME", "TASK"];
4241
4508
  const widths = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length)));
4242
- const fmt = (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" ");
4243
- console.log([fmt(head), ...body.map(fmt)].join("\n"));
4509
+ const fmt = (r, header = false) => r.map((c, i) => {
4510
+ const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
4511
+ return header ? dim(cell2) : i === 2 ? stateColour(r[2], cell2) : cell2;
4512
+ }).join(" ");
4513
+ console.log([fmt(head, true), ...body.map((r) => fmt(r))].join("\n"));
4244
4514
  if (total > rows.length) console.log(`
4245
4515
  ${rows.length} of ${total} \xB7 --limit N for more \xB7 pitroom show <run> for one`);
4246
4516
  return 0;
@@ -4313,7 +4583,7 @@ function sinceMs(s) {
4313
4583
  if (!m) throw new UserError("--since takes 7d, 30d, \u2026 or all");
4314
4584
  return Date.now() - Number(m[1]) * 864e5;
4315
4585
  }
4316
- var readStdin = () => process.stdin.isTTY ? "" : fs25.readFileSync(0, "utf8");
4586
+ var readStdin = () => process.stdin.isTTY ? "" : fs26.readFileSync(0, "utf8");
4317
4587
  function cmdStatusline(p) {
4318
4588
  const input = readStdin();
4319
4589
  const lines = [];
@@ -4352,6 +4622,18 @@ function cmdModels(p) {
4352
4622
  console.log(has(p, "json") ? JSON.stringify(table2, null, 2) : formatModels(table2));
4353
4623
  return 0;
4354
4624
  }
4625
+ function cmdHookStart() {
4626
+ const script = path22.join(path22.dirname(path22.dirname(fileURLToPath3(import.meta.url))), "hooks", "session-start.mjs");
4627
+ if (fs26.existsSync(script)) spawnSync6(process.execPath, [script], { stdio: ["ignore", "inherit", "ignore"], env: process.env });
4628
+ return 0;
4629
+ }
4630
+ function cmdInit(p) {
4631
+ const fallback = flag(p, "fallback");
4632
+ const plan = planInit({ model: flag(p, "model"), fallback: fallback === void 0 ? void 0 : fallback.split(",").map((s) => s.trim()).filter(Boolean) });
4633
+ const written = has(p, "yes") ? writeInit(plan, has(p, "force")) : void 0;
4634
+ console.log(has(p, "json") ? JSON.stringify({ ...plan, written }, null, 2) : formatInit(plan, written));
4635
+ return 0;
4636
+ }
4355
4637
  function cmdSavings(p) {
4356
4638
  const since = flag(p, "since") ?? "all";
4357
4639
  const t = totals(readLedger(sinceMs(since)));
@@ -4382,8 +4664,8 @@ function cmdSavings(p) {
4382
4664
  est. saved ${usd(t.saved)} (vs ${primaryPrice().name} list prices)`);
4383
4665
  const out = flag(p, "card");
4384
4666
  if (out) {
4385
- fs25.writeFileSync(out, card(t, period));
4386
- console.log(`card written to ${path21.resolve(out)}`);
4667
+ fs26.writeFileSync(out, card(t, period));
4668
+ console.log(`card written to ${path22.resolve(out)}`);
4387
4669
  }
4388
4670
  if (has(p, "badge")) console.log(`![pitroom](${badgeUrl(t)})`);
4389
4671
  return 0;
@@ -4403,7 +4685,7 @@ function cmdConfig(p) {
4403
4685
  console.log(JSON.stringify({ path: configPath(), settings: eff, warnings }, null, 2));
4404
4686
  return 0;
4405
4687
  }
4406
- console.log(`config file: ${configPath()}${fs25.existsSync(configPath()) ? "" : " (not present)"}`);
4688
+ console.log(`config file: ${configPath()}${fs26.existsSync(configPath()) ? "" : " (not present)"}`);
4407
4689
  for (const [key, s] of Object.entries(eff)) {
4408
4690
  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);
4409
4691
  console.log(` ${key.padEnd(15)} ${v} (${s.source})`);
@@ -4427,7 +4709,7 @@ function cmdClean(p) {
4427
4709
  const m = readMeta(id);
4428
4710
  if (m.mode === "isolate" && !m.discarded && !m.applied) discardRun(m);
4429
4711
  recordRun(m);
4430
- fs25.rmSync(runDir(id), { recursive: true, force: true });
4712
+ fs26.rmSync(runDir(id), { recursive: true, force: true });
4431
4713
  }
4432
4714
  console.log(`removed ${old.length} run(s); the history and the savings ledger are kept`);
4433
4715
  return 0;
@@ -4437,10 +4719,33 @@ var cmdDiscard = (p) => (console.log(discardRun(freshMeta(resolveRun(p.positiona
4437
4719
 
4438
4720
  // src/core/doctor.ts
4439
4721
  import { spawnSync as spawnSync7 } from "node:child_process";
4440
- import fs26 from "node:fs";
4722
+ import fs27 from "node:fs";
4441
4723
  import os9 from "node:os";
4442
- import path22 from "node:path";
4443
- var MARK = { ok: "\u2714", warn: "!", fail: "\u2718" };
4724
+ import path23 from "node:path";
4725
+ var MARK = { ok: () => green("\u2714"), warn: () => yellow("!"), fail: () => red("\u2718") };
4726
+ var NEXT = [
4727
+ { when: /no default model|no fallback workers/, command: "pitroom init", why: "propose a starter config (fallback models from your catalogue)" },
4728
+ { when: /pitroom install|no Pitroom skills|launcher on PATH/, command: "pitroom install", why: "link the skills and the pitroom command" },
4729
+ { when: /first `node` on PATH/, command: "nvm alias default 24", why: "a current Node first in every new shell" },
4730
+ { when: /not logged in/, command: "claude auth login", why: "sign in the Claude Code worker (Codex: codex login)" }
4731
+ ];
4732
+ function firstNodeOnPath() {
4733
+ for (const dir of (process.env.PATH ?? "").split(path23.delimiter).filter(Boolean)) {
4734
+ const file = path23.join(dir, process.platform === "win32" ? "node.exe" : "node");
4735
+ try {
4736
+ if (!fs27.statSync(file).isFile()) continue;
4737
+ } catch {
4738
+ continue;
4739
+ }
4740
+ const r = spawnSync7(file, ["-p", "process.versions.node"], { encoding: "utf8", timeout: 5e3 });
4741
+ return r.status === 0 ? { path: file, version: r.stdout.trim() } : { path: file, version: "" };
4742
+ }
4743
+ return void 0;
4744
+ }
4745
+ var tooOld = (v) => {
4746
+ const [a = 0, b = 0] = v.split(".").map(Number);
4747
+ return a < 22 || a === 22 && b < 13;
4748
+ };
4444
4749
  var READ_ONLY_HOW = {
4445
4750
  "permission-rules": "per-run permission rules",
4446
4751
  "os-sandbox": "an OS sandbox",
@@ -4449,16 +4754,24 @@ var READ_ONLY_HOW = {
4449
4754
  };
4450
4755
  function doctor4(probe) {
4451
4756
  const checks = [];
4452
- const add = (level, message) => checks.push({ level, message });
4757
+ let current = "Setup";
4758
+ const section2 = (name) => void (current = name);
4759
+ const add = (level, message) => checks.push({ level, message, section: current });
4760
+ const addAll = (list2) => list2.forEach((c) => add(c.level, c.message));
4453
4761
  add("ok", `pitroom ${VERSION2} \xB7 node ${process.versions.node} \xB7 state in ${home()}`);
4762
+ const onPath = firstNodeOnPath();
4763
+ if (onPath && onPath.version && tooOld(onPath.version) && path23.resolve(onPath.path) !== path23.resolve(process.execPath)) {
4764
+ 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)`);
4765
+ }
4454
4766
  add(gitAvailable() ? "ok" : "warn", gitAvailable() ? "git available" : "git not found: --write/--isolate tracking disabled");
4455
4767
  const cfg = loadConfig();
4456
- add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs26.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
4768
+ add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs27.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
4457
4769
  for (const w of cfg.warnings) add("warn", w);
4458
4770
  if (process.platform !== "win32") {
4459
4771
  const guarded = guardEnv(process.env).PATH?.startsWith(shimDir());
4460
4772
  add(guarded ? "ok" : "warn", guarded ? "git guard shim ready" : "git guard unavailable (git not on PATH)");
4461
4773
  }
4774
+ section2("Worker chain");
4462
4775
  const chain = [];
4463
4776
  try {
4464
4777
  const resolved = resolveChain();
@@ -4499,14 +4812,39 @@ function doctor4(probe) {
4499
4812
  add("fail", e.message);
4500
4813
  continue;
4501
4814
  }
4502
- checks.push(...backend.doctor({ models: [...new Set(models)], hasFallback: chain.length > 1 }));
4815
+ section2(backend.name);
4816
+ addAll(backend.doctor({ models: [...new Set(models)], hasFallback: chain.length > 1 }));
4503
4817
  add("ok", `${backend.name}: read-only runs enforced by ${READ_ONLY_HOW[backend.capabilities.readOnly]}`);
4504
4818
  }
4505
- checks.push(...skillChecks());
4506
- if (probe && chain[0]) checks.push(liveProbe(chain[0]));
4507
- for (const c of checks) console.log(`${MARK[c.level]} ${c.message}`);
4819
+ section2("Skills and agents");
4820
+ addAll(skillChecks());
4821
+ if (probe && chain[0]) {
4822
+ section2("Live probe");
4823
+ const p = liveProbe(chain[0]);
4824
+ add(p.level, p.message);
4825
+ }
4826
+ print(checks);
4508
4827
  return checks.some((c) => c.level === "fail") ? 1 : 0;
4509
4828
  }
4829
+ function print(checks) {
4830
+ console.log(`${bold("Pitroom doctor")} ${dim(`v${VERSION2}`)}`);
4831
+ for (const name of [...new Set(checks.map((c) => c.section))]) {
4832
+ console.log(`
4833
+ ${bold(name)}`);
4834
+ for (const c of checks.filter((x) => x.section === name)) console.log(` ${MARK[c.level]()} ${wrapText(c.message, 4)}`);
4835
+ }
4836
+ const count = (level) => checks.filter((c) => c.level === level).length;
4837
+ const [ok, warn, fail] = [count("ok"), count("warn"), count("fail")];
4838
+ console.log(`
4839
+ ${green(`\u2714 ${ok} ok`)} ${warn ? yellow(`! ${warn} warning${warn === 1 ? "" : "s"}`) : dim("! 0 warnings")} ${fail ? red(`\u2718 ${fail} problem${fail === 1 ? "" : "s"}`) : dim("\u2718 0 problems")}`);
4840
+ const next = NEXT.filter((n) => checks.some((c) => c.level !== "ok" && n.when.test(c.message)));
4841
+ if (next.length) {
4842
+ console.log(`
4843
+ ${bold("Next")}`);
4844
+ const width = Math.max(...next.map((n) => n.command.length));
4845
+ for (const n of next) console.log(` ${cyan(n.command.padEnd(width))} ${dim(n.why)}`);
4846
+ }
4847
+ }
4510
4848
  function liveProbe(target) {
4511
4849
  let backend;
4512
4850
  try {
@@ -4554,13 +4892,13 @@ function skillChecks() {
4554
4892
  else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${base2}; run \`pitroom install\`` });
4555
4893
  }
4556
4894
  if (viaPlugin) {
4557
- const linked = installedSkills().some((i) => i.base.includes(`${path22.sep}.claude${path22.sep}`) && i.names.length);
4895
+ const linked = installedSkills().some((i) => i.base.includes(`${path23.sep}.claude${path23.sep}`) && i.names.length);
4558
4896
  checks.push(
4559
4897
  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)" }
4560
4898
  );
4561
4899
  }
4562
4900
  const launcher = launcherPath();
4563
- if (fs26.existsSync(launcher)) {
4901
+ if (fs27.existsSync(launcher)) {
4564
4902
  const r = spawnSync7(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
4565
4903
  checks.push(
4566
4904
  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)}` }
@@ -4572,8 +4910,8 @@ function skillChecks() {
4572
4910
  }
4573
4911
  function pluginInstalled() {
4574
4912
  try {
4575
- const f = path22.join(os9.homedir(), ".claude", "plugins", "installed_plugins.json");
4576
- const plugins = JSON.parse(fs26.readFileSync(f, "utf8")).plugins ?? {};
4913
+ const f = path23.join(os9.homedir(), ".claude", "plugins", "installed_plugins.json");
4914
+ const plugins = JSON.parse(fs27.readFileSync(f, "utf8")).plugins ?? {};
4577
4915
  return Object.keys(plugins).some((k) => k.startsWith("pitroom@"));
4578
4916
  } catch {
4579
4917
  return false;
@@ -4584,30 +4922,30 @@ function superpowersActive() {
4584
4922
  for (const key of claudePlugins()) {
4585
4923
  if (key.startsWith("superpowers@") && claudePluginEnabled(key)) found.push(`Claude Code plugin ${key}`);
4586
4924
  }
4587
- for (const [key, enabled] of codexPlugins()) {
4588
- if (key.startsWith("superpowers@") && enabled) found.push(`Codex plugin ${key}`);
4925
+ for (const [key, enabled2] of codexPlugins()) {
4926
+ if (key.startsWith("superpowers@") && enabled2) found.push(`Codex plugin ${key}`);
4589
4927
  }
4590
4928
  for (const p of openCodePlugins()) {
4591
4929
  if (/superpowers/i.test(p)) found.push(`OpenCode plugin ${p}`);
4592
4930
  }
4593
- for (const base2 of [path22.join(os9.homedir(), ".agents", "skills"), path22.join(os9.homedir(), ".claude", "skills")]) {
4594
- const dir = path22.join(base2, "using-superpowers");
4595
- if (fs26.existsSync(path22.join(dir, "SKILL.md"))) found.push(dir);
4931
+ for (const base2 of [path23.join(os9.homedir(), ".agents", "skills"), path23.join(os9.homedir(), ".claude", "skills")]) {
4932
+ const dir = path23.join(base2, "using-superpowers");
4933
+ if (fs27.existsSync(path23.join(dir, "SKILL.md"))) found.push(dir);
4596
4934
  }
4597
4935
  return found;
4598
4936
  }
4599
4937
  function claudePlugins() {
4600
4938
  try {
4601
- const f = path22.join(os9.homedir(), ".claude", "plugins", "installed_plugins.json");
4602
- return Object.keys(JSON.parse(fs26.readFileSync(f, "utf8")).plugins ?? {});
4939
+ const f = path23.join(os9.homedir(), ".claude", "plugins", "installed_plugins.json");
4940
+ return Object.keys(JSON.parse(fs27.readFileSync(f, "utf8")).plugins ?? {});
4603
4941
  } catch {
4604
4942
  return [];
4605
4943
  }
4606
4944
  }
4607
4945
  function claudePluginEnabled(key) {
4608
4946
  try {
4609
- const f = path22.join(os9.homedir(), ".claude", "settings.json");
4610
- return JSON.parse(fs26.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
4947
+ const f = path23.join(os9.homedir(), ".claude", "settings.json");
4948
+ return JSON.parse(fs27.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
4611
4949
  } catch {
4612
4950
  return true;
4613
4951
  }
@@ -4616,7 +4954,7 @@ function codexPlugins() {
4616
4954
  const plugins = /* @__PURE__ */ new Map();
4617
4955
  let text;
4618
4956
  try {
4619
- text = fs26.readFileSync(path22.join(process.env.CODEX_HOME ?? path22.join(os9.homedir(), ".codex"), "config.toml"), "utf8");
4957
+ text = fs27.readFileSync(path23.join(process.env.CODEX_HOME ?? path23.join(os9.homedir(), ".codex"), "config.toml"), "utf8");
4620
4958
  } catch {
4621
4959
  return plugins;
4622
4960
  }
@@ -4634,11 +4972,11 @@ function codexPlugins() {
4634
4972
  return plugins;
4635
4973
  }
4636
4974
  function openCodePlugins() {
4637
- const dir = path22.join(process.env.XDG_CONFIG_HOME ?? path22.join(os9.homedir(), ".config"), "opencode");
4975
+ const dir = path23.join(process.env.XDG_CONFIG_HOME ?? path23.join(os9.homedir(), ".config"), "opencode");
4638
4976
  const plugins = [];
4639
4977
  for (const name of ["opencode.json", "opencode.jsonc"]) {
4640
4978
  try {
4641
- const text = fs26.readFileSync(path22.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
4979
+ const text = fs27.readFileSync(path23.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
4642
4980
  const list2 = JSON.parse(text).plugin;
4643
4981
  if (Array.isArray(list2)) plugins.push(...list2.filter((p) => typeof p === "string"));
4644
4982
  } catch {
@@ -4646,7 +4984,7 @@ function openCodePlugins() {
4646
4984
  }
4647
4985
  for (const folder of ["plugin", "plugins"]) {
4648
4986
  try {
4649
- for (const f of fs26.readdirSync(path22.join(dir, folder))) plugins.push(path22.join(dir, folder, f));
4987
+ for (const f of fs27.readdirSync(path23.join(dir, folder))) plugins.push(path23.join(dir, folder, f));
4650
4988
  } catch {
4651
4989
  }
4652
4990
  }
@@ -4693,8 +5031,12 @@ Usage
4693
5031
  models each worker offers, with effort levels, your costs and usage
4694
5032
  pitroom statusline [--then CMD] status-bar line: running workers, savings this week (after CMD's)
4695
5033
  pitroom hook-card PostToolUse hook: a card after each Bash \`pitroom\` command
5034
+ pitroom hook-start SessionStart hook: introduces Pitroom to the agent (Codex; Claude Code uses its plugin)
4696
5035
  pitroom doctor [--probe] check workers, models, permissions, skills
4697
5036
  pitroom config effective settings, where each comes from, config file path
5037
+ pitroom init [--model ID] [--fallback A,B] [--yes] [--force]
5038
+ propose a starter config from the worker CLIs and models you have;
5039
+ writes it only with --yes (models are suggested, never chosen for you)
4698
5040
  pitroom install [--copy] [--force] link the skills into ~/.agents/skills + ~/.claude/skills,
4699
5041
  and the CLI into ~/.local/bin
4700
5042
  pitroom uninstall remove what install linked
@@ -4754,8 +5096,10 @@ var COMMANDS = {
4754
5096
  models: cmdModels,
4755
5097
  statusline: cmdStatusline,
4756
5098
  "hook-card": cmdHookCard,
5099
+ "hook-start": cmdHookStart,
4757
5100
  doctor: (p) => doctor4(has(p, "probe")),
4758
5101
  config: cmdConfig,
5102
+ init: cmdInit,
4759
5103
  install: cmdInstall,
4760
5104
  uninstall: cmdUninstall,
4761
5105
  clean: cmdClean,